mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
Add subtitle export IPC and keep click effects visible when cursor is hidden
This commit is contained in:
Vendored
+11
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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() ?? "";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user