Add subtitle export IPC and keep click effects visible when cursor is hidden

This commit is contained in:
webadderall
2026-05-30 18:20:20 +10:00
10 changed files with 594 additions and 35 deletions
+11
View File
@@ -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?: {
@@ -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<typeof exportSubtitleFile>[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();
});
});
+217
View File
@@ -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<CaptionCuePayload> & {
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,
};
}
}
+3
View File
@@ -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)
}
+18
View File
@@ -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?: {
+102
View File
@@ -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<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(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() {
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
disabled={autoCaptions.length === 0 || isExportingSubtitleFile}
className="inline-flex h-8 min-w-[108px] items-center justify-center gap-2 rounded-[5px] border-foreground/10 bg-foreground/5 px-3 text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-45"
title={
autoCaptions.length > 0
? "Export subtitles"
: "Generate captions before exporting subtitles"
}
>
<PhCaptions className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
Subtitles
</span>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={10}
className="border-foreground/10 bg-editor-surface-alt"
>
<DropdownMenuItem
onClick={() => void handleExportSubtitleFile("srt")}
className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
>
Export SRT
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleExportSubtitleFile("vtt")}
className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
>
Export VTT
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@@ -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);
@@ -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" });
-1
View File
@@ -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() ?? "";
+2
View File
@@ -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";