diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c0cf0cdb..bd7ae46b 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -672,6 +672,17 @@ interface Window { message?: string; error?: string; }>; + exportSubtitleFile: (options: { + format: "srt" | "vtt"; + cues: AutoCaptionCue[]; + fileName?: string; + }) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + canceled?: boolean; + }>; setCurrentVideoPath: ( path: string, options?: { diff --git a/electron/ipc/captions/exportSubtitleFile.test.ts b/electron/ipc/captions/exportSubtitleFile.test.ts new file mode 100644 index 00000000..9ab8d05e --- /dev/null +++ b/electron/ipc/captions/exportSubtitleFile.test.ts @@ -0,0 +1,136 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: () => process.env.TEMP ?? process.cwd(), + }, + BrowserWindow: { + fromWebContents: () => null, + }, + dialog: { + showSaveDialog: vi.fn(), + }, +})); + +vi.mock("../utils", () => ({ + approveUserPath: vi.fn(), +})); + +import { dialog } from "electron"; +import { + cuesToSrt, + cuesToVtt, + exportSubtitleFile, + subtitleCuesToFile, +} from "./exportSubtitleFile"; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-subtitle-export-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.allSettled( + tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })), + ); +}); + +describe("subtitle serializers", () => { + it("serializes SRT cues with numbered blocks and comma millisecond timestamps", () => { + expect( + cuesToSrt([ + { start: 0, end: 1500, text: "Hello" }, + { start: 1500, end: 3200, text: "World" }, + ]), + ).toBe( + [ + "1", + "00:00:00,000 --> 00:00:01,500", + "Hello", + "", + "2", + "00:00:01,500 --> 00:00:03,200", + "World", + "", + ].join("\n"), + ); + }); + + it("serializes VTT cues with a WEBVTT header and dot millisecond timestamps", () => { + expect( + cuesToVtt([ + { startMs: 0, endMs: 1500, text: "Hello" }, + { startMs: 1500, endMs: 3200, text: "World" }, + ]), + ).toBe( + [ + "WEBVTT", + "", + "1", + "00:00:00.000 --> 00:00:01.500", + "Hello", + "", + "2", + "00:00:01.500 --> 00:00:03.200", + "World", + "", + ].join("\n"), + ); + }); + + it("skips malformed cues without aborting serialization", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + expect( + subtitleCuesToFile("srt", [ + { startMs: 1000, endMs: 500, text: "bad" }, + { startMs: 1000, endMs: 1500, text: "good" }, + ]), + ).toBe("1\n00:00:01,000 --> 00:00:01,500\ngood\n"); + expect(warnSpy).toHaveBeenCalledWith( + "[subtitle-export] Skipping malformed caption cue:", + expect.objectContaining({ index: 0 }), + ); + }); + + it("returns an empty SRT body and a header-only VTT body for empty cues", () => { + expect(cuesToSrt([])).toBe(""); + expect(cuesToVtt([])).toBe("WEBVTT\n\n"); + }); + + it("preserves multiline cue text literally", () => { + expect(cuesToSrt([{ startMs: 0, endMs: 1000, text: "Hello\nWorld" }])).toBe( + "1\n00:00:00,000 --> 00:00:01,000\nHello\nWorld\n", + ); + }); +}); + +describe("exportSubtitleFile", () => { + it("returns a user-readable error when the selected path cannot be written", async () => { + const dir = await makeTempDir(); + vi.mocked(dialog.showSaveDialog).mockResolvedValue({ + canceled: false, + filePath: path.join(dir, "missing", "captions.srt"), + }); + + const result = await exportSubtitleFile( + { sender: {} } as Parameters[0], + { + format: "srt", + cues: [{ id: "caption-1", startMs: 0, endMs: 1000, text: "Hello" }], + fileName: "captions.srt", + }, + ); + + expect(result.success).toBe(false); + expect(result.message).toContain("Failed to export subtitle file"); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/electron/ipc/captions/exportSubtitleFile.ts b/electron/ipc/captions/exportSubtitleFile.ts new file mode 100644 index 00000000..09c1cfa1 --- /dev/null +++ b/electron/ipc/captions/exportSubtitleFile.ts @@ -0,0 +1,217 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { IpcMainInvokeEvent, SaveDialogOptions } from "electron"; +import { app, BrowserWindow, dialog } from "electron"; +import type { CaptionCuePayload } from "../types"; +import { approveUserPath } from "../utils"; + +export type SubtitleExportFormat = "srt" | "vtt"; + +type SubtitleCueInput = Partial & { + start?: number; + end?: number; +}; + +type NormalizedSubtitleCue = { + startMs: number; + endMs: number; + text: string; +}; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +/** + * Reads the preferred cue timestamp, falling back to legacy start/end fields. + * Returns rounded milliseconds, or null when neither value is usable. + */ +function getCueTimeMs(cue: SubtitleCueInput, primaryKey: "startMs" | "endMs") { + const fallbackKey = primaryKey === "startMs" ? "start" : "end"; + const primaryValue = cue[primaryKey]; + if (isFiniteNumber(primaryValue)) { + return Math.round(primaryValue); + } + + const fallbackValue = cue[fallbackKey]; + return isFiniteNumber(fallbackValue) ? Math.round(fallbackValue) : null; +} + +/** + * Converts raw caption cues into export-ready cues with valid timing and text. + * Malformed cues are skipped so one bad cue does not fail the whole export. + */ +function normalizeSubtitleCues(cues: SubtitleCueInput[]) { + const normalizedCues: NormalizedSubtitleCue[] = []; + + cues.forEach((cue, index) => { + const startMs = getCueTimeMs(cue, "startMs"); + const endMs = getCueTimeMs(cue, "endMs"); + const text = typeof cue.text === "string" ? cue.text.replace(/\r\n?/g, "\n") : ""; + + if (startMs == null || endMs == null || endMs <= startMs || text.trim().length === 0) { + console.warn("[subtitle-export] Skipping malformed caption cue:", { + index, + startMs, + endMs, + hasText: text.trim().length > 0, + }); + return; + } + + normalizedCues.push({ startMs, endMs, text }); + }); + + return normalizedCues; +} + +/** + * Formats milliseconds as a subtitle timestamp using the format delimiter. + * SRT uses commas for milliseconds, while WebVTT uses periods. + */ +function formatTimestamp(ms: number, separator: "," | ".") { + const roundedMs = Math.max(0, Math.round(ms)); + const hours = Math.floor(roundedMs / 3_600_000); + const minutes = Math.floor((roundedMs % 3_600_000) / 60_000); + const seconds = Math.floor((roundedMs % 60_000) / 1_000); + const milliseconds = roundedMs % 1_000; + + return [ + String(hours).padStart(2, "0"), + String(minutes).padStart(2, "0"), + `${String(seconds).padStart(2, "0")}${separator}${String(milliseconds).padStart(3, "0")}`, + ].join(":"); +} + +/** + * Converts caption cues into SubRip content. + * Returns an empty string when no valid cues are available. + */ +export function cuesToSrt(cues: SubtitleCueInput[]) { + const blocks = normalizeSubtitleCues(cues).map((cue, index) => + [ + String(index + 1), + `${formatTimestamp(cue.startMs, ",")} --> ${formatTimestamp(cue.endMs, ",")}`, + cue.text, + ].join("\n"), + ); + + return blocks.length > 0 ? `${blocks.join("\n\n")}\n` : ""; +} + +/** + * Converts caption cues into WebVTT content. + * Always includes the required WEBVTT header. + */ +export function cuesToVtt(cues: SubtitleCueInput[]) { + const blocks = normalizeSubtitleCues(cues).map((cue, index) => + [ + String(index + 1), + `${formatTimestamp(cue.startMs, ".")} --> ${formatTimestamp(cue.endMs, ".")}`, + cue.text, + ].join("\n"), + ); + + return `WEBVTT\n\n${blocks.length > 0 ? `${blocks.join("\n\n")}\n` : ""}`; +} + +/** + * Converts caption cues to the requested subtitle file format. + * Throws when the format is not supported. + */ +export function subtitleCuesToFile(format: SubtitleExportFormat, cues: SubtitleCueInput[]) { + if (format === "srt") { + return cuesToSrt(cues); + } + + if (format === "vtt") { + return cuesToVtt(cues); + } + + throw new Error("Unsupported subtitle export format."); +} + +/** + * Builds the save dialog filter for the selected subtitle format. + */ +function getSubtitleFilter(format: SubtitleExportFormat) { + return format === "srt" + ? { name: "SubRip Subtitle", extensions: ["srt"] } + : { name: "WebVTT Subtitle", extensions: ["vtt"] }; +} + +/** + * Normalizes the requested download name and ensures it has the format extension. + */ +function getSafeFileName(fileName: unknown, format: SubtitleExportFormat) { + if (typeof fileName !== "string" || fileName.trim().length === 0) { + return `captions.${format}`; + } + + const normalizedFileName = fileName.trim(); + return normalizedFileName.toLowerCase().endsWith(`.${format}`) + ? normalizedFileName + : `${normalizedFileName}.${format}`; +} + +/** + * Handles the IPC request for exporting captions to a subtitle file. + * Opens a native save dialog, writes the selected format, and returns export status. + */ +export async function exportSubtitleFile( + event: IpcMainInvokeEvent, + options: { + cues?: SubtitleCueInput[]; + format?: SubtitleExportFormat; + fileName?: string; + }, +) { + try { + const format = options?.format; + if (format !== "srt" && format !== "vtt") { + throw new Error("Choose a subtitle format to export."); + } + + if (!Array.isArray(options.cues)) { + throw new Error("Subtitle export requires caption cues."); + } + + const fileName = getSafeFileName(options.fileName, format); + const saveDialogOptions: SaveDialogOptions = { + title: `Save ${format.toUpperCase()} Subtitle File`, + defaultPath: path.join(app.getPath("downloads"), fileName), + filters: [getSubtitleFilter(format)], + properties: ["createDirectory", "showOverwriteConfirmation"], + }; + const parentWindow = BrowserWindow.fromWebContents(event.sender); + const result = parentWindow + ? await dialog.showSaveDialog(parentWindow, saveDialogOptions) + : await dialog.showSaveDialog(saveDialogOptions); + + if (result.canceled || !result.filePath) { + return { + success: false, + canceled: true, + message: "Subtitle export canceled", + }; + } + + await fs.writeFile(result.filePath, subtitleCuesToFile(format, options.cues), "utf-8"); + approveUserPath(result.filePath); + + return { + success: true, + path: result.filePath, + message: "Subtitle file exported successfully", + }; + } catch (error) { + console.error("Failed to export subtitle file:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + canceled: false, + message: `Failed to export subtitle file: ${errorMessage}`, + error: errorMessage, + }; + } +} diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index 9d9b7f06..0482f551 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -7,6 +7,7 @@ import { sendWhisperModelDownloadProgress, } from "../captions/whisper"; import { generateAutoCaptionsFromVideo } from "../captions/generate"; +import { exportSubtitleFile } from "../captions/exportSubtitleFile"; import { approveUserPath, getRecordingsDir } from "../utils"; export function registerCaptionHandlers() { @@ -204,4 +205,6 @@ export function registerCaptionHandlers() { } }) + ipcMain.handle('export-subtitle-file', exportSubtitleFile) + } diff --git a/electron/preload.ts b/electron/preload.ts index 384682a1..af88cde1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -688,6 +688,24 @@ contextBridge.exposeInMainWorld("electronAPI", { }) => { return ipcRenderer.invoke("generate-auto-captions", options); }, + exportSubtitleFile: (options: { + format: "srt" | "vtt"; + cues: Array<{ + id: string; + startMs: number; + endMs: number; + text: string; + words?: Array<{ + text: string; + startMs: number; + endMs: number; + leadingSpace?: boolean; + }>; + }>; + fileName?: string; + }) => { + return ipcRenderer.invoke("export-subtitle-file", options); + }, setCurrentVideoPath: ( path: string, options?: { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 12908a6d..fefb9de4 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -77,6 +77,7 @@ import { canUseInMemoryExportSaveFallback, describeBlockedInMemoryExportSave, } from "@/lib/exporter/exportSavePolicy"; +import type { SubtitleExportFormat } from "@/lib/exporter/types"; import { matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { @@ -351,6 +352,20 @@ function getErrorMessage(error: unknown): string { return "Something went wrong"; } +function getSubtitleExportFileName( + format: SubtitleExportFormat, + projectPath: string | null, + sourcePath: string | null, +) { + const sourceName = + (projectPath ?? sourcePath) + ?.split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") ?? "captions"; + const safeBaseName = sourceName.replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-").trim(); + return `${safeBaseName || "captions"}.${format}`; +} + export default function VideoEditor() { const { t } = useI18n(); const smokeExportConfig = useMemo( @@ -535,6 +550,7 @@ export default function VideoEditor() { >(initialEditorPreferences.whisperModelPath ? "downloaded" : "idle"); const [whisperModelDownloadProgress, setWhisperModelDownloadProgress] = useState(0); const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false); + const [isExportingSubtitleFile, setIsExportingSubtitleFile] = useState(false); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -2745,6 +2761,53 @@ export default function VideoEditor() { setAutoCaptionSettings((prev) => ({ ...prev, enabled: false })); }, []); + const handleExportSubtitleFile = useCallback( + async (format: SubtitleExportFormat) => { + if (isExportingSubtitleFile) { + return; + } + + if (autoCaptions.length === 0) { + toast.error("Generate captions before exporting a subtitle file"); + return; + } + + setIsExportingSubtitleFile(true); + try { + const result = await window.electronAPI.exportSubtitleFile({ + format, + cues: autoCaptions, + fileName: getSubtitleExportFileName( + format, + currentProjectPath, + currentSourcePath, + ), + }); + + if (result.canceled) { + toast.info("Subtitle export canceled"); + return; + } + + if (!result.success || !result.path) { + toast.error( + result.message || + getErrorMessage(result.error) || + "Failed to export subtitle file", + ); + return; + } + + toast.success(`Subtitle file exported to ${result.path}`); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setIsExportingSubtitleFile(false); + } + }, + [autoCaptions, currentProjectPath, currentSourcePath, isExportingSubtitleFile], + ); + const saveProject = useCallback( async (forceSaveAs: boolean, options?: SaveProjectOptions) => { clearPendingProjectAutosave(); @@ -5685,6 +5748,45 @@ export default function VideoEditor() { )} + + + + + + void handleExportSubtitleFile("srt")} + className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + Export SRT + + void handleExportSubtitleFile("vtt")} + className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + Export VTT + + + diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index f9207d60..2055c607 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -1122,6 +1122,7 @@ export class PixiCursorOverlay { private config: CursorRenderConfig; private lastRenderedPoint: { px: number; py: number } | null = null; private lastRenderedTimeMs: number | null = null; + private cursorVisible = false; private swayRotation = 0; private swaySpring = createSpringState(0); @@ -1292,7 +1293,7 @@ export class PixiCursorOverlay { cy: number; trail: Array<{ cx: number; cy: number }>; } | null { - if (!this.container.visible) { + if (!this.container.visible || !this.cursorVisible) { return null; } @@ -1310,51 +1311,36 @@ export class PixiCursorOverlay { visible: boolean, freeze = false, ): void { - if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) { + 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; } const projectedTarget = projectCursorPositionToViewport(target, viewport.sourceCrop); - if (!projectedTarget.visible) { - this.container.visible = false; - this.lastRenderedPoint = null; - this.lastRenderedTimeMs = null; - this.swayRotation = 0; - resetSpringState(this.swaySpring, 0); - this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; - return; - } - const sameFrameTime = - this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; - const hasTimeDiscontinuity = - this.lastRenderedTimeMs !== null && - Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS; - const shouldFreezeCursorMotion = freeze || hasTimeDiscontinuity; - - if (shouldFreezeCursorMotion) { - if (!sameFrameTime || !this.lastRenderedPoint) { - this.state.snapTo(projectedTarget.cx, projectedTarget.cy, timeMs); - } - } else { - this.state.update(projectedTarget.cx, projectedTarget.cy, timeMs); - } - this.container.visible = true; - - const px = viewport.x + this.state.x * viewport.width; - const py = viewport.y + this.state.y * viewport.height; const h = this.config.dotRadius * getCursorViewportScale(viewport); const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState( @@ -1369,11 +1355,73 @@ export class PixiCursorOverlay { const clickEffectPx = projectedClickSample && projectedClickSample.visible ? viewport.x + projectedClickSample.cx * viewport.width - : px; + : viewport.x + projectedTarget.cx * viewport.width; const clickEffectPy = projectedClickSample && projectedClickSample.visible ? viewport.y + projectedClickSample.cy * viewport.height - : py; + : viewport.y + projectedTarget.cy * viewport.height; + const shouldShowCursorSprite = visible && projectedTarget.visible; + const shouldDrawClickEffect = + this.config.clickEffect !== "none" && + clickProgress > 0 && + Boolean(projectedClickSample?.visible); + + if (!shouldShowCursorSprite && !shouldDrawClickEffect) { + this.container.visible = false; + this.cursorVisible = false; + this.clickRingGraphics.clear(); + 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; + } + this.lastRenderedPoint = null; + this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); + this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; + return; + } + + this.container.visible = true; + this.cursorVisible = shouldShowCursorSprite; + + let px = viewport.x + projectedTarget.cx * viewport.width; + let py = viewport.y + projectedTarget.cy * viewport.height; + let shouldFreezeCursorMotion = true; + + if (shouldShowCursorSprite) { + const sameFrameTime = + this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; + const hasTimeDiscontinuity = + this.lastRenderedTimeMs !== null && + Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS; + shouldFreezeCursorMotion = freeze || hasTimeDiscontinuity; + + if (shouldFreezeCursorMotion) { + if (!sameFrameTime || !this.lastRenderedPoint) { + this.state.snapTo(projectedTarget.cx, projectedTarget.cy, timeMs); + } + } else { + this.state.update(projectedTarget.cx, projectedTarget.cy, timeMs); + } + + px = viewport.x + this.state.x * viewport.width; + py = viewport.y + this.state.y * viewport.height; + } else { + 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; + } + } + const bounceScale = Math.max( 0.72, 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * this.config.clickBounce), @@ -1397,7 +1445,7 @@ export class PixiCursorOverlay { cursorType in this.cursorSprites ? cursorType : "arrow" ) as CursorAssetKey; - if (isStatefulCursorStyle(this.config.style)) { + if (shouldShowCursorSprite && isStatefulCursorStyle(this.config.style)) { this.customCursorShadowSprite.visible = false; this.customCursorSprite.visible = false; @@ -1432,7 +1480,7 @@ export class PixiCursorOverlay { sprite.position.set(px, py); sprite.rotation = swayRotation; } - } else { + } else if (shouldShowCursorSprite) { for (const currentShadowSprite of Object.values(this.cursorShadowSprites)) { currentShadowSprite.visible = false; } @@ -1469,7 +1517,7 @@ export class PixiCursorOverlay { } this.applyCursorMotionBlur(px, py, timeMs, shouldFreezeCursorMotion); - this.lastRenderedPoint = { px, py }; + this.lastRenderedPoint = shouldShowCursorSprite ? { px, py } : null; this.lastRenderedTimeMs = timeMs; } @@ -1534,6 +1582,7 @@ export class PixiCursorOverlay { reset(): void { this.state.reset(); this.clickRingGraphics.clear(); + this.cursorVisible = false; for (const shadowSprite of Object.values(this.cursorShadowSprites)) { shadowSprite.visible = false; shadowSprite.scale.set(1); diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index 9509f4e3..d3279110 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -257,6 +257,28 @@ describe("ModernVideoExporter native static-layout eligibility", () => { ).toBe("unsupported-cursor-click-effect"); }); + it("skips native static-layout when click effects are enabled and cursor is hidden", () => { + const exporter = createExporter({ + showCursor: false, + cursorClickEffect: "echo", + cursorTelemetry: [ + { timeMs: 0, cx: 0.25, cy: 0.35 }, + { timeMs: 1_000, cx: 0.5, cy: 0.55, interactionType: "click" }, + ], + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "copy-source", + audioSourcePath: "recording.mp4", + }, + videoInfo, + 60, + ), + ).toBe("unsupported-cursor-click-effect"); + }); + it("reports frame overlays as the remaining native overlay blocker", () => { const exporter = createExporter({ frame: "macbook" }); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index a68278db..5b65882b 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -1519,7 +1519,6 @@ export class ModernVideoExporter { const speedRegions = this.config.speedRegions ?? []; const hasCursorClickEffect = - this.config.showCursor === true && (this.config.cursorTelemetry?.length ?? 0) > 0 && (this.config.cursorClickEffect ?? "none") !== "none"; const configuredWallpaper = this.config.wallpaper?.trim() ?? ""; diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 72682f01..ef29005e 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -179,6 +179,8 @@ export type ExportMp4FrameRate = 24 | 30 | 60; // GIF Export Types export type ExportFormat = "mp4" | "gif"; +export type SubtitleExportFormat = "srt" | "vtt"; + export type GifFrameRate = 15 | 20 | 25 | 30; export type GifSizePreset = "medium" | "large" | "original";