diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts
index c4410a271..ceb53dbf8 100644
--- a/electron/ipc/register/export.ts
+++ b/electron/ipc/register/export.ts
@@ -252,6 +252,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",
diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts
index a65fd9070..61dbdbaa8 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/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 1fa526616..0c2ffe8ab 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,
@@ -107,6 +108,7 @@ import {
} from "./videoPlayback/uploadedCursorAssets";
import { WebcamCropControl } from "./WebcamCropControl";
import {
+ getCropMatchedWebcamHeightPercent,
getWebcamPositionForPreset,
normalizeWebcamCropRegion,
resolveWebcamCorner,
@@ -1662,6 +1664,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) => {
@@ -2413,7 +2417,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 +2428,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}%`}
@@ -3322,22 +3326,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 && (
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 +3918,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 f18b931f7..827dcb30c 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -118,6 +118,7 @@ import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/a
import { extensionHost } from "@/lib/extensions";
import { useVideoEditorAudio } from "./audio/useVideoEditorAudio";
import { resolveAutoCaptionSourcePath } from "./autoCaptionSource";
+import { type CaptionEditTarget, updateCaptionCuesForEditedTarget } from "./captionEditing";
import { CropControl } from "./CropControl";
import { ExportSettingsMenu } from "./ExportSettingsMenu";
import ExtensionManager from "./ExtensionManager";
@@ -298,6 +299,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 +395,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 +549,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 +668,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 +689,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 +1757,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 +2165,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) {
@@ -2340,6 +2417,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;
@@ -2765,7 +2850,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));
@@ -2788,6 +2872,14 @@ export default function VideoEditor() {
setAutoCaptionSettings((prev) => ({ ...prev, enabled: false }));
}, []);
+ const handleSaveAutoCaptionEdit = useCallback(
+ (target: CaptionEditTarget, text: string) => {
+ setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text));
+ toast.success(t("settings.captions.editSaved", "Caption updated"));
+ },
+ [t],
+ );
+
const saveProject = useCallback(
async (forceSaveAs: boolean, options?: SaveProjectOptions) => {
clearPendingProjectAutosave();
@@ -2831,6 +2923,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 +2991,8 @@ export default function VideoEditor() {
currentProjectSnapshot,
currentPersistedEditorState,
lastSavedSnapshot?.projectId,
+ openProjectSaveDialog,
+ projectDisplayName,
queueProjectSave,
refreshProjectLibrary,
remountPreview,
@@ -2945,7 +3047,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 +3073,7 @@ export default function VideoEditor() {
projectData,
trimmedProjectName,
thumbnailDataUrl,
+ mode,
);
if (result.canceled) {
@@ -3013,6 +3116,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 +3171,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 +3189,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 +3236,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 +3630,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 +3640,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) => {
@@ -5184,6 +5416,7 @@ export default function VideoEditor() {
annotationRegions={annotationRegions}
autoCaptions={autoCaptions}
autoCaptionSettings={autoCaptionSettings}
+ onEditAutoCaption={handleSaveAutoCaptionEdit}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
@@ -5215,21 +5448,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 = (
+
+ );
+
+ const unsavedChangesDialog = (
+
+ );
+
const projectBrowser = (
{
+ void handleImportMediaOrProject();
+ }}
onOpenProject={(projectPath) => {
void handleOpenProjectFromLibrary(projectPath);
}}
@@ -5269,6 +5607,8 @@ export default function VideoEditor() {
Loading video...
{projectBrowser}
+ {projectSaveDialog}
+ {unsavedChangesDialog}
{nativeCaptureUnavailableDialog}
@@ -5289,6 +5629,8 @@ export default function VideoEditor() {
{projectBrowser}
+ {projectSaveDialog}
+ {unsavedChangesDialog}
{nativeCaptureUnavailableDialog}
@@ -5736,7 +6078,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 +6759,8 @@ export default function VideoEditor() {
) : null}
{projectBrowser}
+ {projectSaveDialog}
+ {unsavedChangesDialog}
{nativeCaptureUnavailableDialog}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 488bc3a9d..4c739f1c1 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -22,6 +22,7 @@ import {
DEFAULT_WALLPAPER_RELATIVE_PATH,
isVideoWallpaperSource,
} from "@/lib/wallpapers";
+import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing";
import { buildActiveCaptionLayout } from "./captionLayout";
import {
CAPTION_FONT_WEIGHT,
@@ -166,7 +167,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,15 +180,15 @@ import {
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
- computeFocusFromTransform,
computeZoomTransform,
createMotionBlurState,
type MotionBlurState,
} from "./videoPlayback/zoomTransform";
import {
+ getCropMatchedWebcamHeightPercent,
getWebcamCropSourceRect,
+ getWebcamOverlayDimensionsPx,
getWebcamOverlayPosition,
- getWebcamOverlaySizePx,
} from "./webcamOverlay";
type PlaybackAnimationState = {
@@ -197,6 +201,24 @@ type PlaybackAnimationState = {
y: number;
};
+type CaptionEditSession = {
+ target: CaptionEditTarget;
+ draft: string;
+};
+
+type SceneTransformState = {
+ scale: number;
+ x: number;
+ y: number;
+};
+
+type AnnotationRecordingRect = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
function createPlaybackAnimationState(): PlaybackAnimationState {
return {
scale: 1,
@@ -361,6 +383,7 @@ interface VideoPlaybackProps {
annotationRegions?: AnnotationRegion[];
autoCaptions?: CaptionCue[];
autoCaptionSettings?: AutoCaptionSettings;
+ onEditAutoCaption?: (target: CaptionEditTarget, text: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
@@ -444,6 +467,7 @@ const VideoPlayback = forwardRef(
annotationRegions = [],
autoCaptions = [],
autoCaptionSettings,
+ onEditAutoCaption,
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
@@ -496,6 +520,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();
@@ -525,6 +562,11 @@ const VideoPlayback = forwardRef(
height: number;
} | null>(null);
const captionBoxRef = useRef(null);
+ const captionEditInputRef = useRef(null);
+ const captionEditSessionRef = useRef(null);
+ const [captionEditSession, setCaptionEditSession] = useState(
+ null,
+ );
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef([]);
const selectedZoomIdRef = useRef(null);
@@ -728,6 +770,127 @@ const VideoPlayback = forwardRef(
measureText: (text) => measurementContext.measureText(text).width,
});
}, [autoCaptionSettings, autoCaptions, currentTime]);
+ const isCaptionEditing = captionEditSession !== null;
+ const captionEditDraft = captionEditSession?.draft ?? "";
+ const captionEditTargetId = captionEditSession?.target.id ?? null;
+ const captionEditTextMetrics = useMemo(() => {
+ if (!captionEditSession || !autoCaptionSettings || typeof document === "undefined") {
+ return null;
+ }
+
+ const overlayWidth = overlayRef.current?.clientWidth || 960;
+ const fontSize = getCaptionScaledFontSize(
+ autoCaptionSettings.fontSize,
+ overlayWidth,
+ autoCaptionSettings.maxWidth,
+ );
+ const maxTextWidthPx = getCaptionTextMaxWidth(
+ overlayWidth,
+ autoCaptionSettings.maxWidth,
+ fontSize,
+ );
+ const measurementCanvas = document.createElement("canvas");
+ const measurementContext = measurementCanvas.getContext("2d");
+ if (!measurementContext) {
+ return null;
+ }
+
+ measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`;
+ const measuredWidth = Math.max(
+ ...captionEditSession.draft
+ .split(/\r?\n/)
+ .map((line) => measurementContext.measureText(line || " ").width),
+ );
+
+ return {
+ fontSize,
+ maxTextWidthPx,
+ widthPx: Math.ceil(
+ Math.min(maxTextWidthPx, Math.max(fontSize * 2, measuredWidth + 2)),
+ ),
+ };
+ }, [autoCaptionSettings, captionEditSession]);
+ const captionEditSizeKey = captionEditSession
+ ? `${captionEditTextMetrics?.widthPx ?? 0}:${captionEditDraft}`
+ : "";
+
+ const beginCaptionEdit = useCallback(() => {
+ if (!activeCaptionLayout?.editTarget || !onEditAutoCaption) {
+ return;
+ }
+
+ videoRef.current?.pause();
+ onPlayStateChange(false);
+ const nextSession = {
+ target: activeCaptionLayout.editTarget,
+ draft: activeCaptionLayout.editTarget.text,
+ };
+ captionEditSessionRef.current = nextSession;
+ setCaptionEditSession(nextSession);
+ }, [activeCaptionLayout, onEditAutoCaption, onPlayStateChange]);
+
+ const commitCaptionEdit = useCallback(() => {
+ const session = captionEditSessionRef.current;
+ if (!session || !onEditAutoCaption) {
+ captionEditSessionRef.current = null;
+ setCaptionEditSession(null);
+ return;
+ }
+
+ const normalizedDraft = normalizeCaptionEditText(session.draft);
+ captionEditSessionRef.current = null;
+ if (!normalizedDraft) {
+ setCaptionEditSession(null);
+ return;
+ }
+
+ if (normalizedDraft !== normalizeCaptionEditText(session.target.text)) {
+ onEditAutoCaption(session.target, session.draft);
+ }
+ setCaptionEditSession(null);
+ }, [onEditAutoCaption]);
+
+ const cancelCaptionEdit = useCallback(() => {
+ captionEditSessionRef.current = null;
+ setCaptionEditSession(null);
+ }, []);
+
+ useEffect(() => {
+ if (!captionEditTargetId) {
+ return;
+ }
+
+ const frame = requestAnimationFrame(() => {
+ const input = captionEditInputRef.current;
+ if (!input) {
+ return;
+ }
+
+ input.focus();
+ const cursorPosition = input.value.length;
+ input.setSelectionRange(cursorPosition, cursorPosition);
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [captionEditTargetId]);
+
+ useEffect(() => {
+ if (!captionEditSizeKey) {
+ return;
+ }
+
+ const frame = requestAnimationFrame(() => {
+ const input = captionEditInputRef.current;
+ if (!input) {
+ return;
+ }
+
+ input.style.height = "auto";
+ input.style.height = `${input.scrollHeight}px`;
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [captionEditSizeKey]);
useEffect(() => {
const captionBox = captionBoxRef.current;
@@ -768,7 +931,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 +943,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 +960,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 +989,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 +1001,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 +1013,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 +1046,9 @@ const VideoPlayback = forwardRef(
webcamPositionY,
webcamReactToZoom,
webcamShadow,
- webcamSize,
+ webcamHeight,
webcamVideoPath,
+ webcamWidth,
],
);
@@ -997,6 +1173,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 +2176,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 +2387,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 +2409,7 @@ const VideoPlayback = forwardRef(
return;
}
- const { region, strength, blendedScale, transition } = findDominantRegion(
+ const { region, strength, blendedScale } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{
@@ -2245,47 +2454,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;
@@ -2949,13 +3117,14 @@ const VideoPlayback = forwardRef(
) : 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(),
@@ -3004,9 +3199,88 @@ const VideoPlayback = forwardRef
(
),
)}px`,
boxSizing: "border-box",
+ cursor:
+ onEditAutoCaption && !isCaptionEditing ? "text" : undefined,
+ pointerEvents: onEditAutoCaption ? "auto" : undefined,
}}
>
- {activeCaptionLayout.visibleLines.map((line) => (
+ {captionEditSession ? (
+
) : 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/WebcamCropControl.tsx b/src/components/video-editor/WebcamCropControl.tsx
index 6cbb3ca97..10e5e6ef1 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/captionEditing.test.ts b/src/components/video-editor/captionEditing.test.ts
index 7cfc18314..48d3ce950 100644
--- a/src/components/video-editor/captionEditing.test.ts
+++ b/src/components/video-editor/captionEditing.test.ts
@@ -114,4 +114,31 @@ describe("captionEditing", () => {
expect(updateCaptionCuesForEditedTarget(cues, visibleTarget, " \n\t ")).toBe(cues);
});
+
+ it("keeps sound-effect style captions editable when word entries are blank", () => {
+ const cues: CaptionCue[] = [
+ {
+ id: "sound-effect",
+ startMs: 1_000,
+ endMs: 2_000,
+ text: "clears throat",
+ words: [{ text: "", startMs: 1_000, endMs: 2_000 }],
+ },
+ ];
+
+ const layout = buildActiveCaptionLayout({
+ cues,
+ timeMs: 1_500,
+ settings: DEFAULT_AUTO_CAPTION_SETTINGS,
+ maxWidthPx: 500,
+ measureText: (text) => text.length * 10,
+ });
+
+ expect(layout?.editTarget.text).toBe("clears throat");
+
+ const updated = updateCaptionCuesForEditedTarget(cues, layout!.editTarget, "coughs");
+
+ expect(updated[0].text).toBe("coughs");
+ expect(updated[0].words).toEqual([{ text: "coughs", startMs: 1_000, endMs: 2_000 }]);
+ });
});
diff --git a/src/components/video-editor/captionEditing.ts b/src/components/video-editor/captionEditing.ts
index b5084426f..530457a32 100644
--- a/src/components/video-editor/captionEditing.ts
+++ b/src/components/video-editor/captionEditing.ts
@@ -56,9 +56,16 @@ function buildCaptionWordsForEditedText(
}
function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
+ const validSourceWords = Array.isArray(cue.words)
+ ? cue.words.filter(
+ (word): word is CaptionCueWord =>
+ Boolean(word && typeof word.text === "string") &&
+ normalizeCaptionEditText(word.text).length > 0,
+ )
+ : [];
const sourceWords =
- Array.isArray(cue.words) && cue.words.length > 0
- ? cue.words
+ validSourceWords.length > 0
+ ? validSourceWords
: buildCaptionWordsForEditedText(cue.text, cue.startMs, cue.endMs);
return sourceWords
diff --git a/src/components/video-editor/captionLayout.ts b/src/components/video-editor/captionLayout.ts
index 6c0d987e9..9d383294c 100644
--- a/src/components/video-editor/captionLayout.ts
+++ b/src/components/video-editor/captionLayout.ts
@@ -95,7 +95,7 @@ function splitCaptionWordsFromText(text: string) {
function splitCaptionWords(cue: CaptionCue) {
if (Array.isArray(cue.words) && cue.words.length > 0) {
- return cue.words
+ const words = cue.words
.filter((word): word is CaptionCueWord =>
Boolean(word && typeof word.text === "string"),
)
@@ -109,6 +109,10 @@ function splitCaptionWords(cue: CaptionCue) {
endMs: word.endMs,
}))
.filter((word) => word.text.length > 0);
+
+ if (words.length > 0) {
+ return words;
+ }
}
return splitCaptionWordsFromText(cue.text).map((word) => ({
diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts
new file mode 100644
index 000000000..575c3b676
--- /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 704371a6b..edc36012a 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,
@@ -1028,6 +1032,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 9bc401d5b..4f839b776 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,
@@ -497,6 +501,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/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts
index 2055c607a..c1346ba52 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 = "";
@@ -342,7 +345,7 @@ function clamp(value: number, min: number, max: number) {
function configureCursorTexture(texture: Texture) {
texture.source.scaleMode = "linear";
- texture.source.autoGenerateMipmaps = false;
+ texture.source.autoGenerateMipmaps = true;
return texture;
}
@@ -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/components/video-editor/videoPlayback/layoutUtils.test.ts b/src/components/video-editor/videoPlayback/layoutUtils.test.ts
index 39d21f038..a93db03f3 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 3cce0cd3b..b93ef19d4 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;
diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts
index a78b02685..001f3cd67 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 21cc7dd38..9ce38212f 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 dc24cd97a..0adb90dfc 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 66a05e89c..f0a51b3a0 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 200ecaa02..2db7d2f0c 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 d9b9a1a18..5b5b9c276 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 c528a98fb..5df1385f0 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 d88e4278c..3f9409f02 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 a34e6e4d2..f543ce434 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 687f8383b..2768d9950 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 a68d2a04f..1ae88c027 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 db0ea7e04..a2f78ae39 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/exportBitrate.test.ts b/src/lib/exporter/exportBitrate.test.ts
index c8ef1715e..301689dd2 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 84d871339..a82acb7ac 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;
}
}
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index 049c25bbc..1ba7f355a 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,
@@ -45,15 +45,15 @@ import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/video
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
- computeFocusFromTransform,
computeZoomTransform,
createMotionBlurState,
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";
@@ -1547,6 +1547,9 @@ export class FrameRenderer {
this.config.height,
temporalSnapshot.timeMs,
scaleFactor,
+ undefined,
+ temporalSnapshot.sceneTransform,
+ this.layoutCache?.maskRect,
);
}
@@ -1731,6 +1734,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 +1971,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 +2011,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;
@@ -2405,33 +2378,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 +2386,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 +2451,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 +2550,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.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts
index 1f43a9c92..688e51f21 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 6ab918d8a..ddb52a7da 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,15 +54,15 @@ import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/video
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
- computeFocusFromTransform,
computeZoomTransform,
createMotionBlurState,
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 +214,8 @@ interface WebcamRenderSource {
interface WebcamLayoutCache {
sourceWidth: number;
sourceHeight: number;
- size: number;
+ width: number;
+ height: number;
positionX: number;
positionY: number;
radius: number;
@@ -599,7 +603,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 +626,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 +1471,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 +1563,7 @@ export class FrameRenderer {
private async composeBlurAnnotationFrame(
timeMs: number,
+ sceneTransform?: { scale: number; x: number; y: number },
sourceCanvas?: CanvasImageSource,
): Promise {
if (!this.app) {
@@ -1586,6 +1589,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 +1618,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 +2564,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");
@@ -2604,7 +2614,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 +2634,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 +2645,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 +2657,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 +2916,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 +2936,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 +2952,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,
@@ -3129,7 +3152,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 +3563,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 +3713,7 @@ export class FrameRenderer {
return 0;
}
- const { region, strength, blendedScale, transition } = findDominantRegion(
+ const { region, strength, blendedScale } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{
@@ -3728,43 +3752,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 d32791109..f0e57d13d 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,
@@ -708,6 +717,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 5b65882b8..a1983599d 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,13 +37,11 @@ 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,
+ 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,
@@ -2126,13 +2141,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 +2171,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 +2307,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 +2502,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,