From 7b6608e25ef25fdf2e96ed53527aa4db6c7ff3ad 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 1/8] 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 a51e05fcd48950894b54c475653177a5b5d0cc4e Mon Sep 17 00:00:00 2001 From: surim0n Date: Mon, 15 Jun 2026 12:40:03 -0400 Subject: [PATCH 2/8] 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; From 1b2260ae5fd519e91b5224fb81337cccc130e6f7 Mon Sep 17 00:00:00 2001 From: surim0n Date: Mon, 15 Jun 2026 12:48:39 -0400 Subject: [PATCH 3/8] Add dynamic webcam overlay dimensions --- src/components/video-editor/SettingsPanel.tsx | 35 ++++- src/components/video-editor/VideoEditor.tsx | 8 ++ src/components/video-editor/VideoPlayback.tsx | 50 ++++--- .../video-editor/WebcamCropControl.tsx | 125 +++++------------- .../video-editor/projectPersistence.ts | 10 ++ src/components/video-editor/types.ts | 4 + .../video-editor/webcamOverlay.test.ts | 68 ++++++++++ src/components/video-editor/webcamOverlay.ts | 84 +++++++++++- src/i18n/locales/en/settings.json | 2 + src/i18n/locales/es/settings.json | 2 + src/i18n/locales/fr/settings.json | 2 + src/i18n/locales/it/settings.json | 2 + src/i18n/locales/ko/settings.json | 2 + src/i18n/locales/nl/settings.json | 2 + src/i18n/locales/pt-BR/settings.json | 2 + src/i18n/locales/ru/settings.json | 4 +- src/i18n/locales/zh-CN/settings.json | 2 + src/i18n/locales/zh-TW/settings.json | 2 + src/lib/exporter/frameRenderer.ts | 100 ++++++++------ src/lib/exporter/modernFrameRenderer.ts | 51 ++++--- ...rnVideoExporter.nativeStaticLayout.test.ts | 22 +++ src/lib/exporter/modernVideoExporter.ts | 20 ++- 22 files changed, 416 insertions(+), 183 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1fa52661..f9aeea3b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -107,6 +107,7 @@ import { } from "./videoPlayback/uploadedCursorAssets"; import { WebcamCropControl } from "./WebcamCropControl"; import { + getCropMatchedWebcamHeightPercent, getWebcamPositionForPreset, normalizeWebcamCropRegion, resolveWebcamCorner, @@ -1662,6 +1663,8 @@ export function SettingsPanel({ const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET; const webcamPositionX = webcam?.positionX ?? DEFAULT_WEBCAM_POSITION_X; const webcamPositionY = webcam?.positionY ?? DEFAULT_WEBCAM_POSITION_Y; + const webcamWidth = webcam?.width ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const webcamHeight = webcam?.height ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE; const webcamCrop = normalizeWebcamCropRegion(webcam?.cropRegion); const getWallpaperTileState = (candidateValue: string, previewPath?: string) => { @@ -3874,13 +3877,24 @@ export function SettingsPanel({ />
updateWebcam({ size: v })} + onChange={(v) => updateWebcam({ width: v, size: v })} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + updateWebcam({ height: v })} formatValue={(v) => `${Math.round(v)}%`} parseInput={(text) => parseFloat(text.replace(/%$/, ""))} /> @@ -3906,7 +3920,20 @@ export function SettingsPanel({ previewCurrentTime={webcamPreviewCurrentTime} previewPlaying={webcamPreviewPlaying} previewTimeOffsetMs={webcam?.timeOffsetMs} - onCropChange={(cropRegion) => updateWebcam({ cropRegion })} + onCropChange={(cropRegion, previewFrame) => + updateWebcam({ + cropRegion, + height: previewFrame + ? getCropMatchedWebcamHeightPercent( + webcamWidth, + webcamWidth, + previewFrame.width, + previewFrame.height, + cropRegion, + ) + : webcamHeight, + }) + } />
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f18b931f..d9281715 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2340,6 +2340,14 @@ export default function VideoEditor() { smokeExportConfig.webcamSize === undefined ? prev.size : smokeExportConfig.webcamSize, + width: + smokeExportConfig.webcamSize === undefined + ? (prev.width ?? prev.size) + : smokeExportConfig.webcamSize, + height: + smokeExportConfig.webcamSize === undefined + ? (prev.height ?? prev.size) + : smokeExportConfig.webcamSize, })); setError(null); return; diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 488bc3a9..780feee1 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -182,9 +182,10 @@ import { type MotionBlurState, } from "./videoPlayback/zoomTransform"; import { + getCropMatchedWebcamHeightPercent, getWebcamCropSourceRect, + getWebcamOverlayDimensionsPx, getWebcamOverlayPosition, - getWebcamOverlaySizePx, } from "./webcamOverlay"; type PlaybackAnimationState = { @@ -768,7 +769,8 @@ const VideoPlayback = forwardRef( const motionBlurStateRef = useRef(createMotionBlurState()); const webcamEnabled = webcam?.enabled ?? false; const webcamMargin = webcam?.margin ?? 24; - const webcamSize = webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const webcamWidth = webcam?.width ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const rawWebcamHeight = webcam?.height ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE; const webcamReactToZoom = webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM; const webcamPositionPreset = webcam?.positionPreset ?? webcam?.corner ?? "bottom-right"; const webcamPositionX = webcam?.positionX ?? 1; @@ -779,6 +781,13 @@ const VideoPlayback = forwardRef( const webcamTimeOffsetMs = webcam?.timeOffsetMs; const webcamCropRegion = webcam?.cropRegion; const webcamMirror = webcam?.mirror ?? false; + const webcamHeight = getCropMatchedWebcamHeightPercent( + webcamWidth, + rawWebcamHeight, + webcamVideoDimensions?.width, + webcamVideoDimensions?.height, + webcamCropRegion, + ); const webcamCropPreviewContentStyle = useMemo(() => { if (!webcamVideoDimensions) { return { opacity: 0 }; @@ -789,21 +798,22 @@ const VideoPlayback = forwardRef( webcamVideoDimensions.width, webcamVideoDimensions.height, ); - const coverScale = Math.max(1 / sw, 1 / sh); + const targetAspect = Math.max(0.01, webcamWidth) / Math.max(0.01, webcamHeight); + const coverScale = Math.max(targetAspect / sw, 1 / sh); const drawWidth = webcamVideoDimensions.width * coverScale; const drawHeight = webcamVideoDimensions.height * coverScale; - const drawX = (1 - sw * coverScale) / 2 - sx * coverScale; + const drawX = (targetAspect - sw * coverScale) / 2 - sx * coverScale; const drawY = (1 - sh * coverScale) / 2 - sy * coverScale; return { - left: `${drawX * 100}%`, + left: `${(drawX / targetAspect) * 100}%`, top: `${drawY * 100}%`, - width: `${drawWidth * 100}%`, + width: `${(drawWidth / targetAspect) * 100}%`, height: `${drawHeight * 100}%`, maxWidth: "none", willChange: "left, top, width, height", }; - }, [webcamCropRegion, webcamVideoDimensions]); + }, [webcamCropRegion, webcamHeight, webcamVideoDimensions, webcamWidth]); const applyWebcamBubbleLayout = useCallback( (zoomScale: number) => { @@ -817,10 +827,11 @@ const VideoPlayback = forwardRef( return; } - const scaledSize = getWebcamOverlaySizePx({ + const scaledDimensions = getWebcamOverlayDimensionsPx({ containerWidth: overlay.clientWidth, containerHeight: overlay.clientHeight, - sizePercent: webcamSize, + widthPercent: webcamWidth, + heightPercent: webcamHeight, margin: webcamMargin, zoomScale, reactToZoom: webcamReactToZoom, @@ -828,7 +839,8 @@ const VideoPlayback = forwardRef( const { x, y } = getWebcamOverlayPosition({ containerWidth: overlay.clientWidth, containerHeight: overlay.clientHeight, - size: scaledSize, + width: scaledDimensions.width, + height: scaledDimensions.height, margin: webcamMargin, positionPreset: webcamPositionPreset, positionX: webcamPositionX, @@ -839,18 +851,19 @@ const VideoPlayback = forwardRef( bubble.style.display = "block"; bubble.style.left = `${x}px`; bubble.style.top = `${y}px`; - bubble.style.width = `${scaledSize}px`; - bubble.style.height = `${scaledSize}px`; - bubble.style.aspectRatio = "1 / 1"; + bubble.style.width = `${scaledDimensions.width}px`; + bubble.style.height = `${scaledDimensions.height}px`; + bubble.style.aspectRatio = `${scaledDimensions.width} / ${scaledDimensions.height}`; const squirclePath = getSquircleSvgPath({ x: 0, y: 0, - width: scaledSize, - height: scaledSize, + width: scaledDimensions.width, + height: scaledDimensions.height, radius: webcamCornerRadius, }); - bubble.style.filter = `drop-shadow(0 ${Math.round(scaledSize * 0.06)}px ${Math.round( - scaledSize * 0.22, + const shadowSize = Math.min(scaledDimensions.width, scaledDimensions.height); + bubble.style.filter = `drop-shadow(0 ${Math.round(shadowSize * 0.06)}px ${Math.round( + shadowSize * 0.22, )}px rgba(0, 0, 0, ${webcamShadow}))`; bubble.style.borderRadius = "0px"; bubble.style.boxShadow = "none"; @@ -871,8 +884,9 @@ const VideoPlayback = forwardRef( webcamPositionY, webcamReactToZoom, webcamShadow, - webcamSize, + webcamHeight, webcamVideoPath, + webcamWidth, ], ); diff --git a/src/components/video-editor/WebcamCropControl.tsx b/src/components/video-editor/WebcamCropControl.tsx index 6cbb3ca9..10e5e6ef 100644 --- a/src/components/video-editor/WebcamCropControl.tsx +++ b/src/components/video-editor/WebcamCropControl.tsx @@ -21,7 +21,7 @@ interface WebcamCropControlProps { previewCurrentTime?: number; previewPlaying?: boolean; previewTimeOffsetMs?: number | null; - onCropChange: (cropRegion: CropRegion) => void; + onCropChange: (cropRegion: CropRegion, previewFrame?: PreviewFrame | null) => void; } interface DragState { @@ -72,51 +72,23 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -function normalizeAspectCropRegion(cropRegion: CropRegion, displayAspectRatio: number): CropRegion { +function flipCropHorizontally(cropRegion: CropRegion): CropRegion { const crop = normalizeWebcamCropRegion(cropRegion); - const aspectRatio = - Number.isFinite(displayAspectRatio) && displayAspectRatio > 0 ? displayAspectRatio : 1; - const maxWidth = Math.min(1, 1 / aspectRatio); - const minWidth = Math.min(MIN_CROP_SIZE, maxWidth); - const width = clamp(Math.min(crop.width, crop.height / aspectRatio), minWidth, maxWidth); - const height = width * aspectRatio; - const centerX = crop.x + crop.width / 2; - const centerY = crop.y + crop.height / 2; - const x = clamp(centerX - width / 2, 0, 1 - width); - const y = clamp(centerY - height / 2, 0, 1 - height); - - return { x, y, width, height }; -} - -function flipCropHorizontally(cropRegion: CropRegion, displayAspectRatio: number): CropRegion { - const crop = normalizeAspectCropRegion(cropRegion, displayAspectRatio); return { ...crop, x: clamp(1 - crop.x - crop.width, 0, 1 - crop.width), }; } -function resizeCrop( - cropRegion: CropRegion, - handle: CropHandle, - deltaX: number, - deltaY: number, - displayAspectRatio: number, -) { - const aspectRatio = - Number.isFinite(displayAspectRatio) && displayAspectRatio > 0 ? displayAspectRatio : 1; - const crop = normalizeAspectCropRegion(cropRegion, aspectRatio); - const minWidth = Math.min(MIN_CROP_SIZE, Math.min(1, 1 / aspectRatio)); +function resizeCrop(cropRegion: CropRegion, handle: CropHandle, deltaX: number, deltaY: number) { + const crop = normalizeWebcamCropRegion(cropRegion); if (handle === "move") { - return normalizeAspectCropRegion( - { - ...crop, - x: clamp(crop.x + deltaX, 0, 1 - crop.width), - y: clamp(crop.y + deltaY, 0, 1 - crop.height), - }, - aspectRatio, - ); + return normalizeWebcamCropRegion({ + ...crop, + x: clamp(crop.x + deltaX, 0, 1 - crop.width), + y: clamp(crop.y + deltaY, 0, 1 - crop.height), + }); } let left = crop.x; @@ -124,59 +96,25 @@ function resizeCrop( let right = crop.x + crop.width; let bottom = crop.y + crop.height; - if (handle === "nw") { - const delta = Math.max(deltaX, deltaY / aspectRatio); - const nextWidth = clamp( - crop.width - delta, - minWidth, - Math.min(right, bottom / aspectRatio), - ); - left = right - nextWidth; - top = bottom - nextWidth * aspectRatio; + if (handle === "nw" || handle === "sw") { + left = clamp(left + deltaX, 0, right - MIN_CROP_SIZE); } - - if (handle === "ne") { - const delta = Math.max(deltaX, -deltaY / aspectRatio); - const nextWidth = clamp( - crop.width + delta, - minWidth, - Math.min(1 - left, bottom / aspectRatio), - ); - right = left + nextWidth; - top = bottom - nextWidth * aspectRatio; + if (handle === "ne" || handle === "se") { + right = clamp(right + deltaX, left + MIN_CROP_SIZE, 1); } - - if (handle === "sw") { - const delta = Math.max(-deltaX, deltaY / aspectRatio); - const nextWidth = clamp( - crop.width + delta, - minWidth, - Math.min(right, (1 - top) / aspectRatio), - ); - left = right - nextWidth; - bottom = top + nextWidth * aspectRatio; + if (handle === "nw" || handle === "ne") { + top = clamp(top + deltaY, 0, bottom - MIN_CROP_SIZE); } - - if (handle === "se") { - const delta = Math.max(deltaX, deltaY / aspectRatio); - const nextWidth = clamp( - crop.width + delta, - minWidth, - Math.min(1 - left, (1 - top) / aspectRatio), - ); - right = left + nextWidth; - bottom = top + nextWidth * aspectRatio; + if (handle === "sw" || handle === "se") { + bottom = clamp(bottom + deltaY, top + MIN_CROP_SIZE, 1); } - return normalizeAspectCropRegion( - { - x: left, - y: top, - width: right - left, - height: bottom - top, - }, - aspectRatio, - ); + return normalizeWebcamCropRegion({ + x: left, + y: top, + width: right - left, + height: bottom - top, + }); } export function WebcamCropControl({ @@ -202,10 +140,8 @@ export function WebcamCropControl({ hasPreviewFrame && previewFrame && previewFrame.width > 0 && previewFrame.height > 0 ? previewFrame.width / previewFrame.height : 1; - const sourceCrop = normalizeAspectCropRegion(cropRegion, previewAspectRatio); - const propVisualCrop = mirrored - ? flipCropHorizontally(sourceCrop, previewAspectRatio) - : sourceCrop; + const sourceCrop = normalizeWebcamCropRegion(cropRegion); + const propVisualCrop = mirrored ? flipCropHorizontally(sourceCrop) : sourceCrop; const crop = draftVisualCrop ?? propVisualCrop; const cropLeft = crop.x * 100; const cropTop = crop.y * 100; @@ -224,7 +160,7 @@ export function WebcamCropControl({ } cancelPendingCommit(); pendingCropRef.current = null; - onCropChange(nextCrop); + onCropChange(nextCrop, previewFrame); }; const syncPreviewMedia = useCallback(() => { const video = previewVideoRef.current; @@ -280,12 +216,12 @@ export function WebcamCropControl({ const commitVisualCrop = (nextVisualCrop: CropRegion, immediate = false) => { const nextCrop = mirrored - ? flipCropHorizontally(nextVisualCrop, previewAspectRatio) - : nextVisualCrop; + ? flipCropHorizontally(nextVisualCrop) + : normalizeWebcamCropRegion(nextVisualCrop); if (immediate) { cancelPendingCommit(); pendingCropRef.current = null; - onCropChange(nextCrop); + onCropChange(nextCrop, previewFrame); return; } @@ -358,7 +294,6 @@ export function WebcamCropControl({ dragState.handle, pointer.x - dragState.startX, pointer.y - dragState.startY, - previewAspectRatio, ); setDraftVisualCrop(nextVisualCrop); commitVisualCrop(nextVisualCrop); @@ -402,7 +337,7 @@ export function WebcamCropControl({ } event.preventDefault(); event.stopPropagation(); - commitVisualCrop(resizeCrop(crop, handle, delta.x, delta.y, previewAspectRatio), true); + commitVisualCrop(resizeCrop(crop, handle, delta.x, delta.y), true); }; return ( diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 704371a6..b8481f15 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1028,6 +1028,16 @@ export function normalizeProjectEditor(editor: Partial): Pro ? webcam.corner : DEFAULT_WEBCAM_OVERLAY.corner, size: isFiniteNumber(webcam.size) ? clamp(webcam.size, 10, 100) : DEFAULT_WEBCAM_SIZE, + width: isFiniteNumber(webcam.width) + ? clamp(webcam.width, 10, 100) + : isFiniteNumber(webcam.size) + ? clamp(webcam.size, 10, 100) + : DEFAULT_WEBCAM_SIZE, + height: isFiniteNumber(webcam.height) + ? clamp(webcam.height, 10, 100) + : isFiniteNumber(webcam.size) + ? clamp(webcam.size, 10, 100) + : DEFAULT_WEBCAM_SIZE, reactToZoom: typeof webcam.reactToZoom === "boolean" ? webcam.reactToZoom diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 9bc401d5..35b0fec2 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -136,6 +136,8 @@ export interface WebcamOverlaySettings { positionX: number; positionY: number; size: number; + width: number; + height: number; reactToZoom: boolean; cornerRadius: number; shadow: number; @@ -198,6 +200,8 @@ export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = { positionX: DEFAULT_WEBCAM_POSITION_X, positionY: DEFAULT_WEBCAM_POSITION_Y, size: DEFAULT_WEBCAM_SIZE, + width: DEFAULT_WEBCAM_SIZE, + height: DEFAULT_WEBCAM_SIZE, reactToZoom: DEFAULT_WEBCAM_REACT_TO_ZOOM, cornerRadius: DEFAULT_WEBCAM_CORNER_RADIUS, shadow: DEFAULT_WEBCAM_SHADOW, diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts index a78b0268..001f3cd6 100644 --- a/src/components/video-editor/webcamOverlay.test.ts +++ b/src/components/video-editor/webcamOverlay.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { + getCropMatchedWebcamHeightPercent, getWebcamCropSourceRect, + getWebcamOverlayDimensionsPx, + getWebcamOverlayPosition, isWebcamCropRegionDefault, normalizeWebcamCropRegion, } from "./webcamOverlay"; @@ -20,6 +23,71 @@ describe("normalizeWebcamCropRegion", () => { }); }); +describe("getWebcamOverlayDimensionsPx", () => { + it("resolves independent width and height percentages", () => { + expect( + getWebcamOverlayDimensionsPx({ + containerWidth: 1000, + containerHeight: 800, + widthPercent: 50, + heightPercent: 25, + margin: 0, + zoomScale: 1, + reactToZoom: false, + }), + ).toEqual({ + width: 400, + height: 200, + }); + }); +}); + +describe("getWebcamOverlayPosition", () => { + it("uses rectangular dimensions when anchoring to a preset", () => { + expect( + getWebcamOverlayPosition({ + containerWidth: 1000, + containerHeight: 800, + width: 400, + height: 200, + margin: 20, + positionPreset: "bottom-right", + positionX: 1, + positionY: 1, + legacyCorner: "bottom-right", + }), + ).toEqual({ x: 580, y: 580 }); + }); +}); + +describe("getCropMatchedWebcamHeightPercent", () => { + it("matches height to a non-default crop aspect when width and height are linked", () => { + expect( + getCropMatchedWebcamHeightPercent(60, 60, 1920, 1080, { + x: 0.1, + y: 0.2, + width: 0.6, + height: 0.4, + }), + ).toBeCloseTo(22.5); + }); + + it("preserves manually separated width and height controls", () => { + expect( + getCropMatchedWebcamHeightPercent(60, 45, 1920, 1080, { + x: 0.1, + y: 0.2, + width: 0.6, + height: 0.4, + }), + ).toBe(45); + }); + + it("keeps the default crop square-compatible", () => { + expect(getCropMatchedWebcamHeightPercent(60, 60, 1920, 1080, undefined)).toBe(60); + }); +}); + describe("getWebcamCropSourceRect", () => { it("converts normalized crop settings to source pixels", () => { expect( diff --git a/src/components/video-editor/webcamOverlay.ts b/src/components/video-editor/webcamOverlay.ts index 21cc7dd3..9ce38212 100644 --- a/src/components/video-editor/webcamOverlay.ts +++ b/src/components/video-editor/webcamOverlay.ts @@ -78,10 +78,49 @@ export function getWebcamOverlaySizePx({ return Math.min(maxSize, Math.max(MIN_WEBCAM_OVERLAY_SIZE_PX, scaledSize)); } +export function getWebcamOverlayDimensionsPx({ + containerWidth, + containerHeight, + widthPercent, + heightPercent, + margin, + zoomScale, + reactToZoom, +}: { + containerWidth: number; + containerHeight: number; + widthPercent: number; + heightPercent: number; + margin: number; + zoomScale: number; + reactToZoom: boolean; +}): { width: number; height: number } { + return { + width: getWebcamOverlaySizePx({ + containerWidth, + containerHeight, + sizePercent: widthPercent, + margin, + zoomScale, + reactToZoom, + }), + height: getWebcamOverlaySizePx({ + containerWidth, + containerHeight, + sizePercent: heightPercent, + margin, + zoomScale, + reactToZoom, + }), + }; +} + export function getWebcamOverlayPosition({ containerWidth, containerHeight, size, + width, + height, margin, positionPreset, positionX, @@ -90,7 +129,9 @@ export function getWebcamOverlayPosition({ }: { containerWidth: number; containerHeight: number; - size: number; + size?: number; + width?: number; + height?: number; margin: number; positionPreset: WebcamPositionPreset; positionX: number; @@ -98,8 +139,10 @@ export function getWebcamOverlayPosition({ legacyCorner: WebcamCorner; }): { x: number; y: number } { const safeMargin = Math.max(0, margin); - const availableWidth = Math.max(0, containerWidth - size - safeMargin * 2); - const availableHeight = Math.max(0, containerHeight - size - safeMargin * 2); + const overlayWidth = Math.max(0, width ?? size ?? 0); + const overlayHeight = Math.max(0, height ?? size ?? overlayWidth); + const availableWidth = Math.max(0, containerWidth - overlayWidth - safeMargin * 2); + const availableHeight = Math.max(0, containerHeight - overlayHeight - safeMargin * 2); const presetPosition = positionPreset === "custom" ? { x: clamp(positionX, 0, 1), y: clamp(positionY, 0, 1) } @@ -151,3 +194,38 @@ export function getWebcamCropSourceRect( return { sx, sy, sw, sh }; } + +export function getCropMatchedWebcamHeightPercent( + widthPercent: number, + heightPercent: number, + sourceWidth: number | null | undefined, + sourceHeight: number | null | undefined, + cropRegion: Partial | null | undefined, +): number { + const safeWidthPercent = Number.isFinite(widthPercent) ? widthPercent : 40; + const safeHeightPercent = Number.isFinite(heightPercent) ? heightPercent : safeWidthPercent; + if (Math.abs(safeWidthPercent - safeHeightPercent) > 0.001) { + return clamp(safeHeightPercent, 10, 100); + } + + const crop = normalizeWebcamCropRegion(cropRegion); + if (crop.x <= 0 && crop.y <= 0 && crop.width >= 1 && crop.height >= 1) { + return clamp(safeHeightPercent, 10, 100); + } + + const sourceAspect = + Number.isFinite(sourceWidth) && + Number.isFinite(sourceHeight) && + sourceWidth != null && + sourceHeight != null && + sourceWidth > 0 && + sourceHeight > 0 + ? sourceWidth / sourceHeight + : 1; + const cropAspect = (crop.width * sourceAspect) / Math.max(0.001, crop.height); + if (!Number.isFinite(cropAspect) || cropAspect <= 0) { + return clamp(safeHeightPercent, 10, 100); + } + + return clamp(safeWidthPercent / cropAspect, 10, 100); +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index dc24cd97..0adb90df 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -136,6 +136,8 @@ "webcamFootageAdded": "Webcam footage linked", "webcamFootageRemoved": "Webcam footage removed", "webcamSize": "Webcam Size", + "webcamWidth": "Webcam Width", + "webcamHeight": "Webcam Height", "webcamCrop": "Webcam Crop", "webcamReactToZoom": "Webcam Reacts To Zoom", "webcamMirror": "Mirror webcam", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 66a05e89..f0a51b3a 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -88,6 +88,8 @@ "webcamFootageAdded": "Metraje de cámara vinculado", "webcamFootageRemoved": "Metraje de cámara eliminado", "webcamSize": "Tamaño de cámara", + "webcamWidth": "Ancho de cámara", + "webcamHeight": "Alto de cámara", "webcamCrop": "Recorte de cámara", "webcamReactToZoom": "La cámara reacciona al zoom", "webcamMirror": "Reflejar cámara", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 200ecaa0..2db7d2f0 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -88,6 +88,8 @@ "webcamFootageAdded": "Vidéo de webcam liée", "webcamFootageRemoved": "Vidéo de webcam supprimée", "webcamSize": "Taille de la webcam", + "webcamWidth": "Largeur de la webcam", + "webcamHeight": "Hauteur de la webcam", "webcamCrop": "Recadrage de la webcam", "webcamReactToZoom": "La webcam réagit au zoom", "webcamMirror": "Miroir webcam", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index d9b9a1a1..5b5b9c27 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -110,6 +110,8 @@ "webcamFootageAdded": "Filmato webcam collegato", "webcamFootageRemoved": "Filmato webcam rimosso", "webcamSize": "Dimensione webcam", + "webcamWidth": "Larghezza webcam", + "webcamHeight": "Altezza webcam", "webcamCrop": "Ritaglio webcam", "webcamReactToZoom": "La webcam reagisce allo zoom", "webcamMirror": "Specchia webcam", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index c528a98f..5df1385f 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -88,6 +88,8 @@ "webcamFootageAdded": "웹캠 영상을 연결했습니다", "webcamFootageRemoved": "웹캠 영상을 제거했습니다", "webcamSize": "웹캠 크기", + "webcamWidth": "웹캠 너비", + "webcamHeight": "웹캠 높이", "webcamCrop": "웹캠 자르기", "webcamReactToZoom": "확대 시 웹캠 반응", "webcamMirror": "웹캠 미러링", diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index d88e4278..3f9409f0 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -88,6 +88,8 @@ "webcamFootageAdded": "Webcambeelden gekoppeld", "webcamFootageRemoved": "Webcambeelden verwijderd", "webcamSize": "Webcamgrootte", + "webcamWidth": "Webcambreedte", + "webcamHeight": "Webcamhoogte", "webcamCrop": "Webcam bijsnijden", "webcamReactToZoom": "Webcam reageert op zoom", "webcamMirror": "Webcam spiegelen", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index a34e6e4d..f543ce43 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -88,6 +88,8 @@ "webcamFootageAdded": "Filmagem da webcam vinculada", "webcamFootageRemoved": "Filmagem da webcam removida", "webcamSize": "Tamanho da webcam", + "webcamWidth": "Largura da webcam", + "webcamHeight": "Altura da webcam", "webcamCrop": "Corte da webcam", "webcamReactToZoom": "Webcam reage ao zoom", "webcamMirror": "Espelhar webcam", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 687f8383..2768d995 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -110,6 +110,8 @@ "webcamFootageAdded": "Видео добавлено", "webcamFootageRemoved": "Видео удалено", "webcamSize": "Размер веб-камеры", + "webcamWidth": "Ширина веб-камеры", + "webcamHeight": "Высота веб-камеры", "webcamCrop": "Обрезка веб-камеры", "webcamReactToZoom": "Веб-камера реагирует на зум", "webcamMirror": "Отразить веб-камеру", @@ -216,4 +218,4 @@ "mixedLabel": "Источник", "deleteRegion": "Удалить аудио" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index a68d2a04..1ae88c02 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -105,6 +105,8 @@ "webcamFootageAdded": "已关联摄像头素材", "webcamFootageRemoved": "已移除摄像头素材", "webcamSize": "摄像头大小", + "webcamWidth": "摄像头宽度", + "webcamHeight": "摄像头高度", "webcamCrop": "摄像头裁剪", "webcamReactToZoom": "摄像头随缩放变化", "webcamMirror": "镜像摄像头", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index db0ea7e0..a2f78ae3 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -88,6 +88,8 @@ "webcamFootageAdded": "已連結網路攝影機素材", "webcamFootageRemoved": "已移除網路攝影機素材", "webcamSize": "網路攝影機大小", + "webcamWidth": "網路攝影機寬度", + "webcamHeight": "網路攝影機高度", "webcamCrop": "網路攝影機裁剪", "webcamReactToZoom": "網路攝影機跟隨縮放", "webcamMirror": "鏡像網路攝影機", diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 049c25bb..2f6b134a 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -5,8 +5,8 @@ import type { AnnotationRegion, AutoCaptionSettings, CaptionCue, - CursorClickEffectStyle, CropRegion, + CursorClickEffectStyle, CursorStyle, CursorTelemetryPoint, Padding, @@ -51,9 +51,10 @@ import { type MotionBlurState, } from "@/components/video-editor/videoPlayback/zoomTransform"; import { + getCropMatchedWebcamHeightPercent, getWebcamCropSourceRect, + getWebcamOverlayDimensionsPx, getWebcamOverlayPosition, - getWebcamOverlaySizePx, } from "@/components/video-editor/webcamOverlay"; import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath"; import { extensionHost } from "@/lib/extensions"; @@ -2405,33 +2406,7 @@ export class FrameRenderer { return; } - const margin = webcam.margin ?? 24; - const size = getWebcamOverlaySizePx({ - containerWidth: width, - containerHeight: height, - sizePercent: webcam.size ?? 50, - margin, - zoomScale: this.animationState.appliedScale || 1, - reactToZoom: webcam.reactToZoom ?? true, - }); - const { x, y } = getWebcamOverlayPosition({ - containerWidth: width, - containerHeight: height, - size, - margin, - positionPreset: webcam.positionPreset ?? webcam.corner, - positionX: webcam.positionX ?? 1, - positionY: webcam.positionY ?? 1, - legacyCorner: webcam.corner, - }); - const radius = Math.max(0, webcam.cornerRadius ?? 18); - const bubbleCanvas = this.webcamBubbleCanvas ?? document.createElement("canvas"); - const bubbleSize = Math.max(1, Math.ceil(size)); - if (bubbleCanvas.width !== bubbleSize || bubbleCanvas.height !== bubbleSize) { - bubbleCanvas.width = bubbleSize; - bubbleCanvas.height = bubbleSize; - } this.webcamBubbleCanvas = bubbleCanvas; const bubbleCtx = this.webcamBubbleCtx ?? configureHighQuality2DContext(bubbleCanvas.getContext("2d")); @@ -2439,9 +2414,6 @@ export class FrameRenderer { return; } this.webcamBubbleCtx = bubbleCtx; - bubbleCtx.clearRect(0, 0, bubbleCanvas.width, bubbleCanvas.height); - bubbleCtx.imageSmoothingEnabled = true; - bubbleCtx.imageSmoothingQuality = "high"; const expectedWebcamTargetTime = getWebcamMediaTargetTimeSeconds({ currentTime: this.currentVideoTime, @@ -2507,30 +2479,75 @@ export class FrameRenderer { ? webcamFrameSource.displayWidth : "videoWidth" in webcamFrameSource ? webcamFrameSource.videoWidth - : webcamFrameSource.width) || size; + : webcamFrameSource.width) || 1; const sourceHeight = ("displayHeight" in webcamFrameSource ? webcamFrameSource.displayHeight : "videoHeight" in webcamFrameSource ? webcamFrameSource.videoHeight - : webcamFrameSource.height) || size; + : webcamFrameSource.height) || sourceWidth; + const margin = webcam.margin ?? 24; + const widthPercent = webcam.width ?? webcam.size ?? 50; + const heightPercent = getCropMatchedWebcamHeightPercent( + widthPercent, + webcam.height ?? webcam.size ?? 50, + sourceWidth, + sourceHeight, + webcam.cropRegion, + ); + const dimensions = getWebcamOverlayDimensionsPx({ + containerWidth: width, + containerHeight: height, + widthPercent, + heightPercent, + margin, + zoomScale: this.animationState.appliedScale || 1, + reactToZoom: webcam.reactToZoom ?? true, + }); + const { x, y } = getWebcamOverlayPosition({ + containerWidth: width, + containerHeight: height, + width: dimensions.width, + height: dimensions.height, + margin, + positionPreset: webcam.positionPreset ?? webcam.corner, + positionX: webcam.positionX ?? 1, + positionY: webcam.positionY ?? 1, + legacyCorner: webcam.corner, + }); + const radius = Math.max(0, webcam.cornerRadius ?? 18); + const bubbleWidth = Math.max(1, Math.ceil(dimensions.width)); + const bubbleHeight = Math.max(1, Math.ceil(dimensions.height)); + if (bubbleCanvas.width !== bubbleWidth || bubbleCanvas.height !== bubbleHeight) { + bubbleCanvas.width = bubbleWidth; + bubbleCanvas.height = bubbleHeight; + } + bubbleCtx.clearRect(0, 0, bubbleCanvas.width, bubbleCanvas.height); + bubbleCtx.imageSmoothingEnabled = true; + bubbleCtx.imageSmoothingQuality = "high"; const { sx, sy, sw, sh } = getWebcamCropSourceRect( webcam.cropRegion, sourceWidth, sourceHeight, ); - const coverScale = Math.max(size / sw, size / sh); + const coverScale = Math.max(dimensions.width / sw, dimensions.height / sh); const drawWidth = sw * coverScale; const drawHeight = sh * coverScale; - const drawX = (size - drawWidth) / 2; - const drawY = (size - drawHeight) / 2; + const drawX = (dimensions.width - drawWidth) / 2; + const drawY = (dimensions.height - drawHeight) / 2; bubbleCtx.save(); - drawSquircleOnCanvas(bubbleCtx, { x: 0, y: 0, width: size, height: size, radius }); + drawSquircleOnCanvas(bubbleCtx, { + x: 0, + y: 0, + width: dimensions.width, + height: dimensions.height, + radius, + }); bubbleCtx.clip(); if (webcam.mirror) { bubbleCtx.save(); - bubbleCtx.translate(size, 0); + bubbleCtx.translate(dimensions.width, 0); bubbleCtx.scale(-1, 1); bubbleCtx.drawImage( webcamFrameSource, @@ -2561,14 +2578,15 @@ export class FrameRenderer { if ((webcam.shadow ?? 0) > 0) { const shadow = Math.max(0, Math.min(1, webcam.shadow)); + const shadowSize = Math.min(dimensions.width, dimensions.height); ctx.save(); - ctx.filter = `drop-shadow(0 ${Math.round(size * 0.06)}px ${Math.round(size * 0.22)}px rgba(0,0,0,${shadow}))`; - ctx.drawImage(bubbleCanvas, x, y, size, size); + ctx.filter = `drop-shadow(0 ${Math.round(shadowSize * 0.06)}px ${Math.round(shadowSize * 0.22)}px rgba(0,0,0,${shadow}))`; + ctx.drawImage(bubbleCanvas, x, y, dimensions.width, dimensions.height); ctx.restore(); return; } - ctx.drawImage(bubbleCanvas, x, y, size, size); + ctx.drawImage(bubbleCanvas, x, y, dimensions.width, dimensions.height); } private closeWebcamDecodedFrame(): void { diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 6ab918d8..769a6ba1 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -15,8 +15,8 @@ import type { AnnotationRegion, AutoCaptionSettings, CaptionCue, - CursorClickEffectStyle, CropRegion, + CursorClickEffectStyle, CursorStyle, CursorTelemetryPoint, Padding, @@ -57,9 +57,10 @@ import { type MotionBlurState, } from "@/components/video-editor/videoPlayback/zoomTransform"; import { + getCropMatchedWebcamHeightPercent, getWebcamCropSourceRect, + getWebcamOverlayDimensionsPx, getWebcamOverlayPosition, - getWebcamOverlaySizePx, isWebcamCropRegionDefault, } from "@/components/video-editor/webcamOverlay"; import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath"; @@ -211,7 +212,8 @@ interface WebcamRenderSource { interface WebcamLayoutCache { sourceWidth: number; sourceHeight: number; - size: number; + width: number; + height: number; positionX: number; positionY: number; radius: number; @@ -2604,7 +2606,8 @@ export class FrameRenderer { previousLayout.mirror === nextLayout.mirror && areNearlyEqual(previousLayout.sourceWidth, nextLayout.sourceWidth) && areNearlyEqual(previousLayout.sourceHeight, nextLayout.sourceHeight) && - areNearlyEqual(previousLayout.size, nextLayout.size) && + areNearlyEqual(previousLayout.width, nextLayout.width) && + areNearlyEqual(previousLayout.height, nextLayout.height) && areNearlyEqual(previousLayout.positionX, nextLayout.positionX) && areNearlyEqual(previousLayout.positionY, nextLayout.positionY) && areNearlyEqual(previousLayout.radius, nextLayout.radius) && @@ -2623,10 +2626,10 @@ export class FrameRenderer { this.webcamSprite, nextLayout.sourceWidth, nextLayout.sourceHeight, - nextLayout.size, - nextLayout.size, - nextLayout.size / 2, - nextLayout.size / 2, + nextLayout.width, + nextLayout.height, + nextLayout.width / 2, + nextLayout.height / 2, nextLayout.mirror, ); @@ -2634,8 +2637,8 @@ export class FrameRenderer { drawSquircleOnGraphics(this.webcamMaskGraphics, { x: 0, y: 0, - width: nextLayout.size, - height: nextLayout.size, + width: nextLayout.width, + height: nextLayout.height, radius: nextLayout.radius, }); this.webcamMaskGraphics.fill({ color: 0xffffff }); @@ -2646,16 +2649,17 @@ export class FrameRenderer { continue; } - const offsetY = nextLayout.size * layer.offsetScale * nextLayout.shadowStrength; + const shadowSize = Math.min(nextLayout.width, nextLayout.height); + const offsetY = shadowSize * layer.offsetScale * nextLayout.shadowStrength; this.rasterizeShadowLayer(layer, { x: 0, y: 0, - width: nextLayout.size, - height: nextLayout.size, + width: nextLayout.width, + height: nextLayout.height, radius: nextLayout.radius, offsetY, alpha: layer.alphaScale * nextLayout.shadowStrength, - blur: Math.max(0, nextLayout.size * layer.blurScale * nextLayout.shadowStrength), + blur: Math.max(0, shadowSize * layer.blurScale * nextLayout.shadowStrength), }); } @@ -2904,10 +2908,19 @@ export class FrameRenderer { } const margin = webcam.margin ?? 24; - const size = getWebcamOverlaySizePx({ + const widthPercent = webcam.width ?? webcam.size ?? 50; + const heightPercent = getCropMatchedWebcamHeightPercent( + widthPercent, + webcam.height ?? webcam.size ?? 50, + renderableWebcamSource.width, + renderableWebcamSource.height, + webcam.cropRegion, + ); + const dimensions = getWebcamOverlayDimensionsPx({ containerWidth: this.config.width, containerHeight: this.config.height, - sizePercent: webcam.size ?? 50, + widthPercent, + heightPercent, margin, zoomScale: this.animationState.appliedScale || 1, reactToZoom: webcam.reactToZoom ?? true, @@ -2915,7 +2928,8 @@ export class FrameRenderer { const position = getWebcamOverlayPosition({ containerWidth: this.config.width, containerHeight: this.config.height, - size, + width: dimensions.width, + height: dimensions.height, margin, positionPreset: webcam.positionPreset ?? webcam.corner, positionX: webcam.positionX ?? 1, @@ -2930,7 +2944,8 @@ export class FrameRenderer { const nextLayout: WebcamLayoutCache = { sourceWidth: renderableWebcamSource.width, sourceHeight: renderableWebcamSource.height, - size, + width: dimensions.width, + height: dimensions.height, positionX: position.x, positionY: position.y, radius, diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index d3279110..a20e0dd0 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -708,6 +708,28 @@ describe("ModernVideoExporter native static-layout eligibility", () => { ).toBeNull(); }); + it("skips native static layout for rectangular webcam overlays", () => { + const exporter = createExporter({ + webcam: { + enabled: true, + sourcePath: "C:\\recordly\\webcam.mp4", + width: 60, + height: 35, + }, + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 60, + ), + ).toBe("unsupported-rectangular-webcam-overlay"); + }); + it("allows native speed timelines with a resolvable webcam source", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 5b65882b..27ac7fc1 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -41,6 +41,7 @@ import { import { getWebcamOverlayPosition, getWebcamOverlaySizePx, + isWebcamCropRegionDefault, } from "@/components/video-editor/webcamOverlay"; import { extensionHost } from "@/lib/extensions"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; @@ -1495,6 +1496,17 @@ export class ModernVideoExporter { } } + private hasUnsupportedNativeStaticLayoutWebcamShape(): boolean { + const webcam = this.config.webcam; + if (!webcam?.enabled) { + return false; + } + + const width = webcam.width ?? webcam.size ?? 40; + const height = webcam.height ?? webcam.size ?? 40; + return Math.abs(width - height) > 0.001 || !isWebcamCropRegionDefault(webcam.cropRegion); + } + private getNativeStaticLayoutSkipReasons( audioPlan: NativeAudioPlan, videoInfo: DecodedVideoInfo, @@ -1560,6 +1572,9 @@ export class ModernVideoExporter { if (this.config.webcam?.enabled && !this.getNativeWebcamSourcePath()) { reasons.push("unsupported-webcam-source"); } + if (this.hasUnsupportedNativeStaticLayoutWebcamShape()) { + reasons.push("unsupported-rectangular-webcam-overlay"); + } if (this.config.frame) { reasons.push("unsupported-frame-overlay"); @@ -2021,7 +2036,7 @@ export class ModernVideoExporter { const rawSize = getWebcamOverlaySizePx({ containerWidth: this.config.width, containerHeight: this.config.height, - sizePercent: webcam.size ?? 40, + sizePercent: webcam.width ?? webcam.size ?? 40, margin, zoomScale: 1, reactToZoom: webcam.reactToZoom ?? true, @@ -2533,8 +2548,7 @@ export class ModernVideoExporter { timelineSegments, chunkDurationSec: STATIC_LAYOUT_CHUNK_DURATION_SEC, experimentalWindowsGpuCompositor: this.config.experimentalNativeExport === true, - experimentalNvidiaCudaExport: - this.config.experimentalNvidiaCudaExport === true, + experimentalNvidiaCudaExport: this.config.experimentalNvidiaCudaExport === true, audioOptions: { ...audioOptions, outputDurationSec: effectiveDuration, From d1e8e7ab9183e7ef4bef4a773a83f70582f42592 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:41:25 +1000 Subject: [PATCH 4/8] feat: improve project save flow --- electron/ipc/register/project.ts | 48 ++- src/components/video-editor/VideoEditor.tsx | 356 +++++++++++++++++++- 2 files changed, 378 insertions(+), 26 deletions(-) diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index a65fd907..61dbdbaa 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -80,6 +80,12 @@ function normalizeProjectSaveName(projectName?: string | null) { return sanitizedName || null; } +type NamedProjectSaveMode = "rename" | "copy"; + +function normalizeNamedProjectSaveMode(value: unknown): NamedProjectSaveMode { + return value === "copy" ? "copy" : "rename"; +} + /** * Extracts the persisted source video path from a saved project payload. */ @@ -292,8 +298,10 @@ export function registerProjectHandlers() { try { const projectsDir = await getProjectsDir() const preparedProject = ensureProjectDataHasProjectId(projectData) - const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath) - ? existingProjectPath + const trustedExistingProjectPath = existingProjectPath && + path.extname(existingProjectPath).toLowerCase() === `.${PROJECT_FILE_EXTENSION}` && + (isTrustedProjectPath(existingProjectPath) || isPathInsideDirectory(existingProjectPath, projectsDir)) + ? path.resolve(existingProjectPath) : null if (trustedExistingProjectPath) { @@ -309,6 +317,13 @@ export function registerProjectHandlers() { } } + if (existingProjectPath) { + return { + success: false, + message: 'Project path is no longer trusted. Use Save As to choose a project file.', + } + } + const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}` const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` @@ -351,7 +366,7 @@ export function registerProjectHandlers() { } }) - ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null) => { + ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null, mode?: unknown) => { try { const normalizedProjectName = normalizeProjectSaveName(projectName) if (!normalizedProjectName) { @@ -362,7 +377,7 @@ export function registerProjectHandlers() { } const projectsDir = await getProjectsDir() - const preparedProject = ensureProjectDataHasProjectId(projectData) + const namedSaveMode = normalizeNamedProjectSaveMode(mode) const activeProjectPath = isTrustedProjectPath(currentProjectPath) ? currentProjectPath : null @@ -370,6 +385,22 @@ export function registerProjectHandlers() { projectsDir, `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, ) + const [activeResolvedPath, targetResolvedPath] = await Promise.all([ + activeProjectPath ? resolveComparablePath(activeProjectPath) : Promise.resolve(null), + resolveComparablePath(targetProjectPath), + ]) + const isSavingToDifferentPath = + !activeResolvedPath || activeResolvedPath !== targetResolvedPath + const preparedProject = + namedSaveMode === "copy" && isSavingToDifferentPath + ? (() => { + const projectId = randomUUID() + return { + projectId, + projectData: withProjectId(projectData, projectId), + } + })() + : ensureProjectDataHasProjectId(projectData) const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject( targetProjectPath, @@ -384,13 +415,7 @@ export function registerProjectHandlers() { await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) await rememberRecentProject(targetProjectPath) - if (activeProjectPath) { - const [activeResolvedPath, targetResolvedPath] = await Promise.all([ - resolveComparablePath(activeProjectPath), - resolveComparablePath(targetProjectPath), - ]) - - if (activeResolvedPath !== targetResolvedPath) { + if (namedSaveMode === "rename" && activeProjectPath && isSavingToDifferentPath) { await fs.unlink(activeProjectPath).catch((unlinkError: NodeJS.ErrnoException) => { if (unlinkError.code !== 'ENOENT') { throw unlinkError @@ -407,7 +432,6 @@ export function registerProjectHandlers() { } } await saveRecentProjectPaths(filteredRecentProjectPaths) - } } setCurrentProjectPath(targetProjectPath) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f18b931f..19e366cd 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -298,6 +298,18 @@ type SaveProjectOptions = { captureThumbnail?: boolean; }; +type NamedProjectSaveMode = "rename" | "copy"; + +type PendingProjectSaveDialog = { + resolve: (saved: boolean) => void; +}; + +type PendingUnsavedChangesDialogDecision = "cancel" | "discard" | "save"; + +type PendingUnsavedChangesDialog = { + resolve: (decision: PendingUnsavedChangesDialogDecision) => void; +}; + async function writeSmokeExportReport( outputPath: string | null, report: Record, @@ -382,6 +394,13 @@ export default function VideoEditor() { const [isEditingProjectName, setIsEditingProjectName] = useState(false); const [projectNameDraft, setProjectNameDraft] = useState(""); const [isSavingProjectName, setIsSavingProjectName] = useState(false); + const [projectSaveDialogOpen, setProjectSaveDialogOpen] = useState(false); + const [projectSaveDialogDraft, setProjectSaveDialogDraft] = useState(""); + const [isSavingProjectDialog, setIsSavingProjectDialog] = useState(false); + const [unsavedChangesDialogOpen, setUnsavedChangesDialogOpen] = useState(false); + const [unsavedChangesDialogActionLabel, setUnsavedChangesDialogActionLabel] = useState( + "continue", + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -529,7 +548,7 @@ export default function VideoEditor() { const [autoCaptionSettings, setAutoCaptionSettings] = useState( DEFAULT_AUTO_CAPTION_SETTINGS, ); - const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(true); + const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(false); const [whisperExecutablePath, setWhisperExecutablePath] = useState( initialEditorPreferences.whisperExecutablePath, ); @@ -648,6 +667,7 @@ export default function VideoEditor() { const projectBrowserTriggerRef = useRef(null); const projectBrowserFallbackTriggerRef = useRef(null); const projectNameInputRef = useRef(null); + const projectSaveDialogInputRef = useRef(null); const nextZoomIdRef = useRef(1); const nextClipIdRef = useRef(1); const nextAudioIdRef = useRef(1); @@ -668,6 +688,8 @@ export default function VideoEditor() { const mp4SupportRequestRef = useRef(0); const smokeExportStartedRef = useRef(false); const projectAutosaveTimeoutRef = useRef(null); + const pendingProjectSaveDialogRef = useRef(null); + const pendingUnsavedChangesDialogRef = useRef(null); const projectSaveQueueRef = useRef>(Promise.resolve()); const smokeExportReadyStateRef = useRef>({}); const [historyVersion, setHistoryVersion] = useState(0); @@ -1734,6 +1756,21 @@ export default function VideoEditor() { }; }, [isEditingProjectName]); + useEffect(() => { + if (!projectSaveDialogOpen) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + projectSaveDialogInputRef.current?.focus(); + projectSaveDialogInputRef.current?.select(); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [projectSaveDialogOpen]); + const currentPersistedEditorState = useMemo( () => buildPersistedEditorState({ @@ -2127,6 +2164,45 @@ export default function VideoEditor() { ); }, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]); + const resolveProjectSaveDialog = useCallback((saved: boolean) => { + const pendingDialog = pendingProjectSaveDialogRef.current; + pendingProjectSaveDialogRef.current = null; + setProjectSaveDialogOpen(false); + setIsSavingProjectDialog(false); + pendingDialog?.resolve(saved); + }, []); + + const openProjectSaveDialog = useCallback((initialName: string) => { + pendingProjectSaveDialogRef.current?.resolve(false); + setProjectSaveDialogDraft(initialName); + setProjectSaveDialogOpen(true); + setIsSavingProjectDialog(false); + + return new Promise((resolve) => { + pendingProjectSaveDialogRef.current = { resolve }; + }); + }, []); + + const resolveUnsavedChangesDialog = useCallback( + (decision: PendingUnsavedChangesDialogDecision) => { + const pendingDialog = pendingUnsavedChangesDialogRef.current; + pendingUnsavedChangesDialogRef.current = null; + setUnsavedChangesDialogOpen(false); + pendingDialog?.resolve(decision); + }, + [], + ); + + const openUnsavedChangesDialog = useCallback((actionLabel: string) => { + pendingUnsavedChangesDialogRef.current?.resolve("cancel"); + setUnsavedChangesDialogActionLabel(actionLabel); + setUnsavedChangesDialogOpen(true); + + return new Promise((resolve) => { + pendingUnsavedChangesDialogRef.current = { resolve }; + }); + }, []); + const syncRecordingSessionWebcam = useCallback( async (webcamPath: string | null, timeOffsetMs?: number) => { if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) { @@ -2765,7 +2841,6 @@ export default function VideoEditor() { } setAutoCaptions(result.cues); - setAutoCaptionSettings((prev) => ({ ...prev, enabled: true })); toast.success(result.message || `Generated ${result.cues.length} captions`); } catch (error) { toast.error(getErrorMessage(error)); @@ -2831,6 +2906,14 @@ export default function VideoEditor() { } } + if (forceSaveAs || !targetProjectPath) { + if (options?.silent) { + return false; + } + + return openProjectSaveDialog(projectDisplayName || fileNameBase); + } + const thumbnailDataUrl = shouldCaptureThumbnail ? await captureProjectThumbnail() : undefined; @@ -2891,6 +2974,8 @@ export default function VideoEditor() { currentProjectSnapshot, currentPersistedEditorState, lastSavedSnapshot?.projectId, + openProjectSaveDialog, + projectDisplayName, queueProjectSave, refreshProjectLibrary, remountPreview, @@ -2945,7 +3030,7 @@ export default function VideoEditor() { * Saves the current project directly into the projects library under a chosen name. */ const saveProjectWithName = useCallback( - async (projectName: string) => { + async (projectName: string, mode: NamedProjectSaveMode = "rename") => { const trimmedProjectName = projectName.trim(); if (!trimmedProjectName) { toast.error("Project name is required"); @@ -2971,6 +3056,7 @@ export default function VideoEditor() { projectData, trimmedProjectName, thumbnailDataUrl, + mode, ); if (result.canceled) { @@ -3013,6 +3099,38 @@ export default function VideoEditor() { ], ); + const handleProjectSaveDialogSubmit = useCallback( + async (event?: React.FormEvent) => { + event?.preventDefault(); + const trimmedProjectName = projectSaveDialogDraft.trim(); + + if (!trimmedProjectName) { + toast.error("Project name is required"); + projectSaveDialogInputRef.current?.focus(); + return; + } + + setIsSavingProjectDialog(true); + let saved = false; + try { + saved = await saveProjectWithName(trimmedProjectName, "copy"); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setIsSavingProjectDialog(false); + } + + if (saved) { + resolveProjectSaveDialog(true); + return; + } + + projectSaveDialogInputRef.current?.focus(); + projectSaveDialogInputRef.current?.select(); + }, + [projectSaveDialogDraft, resolveProjectSaveDialog, saveProjectWithName], + ); + /** * Resets the inline project-name editor back to the current saved display name. */ @@ -3036,7 +3154,7 @@ export default function VideoEditor() { setIsSavingProjectName(true); let saved = false; try { - saved = await saveProjectWithName(trimmedProjectName); + saved = await saveProjectWithName(trimmedProjectName, "rename"); } catch (error) { toast.error(getErrorMessage(error)); } finally { @@ -3054,8 +3172,32 @@ export default function VideoEditor() { [closeProjectNameEditor, projectNameDraft, saveProjectWithName], ); + const confirmReplaceSourceWithUnsavedChanges = useCallback( + async (actionLabel: string) => { + if (!hasUnsavedChanges) { + return true; + } + + const decision = await openUnsavedChangesDialog(actionLabel); + if (decision === "discard") { + return true; + } + + if (decision === "save") { + return saveProject(false); + } + + return false; + }, + [hasUnsavedChanges, openUnsavedChangesDialog, saveProject], + ); + const handleOpenProjectFromLibrary = useCallback( async (projectPath: string) => { + if (!(await confirmReplaceSourceWithUnsavedChanges("open another project"))) { + return; + } + const result = await window.electronAPI.openProjectFileAtPath(projectPath); if (result.canceled) { @@ -3077,9 +3219,82 @@ export default function VideoEditor() { await refreshProjectLibrary(); toast.success(`Project loaded from ${result.path}`); }, - [applyLoadedProject, refreshProjectLibrary], + [applyLoadedProject, confirmReplaceSourceWithUnsavedChanges, refreshProjectLibrary], ); + const handleImportMediaOrProject = useCallback(async () => { + if (!(await confirmReplaceSourceWithUnsavedChanges("import a file"))) { + return; + } + + const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true }); + + if (result.canceled) { + return; + } + + if (!result.success) { + toast.error(result.message || "Failed to import file"); + return; + } + + if (result.kind === "project" || result.project) { + const restored = await applyLoadedProject(result.project, result.path ?? null); + if (!restored) { + toast.error("Invalid project file format"); + return; + } + + setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded"); + return; + } + + if (!result.path) { + toast.error("No media file selected"); + return; + } + + const sourcePath = fromFileUrl(result.path); + const sourceVideoUrl = await resolveVideoUrl(sourcePath); + try { + videoPlaybackRef.current?.pause(); + } catch { + // no-op + } + + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + setVideoSourcePath(sourcePath); + setVideoPath(sourceVideoUrl); + setCurrentProjectPath(null); + setLastSavedSnapshot(null); + resetSourceScopedEditorState(); + pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms + ? sourceVideoUrl + : null; + setWebcam((prev) => ({ + ...prev, + enabled: false, + sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + applySessionPresentation(null); + await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: false }); + setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success("Media imported"); + }, [ + applyLoadedProject, + applySessionPresentation, + autoApplyFreshRecordingAutoZooms, + confirmReplaceSourceWithUnsavedChanges, + refreshProjectLibrary, + resetSourceScopedEditorState, + ]); + const handleOpenProjectBrowser = useCallback(async () => { if (projectBrowserOpen) { setProjectBrowserOpen(false); @@ -3398,7 +3613,9 @@ export default function VideoEditor() { const handlePreviewSkipBack = useCallback(() => { const currentMs = timelinePlayheadTime * 1000; const keyframes = timelineRef.current?.keyframes ?? []; - const previous = [...keyframes].reverse().find((keyframe) => keyframe.time < currentMs - 50); + const previous = [...keyframes] + .reverse() + .find((keyframe) => keyframe.time < currentMs - 50); handleSeek(previous ? previous.time / 1000 : Math.max(0, timelinePlayheadTime - 5)); }, [handleSeek, timelinePlayheadTime]); @@ -3406,9 +3623,7 @@ export default function VideoEditor() { const currentMs = timelinePlayheadTime * 1000; const keyframes = timelineRef.current?.keyframes ?? []; const next = keyframes.find((keyframe) => keyframe.time > currentMs + 50); - handleSeek( - next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5), - ); + handleSeek(next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5)); }, [handleSeek, timelineDuration, timelinePlayheadTime]); const handleSelectZoom = useCallback((id: string | null) => { @@ -5215,21 +5430,126 @@ export default function VideoEditor() { volume={ audio.shouldMutePreviewVideo || audio.isCurrentClipMuted ? 0 - : Math.max( - 0, - Math.min(1, previewVolume * audio.embeddedSourcePreviewGain), - ) + : Math.max(0, Math.min(1, previewVolume * audio.embeddedSourcePreviewGain)) } suspendRendering={suspendRendering} /> ); + const projectSaveDialog = ( + { + if (open) { + setProjectSaveDialogOpen(true); + return; + } + + if (!isSavingProjectDialog) { + resolveProjectSaveDialog(false); + } + }} + > + +
void handleProjectSaveDialogSubmit(event)}> + + {t("editor.project.saveTitle", "Save Project")} + + {t( + "editor.project.saveDescription", + "Name this project. It will be saved in your Recordly Projects folder.", + )} + + +
+ +
+ setProjectSaveDialogDraft(event.target.value)} + disabled={isSavingProjectDialog} + className="h-10 flex-1 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" + aria-label={t("editor.project.saveNameLabel", "Project name")} + /> + + .recordly + +
+
+ + + + +
+
+
+ ); + + const unsavedChangesDialog = ( + { + if (open) { + setUnsavedChangesDialogOpen(true); + return; + } + + resolveUnsavedChangesDialog("cancel"); + }} + > + + + Unsaved changes + + {`Save your current project before you ${unsavedChangesDialogActionLabel}?`} + + + + + + + + + + ); + const projectBrowser = ( { + void handleImportMediaOrProject(); + }} onOpenProject={(projectPath) => { void handleOpenProjectFromLibrary(projectPath); }} @@ -5269,6 +5589,8 @@ export default function VideoEditor() {
Loading video...
{projectBrowser} + {projectSaveDialog} + {unsavedChangesDialog} {nativeCaptureUnavailableDialog}
@@ -5289,6 +5611,8 @@ export default function VideoEditor() {
{projectBrowser} + {projectSaveDialog} + {unsavedChangesDialog} {nativeCaptureUnavailableDialog} @@ -5736,7 +6060,9 @@ export default function VideoEditor() { onGifLoopChange={setGifLoop} gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} - showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"} + showCaptionSidecarOption={ + hasCaptionsForSidecar && exportFormat === "mp4" + } includeCaptionSidecar={includeCaptionSidecar} onIncludeCaptionSidecarChange={setIncludeCaptionSidecar} mp4OutputDimensions={mp4OutputDimensions} @@ -6415,6 +6741,8 @@ export default function VideoEditor() { ) : null} {projectBrowser} + {projectSaveDialog} + {unsavedChangesDialog} {nativeCaptureUnavailableDialog} From 4b38154b79d46d9d625a9ac9e5713b68fe826e1b Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:41:25 +1000 Subject: [PATCH 5/8] feat: align preview and export rendering --- src/components/video-editor/VideoPlayback.tsx | 257 +++++++++++------- .../videoPlayback/cursorRenderer.ts | 69 ++--- src/lib/exporter/frameRenderer.ts | 50 +--- src/lib/exporter/modernFrameRenderer.test.ts | 39 +++ src/lib/exporter/modernFrameRenderer.ts | 100 +++---- ...rnVideoExporter.nativeStaticLayout.test.ts | 9 + src/lib/exporter/modernVideoExporter.ts | 73 +---- 7 files changed, 305 insertions(+), 292 deletions(-) diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 488bc3a9..5bf85170 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -166,7 +166,10 @@ import { SNAP_TO_EDGES_RATIO_AUTO, } from "./videoPlayback/cursorFollowCamera"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; -import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; +import { + layoutVideoContent as layoutVideoContentUtil, + scalePreviewBorderRadius, +} from "./videoPlayback/layoutUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { @@ -176,7 +179,6 @@ import { import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; import { applyZoomTransform, - computeFocusFromTransform, computeZoomTransform, createMotionBlurState, type MotionBlurState, @@ -197,6 +199,19 @@ type PlaybackAnimationState = { y: number; }; +type SceneTransformState = { + scale: number; + x: number; + y: number; +}; + +type AnnotationRecordingRect = { + x: number; + y: number; + width: number; + height: number; +}; + function createPlaybackAnimationState(): PlaybackAnimationState { return { scale: 1, @@ -496,6 +511,19 @@ const VideoPlayback = forwardRef( null, ); const [frameUpdateCounter, setFrameUpdateCounter] = useState(0); + const [annotationSceneTransform, setAnnotationSceneTransform] = + useState({ + scale: 1, + x: 0, + y: 0, + }); + const [annotationRecordingRect, setAnnotationRecordingRect] = + useState({ + x: 0, + y: 0, + width: 0, + height: 0, + }); useEffect(() => { let framesSignature = getRegisteredFramesSignature(); @@ -847,7 +875,11 @@ const VideoPlayback = forwardRef( y: 0, width: scaledSize, height: scaledSize, - radius: webcamCornerRadius, + radius: scalePreviewBorderRadius( + overlay.clientWidth, + overlay.clientHeight, + webcamCornerRadius, + ), }); bubble.style.filter = `drop-shadow(0 ${Math.round(scaledSize * 0.06)}px ${Math.round( scaledSize * 0.22, @@ -997,6 +1029,23 @@ const VideoPlayback = forwardRef( renderWidth: result.maskRect.width * renderResolution, renderHeight: result.maskRect.height * renderResolution, }; + setAnnotationRecordingRect((current) => { + if ( + Math.abs(current.x - result.maskRect.x) < 0.1 && + Math.abs(current.y - result.maskRect.y) < 0.1 && + Math.abs(current.width - result.maskRect.width) < 0.1 && + Math.abs(current.height - result.maskRect.height) < 0.1 + ) { + return current; + } + + return { + x: result.maskRect.x, + y: result.maskRect.y, + width: result.maskRect.width, + height: result.maskRect.height, + }; + }); cropBoundsRef.current = result.cropBounds; // Sync extension cursor effects canvas resolution with renderer @@ -1983,6 +2032,7 @@ const VideoPlayback = forwardRef( if (cursorOverlayEnabled) { const cursorOverlay = new PixiCursorOverlay({ dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current, + minViewportScale: 0, style: cursorStyleRef.current, smoothingFactor: cursorSmoothingRef.current, springTuning: { @@ -2193,6 +2243,21 @@ const VideoPlayback = forwardRef( state.x = appliedTransform.x; state.y = appliedTransform.y; state.appliedScale = appliedTransform.scale; + setAnnotationSceneTransform((current) => { + if ( + Math.abs(current.scale - appliedTransform.scale) < 0.001 && + Math.abs(current.x - appliedTransform.x) < 0.1 && + Math.abs(current.y - appliedTransform.y) < 0.1 + ) { + return current; + } + + return { + scale: appliedTransform.scale, + x: appliedTransform.x, + y: appliedTransform.y, + }; + }); }; const ticker = () => { @@ -2200,7 +2265,7 @@ const VideoPlayback = forwardRef( return; } - const { region, strength, blendedScale, transition } = findDominantRegion( + const { region, strength, blendedScale } = findDominantRegion( zoomRegionsRef.current, currentTimeRef.current, { @@ -2245,47 +2310,6 @@ const VideoPlayback = forwardRef( targetScaleFactor = zoomScale; targetFocus = regionFocus; targetProgress = strength; - - if (transition) { - const startTransform = computeZoomTransform({ - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: transition.startScale, - zoomProgress: 1, - focusX: transition.startFocus.cx, - focusY: transition.startFocus.cy, - }); - const endTransform = computeZoomTransform({ - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: transition.endScale, - zoomProgress: 1, - focusX: transition.endFocus.cx, - focusY: transition.endFocus.cy, - }); - - const interpolatedTransform = { - scale: - startTransform.scale + - (endTransform.scale - startTransform.scale) * transition.progress, - x: - startTransform.x + - (endTransform.x - startTransform.x) * transition.progress, - y: - startTransform.y + - (endTransform.y - startTransform.y) * transition.progress, - }; - - targetScaleFactor = interpolatedTransform.scale; - targetFocus = computeFocusFromTransform({ - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: interpolatedTransform.scale, - x: interpolatedTransform.x, - y: interpolatedTransform.y, - }); - targetProgress = 1; - } } const state = animationStateRef.current; @@ -3044,58 +3068,97 @@ const VideoPlayback = forwardRef( ) : null} - {(() => { - const filtered = (annotationRegions || []).filter((annotation) => { - if ( - typeof annotation.startMs !== "number" || - typeof annotation.endMs !== "number" - ) - return false; - - if (annotation.id === selectedAnnotationId) return true; - - const timeMs = Math.round(currentTime * 1000); - return timeMs >= annotation.startMs && timeMs <= annotation.endMs; - }); - - // Sort by z-index (lowest to highest) so higher z-index renders on top - const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); - - // Handle click-through cycling: when clicking same annotation, cycle to next - const handleAnnotationClick = (clickedId: string) => { - if (!onSelectAnnotation) return; - - // If clicking on already selected annotation and there are multiple overlapping - if (clickedId === selectedAnnotationId && sorted.length > 1) { - // Find current index and cycle to next - const currentIndex = sorted.findIndex( - (a) => a.id === clickedId, - ); - const nextIndex = (currentIndex + 1) % sorted.length; - onSelectAnnotation(sorted[nextIndex].id); - } else { - // First click or clicking different annotation - onSelectAnnotation(clickedId); - } - }; - - return sorted.map((annotation) => ( - - onAnnotationPositionChange?.(id, position) - } - onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)} - onClick={handleAnnotationClick} - zIndex={annotation.zIndex} - isSelectedBoost={annotation.id === selectedAnnotationId} - /> - )); - })()} +
+
+ {(() => { + const filtered = (annotationRegions || []).filter((annotation) => { + if ( + typeof annotation.startMs !== "number" || + typeof annotation.endMs !== "number" + ) + return false; + + if (annotation.id === selectedAnnotationId) return true; + + const timeMs = Math.round(currentTime * 1000); + return timeMs >= annotation.startMs && timeMs <= annotation.endMs; + }); + + const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); + + const handleAnnotationClick = (clickedId: string) => { + if (!onSelectAnnotation) return; + + if (clickedId === selectedAnnotationId && sorted.length > 1) { + const currentIndex = sorted.findIndex( + (a) => a.id === clickedId, + ); + const nextIndex = (currentIndex + 1) % sorted.length; + onSelectAnnotation(sorted[nextIndex].id); + } else { + onSelectAnnotation(clickedId); + } + }; + + return sorted.map((annotation) => ( + + onAnnotationPositionChange?.(id, position) + } + onSizeChange={(id, size) => + onAnnotationSizeChange?.(id, size) + } + onClick={handleAnnotationClick} + zIndex={annotation.zIndex} + isSelectedBoost={annotation.id === selectedAnnotationId} + /> + )); + })()} +
+
)} {/* Keep the source video off-screen instead of display:none so the diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 2055c607..d957b110 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -76,6 +76,8 @@ export type NativeCursorAtlas = { export interface CursorRenderConfig { /** Base cursor height in pixels (at reference width of 1920px) */ dotRadius: number; + /** Minimum viewport scale applied to cursor sizing. */ + minViewportScale: number; /** Cursor fill color (hex number for PixiJS) */ dotColor: number; /** Cursor opacity (0–1) */ @@ -108,8 +110,25 @@ export interface CursorRenderConfig { style: CursorStyle; } +const REFERENCE_WIDTH = 1920; +const MIN_CURSOR_VIEWPORT_SCALE = 0.55; +const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08; +const CURSOR_TIME_DISCONTINUITY_MS = 100; +const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7; +const CURSOR_SWAY_SMOOTHING_OFFSET = 0.18; +const CURSOR_SVG_DROP_SHADOW_FILTER = "drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))"; +const CURSOR_SHADOW_COLOR = 0x000000; +const CURSOR_SHADOW_ALPHA = 0.35; +const CURSOR_SHADOW_OFFSET_X = 0; +const CURSOR_SHADOW_OFFSET_Y = 2; +const CURSOR_SHADOW_BLUR = 3; +const CURSOR_SHADOW_PADDING = 12; +const NATIVE_CURSOR_ATLAS_DRAW_HEIGHT = 256; +const NATIVE_CURSOR_ATLAS_PADDING = 2; + export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = { dotRadius: 28, + minViewportScale: MIN_CURSOR_VIEWPORT_SCALE, dotColor: 0xffffff, dotAlpha: 0.95, trailLength: 0, @@ -130,22 +149,6 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = { sway: 0, style: DEFAULT_CURSOR_STYLE, }; - -const REFERENCE_WIDTH = 1920; -const MIN_CURSOR_VIEWPORT_SCALE = 0.55; -const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08; -const CURSOR_TIME_DISCONTINUITY_MS = 100; -const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7; -const CURSOR_SWAY_SMOOTHING_OFFSET = 0.18; -const CURSOR_SVG_DROP_SHADOW_FILTER = "drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))"; -const CURSOR_SHADOW_COLOR = 0x000000; -const CURSOR_SHADOW_ALPHA = 0.35; -const CURSOR_SHADOW_OFFSET_X = 0; -const CURSOR_SHADOW_OFFSET_Y = 2; -const CURSOR_SHADOW_BLUR = 3; -const CURSOR_SHADOW_PADDING = 12; -const NATIVE_CURSOR_ATLAS_DRAW_HEIGHT = 256; -const NATIVE_CURSOR_ATLAS_PADDING = 2; let cursorAssetsPromise: Promise | null = null; let cursorPackAssetsPromise: Promise | null = null; let loadedCursorPackSourcesSignature = ""; @@ -556,10 +559,7 @@ export async function preloadCursorAssets() { sourceAsset.fallbackAnchor, ); - return [ - key, - asset, - ] as const; + return [key, asset] as const; } catch (error) { console.warn( `[CursorRenderer] Failed to load cursor image for: ${style}/${key}`, @@ -807,8 +807,11 @@ function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: num return findLatestSample(samples, timeMs)?.cursorType ?? "arrow"; } -function getCursorViewportScale(viewport: CursorViewportRect) { - return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); +function getCursorViewportScale( + viewport: CursorViewportRect, + minViewportScale = MIN_CURSOR_VIEWPORT_SCALE, +) { + return Math.max(minViewportScale, viewport.width / REFERENCE_WIDTH); } function getCursorSwaySpringConfig(smoothingFactor: number, springTuning: CursorSpringTuning) { @@ -1341,7 +1344,9 @@ export class PixiCursorOverlay { const projectedTarget = projectCursorPositionToViewport(target, viewport.sourceCrop); - const h = this.config.dotRadius * getCursorViewportScale(viewport); + const h = + this.config.dotRadius * + getCursorViewportScale(viewport, this.config.minViewportScale); const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState( samples, @@ -1395,7 +1400,8 @@ export class PixiCursorOverlay { if (shouldShowCursorSprite) { const sameFrameTime = - this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; + this.lastRenderedTimeMs !== null && + Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; const hasTimeDiscontinuity = this.lastRenderedTimeMs !== null && Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS; @@ -1636,14 +1642,13 @@ export function drawCursorOnCanvas( const px = viewport.x + smoothedState.x * viewport.width; const py = viewport.y + smoothedState.y * viewport.height; - const h = config.dotRadius * getCursorViewportScale(viewport); - const { cursorType, clickSample, clickBounceProgress, clickProgress } = - getCursorVisualState( - samples, - timeMs, - config.clickBounceDuration, - config.clickEffectDurationMs, - ); + const h = config.dotRadius * getCursorViewportScale(viewport, config.minViewportScale); + const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState( + samples, + timeMs, + config.clickBounceDuration, + config.clickEffectDurationMs, + ); const projectedClickSample = clickSample ? projectCursorPositionToViewport(clickSample, viewport.sourceCrop) : null; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 049c25bb..6ba9ef23 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -45,7 +45,6 @@ import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/video import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; import { applyZoomTransform, - computeFocusFromTransform, computeZoomTransform, createMotionBlurState, type MotionBlurState, @@ -1547,6 +1546,9 @@ export class FrameRenderer { this.config.height, temporalSnapshot.timeMs, scaleFactor, + undefined, + temporalSnapshot.sceneTransform, + this.layoutCache?.maskRect, ); } @@ -1731,6 +1733,13 @@ export class FrameRenderer { this.config.height, timeMs, scaleFactor, + undefined, + { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + this.layoutCache?.maskRect, ); } @@ -1961,7 +1970,7 @@ export class FrameRenderer { private updateAnimationState(timeMs: number): number { if (!this.cameraContainer || !this.layoutCache) return 0; - const { region, strength, blendedScale, transition } = findDominantRegion( + const { region, strength, blendedScale } = findDominantRegion( this.config.zoomRegions, timeMs, { @@ -2001,43 +2010,6 @@ export class FrameRenderer { targetScaleFactor = zoomScale; targetFocus = regionFocus; targetProgress = strength; - - if (transition) { - const startTransform = computeZoomTransform({ - stageSize: this.layoutCache.stageSize, - baseMask: this.layoutCache.maskRect, - zoomScale: transition.startScale, - zoomProgress: 1, - focusX: transition.startFocus.cx, - focusY: transition.startFocus.cy, - }); - const endTransform = computeZoomTransform({ - stageSize: this.layoutCache.stageSize, - baseMask: this.layoutCache.maskRect, - zoomScale: transition.endScale, - zoomProgress: 1, - focusX: transition.endFocus.cx, - focusY: transition.endFocus.cy, - }); - - const interpolatedTransform = { - scale: - startTransform.scale + - (endTransform.scale - startTransform.scale) * transition.progress, - x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress, - y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress, - }; - - targetScaleFactor = interpolatedTransform.scale; - targetFocus = computeFocusFromTransform({ - stageSize: this.layoutCache.stageSize, - baseMask: this.layoutCache.maskRect, - zoomScale: interpolatedTransform.scale, - x: interpolatedTransform.x, - y: interpolatedTransform.y, - }); - targetProgress = 1; - } } const state = this.animationState; diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index 1f43a9c9..688e51f2 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -236,6 +236,45 @@ describe("ModernFrameRenderer blur export path", () => { expect(renderer.capturePixelsForNativeExport()).not.toBeNull(); }); + it("uses the sampled scene transform for blur annotations during temporal blur", async () => { + const renderer = createRenderer() as any; + renderer.config.zoomTemporalMotionBlur = 1; + renderer.config.zoomMotionBlurSampleCount = 3; + renderer.config.zoomMotionBlurShutterFraction = 0.5; + renderer.app = { canvas: createMockCanvas() }; + renderer.annotationScaleFactor = 1; + renderer.annotationAssets = { imageCache: new Map() }; + renderer.updateCaptionLayer = vi.fn(); + renderer.renderSceneSample = vi.fn(async (sampleTimestamp: number) => ({ + timeMs: sampleTimestamp / 1000, + cursorTimeMs: sampleTimestamp / 1000, + backgroundTimelineTimeMs: sampleTimestamp / 1000, + sceneTransform: { scale: 1.75, x: 120, y: -48 }, + zoom: { scale: 1, focusX: 0.5, focusY: 0.5, progress: 0 }, + })); + + await renderer.renderTemporalMotionBlurFrame(1_000_000, 1_000_000, 1_000_000, 33_333, { + stageSize: { width: 1920, height: 1080 }, + videoSize: { width: 1920, height: 1080 }, + baseScale: 1, + baseOffset: { x: 0, y: 0 }, + maskRect: { + x: 0, + y: 0, + width: 1920, + height: 1080, + sourceCrop: { x: 0, y: 0, width: 1, height: 1 }, + }, + }); + + expect(renderAnnotations).toHaveBeenCalled(); + expect(vi.mocked(renderAnnotations).mock.lastCall?.[7]).toEqual({ + scale: 1.75, + x: 120, + y: -48, + }); + }); + it("prefers decoder-backed sync for video wallpapers during export", async () => { vi.clearAllMocks(); const renderer = new FrameRenderer({ diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 6ab918d8..72b862cb 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -15,8 +15,8 @@ import type { AnnotationRegion, AutoCaptionSettings, CaptionCue, - CursorClickEffectStyle, CropRegion, + CursorClickEffectStyle, CursorStyle, CursorTelemetryPoint, Padding, @@ -39,7 +39,10 @@ import { PixiCursorOverlay, preloadCursorAssets, } from "@/components/video-editor/videoPlayback/cursorRenderer"; -import { computePaddedLayout } from "@/components/video-editor/videoPlayback/layoutUtils"; +import { + computePaddedLayout, + scalePreviewBorderRadius, +} from "@/components/video-editor/videoPlayback/layoutUtils"; import { createSpringState, getZoomSpringConfig, @@ -51,7 +54,6 @@ import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/video import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; import { applyZoomTransform, - computeFocusFromTransform, computeZoomTransform, createMotionBlurState, type MotionBlurState, @@ -599,7 +601,7 @@ export class FrameRenderer { this.webcamRootContainer.visible = false; this.overlayContainer.addChild(this.webcamRootContainer); - this.overlayContainer.addChild(this.annotationContainer); + this.cameraContainer.addChild(this.annotationContainer); this.overlayContainer.addChild(this.captionContainer); this.videoMaskGraphics = new Graphics(); @@ -622,14 +624,14 @@ export class FrameRenderer { massMultiplier: this.config.cursorSpringMassMultiplier, }, motionBlur: this.config.cursorMotionBlur ?? 0, - clickEffect: - this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect, + clickEffect: this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect, clickEffectColor: this.config.cursorClickEffectColor ?? DEFAULT_CURSOR_CONFIG.clickEffectColor, clickEffectScale: this.config.cursorClickEffectScale ?? DEFAULT_CURSOR_CONFIG.clickEffectScale, clickEffectOpacity: - this.config.cursorClickEffectOpacity ?? DEFAULT_CURSOR_CONFIG.clickEffectOpacity, + this.config.cursorClickEffectOpacity ?? + DEFAULT_CURSOR_CONFIG.clickEffectOpacity, clickEffectDurationMs: this.config.cursorClickEffectDurationMs ?? DEFAULT_CURSOR_CONFIG.clickEffectDurationMs, @@ -1467,9 +1469,7 @@ export class FrameRenderer { private calculateAnnotationScaleFactor(): number { const previewWidth = this.config.previewWidth || 1920; const previewHeight = this.config.previewHeight || 1080; - const scaleX = this.config.width / previewWidth; - const scaleY = this.config.height / previewHeight; - return (scaleX + scaleY) / 2; + return (this.config.width / previewWidth + this.config.height / previewHeight) / 2; } private hasActiveBlurAnnotations(timeMs: number): boolean { @@ -1561,6 +1561,7 @@ export class FrameRenderer { private async composeBlurAnnotationFrame( timeMs: number, + sceneTransform?: { scale: number; x: number; y: number }, sourceCanvas?: CanvasImageSource, ): Promise { if (!this.app) { @@ -1586,6 +1587,12 @@ export class FrameRenderer { timeMs, this.annotationScaleFactor, this.annotationAssets ?? undefined, + sceneTransform ?? { + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }, + this.layoutCache?.maskRect, ); this.drawCaptionOverlay(context); @@ -1609,10 +1616,16 @@ export class FrameRenderer { ); for (const annotation of annotations) { - const x = (annotation.position.x / 100) * this.config.width; - const y = (annotation.position.y / 100) * this.config.height; - const width = (annotation.size.width / 100) * this.config.width; - const height = (annotation.size.height / 100) * this.config.height; + const annotationRect = this.layoutCache?.maskRect ?? { + x: 0, + y: 0, + width: this.config.width, + height: this.config.height, + }; + const x = annotationRect.x + (annotation.position.x / 100) * annotationRect.width; + const y = annotationRect.y + (annotation.position.y / 100) * annotationRect.height; + const width = (annotation.size.width / 100) * annotationRect.width; + const height = (annotation.size.height / 100) * annotationRect.height; if (width <= 0 || height <= 0) { continue; @@ -2549,15 +2562,10 @@ export class FrameRenderer { const usesDefaultCropRegion = isWebcamCropRegionDefault(this.config.webcam?.cropRegion); const needsCacheBackedSource = !usesDefaultCropRegion || - (typeof HTMLVideoElement !== "undefined" && - liveSource instanceof HTMLVideoElement); + (typeof HTMLVideoElement !== "undefined" && liveSource instanceof HTMLVideoElement); if (needsCacheBackedSource) { - this.refreshWebcamFrameCache( - liveSource, - liveSourceWidth, - liveSourceHeight, - ); + this.refreshWebcamFrameCache(liveSource, liveSourceWidth, liveSourceHeight); const cachedSource = this.getCachedWebcamRenderSource(); if (cachedSource) { this.setWebcamRenderMode("live"); @@ -3129,7 +3137,11 @@ export class FrameRenderer { (this.config.annotationRegions?.length ?? 0) > 0 || Boolean(this.captionCanvas && this.captionSprite?.visible); if (hasOverlayCanvasWork) { - await this.composeBlurAnnotationFrame(resolvedSnapshot.timeMs, compositeState.canvas); + await this.composeBlurAnnotationFrame( + resolvedSnapshot.timeMs, + resolvedSnapshot.sceneTransform, + compositeState.canvas, + ); } else { this.outputCanvasOverride = compositeState.canvas; } @@ -3536,10 +3548,7 @@ export class FrameRenderer { this.videoSprite.scale.set(layout.scale); this.videoSprite.position.set(layout.spriteX, layout.spriteY); - const previewWidth = this.config.previewWidth || 1920; - const previewHeight = this.config.previewHeight || 1080; - const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight); - const scaledBorderRadius = borderRadius * canvasScaleFactor; + const scaledBorderRadius = scalePreviewBorderRadius(width, height, borderRadius); this.videoMaskGraphics.clear(); drawSquircleOnGraphics(this.videoMaskGraphics, { @@ -3689,7 +3698,7 @@ export class FrameRenderer { return 0; } - const { region, strength, blendedScale, transition } = findDominantRegion( + const { region, strength, blendedScale } = findDominantRegion( this.config.zoomRegions, timeMs, { @@ -3728,43 +3737,6 @@ export class FrameRenderer { targetScaleFactor = zoomScale; targetFocus = regionFocus; targetProgress = strength; - - if (transition) { - const startTransform = computeZoomTransform({ - stageSize: this.layoutCache.stageSize, - baseMask: this.layoutCache.maskRect, - zoomScale: transition.startScale, - zoomProgress: 1, - focusX: transition.startFocus.cx, - focusY: transition.startFocus.cy, - }); - const endTransform = computeZoomTransform({ - stageSize: this.layoutCache.stageSize, - baseMask: this.layoutCache.maskRect, - zoomScale: transition.endScale, - zoomProgress: 1, - focusX: transition.endFocus.cx, - focusY: transition.endFocus.cy, - }); - - const interpolatedTransform = { - scale: - startTransform.scale + - (endTransform.scale - startTransform.scale) * transition.progress, - x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress, - y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress, - }; - - targetScaleFactor = interpolatedTransform.scale; - targetFocus = computeFocusFromTransform({ - stageSize: this.layoutCache.stageSize, - baseMask: this.layoutCache.maskRect, - zoomScale: interpolatedTransform.scale, - x: interpolatedTransform.x, - y: interpolatedTransform.y, - }); - targetProgress = 1; - } } const state = this.animationState; diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index d3279110..4b7d5b2a 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -69,6 +69,7 @@ function createExporter(overrides: Record = {}) { ctx: CanvasRenderingContext2D, wallpaper: string, ) => CanvasGradient | null; + getNativeStaticLayoutCursorSize: (contentWidth: number) => number; }; } @@ -235,6 +236,14 @@ describe("ModernVideoExporter native static-layout eligibility", () => { ).toBeNull(); }); + it("scales native static-layout cursor size with a minimum visible floor", () => { + const exporter = createExporter({ cursorSize: 3, cursorStyle: "tahoe" }); + + expect(exporter.getNativeStaticLayoutCursorSize(1920)).toBeCloseTo(84, 6); + expect(exporter.getNativeStaticLayoutCursorSize(960)).toBeCloseTo(46.2, 6); + expect(exporter.getNativeStaticLayoutCursorSize(480)).toBeCloseTo(46.2, 6); + }); + it("skips native static-layout when cursor click effects are enabled", () => { const exporter = createExporter({ showCursor: true, diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 5b65882b..122537de 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -25,7 +25,10 @@ import { SNAP_TO_EDGES_RATIO_AUTO, } from "@/components/video-editor/videoPlayback/cursorFollowCamera"; import { buildNativeCursorAtlas } from "@/components/video-editor/videoPlayback/cursorRenderer"; -import { computePaddedLayout } from "@/components/video-editor/videoPlayback/layoutUtils"; +import { + computePaddedLayout, + scalePreviewBorderRadius, +} from "@/components/video-editor/videoPlayback/layoutUtils"; import { createSpringState, getZoomSpringConfig, @@ -34,10 +37,7 @@ import { } from "@/components/video-editor/videoPlayback/motionSmoothing"; import { getCursorStyleSizeMultiplier } from "@/components/video-editor/videoPlayback/uploadedCursorAssets"; import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; -import { - computeFocusFromTransform, - computeZoomTransform, -} from "@/components/video-editor/videoPlayback/zoomTransform"; +import { computeZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform"; import { getWebcamOverlayPosition, getWebcamOverlaySizePx, @@ -2126,13 +2126,9 @@ export class ModernVideoExporter { for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) { const timeMs = frameIndex * frameDurationMs; - const { region, strength, blendedScale, transition } = findDominantRegion( - zoomRegions, - timeMs, - { - connectZooms: this.config.connectZooms, - }, - ); + const { region, strength, blendedScale } = findDominantRegion(zoomRegions, timeMs, { + connectZooms: this.config.connectZooms, + }); let targetScale = 1; let targetFocus = DEFAULT_FOCUS; @@ -2160,46 +2156,6 @@ export class ModernVideoExporter { targetScale = zoomScale; targetFocus = regionFocus; targetProgress = strength; - - if (transition) { - const startTransform = computeZoomTransform({ - stageSize, - baseMask, - zoomScale: transition.startScale, - zoomProgress: 1, - focusX: transition.startFocus.cx, - focusY: transition.startFocus.cy, - }); - const endTransform = computeZoomTransform({ - stageSize, - baseMask, - zoomScale: transition.endScale, - zoomProgress: 1, - focusX: transition.endFocus.cx, - focusY: transition.endFocus.cy, - }); - const interpolatedTransform = { - scale: - startTransform.scale + - (endTransform.scale - startTransform.scale) * transition.progress, - x: - startTransform.x + - (endTransform.x - startTransform.x) * transition.progress, - y: - startTransform.y + - (endTransform.y - startTransform.y) * transition.progress, - }; - - targetScale = interpolatedTransform.scale; - targetFocus = computeFocusFromTransform({ - stageSize, - baseMask, - zoomScale: interpolatedTransform.scale, - x: interpolatedTransform.x, - y: interpolatedTransform.y, - }); - targetProgress = 1; - } } const projectedTransform = computeZoomTransform({ @@ -2336,13 +2292,11 @@ export class ModernVideoExporter { const sourceCrop = this.isDefaultCropRegion() ? null : this.getNativeStaticLayoutSourceCrop(videoInfo); - const previewWidth = this.config.previewWidth || 1920; - const previewHeight = this.config.previewHeight || 1080; - const canvasScaleFactor = Math.min( - this.config.width / previewWidth, - this.config.height / previewHeight, + const borderRadius = scalePreviewBorderRadius( + this.config.width, + this.config.height, + this.config.borderRadius ?? 0, ); - const borderRadius = Math.max(0, (this.config.borderRadius ?? 0) * canvasScaleFactor); const shadowIntensity = this.config.showShadow ? Math.min(1, Math.max(0, this.config.shadowIntensity)) : 0; @@ -2533,8 +2487,7 @@ export class ModernVideoExporter { timelineSegments, chunkDurationSec: STATIC_LAYOUT_CHUNK_DURATION_SEC, experimentalWindowsGpuCompositor: this.config.experimentalNativeExport === true, - experimentalNvidiaCudaExport: - this.config.experimentalNvidiaCudaExport === true, + experimentalNvidiaCudaExport: this.config.experimentalNvidiaCudaExport === true, audioOptions: { ...audioOptions, outputDurationSec: effectiveDuration, From 2fffe922c6ebb1ed343224e7c232201a7380ca27 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:41:25 +1000 Subject: [PATCH 6/8] feat: raise mp4 export bitrate for color fidelity --- src/lib/exporter/exportBitrate.test.ts | 25 +++++++++++++++++-------- src/lib/exporter/exportBitrate.ts | 4 ++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/lib/exporter/exportBitrate.test.ts b/src/lib/exporter/exportBitrate.test.ts index c8ef1715..301689dd 100644 --- a/src/lib/exporter/exportBitrate.test.ts +++ b/src/lib/exporter/exportBitrate.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getMp4ExportBitrate, getSourceQualityBitrate } from "./exportBitrate"; describe("export bitrate policy", () => { - it("keeps the legacy source-quality bitrate unchanged", () => { + it("keeps source-quality exports at a fuller screen-recording bitrate", () => { expect(getSourceQualityBitrate(1920, 1080)).toBe(30_000_000); expect( getMp4ExportBitrate({ @@ -12,7 +12,16 @@ describe("export bitrate policy", () => { quality: "source", encodingMode: "quality", }), - ).toBe(27_000_000); + ).toBe(30_000_000); + expect( + getMp4ExportBitrate({ + width: 1920, + height: 1080, + frameRate: 30, + quality: "source", + encodingMode: "balanced", + }), + ).toBe(22_500_000); }); it("raises high-resolution 60fps source-quality exports above the 30fps budget", () => { @@ -32,9 +41,9 @@ describe("export bitrate policy", () => { frameRate: 60, }); - expect(thirtyFpsBitrate).toBe(45_000_000); + expect(thirtyFpsBitrate).toBe(50_000_000); expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); - expect(sixtyFpsBitrate).toBe(63_639_610); + expect(sixtyFpsBitrate).toBe(70_710_678); }); it("keeps modern native static-layout source exports high enough for screen text", () => { @@ -47,7 +56,7 @@ describe("export bitrate policy", () => { encodingMode: "balanced", useModernNativeStaticLayout: true, }), - ).toBe(22_000_000); + ).toBe(22_500_000); expect( getMp4ExportBitrate({ width: 1920, @@ -57,7 +66,7 @@ describe("export bitrate policy", () => { encodingMode: "quality", useModernNativeStaticLayout: true, }), - ).toBe(27_000_000); + ).toBe(30_000_000); }); it("scales modern native static-layout source exports at 60fps", () => { @@ -78,9 +87,9 @@ describe("export bitrate policy", () => { frameRate: 60, }); - expect(thirtyFpsBitrate).toBe(27_000_000); + expect(thirtyFpsBitrate).toBe(30_000_000); expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); - expect(sixtyFpsBitrate).toBe(38_183_766); + expect(sixtyFpsBitrate).toBe(42_426_407); }); it("does not raise fast exports when the requested bitrate is already lower than the cap", () => { diff --git a/src/lib/exporter/exportBitrate.ts b/src/lib/exporter/exportBitrate.ts index 84d87133..a82acb7a 100644 --- a/src/lib/exporter/exportBitrate.ts +++ b/src/lib/exporter/exportBitrate.ts @@ -9,10 +9,10 @@ export function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMod case "fast": return 0.1; case "quality": - return 0.9; + return 1; case "balanced": default: - return 0.5; + return 0.75; } } From f0953a131e3bedf473affa5897363d2c98c2b941 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:07:43 +1000 Subject: [PATCH 7/8] feat: refine preview cursor and dev blur UI --- src/components/video-editor/SettingsPanel.tsx | 27 +++++++++---------- .../videoPlayback/cursorRenderer.ts | 2 +- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1fa52661..fce5f2b9 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -3322,22 +3322,19 @@ export function SettingsPanel({ )} )} -
-
- {showDevMotionControls - ? tSettings( - "effects.exportBlurMovedToDev", - "Export blur tuning is available in Settings > Dev.", - ) - : tSettings( - "effects.exportBlurLocked", - "Export blur is fixed for this build.", - )} -
-
- {`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`} + {showDevMotionControls ? ( +
+
+ {tSettings( + "effects.exportBlurMovedToDev", + "Export blur tuning is available in Settings > Dev.", + )} +
+
+ {`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`} +
-
+ ) : null} {selectedZoomId && (
) : null} - {activeCaptionLayout && autoCaptionSettings ? ( -
+ {activeCaptionLayout && autoCaptionSettings ? ( +
( >
{ + event.stopPropagation(); + if (!isCaptionEditing) { + beginCaptionEdit(); + } + }} + onPointerDown={(event) => { + event.stopPropagation(); + }} + onKeyDown={(event) => { + if (!onEditAutoCaption || isCaptionEditing) { + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + beginCaptionEdit(); + } + }} style={{ backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`, fontFamily: getDefaultCaptionFontFamily(), @@ -3038,9 +3199,88 @@ const VideoPlayback = forwardRef( ), )}px`, boxSizing: "border-box", + cursor: + onEditAutoCaption && !isCaptionEditing ? "text" : undefined, + pointerEvents: onEditAutoCaption ? "auto" : undefined, }} > - {activeCaptionLayout.visibleLines.map((line) => ( + {captionEditSession ? ( +