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 = ( + { + if (open) { + setProjectSaveDialogOpen(true); + return; + } + + if (!isSavingProjectDialog) { + resolveProjectSaveDialog(false); + } + }} + > + +
void handleProjectSaveDialogSubmit(event)}> + + {t("editor.project.saveTitle", "Save Project")} + + {t( + "editor.project.saveDescription", + "Name this project. It will be saved in your Recordly Projects folder.", + )} + + +
+ +
+ setProjectSaveDialogDraft(event.target.value)} + disabled={isSavingProjectDialog} + className="h-10 flex-1 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" + aria-label={t("editor.project.saveNameLabel", "Project name")} + /> + + .recordly + +
+
+ + + + +
+
+
+ ); + + const unsavedChangesDialog = ( + { + if (open) { + setUnsavedChangesDialogOpen(true); + return; + } + + resolveUnsavedChangesDialog("cancel"); + }} + > + + + Unsaved changes + + {`Save your current project before you ${unsavedChangesDialogActionLabel}?`} + + + + + + + + + + ); + const projectBrowser = ( { + void handleImportMediaOrProject(); + }} onOpenProject={(projectPath) => { void handleOpenProjectFromLibrary(projectPath); }} @@ -5269,6 +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 ? ( +