Skip to content

feat(captions): export subtitles as separate SRT/VTT file alongside video#599

Open
mvanhorn wants to merge 2 commits into
webadderallorg:mainfrom
mvanhorn:fix/334-recordly-subtitle-file-export
Open

feat(captions): export subtitles as separate SRT/VTT file alongside video#599
mvanhorn wants to merge 2 commits into
webadderallorg:mainfrom
mvanhorn:fix/334-recordly-subtitle-file-export

Conversation

@mvanhorn
Copy link
Copy Markdown

@mvanhorn mvanhorn commented May 27, 2026

Summary

  • Adds an opt-in path to export the already-generated caption cues as a standalone .srt or .vtt file, distinct from the burn-in caption render path.
  • New module electron/ipc/captions/exportSubtitleFile.ts: pure cuesToSrt(cues) / cuesToVtt(cues) serializers + an IPC handler that opens the Electron save-dialog and writes the file via fs.promises.writeFile. Picking up where PR feat(captions): export subtitle files #355 left off after @meiiie stepped back to reduce PR noise.
  • New unit test electron/ipc/captions/exportSubtitleFile.test.ts covers SRT/VTT formatting, malformed cues (end<start) being clamped, empty cue arrays, multi-line cue text, and format dispatch.
  • Wires a secondary export action into the existing VideoEditor export menu, enabled only when autoCaptions is non-empty.

cc @meiiie - picking up your 2026-05-09 re-up invitation. The burn-in caption path (captionRenderer.ts) is untouched.

Closes #334.

AI was used for assistance.

Summary by CodeRabbit

  • New Features

    • Export captions to SRT or VTT directly from the video editor.
    • "Subtitles" dropdown added to the export toolbar with “Export SRT” and “Export VTT”.
    • Prevents exports when no captions are present and disables the control during export; shows user-facing notifications for success, cancellation, or errors.
    • Generates sanitized default filenames for exported subtitle files.
  • Tests

    • Added tests covering subtitle serialization and file export behavior.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f0f60eac-9235-4c25-9ae0-b9b2986414cc

📥 Commits

Reviewing files that changed from the base of the PR and between c6ab201 and 69fb2a8.

📒 Files selected for processing (1)
  • electron/ipc/captions/exportSubtitleFile.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • electron/ipc/captions/exportSubtitleFile.ts

📝 Walkthrough

Walkthrough

Adds end-to-end subtitle export: typed renderer API, cue normalization and SRT/VTT serialization, Electron save-dialog file write with approval, unit tests, IPC/preload/register wiring, and VideoEditor UI export controls.

Changes

Subtitle Export Feature

Layer / File(s) Summary
Type contracts and public API
src/lib/exporter/types.ts, electron/electron-env.d.ts
SubtitleExportFormat ("srt" | "vtt") and Window.electronAPI.exportSubtitleFile signature with format, cues, optional filename and structured result.
Subtitle conversion and file export implementation
electron/ipc/captions/exportSubtitleFile.ts
Cue normalization (time rounding, newline normalization, skip malformed with warnings), timestamp formatting, SRT/VTT rendering, filename/filters for save dialog, file write, approveUserPath call, and structured success/error responses.
Unit tests: serialization & error paths
electron/ipc/captions/exportSubtitleFile.test.ts
Vitest suite validating SRT numbered blocks and comma milliseconds, VTT WEBVTT header and dot milliseconds, malformed cue skipping with console.warn, empty-cue behavior, multiline preservation, and write-failure reporting.
IPC bridge and registration
electron/ipc/register/captions.ts, electron/preload.ts
Registers 'export-subtitle-file' handler and exposes electronAPI.exportSubtitleFile in preload to forward options via ipcRenderer.invoke.
VideoEditor UI integration
src/components/video-editor/VideoEditor.tsx
Filename generation helper, isExportingSubtitleFile state, handleExportSubtitleFile that invokes IPC and shows toasts, and a "Subtitles" dropdown with "Export SRT"/"Export VTT" options (disabled when no captions or exporting).

Sequence Diagram(s)

sequenceDiagram
  participant VideoEditor
  participant Preload as electronAPI
  participant IPCMain
  participant Handler as exportSubtitleFile
  participant Dialog as dialog.showSaveDialog
  participant FS as fs.writeFile
  participant Approve as approveUserPath
  VideoEditor->>Preload: invoke exportSubtitleFile({format,cues,fileName})
  Preload->>IPCMain: ipcRenderer.invoke('export-subtitle-file', options)
  IPCMain->>Handler: receive options
  Handler->>Handler: normalize cues & serialize to SRT/VTT text
  Handler->>Dialog: showSaveDialog(with format filters)
  Dialog-->>Handler: path or canceled
  alt user canceled
    Handler-->>IPCMain: {success:false, canceled:true}
  else path selected
    Handler->>FS: writeFile(path, content)
    FS-->>Handler: success or error
    Handler->>Approve: approveUserPath(path)
    Approve-->>Handler: approved
    Handler-->>IPCMain: {success:true, path}
  end
  IPCMain-->>Preload: resolved result
  Preload-->>VideoEditor: result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A subtitle tale, both SRT and VTT,
Through Electron pipes it journeys free,
Cues normalized and timestamps neat,
A dropdown clicks—now files we keep! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.23% 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 clearly and specifically describes the main change: adding subtitle export functionality (SRT/VTT formats) as a separate file alongside video export.
Description check ✅ Passed The description covers the main changes, motivation, technical details, and impact. However, it lacks key template sections: no screenshots/video demonstration, incomplete Type of Change checkboxes, and no explicit Testing Guide instructions.
Linked Issues check ✅ Passed The PR successfully implements all coding requirements from issue #334: provides export path for generated captions as separate files, supports SRT and WebVTT formats, and keeps scope limited to subtitle-file export without modifying burn-in rendering.
Out of Scope Changes check ✅ Passed All changes are directly related to issue #334 requirements: subtitle export serializers, IPC handler, preload exposure, UI integration, and supporting test coverage. No unrelated modifications detected.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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 `@electron/ipc/captions/exportSubtitleFile.ts`:
- Around line 44-55: The validation currently checks endMs <= startMs before
clamping, allowing negative times like startMs=-500,endMs=-100 to pass and then
serialize to zero-duration cues; update the logic in the caption normalization
loop (the block that currently logs and returns, and the push into
normalizedCues) to clamp startMs and endMs to a minimum of 0 (and optionally to
fileDuration if available) before performing the duration check, then apply the
existing text.trim() and endMs <= startMs validation and only push into
normalizedCues after clamping and validation.

In `@src/components/video-editor/VideoEditor.tsx`:
- Line 366: The regex used to compute safeBaseName
(sourceName.replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-")) triggers Biome lint rules
for control-character and useless-escape usage; replace it with a Unicode-aware
character-class that uses the control category and removes unnecessary escapes,
e.g. use sourceName.replace(/[\p{Cc}<>:"\\|?*]+/gu, "-") in the VideoEditor.tsx
code where safeBaseName is computed so it matches all control characters via
\p{Cc}, adds the 'u' flag, and no longer contains useless escapes.
🪄 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: 3f192877-9227-4908-95ff-4c9e5e83eb99

📥 Commits

Reviewing files that changed from the base of the PR and between 24e6779 and c6ab201.

📒 Files selected for processing (7)
  • 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/lib/exporter/types.ts

Comment thread electron/ipc/captions/exportSubtitleFile.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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/components/video-editor/VideoEditor.tsx"

python - <<'PY'
from pathlib import Path
p = Path("src/components/video-editor/VideoEditor.tsx")
line = p.read_text(encoding="utf-8").splitlines()[365]  # Line 366 (1-based)
print("Line 366 repr:", repr(line))
print("Contains raw control chars:", any(ord(ch) < 32 for ch in line))
PY

rg -n 'sourceName\.replace\(' "$FILE"

# Optional: run if Biome is available in your environment
if command -v biome >/dev/null 2>&1; then
  biome check "$FILE"
elif command -v npx >/dev/null 2>&1; then
  npx --yes `@biomejs/biome` check "$FILE"
fi

Repository: webadderallorg/Recordly

Length of output: 2460


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ORIG="src/components/video-editor/VideoEditor.tsx"
TMPDIR="$(mktemp -d)"
TMP1="$TMPDIR/VideoEditor.tsx"

# Candidate regex strings (the whole .replace(...) call argument)
# 1) As proposed in the review comment (unescaped / inside the class; \u range)
C1='sourceName.replace(/[<>:"/\\|?*\u0000-\u001F]+/g, "-").trim();'

# 2) Same but keep backslash order explicit (still noControl chars via \u)
C2='sourceName.replace(/[\u0000-\u001F<>:"/\\|?*]+/g, "-").trim();'

# 3) Try using Unicode property escape for control chars (may satisfy rule)
C3='sourceName.replace(/[<>:"/\\|?*\p{Cc}]+/gu, "-").trim();'

# 4) Fallback: escape / inside the class (should be harmless even if Biome dislikes it)
C4='sourceName.replace(/[<>:"\/\\|?*\u0000-\u001F]+/g, "-").trim();'

# Extract current line 366 and ensure it matches what we expect
python3 - <<'PY'
from pathlib import Path
p = Path("src/components/video-editor/VideoEditor.tsx")
line = p.read_text(encoding="utf-8").splitlines()[365]
print("Original line 366:", line)
PY

for i in 1 2 3 4; do
  cp "$ORIG" "$TMP1"
  # Replace the entire line containing safeBaseName assignment.
  # Use a conservative match for the prefix to avoid accidental changes elsewhere.
  python3 - <<PY
from pathlib import Path
import re
p = Path("$TMP1")
lines = p.read_text(encoding="utf-8").splitlines()
idx = 365  # line 366 (1-based)
old = lines[idx]
new = ${!i}
# Above indirection doesn't work; set via explicit mapping below
PY
done

Repository: webadderallorg/Recordly

Length of output: 227


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ORIG="src/components/video-editor/VideoEditor.tsx"

# Ensure biome is available
if command -v biome >/dev/null 2>&1; then
  BIOME=(biome)
elif command -v npx >/dev/null 2>&1; then
  BIOME=(npx --yes `@biomejs/biome`)
else
  echo "Neither biome nor npx/@biomejs/biome is available in this environment."
  exit 1
fi

python3 - <<'PY'
from pathlib import Path
p = Path("src/components/video-editor/VideoEditor.tsx")
lines = p.read_text(encoding="utf-8").splitlines()
print(lines[365])
PY

variants=(
  'const safeBaseName = sourceName.replace(/[<>:"\/\\|?*\u0000-\u001F]+/g, "-").trim();'
  'const safeBaseName = sourceName.replace(/[\u0000-\u001F<>:"\/\\|?*]+/g, "-").trim();'
  'const safeBaseName = sourceName.replace(/[<>:"\/\\|?*\p{Cc}]+/gu, "-").trim();'
  'const safeBaseName = sourceName.replace(/[<>:"/\\|?*\u0000-\u001F]+/g, "-").trim();'
)

TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT

idx=365 # 0-based => line 366 (1-based)

for i in "${!variants[@]}"; do
  tmp="$TMPDIR/VideoEditor.$i.tsx"
  cp "$ORIG" "$tmp"

  # Replace exactly the assignment line, preserving leading whitespace.
  python3 - "$tmp" "$idx" "${variants[$i]}" <<'PY'
import sys
from pathlib import Path
tmp = Path(sys.argv[1])
idx = int(sys.argv[2])
new_stmt = sys.argv[3]

lines = tmp.read_text(encoding="utf-8").splitlines(True)  # keep endlines
old_line = lines[idx]
leading = old_line[:len(old_line) - len(old_line.lstrip(' \t'))]
lines[idx] = leading + new_stmt + ("\n" if old_line.endswith("\n") else "")
tmp.write_text(''.join(lines), encoding="utf-8")
PY

  echo "===== Variant $i ====="
  "${BIOME[@]}" check "$tmp" || true
done

Repository: webadderallorg/Recordly

Length of output: 6967


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ORIG="src/components/video-editor/VideoEditor.tsx"
if command -v biome >/dev/null 2>&1; then
  BIOME=(biome)
elif command -v npx >/dev/null 2>&1; then
  BIOME=(npx --yes `@biomejs/biome`)
else
  echo "biome not available"
  exit 1
fi

TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
tmp="$TMPDIR/VideoEditor.fixed.tsx"
cp "$ORIG" "$tmp"

new_stmt='const safeBaseName = sourceName.replace(/[<>:"/\\|?*\p{Cc}]+/gu, "-").trim();'

python3 - "$tmp" '365' "$new_stmt" <<'PY'
import sys
from pathlib import Path
p = Path(sys.argv[1])
idx = int(sys.argv[2])
new_stmt = sys.argv[3]

lines = p.read_text(encoding="utf-8").splitlines(True)
old_line = lines[idx]
leading = old_line[:len(old_line) - len(old_line.lstrip(' \t'))]
lines[idx] = leading + new_stmt + ("\n" if old_line.endswith("\n") else "")
p.write_text(''.join(lines), encoding="utf-8")
PY

"${BIOME[@]}" check "$tmp" || true

Repository: webadderallorg/Recordly

Length of output: 110


Fix the Biome-lint-blocking regex used to sanitize safeBaseName (Line 366)

Biome reports lint/suspicious/noControlCharactersInRegex and lint/complexity/noUselessEscapeInRegex on the current pattern. Update it to use Unicode control-category escaping:

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

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

(lint/suspicious/noControlCharactersInRegex)


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

(lint/suspicious/noControlCharactersInRegex)


[error] 366-366: 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 366, The regex used to
compute safeBaseName (sourceName.replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-"))
triggers Biome lint rules for control-character and useless-escape usage;
replace it with a Unicode-aware character-class that uses the control category
and removes unnecessary escapes, e.g. use
sourceName.replace(/[\p{Cc}<>:"\\|?*]+/gu, "-") in the VideoEditor.tsx code
where safeBaseName is computed so it matches all control characters via \p{Cc},
adds the 'u' flag, and no longer contains useless escapes.

Addresses CodeRabbit pre-merge docstring coverage warning.
@mvanhorn
Copy link
Copy Markdown
Author

Addressed the docstring coverage warning in 69fb2a8 - added JSDoc to the exported converters (cuesToSrt, cuesToVtt, subtitleCuesToFile), the IPC entry point, and the helpers (getCueTimeMs, normalizeSubtitleCues, formatTimestamp, getSubtitleFilter). One- or two-line summaries; skipped the trivial type guard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Export subtitles as a separate file

1 participant