From 375620bfcf98631075f8b25cdb9cd03965966ebd Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:28:31 +1000 Subject: [PATCH 1/7] fix macos text cursor state --- .../video-editor/videoPlayback/uploadedCursorAssets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts index 38faffba..d017d2d7 100644 --- a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts +++ b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts @@ -1,6 +1,6 @@ import macosClosedHandUrl from "../../../assets/cursors/macos/closedhand-1__50-50.svg"; import macosCrosshairUrl from "../../../assets/cursors/macos/crosshair-1__50-50.svg"; -import macosTextUrl from "../../../assets/cursors/macos/ibeam-1__50-50.svg"; +import macosTextUrl from "../../../assets/cursors/macos/ibeamstroke-1__50-50.svg"; import macosNotAllowedUrl from "../../../assets/cursors/macos/notallowed-1__23-0.svg"; import macosOpenHandUrl from "../../../assets/cursors/macos/openhand-1__50-50.svg"; import macosArrowUrl from "../../../assets/cursors/macos/pointer-1__34-24.svg"; From e99356a04bd277ee4fb8e85276365fef318ada1a Mon Sep 17 00:00:00 2001 From: webadderall Date: Sat, 6 Jun 2026 20:01:19 +1000 Subject: [PATCH 2/7] fix(linux): preserve portal screen source mapping --- electron/ipc/register/sourceMapping.test.ts | 50 +++++++++++++++++++++ electron/ipc/register/sourceMapping.ts | 35 +++++++++++++++ electron/ipc/register/sources.ts | 8 +++- 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 electron/ipc/register/sourceMapping.test.ts create mode 100644 electron/ipc/register/sourceMapping.ts diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts new file mode 100644 index 00000000..d0b68e75 --- /dev/null +++ b/electron/ipc/register/sourceMapping.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { + getScreenSourceIdForDisplay, + LINUX_PORTAL_SCREEN_SOURCE_ID, +} from "./sourceMapping"; + +describe("getScreenSourceIdForDisplay", () => { + it("keeps the live Electron screen source when one is available", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + matchedSourceId: "screen:42:0", + platform: "linux", + }), + ).toBe("screen:42:0"); + }); + + it("routes unmatched Linux Wayland screens through the portal sentinel", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }, + matchedSourceId: null, + platform: "linux", + }), + ).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID); + }); + + it("keeps unmatched Linux X11 screens on the explicit fallback id", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + matchedSourceId: null, + platform: "linux", + }), + ).toBe("screen:fallback:42"); + }); + + it("keeps non-Linux unmatched screens on the explicit fallback id", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + matchedSourceId: undefined, + platform: "win32", + }), + ).toBe("screen:fallback:42"); + }); +}); \ No newline at end of file diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts new file mode 100644 index 00000000..a61b4cf7 --- /dev/null +++ b/electron/ipc/register/sourceMapping.ts @@ -0,0 +1,35 @@ +export const LINUX_PORTAL_SCREEN_SOURCE_ID = "screen:linux-portal"; + +export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) { + const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase(); + if (sessionType === "wayland") { + return true; + } + if (sessionType === "x11") { + return false; + } + + return Boolean(env.WAYLAND_DISPLAY); +} + +export function getScreenSourceIdForDisplay({ + displayId, + env = process.env, + matchedSourceId, + platform, +}: { + displayId: string; + env?: NodeJS.ProcessEnv; + matchedSourceId?: string | null; + platform: NodeJS.Platform | string; +}) { + if (matchedSourceId) { + return matchedSourceId; + } + + if (platform === "linux" && isLikelyLinuxWaylandSession(env)) { + return LINUX_PORTAL_SCREEN_SOURCE_ID; + } + + return `screen:fallback:${displayId}`; +} \ No newline at end of file diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 4ba6b35a..33c9ee74 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -6,6 +6,7 @@ import { selectedSource, setSelectedSource } from "../state"; import type { SelectedSource } from "../types"; import { getScreen, parseWindowId } from "../utils"; import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg"; +import { getScreenSourceIdForDisplay } from "./sourceMapping"; import { getNativeMacWindowSources, resolveMacWindowBounds, @@ -125,7 +126,12 @@ export function registerSourceHandlers({ : `Screen ${index + 1}`; return { - id: matchedSource?.id ?? `screen:fallback:${displayId}`, + id: getScreenSourceIdForDisplay({ + displayId, + env: process.env, + matchedSourceId: matchedSource?.id, + platform: process.platform, + }), name: displayName, originalName: matchedSource?.name ?? displayName, display_id: displayId, From bf99108719aa4508c2138433b197f604a6dafa04 Mon Sep 17 00:00:00 2001 From: webadderall Date: Sat, 6 Jun 2026 20:57:45 +1000 Subject: [PATCH 3/7] fix(windows): use WGC for window capture --- src/hooks/useScreenRecorder.test.ts | 8 ++++++-- src/hooks/useScreenRecorder.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 85811467..d74e884c 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -164,8 +164,12 @@ describe("shouldUseNativeWindowsCaptureForSource", () => { expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true); }); - it("routes window sources through browser capture", () => { - expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(false); + it("keeps native Windows capture on window sources", () => { + expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(true); + }); + + it("keeps browser capture for non-desktop sources", () => { + expect(shouldUseNativeWindowsCaptureForSource({ id: "browser-tab:abc" })).toBe(false); }); }); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index a382ea3d..c5cd7005 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -213,7 +213,10 @@ export function resolveBrowserCaptureCursorPolicy({ export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { - return source?.id?.startsWith("screen:") === true; + return ( + source?.id?.startsWith("screen:") === true || + source?.id?.startsWith("window:") === true + ); } export function createProcessedMicrophoneConstraints( From 578518007c94f5816ba360e3e681357e7e0681b9 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:54:48 +1000 Subject: [PATCH 4/7] chore(release): bump version to 1.3.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a96759f5..a255b232 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recordly", - "version": "1.3.3", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recordly", - "version": "1.3.3", + "version": "1.3.4", "hasInstallScript": true, "dependencies": { "@phosphor-icons/react": "^2.1.10", diff --git a/package.json b/package.json index 7d6c1285..6357c666 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/webadderallorg/Recordly/issues" }, "private": true, - "version": "1.3.3", + "version": "1.3.4", "type": "module", "scripts": { "dev": "vite --config vite.config.ts", From 795cda20eebc1a71bf40b6b0663eb4a0340053bf Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:13:00 +1000 Subject: [PATCH 5/7] Add export caption sidecar option --- electron/electron-env.d.ts | 24 +++ electron/ipc/register/export.ts | 137 +++++++++++++++++- electron/preload.ts | 43 +++++- .../video-editor/ExportSettingsMenu.tsx | 30 ++++ src/components/video-editor/VideoEditor.tsx | 78 +++++++++- .../video-editor/exportStartSettings.test.ts | 3 + .../video-editor/exportStartSettings.ts | 3 + src/lib/exporter/types.ts | 1 + 8 files changed, 309 insertions(+), 10 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a3f663f2..6dc7d396 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -543,6 +543,14 @@ interface Window { tempPath: string; fileName: string; outputPath?: string | null; + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }; }) => Promise<{ success: boolean; path?: string; @@ -614,10 +622,26 @@ interface Window { saveExportedVideo: ( videoData: ArrayBuffer, fileName: string, + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }, ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; writeExportedVideoToPath: ( videoData: ArrayBuffer, outputPath: string, + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }, ) => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 78afeb2f..1223efee 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -246,6 +246,121 @@ function isTempPathSafe(tempPath: string): boolean { return candidate.startsWith(withSep); } +type CaptionSidecarCue = { + startMs: number; + endMs: number; + text: string; +}; + +type CaptionSidecarPayload = { + format: "srt" | "vtt" | "both"; + cues: CaptionSidecarCue[]; +}; + +function toSrtTimestamp(totalMs: number): string { + const ms = Math.max(0, Math.round(totalMs)); + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + const millis = ms % 1000; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`; +} + +function toVttTimestamp(totalMs: number): string { + const ms = Math.max(0, Math.round(totalMs)); + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + const millis = ms % 1000; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; +} + +function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] { + if (!Array.isArray(cues)) { + return []; + } + + return cues + .filter((cue): cue is CaptionSidecarCue => { + return ( + typeof cue === "object" && + cue !== null && + typeof cue.startMs === "number" && + typeof cue.endMs === "number" && + typeof cue.text === "string" && + Number.isFinite(cue.startMs) && + Number.isFinite(cue.endMs) && + cue.endMs > cue.startMs && + cue.text.trim().length > 0 + ); + }) + .map((cue) => ({ + startMs: cue.startMs, + endMs: cue.endMs, + text: cue.text.replace(/\r\n/g, "\n").trim(), + })); +} + +function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null { + if (typeof payload !== "object" || payload === null) { + return null; + } + + const candidate = payload as { + format?: unknown; + cues?: unknown; + }; + + const format = + candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both" + ? candidate.format + : null; + if (!format) { + return null; + } + + const cues = normalizeCaptionSidecarCues(candidate.cues); + if (cues.length === 0) { + return null; + } + + return { format, cues }; +} + +function serializeSrt(cues: CaptionSidecarCue[]): string { + return cues + .map((cue, index) => { + return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`; + }) + .join("\n\n"); +} + +function serializeVtt(cues: CaptionSidecarCue[]): string { + const body = cues + .map((cue) => { + return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`; + }) + .join("\n\n"); + return `WEBVTT\n\n${body}`; +} + +async function writeCaptionSidecars(videoPath: string, payload: CaptionSidecarPayload | null) { + if (!payload) { + return; + } + + const parsed = path.parse(videoPath); + const basePath = path.join(parsed.dir, parsed.name); + + if (payload.format === "srt" || payload.format === "both") { + await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8"); + } + + if (payload.format === "vtt" || payload.format === "both") { + await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8"); + } +} + export function registerExportHandlers() { ipcMain.handle( "native-video-export-start", @@ -829,8 +944,14 @@ export function registerExportHandlers() { ipcMain.handle( "save-exported-video", - async (event, videoData: ArrayBuffer, fileName: string) => { + async ( + event, + videoData: ArrayBuffer, + fileName: string, + captionSidecar?: CaptionSidecarPayload, + ) => { try { + const sidecarPayload = parseCaptionSidecarPayload(captionSidecar); const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); if (sizeError) { return { @@ -866,6 +987,7 @@ export function registerExportHandlers() { } await fs.writeFile(result.filePath, Buffer.from(videoData)); + await writeCaptionSidecars(result.filePath, sidecarPayload); approveUserPath(result.filePath); return { @@ -886,8 +1008,14 @@ export function registerExportHandlers() { ipcMain.handle( "write-exported-video-to-path", - async (_event, videoData: ArrayBuffer, outputPath: string) => { + async ( + _event, + videoData: ArrayBuffer, + outputPath: string, + captionSidecar?: CaptionSidecarPayload, + ) => { try { + const sidecarPayload = parseCaptionSidecarPayload(captionSidecar); const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); if (sizeError) { return { @@ -901,6 +1029,7 @@ export function registerExportHandlers() { const resolvedPath = path.resolve(outputPath); await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, Buffer.from(videoData)); + await writeCaptionSidecars(resolvedPath, sidecarPayload); approveUserPath(resolvedPath); return { @@ -929,6 +1058,7 @@ export function registerExportHandlers() { tempPath: string; fileName: string; outputPath?: string | null; + captionSidecar?: CaptionSidecarPayload; }, ) => { const tempPath = payload?.tempPath; @@ -954,9 +1084,11 @@ export function registerExportHandlers() { } try { + const sidecarPayload = parseCaptionSidecarPayload(payload.captionSidecar); if (payload.outputPath) { const resolvedPath = path.resolve(payload.outputPath); await moveExportedTempFile(tempPath, resolvedPath); + await writeCaptionSidecars(resolvedPath, sidecarPayload); releaseOwnedExportPath(tempPath); approveUserPath(resolvedPath); return { @@ -994,6 +1126,7 @@ export function registerExportHandlers() { } await moveExportedTempFile(tempPath, result.filePath); + await writeCaptionSidecars(result.filePath, sidecarPayload); releaseOwnedExportPath(tempPath); approveUserPath(result.filePath); diff --git a/electron/preload.ts b/electron/preload.ts index 04695d6b..6f941e80 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -456,6 +456,14 @@ contextBridge.exposeInMainWorld("electronAPI", { tempPath: string; fileName: string; outputPath?: string | null; + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }; }) => { return ipcRenderer.invoke("finalize-exported-video", payload); }, @@ -630,11 +638,38 @@ contextBridge.exposeInMainWorld("electronAPI", { openAccessibilityPreferences: () => { return ipcRenderer.invoke("open-accessibility-preferences"); }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke("save-exported-video", videoData, fileName); + saveExportedVideo: ( + videoData: ArrayBuffer, + fileName: string, + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }, + ) => { + return ipcRenderer.invoke("save-exported-video", videoData, fileName, captionSidecar); }, - writeExportedVideoToPath: (videoData: ArrayBuffer, outputPath: string) => { - return ipcRenderer.invoke("write-exported-video-to-path", videoData, outputPath); + writeExportedVideoToPath: ( + videoData: ArrayBuffer, + outputPath: string, + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }, + ) => { + return ipcRenderer.invoke( + "write-exported-video-to-path", + videoData, + outputPath, + captionSidecar, + ); }, openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); diff --git a/src/components/video-editor/ExportSettingsMenu.tsx b/src/components/video-editor/ExportSettingsMenu.tsx index 4d690a0a..5e36cdd7 100644 --- a/src/components/video-editor/ExportSettingsMenu.tsx +++ b/src/components/video-editor/ExportSettingsMenu.tsx @@ -29,6 +29,9 @@ interface ExportSettingsMenuProps { experimentalNvidiaCudaExport?: boolean; onExperimentalNvidiaCudaExportChange?: (enabled: boolean) => void; nvidiaCudaExportAvailable?: boolean; + showCaptionSidecarOption?: boolean; + includeCaptionSidecar?: boolean; + onIncludeCaptionSidecarChange?: (enabled: boolean) => void; mp4OutputDimensions?: Record; gifFrameRate: GifFrameRate; onGifFrameRateChange?: (rate: GifFrameRate) => void; @@ -55,6 +58,9 @@ export function ExportSettingsMenu({ experimentalNvidiaCudaExport = false, onExperimentalNvidiaCudaExportChange, nvidiaCudaExportAvailable = false, + showCaptionSidecarOption = false, + includeCaptionSidecar = false, + onIncludeCaptionSidecarChange, mp4OutputDimensions, gifFrameRate, onGifFrameRateChange, @@ -365,6 +371,30 @@ export function ExportSettingsMenu({ /> ) : null} + {showCaptionSidecarOption ? ( +
+
+

+ {tSettings("export.captionSidecar.title", "Export captions file")} +

+

+ {tSettings( + "export.captionSidecar.hint", + "Save .srt and .vtt files next to your exported video.", + )} +

+
+ +
+ ) : null} ) : (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9ed08752..f18b931f 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -226,6 +226,14 @@ type PendingExportSave = { fileName: string; arrayBuffer?: ArrayBuffer; tempFilePath?: string; + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }; }; type CancelableExporter = { @@ -521,6 +529,7 @@ export default function VideoEditor() { const [autoCaptionSettings, setAutoCaptionSettings] = useState( DEFAULT_AUTO_CAPTION_SETTINGS, ); + const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(true); const [whisperExecutablePath, setWhisperExecutablePath] = useState( initialEditorPreferences.whisperExecutablePath, ); @@ -596,6 +605,32 @@ export default function VideoEditor() { const [gifSizePreset, setGifSizePreset] = useState( initialEditorPreferences.gifSizePreset, ); + const hasCaptionsForSidecar = autoCaptionSettings.enabled && autoCaptions.length > 0; + const captionSidecarCues = useMemo( + () => + autoCaptions + .filter( + (cue) => + Number.isFinite(cue.startMs) && + Number.isFinite(cue.endMs) && + cue.endMs > cue.startMs && + typeof cue.text === "string" && + cue.text.trim().length > 0, + ) + .map((cue) => ({ + startMs: cue.startMs, + endMs: cue.endMs, + text: cue.text, + })), + [autoCaptions], + ); + const captionSidecarPayload = + hasCaptionsForSidecar && captionSidecarCues.length > 0 && includeCaptionSidecar + ? { + format: "both" as const, + cues: captionSidecarCues, + } + : undefined; const [exportedFilePath, setExportedFilePath] = useState(undefined); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); @@ -1283,7 +1318,12 @@ export default function VideoEditor() { }, []); const saveBlobExport = useCallback( - async (blob: Blob, fileName: string, outputPath: string | null = null) => { + async ( + blob: Blob, + fileName: string, + outputPath: string | null = null, + captionSidecar?: PendingExportSave["captionSidecar"], + ) => { const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; const hasExportStreamApi = typeof window !== "undefined" && @@ -1300,10 +1340,12 @@ export default function VideoEditor() { tempPath: tempFilePath, fileName, outputPath, + captionSidecar, }), pendingSave: { fileName, tempFilePath, + captionSidecar, } satisfies PendingExportSave, }; } @@ -1342,11 +1384,20 @@ export default function VideoEditor() { const arrayBuffer = await blob.arrayBuffer(); return { saveResult: outputPath - ? await window.electronAPI.writeExportedVideoToPath(arrayBuffer, outputPath) - : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName), + ? await window.electronAPI.writeExportedVideoToPath( + arrayBuffer, + outputPath, + captionSidecar, + ) + : await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + captionSidecar, + ), pendingSave: { fileName, arrayBuffer, + captionSidecar, } satisfies PendingExportSave, }; }, @@ -4458,6 +4509,10 @@ export default function VideoEditor() { if (result.success && (result.blob || result.tempFilePath)) { const timestamp = Date.now(); const fileName = `export-${timestamp}.mp4`; + const sidecarForThisExport = + settings.includeCaptionSidecar && captionSidecarPayload + ? captionSidecarPayload + : undefined; markExportAsSaving(); let saveResult: { @@ -4479,8 +4534,13 @@ export default function VideoEditor() { smokeExportConfig.enabled && smokeExportConfig.outputPath ? smokeExportConfig.outputPath : null, + captionSidecar: sidecarForThisExport, }); - pendingOnCancel = { fileName, tempFilePath: result.tempFilePath }; + pendingOnCancel = { + fileName, + tempFilePath: result.tempFilePath, + captionSidecar: sidecarForThisExport, + }; } else if (result.blob) { // Legacy fallback: some export paths still surface a Blob, but in // Electron we stream it into a temp file first so save/finalize @@ -4489,6 +4549,7 @@ export default function VideoEditor() { result.blob, fileName, smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, + sidecarForThisExport, ); saveResult = blobSave.saveResult; pendingOnCancel = blobSave.pendingSave; @@ -4701,6 +4762,7 @@ export default function VideoEditor() { annotationRegions, autoCaptions, autoCaptionSettings, + captionSidecarPayload, isPlaying, exportQuality, effectiveZoomRegions, @@ -4858,6 +4920,7 @@ export default function VideoEditor() { sourceWidth, sourceHeight, exportFormat, + includeCaptionSidecar: hasCaptionsForSidecar && includeCaptionSidecar, exportEncodingMode, exportQuality, mp4FrameRate, @@ -4881,6 +4944,8 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + hasCaptionsForSidecar, + includeCaptionSidecar, exportBackendPreference, exportPipelineModel, handleExport, @@ -4925,11 +4990,13 @@ export default function VideoEditor() { tempPath: pendingSave.tempFilePath, fileName: pendingSave.fileName, outputPath: null, + captionSidecar: pendingSave.captionSidecar, }); } else if (pendingSave.arrayBuffer) { saveResult = await window.electronAPI.saveExportedVideo( pendingSave.arrayBuffer, pendingSave.fileName, + pendingSave.captionSidecar, ); } else { saveResult = { success: false, message: "No pending export to save" }; @@ -5669,6 +5736,9 @@ export default function VideoEditor() { onGifLoopChange={setGifLoop} gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} + showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"} + includeCaptionSidecar={includeCaptionSidecar} + onIncludeCaptionSidecarChange={setIncludeCaptionSidecar} mp4OutputDimensions={mp4OutputDimensions} gifOutputDimensions={gifOutputDimensions} onExport={handleStartExportFromDropdown} diff --git a/src/components/video-editor/exportStartSettings.test.ts b/src/components/video-editor/exportStartSettings.test.ts index 357e7fb3..567807da 100644 --- a/src/components/video-editor/exportStartSettings.test.ts +++ b/src/components/video-editor/exportStartSettings.test.ts @@ -5,6 +5,7 @@ const baseOptions = { sourceWidth: 1920, sourceHeight: 1080, exportFormat: "mp4" as const, + includeCaptionSidecar: true, exportEncodingMode: "balanced" as const, exportQuality: "good" as const, mp4FrameRate: 30 as const, @@ -19,6 +20,7 @@ describe("resolveExportStartSettings", () => { it("preserves MP4 dropdown settings", () => { expect(resolveExportStartSettings(baseOptions)).toEqual({ format: "mp4", + includeCaptionSidecar: true, encodingMode: "balanced", mp4FrameRate: 30, backendPreference: "auto", @@ -41,6 +43,7 @@ describe("resolveExportStartSettings", () => { }), ).toEqual({ format: "gif", + includeCaptionSidecar: false, encodingMode: undefined, mp4FrameRate: undefined, backendPreference: undefined, diff --git a/src/components/video-editor/exportStartSettings.ts b/src/components/video-editor/exportStartSettings.ts index 6b57268a..dc0aeab7 100644 --- a/src/components/video-editor/exportStartSettings.ts +++ b/src/components/video-editor/exportStartSettings.ts @@ -16,6 +16,7 @@ export function resolveExportStartSettings({ sourceWidth, sourceHeight, exportFormat, + includeCaptionSidecar, exportEncodingMode, exportQuality, mp4FrameRate, @@ -28,6 +29,7 @@ export function resolveExportStartSettings({ sourceWidth: number; sourceHeight: number; exportFormat: ExportFormat; + includeCaptionSidecar: boolean; exportEncodingMode: ExportEncodingMode; exportQuality: ExportQuality; mp4FrameRate: ExportMp4FrameRate; @@ -44,6 +46,7 @@ export function resolveExportStartSettings({ return { format: exportFormat, + includeCaptionSidecar: exportFormat === "mp4" ? includeCaptionSidecar : false, encodingMode: exportFormat === "mp4" ? exportEncodingMode : undefined, mp4FrameRate: exportFormat === "mp4" ? mp4FrameRate : undefined, backendPreference: exportFormat === "mp4" ? exportBackendPreference : undefined, diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 72682f01..f474f998 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -193,6 +193,7 @@ export interface GifExportConfig { export interface ExportSettings { format: ExportFormat; + includeCaptionSidecar?: boolean; // MP4 settings quality?: ExportQuality; encodingMode?: ExportEncodingMode; From 8c7d23d8288bd94114c11fac3cb4b1dab00c532b Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:24:35 +1000 Subject: [PATCH 6/7] Handle caption sidecar save failures --- electron/ipc/register/export.ts | 161 +++++------------- .../register/exportCaptionSidecars.test.ts | 112 ++++++++++++ .../ipc/register/exportCaptionSidecars.ts | 157 +++++++++++++++++ 3 files changed, 307 insertions(+), 123 deletions(-) create mode 100644 electron/ipc/register/exportCaptionSidecars.test.ts create mode 100644 electron/ipc/register/exportCaptionSidecars.ts diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 1223efee..c4410a27 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -5,6 +5,12 @@ import path from "node:path"; import type { Readable, Writable } from "node:stream"; import type { SaveDialogOptions } from "electron"; import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import { + parseCaptionSidecarPayload, + type CaptionSidecarPayload, + withCaptionSidecarMessage, + writeCaptionSidecarsBestEffort, +} from "./exportCaptionSidecars"; import { closeExportStream, isOwnedExportPath, @@ -246,121 +252,6 @@ function isTempPathSafe(tempPath: string): boolean { return candidate.startsWith(withSep); } -type CaptionSidecarCue = { - startMs: number; - endMs: number; - text: string; -}; - -type CaptionSidecarPayload = { - format: "srt" | "vtt" | "both"; - cues: CaptionSidecarCue[]; -}; - -function toSrtTimestamp(totalMs: number): string { - const ms = Math.max(0, Math.round(totalMs)); - const hours = Math.floor(ms / 3_600_000); - const minutes = Math.floor((ms % 3_600_000) / 60_000); - const seconds = Math.floor((ms % 60_000) / 1000); - const millis = ms % 1000; - return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`; -} - -function toVttTimestamp(totalMs: number): string { - const ms = Math.max(0, Math.round(totalMs)); - const hours = Math.floor(ms / 3_600_000); - const minutes = Math.floor((ms % 3_600_000) / 60_000); - const seconds = Math.floor((ms % 60_000) / 1000); - const millis = ms % 1000; - return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; -} - -function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] { - if (!Array.isArray(cues)) { - return []; - } - - return cues - .filter((cue): cue is CaptionSidecarCue => { - return ( - typeof cue === "object" && - cue !== null && - typeof cue.startMs === "number" && - typeof cue.endMs === "number" && - typeof cue.text === "string" && - Number.isFinite(cue.startMs) && - Number.isFinite(cue.endMs) && - cue.endMs > cue.startMs && - cue.text.trim().length > 0 - ); - }) - .map((cue) => ({ - startMs: cue.startMs, - endMs: cue.endMs, - text: cue.text.replace(/\r\n/g, "\n").trim(), - })); -} - -function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null { - if (typeof payload !== "object" || payload === null) { - return null; - } - - const candidate = payload as { - format?: unknown; - cues?: unknown; - }; - - const format = - candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both" - ? candidate.format - : null; - if (!format) { - return null; - } - - const cues = normalizeCaptionSidecarCues(candidate.cues); - if (cues.length === 0) { - return null; - } - - return { format, cues }; -} - -function serializeSrt(cues: CaptionSidecarCue[]): string { - return cues - .map((cue, index) => { - return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`; - }) - .join("\n\n"); -} - -function serializeVtt(cues: CaptionSidecarCue[]): string { - const body = cues - .map((cue) => { - return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`; - }) - .join("\n\n"); - return `WEBVTT\n\n${body}`; -} - -async function writeCaptionSidecars(videoPath: string, payload: CaptionSidecarPayload | null) { - if (!payload) { - return; - } - - const parsed = path.parse(videoPath); - const basePath = path.join(parsed.dir, parsed.name); - - if (payload.format === "srt" || payload.format === "both") { - await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8"); - } - - if (payload.format === "vtt" || payload.format === "both") { - await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8"); - } -} - export function registerExportHandlers() { ipcMain.handle( "native-video-export-start", @@ -987,13 +878,19 @@ export function registerExportHandlers() { } await fs.writeFile(result.filePath, Buffer.from(videoData)); - await writeCaptionSidecars(result.filePath, sidecarPayload); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + result.filePath, + sidecarPayload, + ); approveUserPath(result.filePath); return { success: true, path: result.filePath, - message: "Video exported successfully", + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), }; } catch (error) { console.error("Failed to save exported video:", error); @@ -1029,13 +926,19 @@ export function registerExportHandlers() { const resolvedPath = path.resolve(outputPath); await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, Buffer.from(videoData)); - await writeCaptionSidecars(resolvedPath, sidecarPayload); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + resolvedPath, + sidecarPayload, + ); approveUserPath(resolvedPath); return { success: true, path: resolvedPath, - message: "Video exported successfully", + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), canceled: false, }; } catch (error) { @@ -1088,14 +991,20 @@ export function registerExportHandlers() { if (payload.outputPath) { const resolvedPath = path.resolve(payload.outputPath); await moveExportedTempFile(tempPath, resolvedPath); - await writeCaptionSidecars(resolvedPath, sidecarPayload); releaseOwnedExportPath(tempPath); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + resolvedPath, + sidecarPayload, + ); approveUserPath(resolvedPath); return { success: true, path: resolvedPath, canceled: false, - message: "Video exported successfully", + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), }; } @@ -1126,15 +1035,21 @@ export function registerExportHandlers() { } await moveExportedTempFile(tempPath, result.filePath); - await writeCaptionSidecars(result.filePath, sidecarPayload); releaseOwnedExportPath(tempPath); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + result.filePath, + sidecarPayload, + ); approveUserPath(result.filePath); return { success: true, path: result.filePath, canceled: false, - message: "Video exported successfully", + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), }; } catch (error) { console.error("Failed to finalize exported video:", error); diff --git a/electron/ipc/register/exportCaptionSidecars.test.ts b/electron/ipc/register/exportCaptionSidecars.test.ts new file mode 100644 index 00000000..6839e959 --- /dev/null +++ b/electron/ipc/register/exportCaptionSidecars.test.ts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseCaptionSidecarPayload, + serializeSrt, + serializeVtt, + withCaptionSidecarMessage, + writeCaptionSidecarsBestEffort, +} from "./exportCaptionSidecars"; + +describe("exportCaptionSidecars", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("serializes SRT cues with stable numbering and timestamps", () => { + expect( + serializeSrt([ + { + startMs: 1234, + endMs: 5678, + text: "Hello\nworld", + }, + ]), + ).toBe("1\n00:00:01,234 --> 00:00:05,678\nHello\nworld"); + }); + + it("serializes VTT cues with header and dot timestamps", () => { + expect( + serializeVtt([ + { + startMs: 0, + endMs: 2000, + text: "Caption", + }, + ]), + ).toBe("WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nCaption"); + }); + + it("drops malformed cues when parsing sidecar payloads", () => { + expect( + parseCaptionSidecarPayload({ + format: "both", + cues: [ + { startMs: 0, endMs: 1000, text: "ok" }, + { startMs: 2000, endMs: 1000, text: "bad range" }, + { startMs: 1000, endMs: 2000, text: " " }, + ], + }), + ).toEqual({ + format: "both", + cues: [{ startMs: 0, endMs: 1000, text: "ok" }], + }); + }); + + it("returns a warning result instead of throwing when sidecar writes fail", async () => { + const writeFileSpy = vi.spyOn(fs, "writeFile").mockRejectedValueOnce(new Error("disk full")); + + await expect( + writeCaptionSidecarsBestEffort("/tmp/export.mp4", { + format: "srt", + cues: [{ startMs: 0, endMs: 1000, text: "Caption" }], + }), + ).resolves.toEqual({ + wroteAny: false, + error: "disk full", + }); + + expect(writeFileSpy).toHaveBeenCalledTimes(1); + }); + + it("writes requested caption sidecars when the filesystem succeeds", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-sidecar-test-")); + const videoPath = path.join(tempDir, "clip.mp4"); + + try { + await expect( + writeCaptionSidecarsBestEffort(videoPath, { + format: "both", + cues: [{ startMs: 0, endMs: 1000, text: "Caption" }], + }), + ).resolves.toEqual({ wroteAny: true, error: null }); + + await expect(fs.readFile(path.join(tempDir, "clip.srt"), "utf8")).resolves.toContain( + "00:00:00,000 --> 00:00:01,000", + ); + await expect(fs.readFile(path.join(tempDir, "clip.vtt"), "utf8")).resolves.toContain( + "WEBVTT", + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("appends a non-fatal caption warning only when sidecar writes fail", () => { + expect( + withCaptionSidecarMessage("Video exported successfully", { + wroteAny: false, + error: "disk full", + }), + ).toBe("Video exported successfully Captions could not be saved alongside the video."); + + expect( + withCaptionSidecarMessage("Video exported successfully", { + wroteAny: true, + error: null, + }), + ).toBe("Video exported successfully"); + }); +}); \ No newline at end of file diff --git a/electron/ipc/register/exportCaptionSidecars.ts b/electron/ipc/register/exportCaptionSidecars.ts new file mode 100644 index 00000000..6711d3bb --- /dev/null +++ b/electron/ipc/register/exportCaptionSidecars.ts @@ -0,0 +1,157 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export type CaptionSidecarCue = { + startMs: number; + endMs: number; + text: string; +}; + +export type CaptionSidecarPayload = { + format: "srt" | "vtt" | "both"; + cues: CaptionSidecarCue[]; +}; + +export type CaptionSidecarWriteResult = { + wroteAny: boolean; + error: string | null; +}; + +function toSrtTimestamp(totalMs: number): string { + const ms = Math.max(0, Math.round(totalMs)); + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + const millis = ms % 1000; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`; +} + +function toVttTimestamp(totalMs: number): string { + const ms = Math.max(0, Math.round(totalMs)); + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + const millis = ms % 1000; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; +} + +function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] { + if (!Array.isArray(cues)) { + return []; + } + + return cues + .filter((cue): cue is CaptionSidecarCue => { + return ( + typeof cue === "object" && + cue !== null && + typeof cue.startMs === "number" && + typeof cue.endMs === "number" && + typeof cue.text === "string" && + Number.isFinite(cue.startMs) && + Number.isFinite(cue.endMs) && + cue.endMs > cue.startMs && + cue.text.trim().length > 0 + ); + }) + .map((cue) => ({ + startMs: cue.startMs, + endMs: cue.endMs, + text: cue.text.replace(/\r\n/g, "\n").trim(), + })); +} + +export function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null { + if (typeof payload !== "object" || payload === null) { + return null; + } + + const candidate = payload as { + format?: unknown; + cues?: unknown; + }; + + const format = + candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both" + ? candidate.format + : null; + if (!format) { + return null; + } + + const cues = normalizeCaptionSidecarCues(candidate.cues); + if (cues.length === 0) { + return null; + } + + return { format, cues }; +} + +export function serializeSrt(cues: CaptionSidecarCue[]): string { + return cues + .map((cue, index) => { + return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`; + }) + .join("\n\n"); +} + +export function serializeVtt(cues: CaptionSidecarCue[]): string { + const body = cues + .map((cue) => { + return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`; + }) + .join("\n\n"); + return `WEBVTT\n\n${body}`; +} + +export async function writeCaptionSidecars( + videoPath: string, + payload: CaptionSidecarPayload | null, +) { + if (!payload) { + return; + } + + const parsed = path.parse(videoPath); + const basePath = path.join(parsed.dir, parsed.name); + + if (payload.format === "srt" || payload.format === "both") { + await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8"); + } + + if (payload.format === "vtt" || payload.format === "both") { + await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8"); + } +} + +export async function writeCaptionSidecarsBestEffort( + videoPath: string, + payload: CaptionSidecarPayload | null, +): Promise { + if (!payload) { + return { wroteAny: false, error: null }; + } + + try { + await writeCaptionSidecars(videoPath, payload); + return { wroteAny: true, error: null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[export] Failed to write caption sidecars:", { + videoPath, + message, + }); + return { wroteAny: false, error: message }; + } +} + +export function withCaptionSidecarMessage( + baseMessage: string, + captionSidecarResult: CaptionSidecarWriteResult, +) { + if (!captionSidecarResult.error) { + return baseMessage; + } + + return `${baseMessage} Captions could not be saved alongside the video.`; +} \ No newline at end of file From a51e05fcd48950894b54c475653177a5b5d0cc4e Mon Sep 17 00:00:00 2001 From: surim0n Date: Mon, 15 Jun 2026 12:40:03 -0400 Subject: [PATCH 7/7] Allow advanced padding to position video vertically --- src/components/video-editor/SettingsPanel.tsx | 5 +- .../video-editor/projectPersistence.test.ts | 46 +++++++++++++++ .../video-editor/projectPersistence.ts | 8 ++- src/components/video-editor/types.ts | 2 + .../videoPlayback/layoutUtils.test.ts | 58 ++++++++++++++++++- .../video-editor/videoPlayback/layoutUtils.ts | 58 ++++++++++--------- 6 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 src/components/video-editor/projectPersistence.test.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1fa52661..a74ec409 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -73,6 +73,7 @@ import type { ZoomTransitionEasing, } from "./types"; import { + ADVANCED_VERTICAL_PADDING_MAX, DEFAULT_AUTO_CAPTION_SETTINGS, DEFAULT_CROP_REGION, DEFAULT_CURSOR_CLICK_BOUNCE, @@ -2413,7 +2414,7 @@ export function SettingsPanel({ value={padding.top} defaultValue={DEFAULT_PADDING.top} min={0} - max={100} + max={ADVANCED_VERTICAL_PADDING_MAX} step={1} onChange={(v) => handlePaddingSideChange("top", v)} formatValue={(v) => `${v}%`} @@ -2424,7 +2425,7 @@ export function SettingsPanel({ value={padding.bottom} defaultValue={DEFAULT_PADDING.bottom} min={0} - max={100} + max={ADVANCED_VERTICAL_PADDING_MAX} step={1} onChange={(v) => handlePaddingSideChange("bottom", v)} formatValue={(v) => `${v}%`} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts new file mode 100644 index 00000000..575c3b67 --- /dev/null +++ b/src/components/video-editor/projectPersistence.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeProjectEditor } from "./projectPersistence"; +import { ADVANCED_VERTICAL_PADDING_MAX } from "./types"; + +describe("normalizeProjectEditor", () => { + it("preserves the extended advanced vertical padding range", () => { + const editor = normalizeProjectEditor({ + padding: { + top: 240, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 22, + right: 22, + linked: false, + }, + }); + + expect(editor.padding).toMatchObject({ + top: 240, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 22, + right: 22, + linked: false, + }); + }); + + it("keeps linked padding clamped to the original range", () => { + const editor = normalizeProjectEditor({ + padding: { + top: ADVANCED_VERTICAL_PADDING_MAX, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: ADVANCED_VERTICAL_PADDING_MAX, + right: ADVANCED_VERTICAL_PADDING_MAX, + linked: true, + }, + }); + + expect(editor.padding).toMatchObject({ + top: 100, + bottom: 100, + left: 100, + right: 100, + linked: true, + }); + }); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 704371a6..1d4be3d8 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -22,6 +22,7 @@ import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers"; import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets"; import { + ADVANCED_VERTICAL_PADDING_MAX, type AnnotationRegion, type AudioRegion, type AutoCaptionAnimation, @@ -950,14 +951,17 @@ export function normalizeProjectEditor(editor: Partial): Pro const p = editor.padding; if (p && typeof p === "object") { const linked = typeof p.linked === "boolean" ? p.linked : true; - const top = isFiniteNumber(p.top) ? clamp(p.top, 0, 100) : DEFAULT_PADDING.top; + const verticalMax = linked ? 100 : ADVANCED_VERTICAL_PADDING_MAX; + const top = isFiniteNumber(p.top) + ? clamp(p.top, 0, verticalMax) + : DEFAULT_PADDING.top; if (linked) { return { top, bottom: top, left: top, right: top, linked: true }; } return { top, bottom: isFiniteNumber(p.bottom) - ? clamp(p.bottom, 0, 100) + ? clamp(p.bottom, 0, verticalMax) : DEFAULT_PADDING.bottom, left: isFiniteNumber(p.left) ? clamp(p.left, 0, 100) : DEFAULT_PADDING.left, right: isFiniteNumber(p.right) ? clamp(p.right, 0, 100) : DEFAULT_PADDING.right, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 9bc401d5..91d67624 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -497,6 +497,8 @@ export const DEFAULT_CROP_REGION: CropRegion = { height: 1, }; +export const ADVANCED_VERTICAL_PADDING_MAX = 250; + export interface Padding { top: number; bottom: number; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.test.ts b/src/components/video-editor/videoPlayback/layoutUtils.test.ts index 39d21f03..a93db03f 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.test.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.test.ts @@ -1,5 +1,59 @@ import { describe, expect, it } from "vitest"; -import { scalePreviewBorderRadius } from "./layoutUtils"; + +import { ADVANCED_VERTICAL_PADDING_MAX } from "../types"; +import { computePaddedLayout, scalePreviewBorderRadius } from "./layoutUtils"; + +const BASE_LAYOUT_PARAMS = { + width: 1000, + height: 1000, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + videoWidth: 1000, + videoHeight: 1000, +}; + +describe("computePaddedLayout", () => { + it("allows advanced bottom padding to pin the video to the top edge", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { + top: 0, + bottom: ADVANCED_VERTICAL_PADDING_MAX, + left: 0, + right: 0, + linked: false, + }, + }); + + expect(layout.centerOffsetY).toBeCloseTo(0); + }); + + it("allows advanced top padding to pin the video to the bottom edge", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { + top: ADVANCED_VERTICAL_PADDING_MAX, + bottom: 0, + left: 0, + right: 0, + linked: false, + }, + }); + + expect(layout.centerOffsetY + layout.croppedDisplayHeight).toBeCloseTo( + BASE_LAYOUT_PARAMS.height, + ); + }); + + it("preserves linked padding centering behavior", () => { + const layout = computePaddedLayout({ + ...BASE_LAYOUT_PARAMS, + padding: { top: 20, bottom: 20, left: 20, right: 20, linked: true }, + }); + + expect(layout.centerOffsetY).toBeCloseTo(40); + expect(layout.centerOffsetY + layout.croppedDisplayHeight).toBeCloseTo(960); + }); +}); describe("scalePreviewBorderRadius", () => { it("matches export scaling against the logical preview size", () => { @@ -13,4 +67,4 @@ describe("scalePreviewBorderRadius", () => { expect(scalePreviewBorderRadius(960, 0, 16)).toBe(0); expect(scalePreviewBorderRadius(960, 540, -8)).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 3cce0cd3..b93ef19d 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -1,16 +1,12 @@ import { Application, Graphics, Sprite } from "pixi.js"; import { drawSquircleOnGraphics } from "@/lib/geometry/squircle"; -import type { CropRegion, Padding } from "../types"; +import { ADVANCED_VERTICAL_PADDING_MAX, type CropRegion, type Padding } from "../types"; export const PADDING_SCALE_FACTOR = 0.2; export const BASE_PREVIEW_WIDTH = 1920; export const BASE_PREVIEW_HEIGHT = 1080; -export function scalePreviewBorderRadius( - width: number, - height: number, - borderRadius = 0, -): number { +export function scalePreviewBorderRadius(width: number, height: number, borderRadius = 0): number { if (width <= 0 || height <= 0) { return 0; } @@ -23,12 +19,7 @@ export function isZeroPadding(padding: Padding | number): boolean { if (typeof padding === "number") { return padding === 0; } - return ( - padding.top === 0 && - padding.bottom === 0 && - padding.left === 0 && - padding.right === 0 - ); + return padding.top === 0 && padding.bottom === 0 && padding.left === 0 && padding.right === 0; } export interface PaddedLayoutResult { @@ -64,13 +55,21 @@ export function computePaddedLayout(params: { ? { top: padding, bottom: padding, left: padding, right: padding } : padding; - // Padding is a percentage (0-100) - // Clamp to ensure we don't have overlapping padding that exceeds 100% of a dimension - const clampPercent = (v: number) => Math.min(100, Math.max(0, v)); - const leftPadFrac = (clampPercent(p.left) / 100) * PADDING_SCALE_FACTOR; - const rightPadFrac = (clampPercent(p.right) / 100) * PADDING_SCALE_FACTOR; - const topPadFrac = (clampPercent(p.top) / 100) * PADDING_SCALE_FACTOR; - const bottomPadFrac = (clampPercent(p.bottom) / 100) * PADDING_SCALE_FACTOR; + // Padding is a percentage. Linked padding keeps the original 0-100 scaling + // behavior; advanced vertical padding gets extra range for positioning. + const isAdvancedPadding = typeof padding !== "number" && padding.linked === false; + const clampPercent = (v: number, max = 100) => Math.min(max, Math.max(0, v)); + const leftPercent = clampPercent(p.left); + const rightPercent = clampPercent(p.right); + const topPercent = clampPercent(p.top, isAdvancedPadding ? ADVANCED_VERTICAL_PADDING_MAX : 100); + const bottomPercent = clampPercent( + p.bottom, + isAdvancedPadding ? ADVANCED_VERTICAL_PADDING_MAX : 100, + ); + const leftPadFrac = (leftPercent / 100) * PADDING_SCALE_FACTOR; + const rightPadFrac = (rightPercent / 100) * PADDING_SCALE_FACTOR; + const topPadFrac = (Math.min(topPercent, 100) / 100) * PADDING_SCALE_FACTOR; + const bottomPadFrac = (Math.min(bottomPercent, 100) / 100) * PADDING_SCALE_FACTOR; const availableFracW = Math.max(0, 1.0 - leftPadFrac - rightPadFrac); const availableFracH = Math.max(0, 1.0 - topPadFrac - bottomPadFrac); @@ -103,17 +102,24 @@ export function computePaddedLayout(params: { const fullFrameDisplayH = fullFrameVideoH * scale; const availableCenterX = leftPadFrac * width + maxDisplayWidth / 2; - const availableCenterY = topPadFrac * height + maxDisplayHeight / 2; + const availableCenterY = isAdvancedPadding + ? (() => { + const verticalTravel = Math.max(0, height - fullFrameDisplayH); + const centeredOffsetY = verticalTravel / 2; + const directionalOffsetY = + centeredOffsetY + + ((topPercent - bottomPercent) / ADVANCED_VERTICAL_PADDING_MAX) * + centeredOffsetY; + const frameOffsetY = Math.min(verticalTravel, Math.max(0, directionalOffsetY)); + return frameOffsetY + fullFrameDisplayH / 2; + })() + : topPadFrac * height + maxDisplayHeight / 2; const frameCenterX = availableCenterX - fullFrameDisplayW / 2; const frameCenterY = availableCenterY - fullFrameDisplayH / 2; - const centerOffsetX = insets - ? frameCenterX + insets.left * fullFrameDisplayW - : frameCenterX; - const centerOffsetY = insets - ? frameCenterY + insets.top * fullFrameDisplayH - : frameCenterY; + const centerOffsetX = insets ? frameCenterX + insets.left * fullFrameDisplayW : frameCenterX; + const centerOffsetY = insets ? frameCenterY + insets.top * fullFrameDisplayH : frameCenterY; const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth; const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;