From a013e92f56ea95ab62fd25f4e90a86a29a35b87c Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Thu, 7 May 2026 13:56:33 +0200 Subject: [PATCH 01/30] add: When user drags the playhead, it will scrub through, which is an intended from multiple video editor. scrubbing stops the playhead. as per usual editors --- src/components/video-editor/VideoEditor.tsx | 8 +- .../video-editor/timeline/TimelineEditor.tsx | 120 ++++++++++++++---- 2 files changed, 101 insertions(+), 27 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index a19098d6..e1dc8894 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -3275,8 +3275,14 @@ export default function VideoEditor() { }, []); function handleSeek(time: number) { - const video = videoPlaybackRef.current?.video; + const playback = videoPlaybackRef.current; + const video = playback?.video; if (!video) return; + + if (!video.paused) { + playback?.pause(); + } + video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 740d01cb..4024dd3a 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -323,11 +323,17 @@ function PlaybackCursor({ useEffect(() => { if (!isDragging) return; - const handleMouseMove = (e: MouseEvent) => { - if (!timelineRef.current || !onSeek) return; + let frameId: number | null = null; + let lastMouseEvent: MouseEvent | null = null; + + const updateSeek = () => { + if (!lastMouseEvent || !timelineRef.current || !onSeek) { + frameId = null; + return; + } const rect = timelineRef.current.getBoundingClientRect(); - const clickX = e.clientX - rect.left - sidebarWidth; + const clickX = lastMouseEvent.clientX - rect.left - sidebarWidth; // Allow dragging outside to 0 or max, but clamp the value const relativeMs = pixelsToValue(clickX); @@ -347,6 +353,14 @@ function PlaybackCursor({ } onSeek(absoluteMs / 1000); + frameId = null; + }; + + const handleMouseMove = (e: MouseEvent) => { + lastMouseEvent = e; + if (frameId === null) { + frameId = requestAnimationFrame(updateSeek); + } }; const handleMouseUp = () => { @@ -359,6 +373,9 @@ function PlaybackCursor({ document.body.style.cursor = "ew-resize"; return () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); document.body.style.cursor = ""; @@ -427,9 +444,11 @@ function PlaybackCursor({ function TimelineAxis({ videoDurationMs, currentTimeMs, + onMouseDown, }: { videoDurationMs: number; currentTimeMs: number; + onMouseDown?: React.MouseEventHandler; }) { const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; @@ -494,10 +513,11 @@ function TimelineAxis({ return (
{/* Minor Ticks */} {markers.minorTicks.map((time) => { @@ -645,6 +665,7 @@ function Timeline({ const localTimelineRef = useRef(null); const [isTimelineHovered, setIsTimelineHovered] = useState(false); const [timelineHoverMs, setTimelineHoverMs] = useState(null); + const [isSeeking, setIsSeeking] = useState(false); const [isZoomRowHovered, setIsZoomRowHovered] = useState(false); const [zoomRowHoverMs, setZoomRowHoverMs] = useState(null); @@ -656,12 +677,22 @@ function Timeline({ [setTimelineRef], ); - const handleTimelineClick = useCallback( + const handleTimelineMouseDown = useCallback( (e: React.MouseEvent) => { - if (!onSeek || videoDurationMs <= 0) return; + if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) return; - // Only clear selection if clicking on empty space (not on items) - // This is handled by event propagation - items stop propagation + const rect = localTimelineRef.current.getBoundingClientRect(); + const clickX = e.clientX - rect.left - sidebarWidth; + + if (clickX < 0) return; + + setIsSeeking(true); + + const relativeMs = pixelsToValue(clickX); + const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + onSeek(absoluteMs / 1000); + + // Clear selection when clicking on empty space onSelectZoom?.(null); onSelectTrim?.(null); onSelectClip?.(null); @@ -669,20 +700,13 @@ function Timeline({ onSelectSpeed?.(null); onSelectAudio?.(null); onClearBlockSelection?.(); - - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left - sidebarWidth; - - if (clickX < 0) return; - - const relativeMs = pixelsToValue(clickX); - const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); - const timeInSeconds = absoluteMs / 1000; - - onSeek(timeInSeconds); }, [ onSeek, + videoDurationMs, + sidebarWidth, + range.start, + pixelsToValue, onSelectZoom, onSelectTrim, onSelectClip, @@ -690,13 +714,54 @@ function Timeline({ onSelectSpeed, onSelectAudio, onClearBlockSelection, - videoDurationMs, - sidebarWidth, - range.start, - pixelsToValue, ], ); + useEffect(() => { + if (!isSeeking) return; + + let frameId: number | null = null; + let lastMouseEvent: MouseEvent | null = null; + + const updateSeek = () => { + if (!lastMouseEvent || !localTimelineRef.current || !onSeek || videoDurationMs <= 0) { + frameId = null; + return; + } + + const rect = localTimelineRef.current.getBoundingClientRect(); + const clickX = lastMouseEvent.clientX - rect.left - sidebarWidth; + + const relativeMs = pixelsToValue(clickX); + const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + + onSeek(absoluteMs / 1000); + frameId = null; + }; + + const handleMouseMove = (e: MouseEvent) => { + lastMouseEvent = e; + if (frameId === null) { + frameId = requestAnimationFrame(updateSeek); + } + }; + + const handleMouseUp = () => { + setIsSeeking(false); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isSeeking, onSeek, videoDurationMs, sidebarWidth, pixelsToValue, range.start]); + const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID); const clipItems = items.filter((item) => item.rowId === CLIP_ROW_ID); const annotationItems = items.filter((item) => isAnnotationTrackRowId(item.rowId)); @@ -852,14 +917,17 @@ function Timeline({ ...style, height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`, }} - className="select-none bg-editor-bg relative cursor-pointer group flex flex-col" - onClick={handleTimelineClick} + className="select-none bg-editor-bg relative group flex flex-col" onMouseEnter={handleTimelineMouseEnter} onMouseMove={handleTimelineMouseMove} onMouseLeave={handleTimelineMouseLeave} >
- + Date: Thu, 7 May 2026 14:14:47 +0200 Subject: [PATCH 02/30] good catch. Stopping seek is timeline seeking only --- src/components/video-editor/VideoEditor.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e1dc8894..62bb0273 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -3274,18 +3274,22 @@ export default function VideoEditor() { setAutoSuggestZoomsTrigger(0); }, []); - function handleSeek(time: number) { + function handleSeek(time: number, options: { pause?: boolean } = {}) { const playback = videoPlaybackRef.current; const video = playback?.video; if (!video) return; - if (!video.paused) { + if (options.pause && !video.paused) { playback?.pause(); } video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; } + const handleTimelineSeek = useCallback((time: number) => { + handleSeek(time, { pause: true }); + }, []); + const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); if (id) { @@ -6271,7 +6275,7 @@ export default function VideoEditor() { videoDuration={duration} currentTime={currentTime} playheadTime={timelinePlayheadTime} - onSeek={handleSeek} + onSeek={handleTimelineSeek} videoPath={videoPath} cursorTelemetry={normalizedCursorTelemetry} autoSuggestZoomsTrigger={autoSuggestZoomsTrigger} From e32f50495b3830493967c677824939aa01c16356 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Thu, 7 May 2026 14:22:06 +0200 Subject: [PATCH 03/30] fix: selection according to copilot --- src/components/video-editor/timeline/Item.tsx | 1 + .../video-editor/timeline/TimelineEditor.tsx | 30 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 4af2d8d4..f21f71e5 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -102,6 +102,7 @@ export default function Item({ {...listeners} {...attributes} onPointerDownCapture={() => onSelect?.()} + data-timeline-item="true" className="group h-full" >
{ + useLayoutEffect(() => { if (!isDragging) return; let frameId: number | null = null; @@ -679,6 +680,7 @@ function Timeline({ const handleTimelineMouseDown = useCallback( (e: React.MouseEvent) => { + e.stopPropagation(); if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) return; const rect = localTimelineRef.current.getBoundingClientRect(); @@ -717,7 +719,7 @@ function Timeline({ ], ); - useEffect(() => { + useLayoutEffect(() => { if (!isSeeking) return; let frameId: number | null = null; @@ -910,6 +912,29 @@ function Timeline({ [canPlaceZoomAtMs, onAddZoomAtMs, videoDurationMs, zoomRowHoverMs], ); + const handleBackgroundMouseDown = useCallback((e: React.MouseEvent) => { + // Do not clear selection if clicking on an item + if ((e.target as HTMLElement).closest("[data-timeline-item]")) { + return; + } + + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectClip?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onClearBlockSelection?.(); + }, [ + onSelectZoom, + onSelectTrim, + onSelectClip, + onSelectAnnotation, + onSelectSpeed, + onSelectAudio, + onClearBlockSelection, + ]); + return (
Date: Fri, 8 May 2026 01:17:19 +0500 Subject: [PATCH 04/30] add Russian localization --- src/i18n/locales/ru/common.json | 26 ++++ src/i18n/locales/ru/dialogs.json | 62 +++++++++ src/i18n/locales/ru/editor.json | 137 +++++++++++++++++++ src/i18n/locales/ru/extensions.json | 60 ++++++++ src/i18n/locales/ru/launch.json | 77 +++++++++++ src/i18n/locales/ru/settings.json | 205 ++++++++++++++++++++++++++++ src/i18n/locales/ru/shortcuts.json | 16 +++ src/i18n/locales/ru/timeline.json | 41 ++++++ 8 files changed, 624 insertions(+) create mode 100644 src/i18n/locales/ru/common.json create mode 100644 src/i18n/locales/ru/dialogs.json create mode 100644 src/i18n/locales/ru/editor.json create mode 100644 src/i18n/locales/ru/extensions.json create mode 100644 src/i18n/locales/ru/launch.json create mode 100644 src/i18n/locales/ru/settings.json create mode 100644 src/i18n/locales/ru/shortcuts.json create mode 100644 src/i18n/locales/ru/timeline.json diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json new file mode 100644 index 00000000..8ea45bfb --- /dev/null +++ b/src/i18n/locales/ru/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly Editor", + "subtitle": "Запись экрана и редактирование видео", + "language": "Язык", + "manageRecordings": "Открыть папку с записями" + }, + "actions": { + "cancel": "Отмена", + "close": "Закрыть", + "export": "Экспорт", + "load": "Загрузить", + "redo": "Повторить", + "reset": "Сброс", + "save": "Сохранить", + "undo": "Отменить", + "delete": "Удалить", + "done": "Готово" + }, + "errors": { + "invalidFileType": "Неверный формат файла", + "failedToUploadImage": "Не удалось загрузить изображение", + "fileReadError": "Произошла ошибка при чтении файла." + } +} diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json new file mode 100644 index 00000000..aceb1ea5 --- /dev/null +++ b/src/i18n/locales/ru/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Пожалуйста, попробуйте еще раз", + "compilingGifProgress": "Создание GIF... {{progress}}%", + "compilingGifWait": "Создание GIF... Это займет некоторое время", + "takeMoment": "Это займет некоторое время...", + "exportFailed": "Экспорт не удался", + "compilingGifTitle": "Создание GIF", + "exportingFormat": "Экспорт в {{format}}", + "exportComplete": "Экспорт завершён", + "formatReady": "Ваш {{format}} готов", + "showInFolder": "Показать в Folder", + "compiling": "Создание", + "renderingFrames": "Обработка кадров", + "processing": "Обработка...", + "status": "Статус", + "format": "Формат", + "compilingStatus": "Создание...", + "frames": "Кадров", + "cancelExport": "Отменить экспорт", + "reopenSaveDialog": "Снова открыть окно сохранения", + "savedSuccess": "{{format}} успешно сохранён!" + }, + "addFont": { + "title": "Google Fonts", + "heading": "Добавить Google Font", + "description": "Добавьте шрифт из Google Fonts для использования в аннотациях.", + "urlLabel": "Ссылка на импорт из Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Выберите шрифт на Google Fonts → нажмите \"Get font\" → скопируйте ссылку из блока @import", + "nameLabel": "Название шрифта", + "namePlaceholder": "Мой шрифт", + "nameHelp": "Так он будет отображаться в списке", + "adding": "Добавление...", + "addFont": "Добавить шрифт", + "enterUrl": "Введите ссылку для импорта из Google Fonts", + "invalidUrl": "Введите корректную ссылку на Google Fonts", + "enterName": "Введите название шрифта", + "extractFailed": "Не удалось определить семейство шрифтов по ссылке", + "addSuccess": "Шрифт \"{{name}}\" успешно добавлен", + "addFailed": "Не удалось добавить шрифт", + "loadTimeout": "Шрифт загружается слишком долго. Проверьте ссылку и попробуйте снова.", + "loadFailed": "Не удалось загрузить шрифт. Проверьте ссылку на Google Fonts." + }, + "shortcutsConfig": { + "title": "Сочетания клавиш", + "configurable": "Настраиваемые", + "fixed": "Фиксированные", + "pressEscToCancel": "Нажмите Esc для отмены", + "clickToChange": "Нажмите, чтобы изменить", + "pressAKey": "Нажмите клавишу…", + "alreadyUsedBy": "Уже используется в {{action}}", + "swap": "Заменить", + "reserved": "Это сочетание клавиш зарезервировано для \"{{label}}\" – его нельзя изменить.", + "saved": "Сочетания клавиш сохранены", + "resetNotice": "Сброс к настройкам по умолчанию — нажмите «Сохранить»", + "instructions": "Выберите сочетание и нажмите новую комбинацию клавиш. Нажмите Esc для отмены.", + "resetToDefaults": "Сбросить по умолчанию", + "cancel": "Отмена", + "save": "Сохранить" + } +} diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json new file mode 100644 index 00000000..aa70c119 --- /dev/null +++ b/src/i18n/locales/ru/editor.json @@ -0,0 +1,137 @@ +{ + "playback": { + "play": "Воспроизвести", + "pause": "Пауза", + "skipBack": "Перемотать назад", + "skipForward": "Перемотать вперёд", + "muteUnmute": "Вкл./выкл. звук" + }, + "annotations": { + "settings": "Настройки аннотации", + "active": "Активно", + "text": "Текст", + "image": "Изображение", + "arrow": "Стрелки", + "blur": "Размытие", + "textContent": "Текст", + "textPlaceholder": "Введите текст...", + "fontStyle": "Стиль шрифта", + "selectStyle": "Выберите стиль", + "size": "Размер", + "toggleBold": "Жирный", + "toggleItalic": "Курсив", + "toggleUnderline": "Подчёркивание", + "alignLeft": "По левому краю", + "alignCenter": "По центру", + "alignRight": "По правому краю", + "textColor": "Цвет текста", + "background": "Фон", + "none": "Нет", + "clearBackground": "Убрать фон", + "uploadImage": "Загрузить изображение", + "supportedFormats": "Форматы: JPG, PNG, GIF, WebP", + "arrowDirection": "Направление стелки", + "strokeWidth": "Толщина: {{width}}px", + "arrowColor": "Цвет стрелки", + "deleteAnnotation": "Удалить аннотацию", + "shortcutsAndTips": "Горячие клавиши и советы", + "tipSelectAnnotation": "Переместите ползунок в область пересечния аннотаций и выберите нужную.", + "tipCycleForward": "Нажмите Tab для переключения между элементами.", + "tipCycleBackward": "Shift+Tab — для переключения в обратном направлении.", + "imageUploadSuccess": "Изображение загружено.", + "imageUploadError": "Загрузите файл JPG, PNG, GIF, или WebP.", + "blurStrength": "Сила размытия: {{strength}}", + "solidColor": "Спплошной цвет (цензура)", + "borderRadius": "Скругление углов" + }, + + "fontStyles": { + "classic": "Классический", + "editor": "Базовый", + "strong": "Акцентный", + "typewriter": "Моноширинный", + "deco": "Декоративный", + "simple": "Простой", + "modern": "Современный", + "clean": "Минималистичный" + }, + "format": { + "mp4Video": "Видео MP4", + "mp4Description": "Высококачественный видеофайл", + "gifAnimation": "GIF-анимация", + "gifDescription": "Анимированное изображение для соцсетей и мессенджеров" + }, + "gifOptions": { + "frameRate": "Частота кадров", + "outputSize": "Размер", + "outputDimensions": "Разрешение: {{width}} × {{height}}px", + "loopAnimation": "Зациклить анимацию", + "loopDescription": "GIF будет воспроизводиться непрерывно" + }, + "tutorial": { + "howTrimmingWorks": "Как работает обрезка", + "title": "Как работает обрезка", + "understanding": "Как вырезать ненужные фрагменты из видео.", + "descriptionP1": "Инструмент обрезки позволяет задать участки, которые нужно", + "descriptionRemove": "удалить", + "descriptionP2": "из видео.", + "descriptionP3": "Все красные части таймлайна будут вырезаны при экспорте.", + "visualExample": "Пример", + "removed": "УДАЛЕНО", + "kept": "Оставлено", + "finalVideo": "Итоговое видео", + "part": "Часть {{number}}", + "addTrimStep": "1. Добавьте обрезку", + "addTrimDesc": "Нажмите T или иконку ножниц, чтобы отметить участок для удаления.", + "adjustStep": "2. Настройте", + "adjustDesc": "Перетащите края красной области, чтобы точно выделить нужный фрагмент." + }, + "feedback": { + "trigger": "Обратная связь", + "title": "Обратная связь и контакты", + "description": "Свяжитесь с нами или сообщите о проблеме, если что-то не работает или отсутствует.", + "emailLabel": "Email", + "xLabel": "X", + "reportIssue": "Сообщить о проблеме / отправить отзыв", + "openFailed": "Не удалось открыть ссылку." + }, + "keyboardShortcuts": { + "trigger": "Горячие клавиши", + "title": "Горячие клавиши", + "description": "Памятка для работы с таймлайном.", + "customizeTooltip": "Настроить клавиши", + "customize": "Настроить", + "panTimeline": "Перемещение по таймлайну", + "zoomTimeline": "Увеличение таймлайна", + "cycleAnnotations": "Переключение аннотаций", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Сохранить ещё раз", + "showInFolder": "Показать в папке" + }, + "project": { + "untitled": "Без названия" + }, + "exportStatus": { + "exporting": "Экспорт", + "renderingFile": "Обработка файла.", + "preparing": "Подготовка к экспорту...", + "completePercent": "Готово: {{percent}}%", + "issue": "Ошибка экспорта", + "complete": "Экспорт завершён", + "savedSuccessfully": "Файл сохранён" + }, + "export": { + "processingAudioEdits": "Обработка звука (скорость, наложения)" + }, + "toolbar": { + "addLayer": "Добавить слой", + "splitClip": "Разрезать клип (C)" + }, + "timeline": { + "expand": "Развернуть таймлайн", + "collapse": "Свернуть таймлайн" + }, + "openRecordingsFolder": "Открыть папку с записями" +} diff --git a/src/i18n/locales/ru/extensions.json b/src/i18n/locales/ru/extensions.json new file mode 100644 index 00000000..330e3fbb --- /dev/null +++ b/src/i18n/locales/ru/extensions.json @@ -0,0 +1,60 @@ +{ + "title": "Расширения", + "tabs": { + "browse": "Обзор", + "installed": "Установленные" + }, + "actions": { + "submit": "Отправить расширение", + "docs": "Документация", + "refresh": "Обновить", + "openFolder": "Открыть папку с расширениями", + "uninstall": "Удалить", + "install": "Установить", + "installing": "Установка", + "add": "Добавить", + "retry": "Повторить", + "close": "Закрыть", + "folder": "Папка с расширениями" + }, + "status": { + "enabled": "Включить", + "disabled": "Выключить", + "installed": "Установлено" + }, + "detail": { + "by": "Автор: {{author}}", + "unknownAuthor": "Неизвестный автор", + "noDescription": "Описание отсутствует", + "downloads": "Загрузок: {{count}}", + "preview": "Предпросмотрw", + "screenshotAlt": "Скриншот {{number}}", + "description": "Описание", + "tags": "Теги", + "permissions": "Разрешения", + "location": "Расположение", + "error": "Ошибка: {{message}}" + }, + "empty": { + "title": "У вас пока нет расширений", + "description": "Устанавливайте расширения: рамки, курсоры и дополнительные инструменты для редактирования." + }, + "search": { + "placeholder": "Поиск расширений...", + "noResults": "Ничего не найдено", + "noMarketplace": "В магазине пока нет доступных расширений", + "count": "Найдено расширений: {{count}}", + "countPlural": "Найдено расширений: {{count}}" + }, + "toast": { + "installedAndEnabled": "Расширение установлено и включено", + "uninstalled": "Удалено: {{name}}", + "uninstallFailed": "Не удалось удалить {{name}}", + "searchFailed": "Ошибка при поиске в магазине расширений", + "refreshed": "Список расширений обновлен", + "refreshFailed": "Не удалось обновить расширения", + "marketplaceInstalled": "Установлено и включено: {{name}}", + "marketplaceInstallFailed": "Не удалось установить {{name}}", + "enableFailed": "Не удалось включить расширение" + } +} \ No newline at end of file diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json new file mode 100644 index 00000000..c5715bdf --- /dev/null +++ b/src/i18n/locales/ru/launch.json @@ -0,0 +1,77 @@ +{ + "recording": { + "disableSystemAudio": "Отключить системные звуки", + "enableSystemAudio": "Включить системные звуки", + "disableMicrophone": "Отключить микрофон", + "enableMicrophone": "Включить микрофон", + "disableWebcam": "Отключить наложение веб-камеры", + "enableWebcam": "Включить наложение веб-камеры", + "countdownDelay": "Обратный отсчёт", + "noDelay": "Без задержки", + "record": "Запись", + "recordingFolder": "Место сохранения: {{path}}", + "chooseRecordingsFolder": "Изменить место сохранения", + "folderPath": "Папка: /{{name}}/", + "openVideoFile": "Открыть записанное видео", + "openProject": "Открыть проект", + "hideHudFromVideo": "Скрыть интерфейс во время записи", + "showHudInVideo": "Показать интерфейс во время записи", + "hideHud": "Скрыть интерфейс", + "closeApp": "Закрыть приложение", + "screens": "Экраны", + "windows": "Окна", + "noSourcesFound": "Источники изображения не найдены", + "microphone": "Микрофон", + "turnOffMicrophone": "Выключить микрофон", + "selectMicToEnable": "Выберите микрофон для записи звука", + "noMicrophonesFound": "Нет доступных микрофонов", + "webcam": "Веб-камера", + "turnOffWebcam": "Выключить веб-камеру", + "hideFloatingWebcamPreview": "Скрыть предпросмотр", + "showFloatingWebcamPreview": "Включить предпросмотр", + "selectWebcamToEnable": "Выберите веб-камеру", + "noWebcamsFound": "Нет доступных веб-камер", + "recordingsFolder": "Место сохранения", + "language": "Язык", + "paused": "ПАУЗА", + "rec": "ЗАПИСЬ", + "resume": "Возобновить", + "pause": "Пауза", + "stop": "Стоп", + "cancel": "Отмена", + "more": "Ещё", + "update": { + "update": "Обновить", + "updated": "Обновлено", + "idleTitle": "Проверить обновления.", + "checkingTitle": "Поиск обновлений...", + "downloadingTitle": "Загрузка обновления...", + "errorTitle": "Ошибка при проверке обновления. Нажмите, чтобы повторить", + "upToDateTitle": "У вас актуальная версия Recordly {{version}}.", + "availableTitle": "Доступна новая версия Recordly {{version}}.", + "availableGenericTitle": "Доступно обновление." + } + }, + "sourceSelector": { + "loadingSources": "Загрузка источников...", + "screens": "Экраны", + "windows": "Окна", + "noScreensAvailable": "Нет экранов для захвата", + "noWindowsAvailable": "Нет окон для захвата", + "windowsNote": "Записать можно только видимые (не свёрнутые) окна.", + "windowPlaceholder": "Окно", + "cancel": "Отменить", + "share": "Поделиться" + }, + "permissions": { + "screenRecordingNeeded": "Recordly нужно разрешение на запись экрана. Мы открыли настройки – включите доступ и перезапустите приложение.", + "screenRecordingMissing": "Доступа к записи экрана всё ещё нет. Мы снова открыли настройки. Разрешите доступ и перезапустите Recordly.", + "accessibilityNeeded": "Разрешите Recordly использовать универсальный доступ, чтобы отслеживать курсор. Мы открыли настройки – включите его и перезапустите приложение.", + "accessibilityMissing": "Разрешения для универсального доступа всё ещё нет. Включите его в настройках и перезапустите Recordly.", + "selectSource": "Выберите источник записи", + "systemAudioUnavailable": "Системные звуки недоступны для этого источника. Запись продолжится без их захвата.", + "microphoneDenied": "Нет доступа к микрофону. Запись продолжится без вашего голоса.", + "failedToStart": "Не удалось начать запись: {{error}}", + "failedToStartGeneric": "Не удалось начать запись" + } +} diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json new file mode 100644 index 00000000..25f1022a --- /dev/null +++ b/src/i18n/locales/ru/settings.json @@ -0,0 +1,205 @@ +{ + "zoom": { + "level": "Масштабирование", + "selectRegion": "Выберите область для масштабирования", + "deleteZoom": "Удалить", + "modeAuto": "Автоматически", + "modeManual": "Вручную", + "modeManualDescription": "Установить фиксированную точку фокусировки", + "modeAutoDescription": "Камера выравнивается по центру, когда курсор приближается к краю увеличенного изображения" + }, + "trim": { + "deleteRegion": "Удалить" + }, + "speed": { + "playbackSpeed": "Скорость воспроизведения", + "selectRegion": "Диапазон скорости", + "deleteRegion": "Удалить", + "label": "Скорость" + }, + "clip": { + "title": "Клин", + "muteAudio": "Выключить звук", + "delete": "Удалить" + }, + "effects": { + "title": "Эффекты", + "show": "Показать", + "showCursor": "Показать курсор", + "loopCursor": "Зациклить курсор", + "cursorStyle": "Стиль курсора", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe (инверсия)", + "dot": "Точка", + "figma": "Минималистичный", + "lavender": "Лаванда", + "parched": "Опаленный", + "chooper": "Чоппер", + "amongus": "Among Us", + "turtle": "Черепаха" + }, + "backgroundBlur": "Размытие фона", + "zoomMotionBlur": "Размытие при зуме", + "temporalZoomMotionBlur": "Временное размытие зума", + "temporalZoomMotionBlurDescription": "Настройка плавности зума по времени и кадрам.", + "zoomMotionBlurSamples": "Образцы размытия", + "zoomMotionBlurShutter": "Выдержка", + "auto": "Авто", + "connectZooms": "Связать зумы", + "connectZoomsDescription": "Плавный переход между зумами.", + "autoApplyFreshRecordingZooms": "Автозум для новых записей", + "autoApplyFreshRecordingZoomsDescription": "Автоматически предлагает зум при открытии записи.", + "zoomGeneralTitle": "Общее", + "zoomGeneralDescription": "Настройки движения зума.", + "zoomInTitle": "Приближение", + "zoomInDescription": "Настройка входа камеры в область зума.", + "zoomOutTitle": "Отдаление", + "zoomOutDescription": "Настройка выхода камеры из области зума.", + "connectedZoomTitle": "Между зумами", + "connectedZoomDescription": "Настройка плавного перехода между зумами.", + "motionPresetsTitle": "Пресеты движения", + "motionPresetsZoomHint": "Предварительные параметры доступны в настройках.", + "animationPresets": "Пресеты анимации", + "cursorMotionPresets": "Предустановки движения курсора", + "motionPresets": { + "focused": { + "label": "Фокус", + "description": "Быстрая анимация для демо-роликов, пошаговых инструкций и повседневных записей." + }, + "smooth": { + "label": "Спокойный", + "description": "Мягкая анимация для видеороликов в стиле Keynote и презентаций." + } + }, + "zoomInDuration": "Длительность приближения", + "zoomInOverlap": "Перекрытие приближения", + "zoomOutDuration": "Длительность отдаления", + "zoomInEasing": "Кривая приближения", + "zoomOutEasing": "Кривая отдаления", + "connectedZoomGap": "Интервал между зумом", + "connectedZoomDuration": "Длительность интервала", + "connectedZoomEasing": "Кривая панаромирования", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Плавно", + "smooth": "Мягко", + "snappy": "Резко", + "linear": "Линейно" + }, + "cursorSize": "Размер курсора", + "cursorSmoothing": "Сглаживание курсора", + "cursorSpringStiffness": "Жёсткость пружины", + "cursorSpringDamping": "Затухание", + "cursorSpringMass": "Масса (инерция)", + "off": "Выкл.", + "cursorMotionBlur": "Размытие в движении", + "cursorClickBounce": "Отскок при клике", + "cursorClickBounceDuration": "Скорость отскока", + "cursorSway": "Покачивание курсора", + "webcam": "Наложение веб-камеры", + "webcamFootage": "Запись с веб-камеры", + "webcamFootageDescription": "Видео не добавлено", + "uploadWebcamFootage": "Загрузить видео", + "replaceWebcamFootage": "Заменить видео", + "removeWebcamFootage": "Удалить видео", + "webcamFootageAdded": "Видео добавлено", + "webcamFootageRemoved": "Видео удалено", + "webcamSize": "Размер веб-камеры", + "webcamCrop": "Обрезка веб-камеры", + "webcamReactToZoom": "Веб-камера реагирует на зум", + "webcamMirror": "Отразить веб-камеру", + "webcamRoundness": "Скругление веб-камеры", + "webcamShadow": "Тень веб-камеры", + "shadow": "Тень", + "radius": "Радиус", + "roundness": "Скругление", + "padding": "Отступы", + "paddingLinked": "Связаные (одинаковые)", + "paddingUnlinked": "Раздельные", + "paddingTop": "Сверху", + "paddingBottom": "Снизу", + "paddingLeft": "Слева", + "paddingRight": "Справа", + "removeBackground": "Удалить фон" + }, + "sections": { + "scene": "Сцена", + "captions": "Субтитры", + "zoom": "Зум", + "cursor": "Курсор", + "webcam": "Веб-камера", + "frame": "Рамка", + "crop": "Обрезать видео" + }, + "captions": { + "enabled": "Показать", + "language": "Язык", + "downloading": "Загрузка...", + "deleteModel": "Удалить модель", + "clearModel": "Очистить модель", + "downloadModel": "Загрузить модель", + "generating": "Создание...", + "generateFull": "Создать субтитры", + "regenerateFull": "Пересоздать субтитры", + "clearFull": "Удалить субтитры", + "fontSettings": "Настройка шрифта", + "defaultFont": "По умолчанию", + "fontFamily": "Шрифт", + "fontSize": "Размер", + "rowCount": "Строки", + "animation": "Анимация", + "animationOff": "Выкл", + "animationFade": "Появление", + "animationRise": "Подъем", + "animationPop": "Всплытие", + "bottomOffset": "Отступ снизу", + "maxWidth": "Максимальная ширина", + "boxRadius": "Скругление", + "backgroundOpacity": "Прозрачность фона", + "textColor": "Цвет текста" + }, + "crop": { + "title": "Обрезка видео", + "instruction": "Перетащите края для обрезки", + "top": "Верх", + "bottom": "Низ", + "left": "Левый край", + "right": "Правый край", + "openEditor": "Открыть редактор" + }, + "background": { + "title": "Фон", + "image": "Изображение", + "color": "Цвет", + "gradient": "Градиент", + "wallpaperPreview": "Предпросмотр", + "uploadCustom": "Загрузить", + "uploadSuccess": "Изображение загружено.", + "uploadError": "Загрузите JPG или JPEG.", + "uploadErrorDescription": "Поддерживаются только JPG и JPEG." + }, + "export": { + "title": "Экспорт", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Низкое", + "medium": "Среднее", + "high": "Высокое", + "original": "Исходное" + }, + "fpsTitle": "FPS", + "loop": "Зациклить", + "outputDimensions": "Размер: {{dimensions}}px", + "sizePresetOriginalShort": "Ориг", + "sizePresetMediumShort": "Сред", + "sizePresetLargeShort": "Бол", + "loadProject": "Загрузить проект", + "saveProject": "Сохранить проект", + "exportVideo": "Экспортировать {{format}}", + "reportBug": "Сообщить об ошибке", + "starOnGithub": "Оценить на GitHub" + } +} diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json new file mode 100644 index 00000000..9d23ec85 --- /dev/null +++ b/src/i18n/locales/ru/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Добавить зум", + "addTrim": "Обрезать", + "addSpeed": "Увеличить скорость", + "addAnnotation": "Добавить аннотацию", + "addKeyframe": "Добавить ключ", + "deleteSelected": "Удалить выбранное", + "playPause": "Воспроизведение / Пауза", + "cycleForward": "Следующая аннотация", + "cycleBackward": "Предыдущая аннотация", + "deleteSelectedAlt": "Удалить выбранное (alt)", + "panTimeline": "Прокрутка таймлайна", + "zoomTimeline": "Масштабирование таймлайн" + } +} diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json new file mode 100644 index 00000000..5b89d466 --- /dev/null +++ b/src/i18n/locales/ru/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Нельзя добавить зум здесь", + "existsOrNoSpace": "Зум уже есть или недостаточно места для его добавления.", + "suggestHandlerUnavailable": "Подсказки недоступны", + "noTelemetry": "Нет данных о курсоре", + "recordFirst": "Запишите видео для генерации подсказок на основе курсора.", + "noUsableTelemetry": "Недостаточно данных о курсоре", + "notEnoughMovement": "В видео мало движения курсора.", + "noInteractionMoments": "Нет точек взаимодействия", + "tryRecording": "Попробуйте записать видео с паузами или щелчками мыши во время важных действий.", + "noAutoZoomSlots": "Нет слотов с авто-зумом.", + "dwellPointsOverlap": "Конфликт с существующими зумами.", + "addedSuggestions": "Добавлено зумов: {{count}}", + "label": "Зум {{index}}", + "addZoom": "Добавить зум (Z)", + "suggestZooms": "Предложить зумы на основе курсора" + }, + "trim": { + "cannotPlace": "Нельзя обрезать здесь", + "existsOrNoSpace": "Уже обрезано или недостаточно места.", + "label": "Обрезка {{index}}", + "addTrim": "Обрезать (T)" + }, + "speed": { + "cannotPlace": "Нельзя изменить скорость здесь", + "existsOrNoSpace": "Скорость уже задана или недостаточно места.", + "label": "Скорость" + }, + "annotation": { + "label": "Аннотация", + "image": "Изображение", + "addAnnotation": "Добавить аннотацию (A)" + }, + "audio": { + "label": "Аудио" + }, + "addSpeed": "Скорость (S)", + "resizeLeft": "Изменить размер слева", + "resizeRight": "Изменить размер справа" +} From 4b5f6f392a96f841ffb6e010f0265505f42af4b1 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Fri, 8 May 2026 09:28:29 +0200 Subject: [PATCH 05/30] fix CodeRabbit comments --- src/components/video-editor/VideoEditor.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e244ea20..ca9ab8c5 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -3409,7 +3409,7 @@ export default function VideoEditor() { setAutoSuggestZoomsTrigger(0); }, []); - function handleSeek(time: number, options: { pause?: boolean } = {}) { + const handleSeek = useCallback((time: number, options: { pause?: boolean } = {}) => { const playback = videoPlaybackRef.current; const video = playback?.video; if (!video) return; @@ -3419,11 +3419,11 @@ export default function VideoEditor() { } video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; - } + }, [mapTimelineTimeToSourceTime]); const handleTimelineSeek = useCallback((time: number) => { handleSeek(time, { pause: true }); - }, []); + }, [handleSeek]); const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); From 3ccf27f84e8a75eeed81b25bec14eddf770b0adf Mon Sep 17 00:00:00 2001 From: voinkovpost Date: Fri, 8 May 2026 12:40:42 +0500 Subject: [PATCH 06/30] add russian localization --- src/i18n/locales/ru/common.json | 2 +- src/i18n/locales/ru/dialogs.json | 2 +- src/i18n/locales/ru/editor.json | 2 +- src/i18n/locales/ru/extensions.json | 2 +- src/i18n/locales/ru/launch.json | 2 +- src/i18n/locales/ru/settings.json | 2 +- src/i18n/locales/ru/shortcuts.json | 2 +- src/i18n/locales/ru/timeline.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 8ea45bfb..3946d72b 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -23,4 +23,4 @@ "failedToUploadImage": "Не удалось загрузить изображение", "fileReadError": "Произошла ошибка при чтении файла." } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index aceb1ea5..ded13c13 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -59,4 +59,4 @@ "cancel": "Отмена", "save": "Сохранить" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index aa70c119..e0bf5764 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -134,4 +134,4 @@ "collapse": "Свернуть таймлайн" }, "openRecordingsFolder": "Открыть папку с записями" -} +} \ No newline at end of file diff --git a/src/i18n/locales/ru/extensions.json b/src/i18n/locales/ru/extensions.json index 330e3fbb..239fa9a3 100644 --- a/src/i18n/locales/ru/extensions.json +++ b/src/i18n/locales/ru/extensions.json @@ -27,7 +27,7 @@ "unknownAuthor": "Неизвестный автор", "noDescription": "Описание отсутствует", "downloads": "Загрузок: {{count}}", - "preview": "Предпросмотрw", + "preview": "Предпросмотр", "screenshotAlt": "Скриншот {{number}}", "description": "Описание", "tags": "Теги", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index c5715bdf..03427d44 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -74,4 +74,4 @@ "failedToStart": "Не удалось начать запись: {{error}}", "failedToStartGeneric": "Не удалось начать запись" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 25f1022a..fc43a7b1 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -202,4 +202,4 @@ "reportBug": "Сообщить об ошибке", "starOnGithub": "Оценить на GitHub" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index 9d23ec85..9e41828a 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -13,4 +13,4 @@ "panTimeline": "Прокрутка таймлайна", "zoomTimeline": "Масштабирование таймлайн" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index 5b89d466..8c1d2b77 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -38,4 +38,4 @@ "addSpeed": "Скорость (S)", "resizeLeft": "Изменить размер слева", "resizeRight": "Изменить размер справа" -} +} \ No newline at end of file From b0b5fafaed34cd503304b0269c8e22d1f07d14cf Mon Sep 17 00:00:00 2001 From: voinkovpost Date: Fri, 8 May 2026 13:08:19 +0500 Subject: [PATCH 07/30] fix: address review comments --- src/i18n/locales/ru/dialogs.json | 2 +- src/i18n/locales/ru/editor.json | 6 +++--- src/i18n/locales/ru/settings.json | 6 +++--- src/i18n/locales/ru/shortcuts.json | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index ded13c13..da6ed8ee 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -9,7 +9,7 @@ "exportingFormat": "Экспорт в {{format}}", "exportComplete": "Экспорт завершён", "formatReady": "Ваш {{format}} готов", - "showInFolder": "Показать в Folder", + "showInFolder": "Показать в папке", "compiling": "Создание", "renderingFrames": "Обработка кадров", "processing": "Обработка...", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index e0bf5764..71e2f7cc 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -30,18 +30,18 @@ "clearBackground": "Убрать фон", "uploadImage": "Загрузить изображение", "supportedFormats": "Форматы: JPG, PNG, GIF, WebP", - "arrowDirection": "Направление стелки", + "arrowDirection": "Направление стрелки", "strokeWidth": "Толщина: {{width}}px", "arrowColor": "Цвет стрелки", "deleteAnnotation": "Удалить аннотацию", "shortcutsAndTips": "Горячие клавиши и советы", - "tipSelectAnnotation": "Переместите ползунок в область пересечния аннотаций и выберите нужную.", + "tipSelectAnnotation": "Переместите ползунок в область пересечения аннотаций и выберите нужную.", "tipCycleForward": "Нажмите Tab для переключения между элементами.", "tipCycleBackward": "Shift+Tab — для переключения в обратном направлении.", "imageUploadSuccess": "Изображение загружено.", "imageUploadError": "Загрузите файл JPG, PNG, GIF, или WebP.", "blurStrength": "Сила размытия: {{strength}}", - "solidColor": "Спплошной цвет (цензура)", + "solidColor": "Сплошной цвет (цензура)", "borderRadius": "Скругление углов" }, diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index fc43a7b1..c08dbc79 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -18,7 +18,7 @@ "label": "Скорость" }, "clip": { - "title": "Клин", + "title": "Клип", "muteAudio": "Выключить звук", "delete": "Удалить" }, @@ -80,7 +80,7 @@ "zoomOutEasing": "Кривая отдаления", "connectedZoomGap": "Интервал между зумом", "connectedZoomDuration": "Длительность интервала", - "connectedZoomEasing": "Кривая панаромирования", + "connectedZoomEasing": "Кривая панорамирования", "zoomEasingOptions": { "recordly": "Recordly", "glide": "Плавно", @@ -116,7 +116,7 @@ "radius": "Радиус", "roundness": "Скругление", "padding": "Отступы", - "paddingLinked": "Связаные (одинаковые)", + "paddingLinked": "Связанные (одинаковые)", "paddingUnlinked": "Раздельные", "paddingTop": "Сверху", "paddingBottom": "Снизу", diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index 9e41828a..9898e48c 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -11,6 +11,6 @@ "cycleBackward": "Предыдущая аннотация", "deleteSelectedAlt": "Удалить выбранное (alt)", "panTimeline": "Прокрутка таймлайна", - "zoomTimeline": "Масштабирование таймлайн" + "zoomTimeline": "Масштабирование таймлайна" } } \ No newline at end of file From df68527b2b90a6b3d0ff027987ca3f21f764b90b Mon Sep 17 00:00:00 2001 From: voinkovpost Date: Fri, 8 May 2026 13:15:35 +0500 Subject: [PATCH 08/30] fix: address review comments --- src/i18n/locales/ru/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index c08dbc79..88e48ec5 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -78,7 +78,7 @@ "zoomOutDuration": "Длительность отдаления", "zoomInEasing": "Кривая приближения", "zoomOutEasing": "Кривая отдаления", - "connectedZoomGap": "Интервал между зумом", + "connectedZoomGap": "Интервал между зумами", "connectedZoomDuration": "Длительность интервала", "connectedZoomEasing": "Кривая панорамирования", "zoomEasingOptions": { From 22494507d09b451fe328aa181db3959fba5a955c Mon Sep 17 00:00:00 2001 From: voinkovpost Date: Fri, 8 May 2026 14:44:44 +0500 Subject: [PATCH 09/30] fix(i18n): complete Russian locale parity with English --- src/i18n/locales/ru/editor.json | 5 +++++ src/i18n/locales/ru/launch.json | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 71e2f7cc..673f27ee 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -113,6 +113,11 @@ "project": { "untitled": "Без названия" }, + "nativeCaptureUnavailable": { + "title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.", + "description": "Устройство не поддерживает нативный захват изображения. Запись продолжится, но без сглаживания курсора.", + "confirm": "Хорошо" + }, "exportStatus": { "exporting": "Экспорт", "renderingFile": "Обработка файла.", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 03427d44..65bd57b8 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "Включить системные звуки", "disableMicrophone": "Отключить микрофон", "enableMicrophone": "Включить микрофон", + "micToggleDisabledTip": "Нельзя переключать микрофон во время записи", "disableWebcam": "Отключить наложение веб-камеры", "enableWebcam": "Включить наложение веб-камеры", "countdownDelay": "Обратный отсчёт", @@ -20,6 +21,8 @@ "closeApp": "Закрыть приложение", "screens": "Экраны", "windows": "Окна", + "screen": "Экран", + "window": "Окно", "noSourcesFound": "Источники изображения не найдены", "microphone": "Микрофон", "turnOffMicrophone": "Выключить микрофон", From 427955b04b68259651c30909591c211cfdb1d820 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 17:25:23 +0700 Subject: [PATCH 10/30] fix(export): fallback webcam packet streaming failures --- src/lib/exporter/modernFrameRenderer.test.ts | 195 +++++++++++++++- src/lib/exporter/modernFrameRenderer.ts | 230 ++++++++++++------- 2 files changed, 342 insertions(+), 83 deletions(-) diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index f8b95fd5..c0c0927e 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -1,7 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; -const { initializeForwardFrameSourceMock, resolveMediaElementSourceMock } = vi.hoisted(() => ({ +const { + cancelForwardFrameSourceMock, + destroyForwardFrameSourceMock, + getForwardFrameAtTimeMock, + initializeForwardFrameSourceMock, + resolveMediaElementSourceMock, +} = vi.hoisted(() => ({ + cancelForwardFrameSourceMock: vi.fn(), + destroyForwardFrameSourceMock: vi.fn(async () => undefined), + getForwardFrameAtTimeMock: vi.fn(async () => null), initializeForwardFrameSourceMock: vi.fn(async () => undefined), resolveMediaElementSourceMock: vi.fn(async () => ({ src: "blob:background", @@ -82,6 +91,9 @@ vi.mock("@/components/video-editor/videoPlayback/cursorRenderer", () => ({ vi.mock("./forwardFrameSource", () => ({ ForwardFrameSource: class { + cancel = cancelForwardFrameSourceMock; + destroy = destroyForwardFrameSourceMock; + getFrameAtTime = getForwardFrameAtTimeMock; initialize = initializeForwardFrameSourceMock; }, })); @@ -248,6 +260,43 @@ describe("ModernFrameRenderer blur export path", () => { }); describe("ModernFrameRenderer webcam frame cache", () => { + it("uses staging canvas instead of recursing when WebGPU frame retention fails", () => { + const renderer = createRenderer() as any; + const originalVideoFrame = (globalThis as any).VideoFrame; + + (globalThis as any).VideoFrame = class { + constructor() { + throw new Error("retain failed"); + } + }; + + try { + renderer.rendererBackend = "webgpu"; + const frame = { + displayWidth: 320, + displayHeight: 180, + timestamp: 0, + } as VideoFrame; + + const result = renderer.stageVideoFrameForTexture(frame, "webcam", 640, 360); + + expect(result).toBe(renderer.webcamVideoFrameStagingCanvas); + expect(renderer.webcamVideoFrameStagingCtx.drawImage).toHaveBeenCalledWith( + frame, + 0, + 0, + 320, + 180, + ); + } finally { + if (originalVideoFrame === undefined) { + delete (globalThis as any).VideoFrame; + } else { + (globalThis as any).VideoFrame = originalVideoFrame; + } + } + }); + it("keeps the refresh throttle for default crop regions", () => { const renderer = createRenderer() as any; @@ -270,3 +319,147 @@ describe("ModernFrameRenderer webcam frame cache", () => { expect(renderer.shouldRefreshWebcamFrameCache(1280, 720)).toBe(true); }); }); + +describe("ModernFrameRenderer webcam export fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockResolvedValue(null); + resolveMediaElementSourceMock.mockResolvedValue({ + src: "blob:webcam", + revoke: vi.fn(), + }); + + Object.assign(globalThis, { + window: { + clearTimeout, + setTimeout, + }, + HTMLMediaElement: { + HAVE_CURRENT_DATA: 2, + }, + cancelAnimationFrame: vi.fn(), + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + document: { + createElement: vi.fn((tag: string) => { + if (tag === "video") { + return { + duration: 5, + readyState: 2, + videoWidth: 640, + videoHeight: 360, + muted: true, + loop: true, + playsInline: true, + preload: "auto", + src: "", + currentTime: 0, + seeking: false, + load: vi.fn(), + pause: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + } + if (tag !== "canvas") { + throw new Error(`Unexpected element requested in test: ${tag}`); + } + + return createMockCanvas(); + }), + }, + }); + }); + + it("falls back to media-element webcam sync when packet streaming fails after initialize", async () => { + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + }; + renderer.config.webcamUrl = "file:///tmp/webcam.webm"; + + await renderer.setupWebcamSource(); + await expect(renderer.syncWebcamFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("file:///tmp/webcam.webm"); + expect(renderer.webcamForwardFrameSource).toBeNull(); + expect(renderer.webcamVideoElement).toBeTruthy(); + }); + + it("tears down the media-element fallback when readiness times out", async () => { + vi.useFakeTimers(); + const originalCreateElement = (globalThis as any).document.createElement; + const revoke = vi.fn(); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:webcam-timeout", + revoke, + }); + Object.assign((globalThis as any).window, { + clearTimeout, + setTimeout, + }); + + (globalThis as any).document.createElement = vi.fn((tag: string) => { + if (tag === "video") { + return { + duration: Number.NaN, + readyState: 0, + videoWidth: 0, + videoHeight: 0, + muted: true, + loop: true, + playsInline: true, + preload: "auto", + src: "", + currentTime: 0, + seeking: false, + load: vi.fn(), + pause: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + } + if (tag !== "canvas") { + throw new Error(`Unexpected element requested in test: ${tag}`); + } + + return createMockCanvas(); + }); + + try { + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + }; + renderer.config.webcamUrl = "file:///tmp/webcam.webm"; + + await renderer.setupWebcamSource(); + const syncPromise = renderer.syncWebcamFrame(1); + + await vi.advanceTimersByTimeAsync(5_001); + await expect(syncPromise).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(revoke).toHaveBeenCalled(); + expect(renderer.webcamForwardFrameSource).toBeNull(); + expect(renderer.webcamVideoElement).toBeNull(); + } finally { + (globalThis as any).document.createElement = originalCreateElement; + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index c6830315..02e648aa 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -246,10 +246,14 @@ type PixiRendererAttempt = { const CANVAS_RENDERER_NOT_IMPLEMENTED_HINT = "CanvasRenderer is not yet implemented"; const NO_RENDERER_HINT = "no available renderer"; const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; function isCanvasRenderer(application: Application): boolean { const rendererName = application?.renderer?.constructor?.name?.toLowerCase(); - return Boolean(rendererName && (rendererName.includes("canvasrenderer") || rendererName.includes("canvas"))); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); } function toErrorMessage(error: unknown): string { @@ -681,7 +685,8 @@ export class FrameRenderer { backend, ); const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); if (isCanvasRenderer(app)) { throw new Error( @@ -691,7 +696,8 @@ export class FrameRenderer { return { app, backend }; } catch (error) { const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); failures.push({ backend, @@ -894,7 +900,9 @@ export class FrameRenderer { } const cachedTimestamp = - kind === "scene" ? this.retainedSceneBitmapTimestamp : this.retainedBackgroundBitmapTimestamp; + kind === "scene" + ? this.retainedSceneBitmapTimestamp + : this.retainedBackgroundBitmapTimestamp; const cachedBitmap = kind === "scene" ? this.retainedSceneBitmap : this.retainedBackgroundBitmap; if (cachedTimestamp === frame.timestamp && cachedBitmap) { @@ -924,8 +932,8 @@ export class FrameRenderer { private resolveRetainedVideoFrameSource( frame: VideoFrame, kind: "scene" | "background" | "webcam", - _fallbackWidth: number, - _fallbackHeight: number, + fallbackWidth: number, + fallbackHeight: number, ): CanvasImageSource | VideoFrame { if (this.rendererBackend !== "webgpu") { return frame; @@ -948,12 +956,7 @@ export class FrameRenderer { `[ModernFrameRenderer] Failed to retain ${kind} VideoFrame, falling back to staging canvas:`, error, ); - return this.stageVideoFrameForTexture( - frame, - "scene", - this.config.videoWidth, - this.config.videoHeight, - ); + return this.stageVideoFrameOnCanvas(frame, kind, fallbackWidth, fallbackHeight); } } @@ -1010,21 +1013,12 @@ export class FrameRenderer { return { canvas, context }; } - private stageVideoFrameForTexture( + private stageVideoFrameOnCanvas( frame: VideoFrame, kind: "scene" | "background" | "webcam", fallbackWidth: number, fallbackHeight: number, ): CanvasImageSource | VideoFrame { - if (this.rendererBackend === "webgpu") { - return this.resolveRetainedVideoFrameSource( - frame, - kind, - fallbackWidth, - fallbackHeight, - ); - } - const width = Math.max(1, frame.displayWidth || fallbackWidth); const height = Math.max(1, frame.displayHeight || fallbackHeight); const staging = this.ensureVideoFrameStagingCanvas(kind, width, height); @@ -1037,6 +1031,19 @@ export class FrameRenderer { return staging.canvas; } + private stageVideoFrameForTexture( + frame: VideoFrame, + kind: "scene" | "background" | "webcam", + fallbackWidth: number, + fallbackHeight: number, + ): CanvasImageSource | VideoFrame { + if (this.rendererBackend === "webgpu") { + return this.resolveRetainedVideoFrameSource(frame, kind, fallbackWidth, fallbackHeight); + } + + return this.stageVideoFrameOnCanvas(frame, kind, fallbackWidth, fallbackHeight); + } + private replaceSpriteTexture( sprite: Sprite, source: CanvasImageSource | VideoFrame, @@ -2085,6 +2092,99 @@ export class FrameRenderer { return getRenderableAssetUrl(wallpaperAsset); } + private disposeWebcamMediaElement(video: HTMLVideoElement): void { + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + } + + private clearWebcamMediaElement(): void { + if (this.webcamVideoElement) { + this.disposeWebcamMediaElement(this.webcamVideoElement); + } + + this.webcamVideoElement = null; + this.webcamSeekPromise = null; + this.cleanupWebcamSource?.(); + this.cleanupWebcamSource = null; + } + + private async loadWebcamMediaElementSource(webcamUrl: string): Promise { + this.clearWebcamMediaElement(); + + let webcamSource: Awaited>; + try { + webcamSource = await resolveMediaElementSource(webcamUrl); + } catch (error) { + console.warn("[FrameRenderer] Unable to resolve webcam media element source:", error); + return false; + } + this.cleanupWebcamSource = webcamSource.revoke; + + const video = document.createElement("video"); + video.src = webcamSource.src; + video.muted = true; + video.preload = "auto"; + video.playsInline = true; + video.load(); + + const ready = await new Promise((resolve) => { + let readyTimeout: number | null = null; + const onReady = () => { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + cleanup(); + resolve(true); + }; + const onError = () => { + cleanup(); + resolve(false); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("canplaythrough", onReady); + video.removeEventListener("error", onError); + if (readyTimeout !== null) { + window.clearTimeout(readyTimeout); + readyTimeout = null; + } + }; + + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + resolve(true); + return; + } + + video.addEventListener("loadeddata", onReady, { once: true }); + video.addEventListener("canplay", onReady, { once: true }); + video.addEventListener("canplaythrough", onReady, { once: true }); + video.addEventListener("error", onError, { once: true }); + readyTimeout = window.setTimeout(() => { + console.warn( + `[FrameRenderer] Webcam media element fallback did not become ready within ${WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + onError(); + }, WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS); + }); + + if (ready && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + this.webcamVideoElement = video; + this.lastSyncedWebcamTime = null; + return true; + } + + console.warn("[FrameRenderer] Webcam overlay unavailable during export"); + this.disposeWebcamMediaElement(video); + this.clearWebcamMediaElement(); + return false; + } + private async setupWebcamSource(): Promise { const webcamUrl = this.config.webcamUrl; if (!this.config.webcam?.enabled || !webcamUrl) { @@ -2092,9 +2192,7 @@ export class FrameRenderer { void this.webcamForwardFrameSource?.destroy(); this.webcamForwardFrameSource = null; this.closeWebcamDecodedFrame(); - this.cleanupWebcamSource?.(); - this.cleanupWebcamSource = null; - this.webcamVideoElement = null; + this.clearWebcamMediaElement(); this.webcamFrameCacheCanvas = null; this.webcamFrameCacheCtx = null; this.lastSyncedWebcamTime = null; @@ -2108,8 +2206,7 @@ export class FrameRenderer { void this.webcamForwardFrameSource?.destroy(); this.webcamForwardFrameSource = null; this.closeWebcamDecodedFrame(); - this.cleanupWebcamSource?.(); - this.cleanupWebcamSource = null; + this.clearWebcamMediaElement(); this.webcamFrameCacheCanvas = null; this.webcamFrameCacheCtx = null; this.lastWebcamCacheRefreshTime = null; @@ -2132,55 +2229,7 @@ export class FrameRenderer { ); } - const webcamSource = await resolveMediaElementSource(webcamUrl); - this.cleanupWebcamSource = webcamSource.revoke; - - const video = document.createElement("video"); - video.src = webcamSource.src; - video.muted = true; - video.preload = "auto"; - video.playsInline = true; - video.load(); - - await new Promise((resolve, reject) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error("Failed to load webcam source for export")); - }; - const cleanup = () => { - video.removeEventListener("loadeddata", onReady); - video.removeEventListener("canplay", onReady); - video.removeEventListener("canplaythrough", onReady); - video.removeEventListener("error", onError); - }; - - if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { - resolve(); - return; - } - - video.addEventListener("loadeddata", onReady, { once: true }); - video.addEventListener("canplay", onReady, { once: true }); - video.addEventListener("canplaythrough", onReady, { once: true }); - video.addEventListener("error", onError, { once: true }); - }).catch((error) => { - console.warn("[FrameRenderer] Webcam overlay unavailable during export:", error); - this.webcamVideoElement = null; - }); - - if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { - this.webcamVideoElement = video; - return; - } - - this.webcamVideoElement = null; + await this.loadWebcamMediaElementSource(webcamUrl); this.lastSyncedWebcamTime = null; } @@ -2457,13 +2506,30 @@ export class FrameRenderer { if (this.webcamForwardFrameSource) { const clampedTime = clampMediaTimeToDuration(webcamTargetTime, null); - const decodedFrame = await this.webcamForwardFrameSource.getFrameAtTime(clampedTime); - this.closeWebcamDecodedFrame(); - this.webcamDecodedFrame = decodedFrame; - if (decodedFrame) { - this.lastSyncedWebcamTime = clampedTime; + try { + const decodedFrame = + await this.webcamForwardFrameSource.getFrameAtTime(clampedTime); + this.closeWebcamDecodedFrame(); + this.webcamDecodedFrame = decodedFrame; + if (decodedFrame) { + this.lastSyncedWebcamTime = clampedTime; + } + return; + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed webcam source failed during export; falling back to media element sync:", + error, + ); + this.webcamForwardFrameSource.cancel(); + void this.webcamForwardFrameSource.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + this.lastSyncedWebcamTime = null; + const webcamUrl = this.config.webcamUrl; + if (!webcamUrl || !(await this.loadWebcamMediaElementSource(webcamUrl))) { + return; + } } - return; } const webcamVideo = this.webcamVideoElement; From 1efc9f1706b689a07fde90dae0ff41ce207db875 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 20:07:36 +0700 Subject: [PATCH 11/30] fix(electron): avoid cjs dirname binding collision --- electron/main.ts | 4 ++-- electron/windows.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 3aea001b..a4836f74 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -52,7 +52,7 @@ import { showUpdateToastWindow, } from "./windows"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronMainDir = path.dirname(fileURLToPath(import.meta.url)); const IS_SMOKE_EXPORT = process.env.RECORDLY_SMOKE_EXPORT === "1"; function ignoreBrokenConsolePipe(stream: NodeJS.WritableStream | undefined) { @@ -118,7 +118,7 @@ async function ensureRecordingsDir() { // │ │ ├── main.js // │ │ └── preload.mjs // │ -process.env.APP_ROOT = path.join(__dirname, ".."); +process.env.APP_ROOT = path.join(electronMainDir, ".."); // Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; diff --git a/electron/windows.ts b/electron/windows.ts index cd8bf052..f1b448b6 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -7,10 +7,10 @@ import { app, BrowserWindow, ipcMain } from "electron"; import { USER_DATA_PATH } from "./appPaths"; import { getPackagedRendererBaseUrl } from "./rendererServer"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url)); const nodeRequire = createRequire(import.meta.url); -const APP_ROOT = path.join(__dirname, ".."); +const APP_ROOT = path.join(electronWindowsDir, ".."); const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; const RENDERER_DIST = path.join(APP_ROOT, "dist"); const WINDOW_ICON_PATH = path.join( @@ -374,7 +374,7 @@ export function createHudOverlayWindow(): BrowserWindow { hasShadow: false, show: false, webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, webSecurity: false, @@ -537,7 +537,7 @@ export function createUpdateToastWindow(): BrowserWindow { ...(parentWindow ? { parent: parentWindow } : {}), backgroundColor: useTransparentToastWindow ? "#00000000" : "#101418", webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, backgroundThrottling: false, @@ -726,7 +726,7 @@ export function createEditorWindow(): BrowserWindow { show: false, backgroundColor: "#000000", webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, webSecurity: false, @@ -799,7 +799,7 @@ export function createSourceSelectorWindow(): BrowserWindow { }), backgroundColor: "#00000000", webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, }, @@ -846,7 +846,7 @@ export function createCountdownWindow(): BrowserWindow { focusable: true, show: false, webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, }, From 0a27c0813c714e51b5ea7ff8deeb956628b3e75a Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 21:34:32 +0700 Subject: [PATCH 12/30] fix(export): fallback video wallpaper packet failures --- src/lib/exporter/frameRenderer.test.ts | 57 ++++++- src/lib/exporter/frameRenderer.ts | 163 ++++++++++++++----- src/lib/exporter/modernFrameRenderer.test.ts | 44 +++++ src/lib/exporter/modernFrameRenderer.ts | 147 ++++++++++++----- 4 files changed, 326 insertions(+), 85 deletions(-) diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 671c1c4b..651bb08f 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -1,7 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; -const { initializeForwardFrameSourceMock, resolveMediaElementSourceMock } = vi.hoisted(() => ({ +const { + cancelForwardFrameSourceMock, + destroyForwardFrameSourceMock, + getForwardFrameAtTimeMock, + initializeForwardFrameSourceMock, + resolveMediaElementSourceMock, +} = vi.hoisted(() => ({ + cancelForwardFrameSourceMock: vi.fn(), + destroyForwardFrameSourceMock: vi.fn(async () => undefined), + getForwardFrameAtTimeMock: vi.fn(async () => null), initializeForwardFrameSourceMock: vi.fn(async () => undefined), resolveMediaElementSourceMock: vi.fn(async () => ({ src: "blob:background", @@ -68,6 +77,9 @@ vi.mock("@/components/video-editor/videoPlayback/cursorRenderer", () => ({ vi.mock("./forwardFrameSource", () => ({ ForwardFrameSource: class { + cancel = cancelForwardFrameSourceMock; + destroy = destroyForwardFrameSourceMock; + getFrameAtTime = getForwardFrameAtTimeMock; initialize = initializeForwardFrameSourceMock; }, })); @@ -449,6 +461,7 @@ describe("FrameRenderer webcam export path", () => { }); it("prefers decoder-backed sync for video wallpapers during export", async () => { + vi.clearAllMocks(); const renderer = new FrameRenderer({ width: 1920, height: 1080, @@ -479,4 +492,46 @@ describe("FrameRenderer webcam export path", () => { expect(renderer.backgroundVideoElement).toBeNull(); expect(renderer.backgroundSprite).toBeTruthy(); }); + + it("falls back to media-element sync when video wallpaper packet streaming fails", async () => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:background-video", + revoke: vi.fn(), + }); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as unknown as { + setupBackground: () => Promise; + syncBackgroundFrame: (timeSeconds: number) => Promise; + backgroundForwardFrameSource: unknown; + backgroundVideoElement: FakeVideoElement | null; + }; + + await renderer.setupBackground(); + await expect(renderer.syncBackgroundFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(renderer.backgroundForwardFrameSource).toBeNull(); + expect(renderer.backgroundVideoElement).toBeTruthy(); + }); }); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 8e723d68..026b11ef 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -156,7 +156,10 @@ const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; function isCanvasRenderer(renderer: Application): boolean { const rendererName = renderer?.renderer?.constructor?.name?.toLowerCase(); - return Boolean(rendererName && (rendererName.includes("canvasrenderer") || rendererName.includes("canvas"))); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); } function toErrorMessage(error: unknown): string { @@ -357,7 +360,8 @@ export class FrameRenderer { backend, ); const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); if (isCanvasRenderer(app)) { throw new Error( @@ -367,9 +371,13 @@ export class FrameRenderer { return { app, backend }; } catch (error) { const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); - failures.push({ backend, message: `${toErrorMessage(error)} (after ${elapsed}ms)` }); + failures.push({ + backend, + message: `${toErrorMessage(error)} (after ${elapsed}ms)`, + }); console.warn( `[FrameRenderer] ${backend} renderer unavailable after ${elapsed}ms; trying next backend.`, error, @@ -574,44 +582,9 @@ export class FrameRenderer { ); } - const backgroundSource = await resolveMediaElementSource(videoSrc); - this.cleanupBackgroundSource = backgroundSource.revoke; - - const video = document.createElement("video"); - video.muted = true; - video.loop = true; - video.playsInline = true; - video.preload = "auto"; - video.src = backgroundSource.src; - video.load(); - - await new Promise((resolve, reject) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error(`Failed to load video wallpaper: ${wallpaper}`)); - }; - const cleanup = () => { - video.removeEventListener("loadeddata", onReady); - video.removeEventListener("canplay", onReady); - video.removeEventListener("error", onError); - }; - - video.addEventListener("loadeddata", onReady); - video.addEventListener("canplay", onReady); - video.addEventListener("error", onError); - onReady(); - }); - - this.backgroundVideoElement = video; - this.lastSyncedBackgroundLoopTimeSec = null; - this.drawVideoFrameToBackground(); + if (!(await this.loadBackgroundMediaElementSource(videoSrc, wallpaper))) { + throw new Error(`Failed to load video wallpaper: ${wallpaper}`); + } this.backgroundSprite = bgCanvas; return; } @@ -828,8 +801,20 @@ export class FrameRenderer { } } - const decodedFrame = - await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + let decodedFrame: VideoFrame | null = null; + try { + decodedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper failed during export; falling back to media element sync:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } const resolvedDecodedDuration = this.backgroundForwardFrameSource.getResolvedDurationSec(); if ( @@ -865,6 +850,10 @@ export class FrameRenderer { "[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:", error, ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; } } this.closeBackgroundDecodedFrame(); @@ -883,6 +872,92 @@ export class FrameRenderer { await this.syncBackgroundVideo(timeSeconds); } + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.lastSyncedBackgroundLoopTimeSec = null; + + return sourceUrl ? this.loadBackgroundMediaElementSource(sourceUrl, sourceUrl) : false; + } + + private async loadBackgroundMediaElementSource( + videoSrc: string, + errorLabel: string, + ): Promise { + if (this.backgroundVideoElement) { + try { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + + let backgroundSource: Awaited>; + try { + backgroundSource = await resolveMediaElementSource(videoSrc); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to resolve video wallpaper fallback source:", + error, + ); + return false; + } + this.cleanupBackgroundSource = backgroundSource.revoke; + + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.src = backgroundSource.src; + video.load(); + + const ready = await new Promise((resolve) => { + const onReady = () => { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + cleanup(); + resolve(true); + }; + const onError = () => { + cleanup(); + resolve(false); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("error", onError); + }; + + video.addEventListener("loadeddata", onReady); + video.addEventListener("canplay", onReady); + video.addEventListener("error", onError); + onReady(); + }); + + if (!ready) { + console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + return false; + } + + this.backgroundVideoElement = video; + this.lastSyncedBackgroundLoopTimeSec = null; + this.drawVideoFrameToBackground(); + return true; + } + private async syncBackgroundVideo(timeSeconds: number): Promise { const video = this.backgroundVideoElement; if (!video) return; diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index c0c0927e..7df341f9 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -183,6 +183,11 @@ describe("ModernFrameRenderer blur export path", () => { beforeEach(() => { Object.assign(globalThis, { window: globalThis, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + cancelAnimationFrame: vi.fn(), HTMLMediaElement: { HAVE_CURRENT_DATA: 2, }, @@ -232,6 +237,7 @@ describe("ModernFrameRenderer blur export path", () => { }); it("prefers decoder-backed sync for video wallpapers during export", async () => { + vi.clearAllMocks(); const renderer = new FrameRenderer({ width: 1920, height: 1080, @@ -257,6 +263,44 @@ describe("ModernFrameRenderer blur export path", () => { expect(renderer.backgroundForwardFrameSource).toBeTruthy(); expect(renderer.backgroundVideoElement).toBeNull(); }); + + it("falls back to media-element sync when video wallpaper packet streaming fails", async () => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:background-video", + revoke: vi.fn(), + }); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + nativeReadbackMode: "pixels", + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as any; + + await renderer.setupBackground(); + await expect(renderer.syncBackgroundFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(renderer.backgroundForwardFrameSource).toBeNull(); + expect(renderer.backgroundVideoElement).toBeTruthy(); + }); }); describe("ModernFrameRenderer webcam frame cache", () => { diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 02e648aa..372dd2a4 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -1144,44 +1144,9 @@ export class FrameRenderer { ); } - const backgroundSource = await resolveMediaElementSource(videoSrc); - this.cleanupBackgroundSource = backgroundSource.revoke; - - const video = document.createElement("video"); - video.muted = true; - video.loop = true; - video.playsInline = true; - video.preload = "auto"; - video.src = backgroundSource.src; - video.load(); - - await new Promise((resolve, reject) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error(`Failed to load video wallpaper: ${wallpaper}`)); - }; - const cleanup = () => { - video.removeEventListener("loadeddata", onReady); - video.removeEventListener("canplay", onReady); - video.removeEventListener("error", onError); - }; - - video.addEventListener("loadeddata", onReady); - video.addEventListener("canplay", onReady); - video.addEventListener("error", onError); - onReady(); - }); - - this.backgroundVideoElement = video; - this.lastSyncedBackgroundLoopTimeSec = null; - this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight); + if (!(await this.loadBackgroundMediaElementSource(videoSrc, wallpaper))) { + throw new Error(`Failed to load video wallpaper: ${wallpaper}`); + } return; } @@ -1853,8 +1818,20 @@ export class FrameRenderer { } } - const decodedFrame = - await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + let decodedFrame: VideoFrame | null = null; + try { + decodedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper failed during export; falling back to media element sync:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } const resolvedDecodedDuration = this.backgroundForwardFrameSource.getResolvedDurationSec(); if ( @@ -1897,6 +1874,10 @@ export class FrameRenderer { "[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:", error, ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; } } this.closeBackgroundDecodedFrame(); @@ -2092,6 +2073,92 @@ export class FrameRenderer { return getRenderableAssetUrl(wallpaperAsset); } + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.lastSyncedBackgroundLoopTimeSec = null; + + return sourceUrl ? this.loadBackgroundMediaElementSource(sourceUrl, sourceUrl) : false; + } + + private async loadBackgroundMediaElementSource( + videoSrc: string, + errorLabel: string, + ): Promise { + if (this.backgroundVideoElement) { + try { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + + let backgroundSource: Awaited>; + try { + backgroundSource = await resolveMediaElementSource(videoSrc); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to resolve video wallpaper fallback source:", + error, + ); + return false; + } + this.cleanupBackgroundSource = backgroundSource.revoke; + + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.src = backgroundSource.src; + video.load(); + + const ready = await new Promise((resolve) => { + const onReady = () => { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + cleanup(); + resolve(true); + }; + const onError = () => { + cleanup(); + resolve(false); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("error", onError); + }; + + video.addEventListener("loadeddata", onReady); + video.addEventListener("canplay", onReady); + video.addEventListener("error", onError); + onReady(); + }); + + if (!ready) { + console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + return false; + } + + this.backgroundVideoElement = video; + this.lastSyncedBackgroundLoopTimeSec = null; + await this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight); + return true; + } + private disposeWebcamMediaElement(video: HTMLVideoElement): void { try { video.pause(); From 7aefde75da0ff2bee0339be540c82fad1b3e2c37 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 21:35:38 +0700 Subject: [PATCH 13/30] fix(editor): smooth companion audio preview sync --- src/components/video-editor/VideoEditor.tsx | 138 +++++++++++--------- 1 file changed, 79 insertions(+), 59 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9c402f02..c0656d3f 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -339,6 +339,11 @@ async function writeSmokeExportReport( const SMOKE_EXPORT_READY_TIMEOUT_MS = 30_000; const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30; const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error"; +const SOURCE_AUDIO_PREVIEW_PLAYING_SEEK_DRIFT_SECONDS = 0.18; +const SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS = 0.01; +const SOURCE_AUDIO_PREVIEW_RATE_TOLERANCE_SECONDS = 0.08; +const SOURCE_AUDIO_PREVIEW_RATE_CORRECTION_WINDOW_SECONDS = 8; +const SOURCE_AUDIO_PREVIEW_MAX_RATE_ADJUSTMENT = 0.015; const PROJECT_AUTOSAVE_DELAY_MS = 1000; const EXPORT_ERROR_TOAST_DURATION_MS = 20000; @@ -439,7 +444,9 @@ function getSmokeExportConfig(search: string): SmokeExportConfig { : enabled && params.get("smokeBackendPreference") === "breeze" ? "breeze" : undefined, - renderBackend: enabled ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) : undefined, + renderBackend: enabled + ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) + : undefined, maxEncodeQueue: enabled ? parseSmokeExportNumber(params.get("smokeMaxEncodeQueue")) : undefined, @@ -595,9 +602,7 @@ export default function VideoEditor() { ); const devOpenRecordingConfig = useMemo( () => - getDevOpenRecordingConfig( - typeof window === "undefined" ? "" : window.location.search, - ), + getDevOpenRecordingConfig(typeof window === "undefined" ? "" : window.location.search), [], ); const [appPlatform, setAppPlatform] = useState( @@ -1730,16 +1735,16 @@ export default function VideoEditor() { }, [activeEffectSection]); const buildPersistedEditorState = useCallback( - ( - editor: Partial<{ - wallpaper: string; - shadowIntensity: number; - backgroundBlur: number; - zoomMotionBlur: number; - zoomMotionBlurTuning: ZoomMotionBlurTuning; - zoomTemporalMotionBlur: number; - zoomMotionBlurSampleCount: number | null; - zoomMotionBlurShutterFraction: number | null; + ( + editor: Partial<{ + wallpaper: string; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur: number; + zoomMotionBlurTuning: ZoomMotionBlurTuning; + zoomTemporalMotionBlur: number; + zoomMotionBlurSampleCount: number | null; + zoomMotionBlurShutterFraction: number | null; connectZooms: boolean; zoomInDurationMs: number; zoomInOverlapMs: number; @@ -2135,15 +2140,15 @@ export default function VideoEditor() { preserveProjectPath: Boolean(path), }, ); - } else { - await window.electronAPI.setCurrentVideoPath(sourcePath, { - preserveProjectPath: Boolean(path), - }); - } - const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); - applySessionPresentation(sessionResult?.success ? sessionResult.session : null); + } else { + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(path), + }); + } + const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); + applySessionPresentation(sessionResult?.success ? sessionResult.session : null); - setWallpaper(normalizedEditor.wallpaper); + setWallpaper(normalizedEditor.wallpaper); setShadowIntensity(normalizedEditor.shadowIntensity); setBackgroundBlur(normalizedEditor.backgroundBlur); setZoomMotionBlur(normalizedEditor.zoomMotionBlur); @@ -2157,10 +2162,10 @@ export default function VideoEditor() { setZoomOutDurationMs(normalizedEditor.zoomOutDurationMs); setConnectedZoomGapMs(normalizedEditor.connectedZoomGapMs); setConnectedZoomDurationMs(normalizedEditor.connectedZoomDurationMs); - setZoomInEasing(normalizedEditor.zoomInEasing); - setZoomOutEasing(normalizedEditor.zoomOutEasing); - setConnectedZoomEasing(normalizedEditor.connectedZoomEasing); - setShowCursor(normalizedEditor.showCursor); + setZoomInEasing(normalizedEditor.zoomInEasing); + setZoomOutEasing(normalizedEditor.zoomOutEasing); + setConnectedZoomEasing(normalizedEditor.connectedZoomEasing); + setShowCursor(normalizedEditor.showCursor); setLoopCursor(normalizedEditor.loopCursor); setCursorStyle(normalizedEditor.cursorStyle); setCursorSize(normalizedEditor.cursorSize); @@ -2249,7 +2254,12 @@ export default function VideoEditor() { await refreshProjectLibrary(); return true; }, - [buildPersistedEditorState, refreshProjectLibrary, syncHistoryButtons], + [ + applySessionPresentation, + buildPersistedEditorState, + refreshProjectLibrary, + syncHistoryButtons, + ], ); const currentProjectSnapshot = useMemo(() => { @@ -3907,7 +3917,7 @@ export default function VideoEditor() { ), ); }, - [applySessionPresentation], + [], ); const handleAnnotationDelete = useCallback( @@ -4326,7 +4336,9 @@ export default function VideoEditor() { const previousTimelineTime = lastSourceAudioSyncTimeRef.current; const timelineJumped = previousTimelineTime === null || Math.abs(currentTime - previousTimelineTime) > 0.25; - const driftThreshold = isPlaying ? 0.35 : 0.01; + const driftThreshold = isPlaying + ? SOURCE_AUDIO_PREVIEW_PLAYING_SEEK_DRIFT_SECONDS + : SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS; for (const audio of sourceAudioElementsRef.current.values()) { enablePitchPreservingPlayback(audio); @@ -4354,6 +4366,9 @@ export default function VideoEditor() { basePlaybackRate: targetPlaybackRate, currentTime: audio.currentTime, targetTime, + toleranceSeconds: SOURCE_AUDIO_PREVIEW_RATE_TOLERANCE_SECONDS, + correctionWindowSeconds: SOURCE_AUDIO_PREVIEW_RATE_CORRECTION_WINDOW_SECONDS, + maxAdjustment: SOURCE_AUDIO_PREVIEW_MAX_RATE_ADJUSTMENT, }); if (Math.abs(audio.playbackRate - syncedPlaybackRate) > 0.001) { audio.playbackRate = syncedPlaybackRate; @@ -4606,8 +4621,7 @@ export default function VideoEditor() { ? (smokeExportConfig.fps ?? settings.mp4FrameRate ?? mp4FrameRate) : (settings.mp4FrameRate ?? mp4FrameRate); const pipelineModel = smokeExportConfig.enabled - ? (smokeExportConfig.pipelineModel ?? - "modern") + ? (smokeExportConfig.pipelineModel ?? "modern") : (settings.pipelineModel ?? exportPipelineModel); const useExperimentalNativeExport = pipelineModel === "modern" && @@ -4617,10 +4631,12 @@ export default function VideoEditor() { ? "webcodecs" : useExperimentalNativeExport ? "auto" - : smokeExportConfig.enabled - ? (smokeExportConfig.backendPreference ?? - (smokeExportConfig.useNativeExport ? "breeze" : "webcodecs")) - : (settings.backendPreference ?? exportBackendPreference); + : smokeExportConfig.enabled + ? (smokeExportConfig.backendPreference ?? + (smokeExportConfig.useNativeExport + ? "breeze" + : "webcodecs")) + : (settings.backendPreference ?? exportBackendPreference); const supportedSourceDimensions = await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate); const { width: exportWidth, height: exportHeight } = @@ -5368,31 +5384,35 @@ export default function VideoEditor() { ? isExportPreparing ? t("editor.exportStatus.preparing", "Preparing export...") : isExportSaving - ? t("editor.exportStatus.saving", "Opening save dialog...") - : isRenderingAudio - ? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", { - percent: Math.round((exportProgress.audioProgress ?? 0) * 100), - }) - : isExportFinalizing - ? exportFormat === "mp4" && exportPipelineModel === "modern" - ? isExportFinalSaveIndeterminate - ? t( - "editor.exportStatus.muxingAndSaving", - "Muxing audio and saving file...", - ) - : t( - "editor.exportStatus.muxingAndSavingPercent", - "Muxing and saving {{percent}}%", - { - percent: exportFinalizingPercent ?? 100, - }, - ) - : t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", { - percent: exportFinalizingPercent ?? 100, - }) - : t("editor.exportStatus.completePercent", "{{percent}}% complete", { - percent: Math.round(exportProgress.percentage), + ? t("editor.exportStatus.saving", "Opening save dialog...") + : isRenderingAudio + ? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", { + percent: Math.round((exportProgress.audioProgress ?? 0) * 100), }) + : isExportFinalizing + ? exportFormat === "mp4" && exportPipelineModel === "modern" + ? isExportFinalSaveIndeterminate + ? t( + "editor.exportStatus.muxingAndSaving", + "Muxing audio and saving file...", + ) + : t( + "editor.exportStatus.muxingAndSavingPercent", + "Muxing and saving {{percent}}%", + { + percent: exportFinalizingPercent ?? 100, + }, + ) + : t( + "editor.exportStatus.finalizingPercent", + "Finalizing {{percent}}%", + { + percent: exportFinalizingPercent ?? 100, + }, + ) + : t("editor.exportStatus.completePercent", "{{percent}}% complete", { + percent: Math.round(exportProgress.percentage), + }) : t("editor.exportStatus.preparing", "Preparing export..."); const projectBrowser = ( From 31cb818dfd90b5db3e54650982a5aa60d8a64b11 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 21:57:04 +0700 Subject: [PATCH 14/30] fix(export): harden background fallback cleanup --- src/components/video-editor/VideoEditor.tsx | 17 +++---- src/lib/exporter/frameRenderer.ts | 55 ++++++++++++++++----- src/lib/exporter/modernFrameRenderer.ts | 55 ++++++++++++++++----- 3 files changed, 94 insertions(+), 33 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c0656d3f..0145f273 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2140,13 +2140,14 @@ export default function VideoEditor() { preserveProjectPath: Boolean(path), }, ); + const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); + applySessionPresentation(sessionResult?.success ? sessionResult.session : null); } else { await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: Boolean(path), }); + applySessionPresentation(null); } - const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); - applySessionPresentation(sessionResult?.success ? sessionResult.session : null); setWallpaper(normalizedEditor.wallpaper); setShadowIntensity(normalizedEditor.shadowIntensity); @@ -4629,13 +4630,11 @@ export default function VideoEditor() { const backendPreference = pipelineModel === "legacy" ? "webcodecs" - : useExperimentalNativeExport - ? "auto" - : smokeExportConfig.enabled - ? (smokeExportConfig.backendPreference ?? - (smokeExportConfig.useNativeExport - ? "breeze" - : "webcodecs")) + : smokeExportConfig.enabled + ? (smokeExportConfig.backendPreference ?? + (smokeExportConfig.useNativeExport ? "breeze" : "webcodecs")) + : useExperimentalNativeExport + ? "auto" : (settings.backendPreference ?? exportBackendPreference); const supportedSourceDimensions = await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 026b11ef..3a01403e 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -153,6 +153,7 @@ type PixiRendererAttempt = { }; const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; function isCanvasRenderer(renderer: Application): boolean { const rendererName = renderer?.renderer?.constructor?.name?.toLowerCase(); @@ -924,31 +925,61 @@ export class FrameRenderer { video.load(); const ready = await new Promise((resolve) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; + let settled = false; + let timeoutId: ReturnType | null = null; + + function cleanup() { + if (timeoutId !== null) { + clearTimeout(timeoutId); } - cleanup(); - resolve(true); - }; - const onError = () => { - cleanup(); - resolve(false); - }; - const cleanup = () => { video.removeEventListener("loadeddata", onReady); video.removeEventListener("canplay", onReady); video.removeEventListener("error", onError); - }; + } + + function settle(value: boolean) { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + } + function onReady() { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + settle(true); + } + function onError() { + settle(false); + } video.addEventListener("loadeddata", onReady); video.addEventListener("canplay", onReady); video.addEventListener("error", onError); + timeoutId = setTimeout(() => { + console.warn( + `[FrameRenderer] Video wallpaper media element fallback did not become ready within ${BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + settle(false); + }, BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS); onReady(); }); if (!ready) { console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors on failed fallback. + } + backgroundSource.revoke(); + if (this.cleanupBackgroundSource === backgroundSource.revoke) { + this.cleanupBackgroundSource = null; + } return false; } diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 372dd2a4..cf52faea 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -246,6 +246,7 @@ type PixiRendererAttempt = { const CANVAS_RENDERER_NOT_IMPLEMENTED_HINT = "CanvasRenderer is not yet implemented"; const NO_RENDERER_HINT = "no available renderer"; const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; const WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; function isCanvasRenderer(application: Application): boolean { @@ -2125,31 +2126,61 @@ export class FrameRenderer { video.load(); const ready = await new Promise((resolve) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; + let settled = false; + let timeoutId: ReturnType | null = null; + + function cleanup() { + if (timeoutId !== null) { + clearTimeout(timeoutId); } - cleanup(); - resolve(true); - }; - const onError = () => { - cleanup(); - resolve(false); - }; - const cleanup = () => { video.removeEventListener("loadeddata", onReady); video.removeEventListener("canplay", onReady); video.removeEventListener("error", onError); - }; + } + + function settle(value: boolean) { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + } + function onReady() { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + settle(true); + } + function onError() { + settle(false); + } video.addEventListener("loadeddata", onReady); video.addEventListener("canplay", onReady); video.addEventListener("error", onError); + timeoutId = setTimeout(() => { + console.warn( + `[FrameRenderer] Video wallpaper media element fallback did not become ready within ${BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + settle(false); + }, BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS); onReady(); }); if (!ready) { console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors on failed fallback. + } + backgroundSource.revoke(); + if (this.cleanupBackgroundSource === backgroundSource.revoke) { + this.cleanupBackgroundSource = null; + } return false; } From 4f89a6d38daa8c397ab4b4ade1cc13568abf07e8 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 22:40:40 +0700 Subject: [PATCH 15/30] fix(export): keep large saves off arraybuffer path --- electron/ipc/register/export.ts | 37 ++++++++++++++ src/components/video-editor/VideoEditor.tsx | 40 ++++++++++++++- src/lib/exporter/exportSavePolicy.test.ts | 54 +++++++++++++++++++++ src/lib/exporter/exportSavePolicy.ts | 46 ++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/lib/exporter/exportSavePolicy.test.ts create mode 100644 src/lib/exporter/exportSavePolicy.ts diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index c095cf39..ede2272f 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -51,6 +51,16 @@ function getPartialExportDestinationPath(destinationPath: string) { return path.join(parsed.dir, `.recordly-partial-${parsed.name}-${suffix}${parsed.ext}`); } +const MAX_IN_MEMORY_EXPORT_BYTES = 0x7fffffff; + +function getInMemoryExportTooLargeMessage(byteLength: number) { + if (byteLength <= MAX_IN_MEMORY_EXPORT_BYTES) { + return null; + } + + return "Export is too large for the legacy in-memory save path. Please retry with temp-file streaming enabled."; +} + export async function moveExportedTempFile(tempPath: string, destinationPath: string) { await fs.mkdir(path.dirname(destinationPath), { recursive: true }); try { @@ -700,6 +710,14 @@ export function registerExportHandlers() { "mux-exported-video-audio", async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + error: sizeError, + }; + } + const result = await muxExportedVideoAudioBuffer(videoData, options ?? {}); // Register the muxed output so finalize-exported-video / discard- // exported-temp accept it. Returning a temp path (instead of the @@ -797,6 +815,15 @@ export function registerExportHandlers() { "save-exported-video", async (event, videoData: ArrayBuffer, fileName: string) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + message: sizeError, + error: sizeError, + }; + } + // Determine file type from extension const isGif = fileName.toLowerCase().endsWith(".gif"); const filters = isGif @@ -845,6 +872,16 @@ export function registerExportHandlers() { "write-exported-video-to-path", async (_event, videoData: ArrayBuffer, outputPath: string) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + message: sizeError, + canceled: false, + error: sizeError, + }; + } + const resolvedPath = path.resolve(outputPath); await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, Buffer.from(videoData)); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0145f273..9cc3d413 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -76,6 +76,10 @@ import { VideoExporter, } from "@/lib/exporter"; import { getMp4ExportBitrate, getSourceQualityBitrate } from "@/lib/exporter/exportBitrate"; +import { + canUseInMemoryExportSaveFallback, + describeBlockedInMemoryExportSave, +} from "@/lib/exporter/exportSavePolicy"; import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; import { resolveSourceAudioFallbackPaths } from "@/lib/exporter/sourceAudioFallback"; import { @@ -1485,6 +1489,12 @@ export default function VideoEditor() { const saveBlobExport = useCallback( async (blob: Blob, fileName: string, outputPath: string | null = null) => { const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; + const hasExportStreamApi = + typeof window !== "undefined" && + typeof window.electronAPI?.openExportStream === "function" && + typeof window.electronAPI?.writeExportStreamChunk === "function" && + typeof window.electronAPI?.closeExportStream === "function"; + let streamError: unknown = null; try { const tempFilePath = await streamExportBlobToTempFile(blob, extension); @@ -1502,9 +1512,37 @@ export default function VideoEditor() { }; } } catch (error) { - console.warn("[export] Falling back to in-memory blob save", error); + streamError = error; + console.warn("[export] Temp-file blob save failed", error); } + if ( + !canUseInMemoryExportSaveFallback({ + blobSize: blob.size, + extension, + hasExportStreamApi, + }) + ) { + const message = describeBlockedInMemoryExportSave({ + blobSize: blob.size, + extension, + }); + console.error("[export] Refusing in-memory blob save fallback", { + fileName, + blobSize: blob.size, + extension, + hasExportStreamApi, + streamError, + }); + throw new Error(message); + } + + console.warn("[export] Falling back to in-memory blob save", { + fileName, + blobSize: blob.size, + extension, + hasExportStreamApi, + }); const arrayBuffer = await blob.arrayBuffer(); return { saveResult: outputPath diff --git a/src/lib/exporter/exportSavePolicy.test.ts b/src/lib/exporter/exportSavePolicy.test.ts new file mode 100644 index 00000000..fb60c285 --- /dev/null +++ b/src/lib/exporter/exportSavePolicy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + canUseInMemoryExportSaveFallback, + describeBlockedInMemoryExportSave, + isExportTooLargeForInMemorySave, + MAX_IN_MEMORY_EXPORT_BYTES, + normalizeExportExtension, +} from "./exportSavePolicy"; + +describe("exportSavePolicy", () => { + it("normalizes export extensions before policy checks", () => { + expect(normalizeExportExtension(" MP4 ")).toBe("mp4"); + }); + + it("blocks the legacy in-memory save path above Node's Buffer limit", () => { + expect(isExportTooLargeForInMemorySave(MAX_IN_MEMORY_EXPORT_BYTES + 1)).toBe(true); + expect( + canUseInMemoryExportSaveFallback({ + blobSize: MAX_IN_MEMORY_EXPORT_BYTES + 1, + extension: "gif", + hasExportStreamApi: false, + }), + ).toBe(false); + }); + + it("keeps Electron MP4 exports on the temp-file save path", () => { + expect( + canUseInMemoryExportSaveFallback({ + blobSize: 1024, + extension: "mp4", + hasExportStreamApi: true, + }), + ).toBe(false); + }); + + it("allows small non-MP4 exports to use the legacy save fallback", () => { + expect( + canUseInMemoryExportSaveFallback({ + blobSize: 1024, + extension: "gif", + hasExportStreamApi: false, + }), + ).toBe(true); + }); + + it("explains blocked large saves without mentioning implementation stack traces", () => { + expect( + describeBlockedInMemoryExportSave({ + blobSize: MAX_IN_MEMORY_EXPORT_BYTES + 1, + extension: "mp4", + }), + ).toContain("too large"); + }); +}); diff --git a/src/lib/exporter/exportSavePolicy.ts b/src/lib/exporter/exportSavePolicy.ts new file mode 100644 index 00000000..c48516ed --- /dev/null +++ b/src/lib/exporter/exportSavePolicy.ts @@ -0,0 +1,46 @@ +export const MAX_IN_MEMORY_EXPORT_BYTES = 0x7fffffff; + +export function normalizeExportExtension(extension: string): string { + return extension.trim().toLowerCase(); +} + +export function isExportTooLargeForInMemorySave(byteLength: number): boolean { + return byteLength > MAX_IN_MEMORY_EXPORT_BYTES; +} + +export function canUseInMemoryExportSaveFallback({ + blobSize, + extension, + hasExportStreamApi, +}: { + blobSize: number; + extension: string; + hasExportStreamApi: boolean; +}): boolean { + if (isExportTooLargeForInMemorySave(blobSize)) { + return false; + } + + // In Electron, MP4 exports should stay on the temp-file path. If that path + // failed, silently falling back to ArrayBuffer reintroduces the >2 GiB crash. + if (hasExportStreamApi && normalizeExportExtension(extension) === "mp4") { + return false; + } + + return true; +} + +export function describeBlockedInMemoryExportSave({ + blobSize, + extension, +}: { + blobSize: number; + extension: string; +}): string { + const normalizedExtension = normalizeExportExtension(extension) || "export"; + if (isExportTooLargeForInMemorySave(blobSize)) { + return `The ${normalizedExtension.toUpperCase()} export is too large to save through the legacy in-memory path. Please retry the export so Recordly can save it through the temp-file streaming path.`; + } + + return `The ${normalizedExtension.toUpperCase()} export could not be saved through the temp-file streaming path, and Recordly will not fall back to the legacy in-memory path for MP4 exports. Please retry the export.`; +} From b5bb128206bd8addbbb1bcff62a91f9c08bc95e0 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 22:56:29 +0700 Subject: [PATCH 16/30] fix(timeline): include slow clip duration in range --- src/components/video-editor/VideoEditor.tsx | 14 ++++++++++--- src/components/video-editor/types.test.ts | 23 ++++++++++++++++++++- src/components/video-editor/types.ts | 12 +++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9cc3d413..189f54bf 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -186,6 +186,7 @@ import { extendAutoFullTrackClip, type FigureData, getClipSourceEndMs, + getTimelineDurationMs, type Padding, mapSourceTimeToTimelineTime as resolveSourceTimeToTimelineTime, mapTimelineTimeToSourceTime as resolveTimelineTimeToSourceTime, @@ -3418,6 +3419,10 @@ export default function VideoEditor() { () => mapSourceTimeToTimelineTime(currentTime * 1000) / 1000, [currentTime, mapSourceTimeToTimelineTime], ); + const timelineDuration = useMemo( + () => getTimelineDurationMs(clipRegions, duration * 1000) / 1000, + [clipRegions, duration], + ); // Merge clip speeds into speed regions so playback + export respect per-clip speed const effectiveSpeedRegions = useMemo(() => { @@ -6549,14 +6554,17 @@ export default function VideoEditor() { handleSeek( next ? next.time / 1000 - : Math.min(duration, timelinePlayheadTime + 5), + : Math.min( + timelineDuration, + timelinePlayheadTime + 5, + ), ); }} > - {formatTime(duration)} + {formatTime(timelineDuration)}
@@ -6642,7 +6650,7 @@ export default function VideoEditor() { { it("extends the default full-track clip when metadata duration grows", () => { @@ -157,3 +158,23 @@ describe("clip timeline mapping", () => { expect(deriveNextId("clip", clipsFromTrims.map((clip) => clip.id))).toBe(4); }); }); + +describe("getTimelineDurationMs", () => { + it("extends the timeline when a slow clip becomes longer than the source duration", () => { + expect( + getTimelineDurationMs( + [{ id: "clip-1", startMs: 0, endMs: 20_000, speed: 0.5 }], + 10_000, + ), + ).toBe(20_000); + }); + + it("keeps the source duration when speed edits make clips shorter", () => { + expect( + getTimelineDurationMs( + [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 2 }], + 10_000, + ), + ).toBe(10_000); + }); +}); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 18ef9a1c..92f84fd6 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -177,6 +177,18 @@ export function getClipSourceEndMs(clip: ClipRegion): number { return Math.round(clip.startMs + displayDurationMs * speed); } +export function getTimelineDurationMs(clips: ClipRegion[], sourceDurationMs: number): number { + const baseDurationMs = Math.max(0, Math.round(sourceDurationMs)); + if (clips.length === 0) { + return baseDurationMs; + } + + return clips.reduce( + (durationMs, clip) => Math.max(durationMs, Math.max(0, Math.round(clip.endMs))), + baseDurationMs, + ); +} + export function sortClipRegions(clips: ClipRegion[]): ClipRegion[] { return [...clips].sort((left, right) => left.startMs - right.startMs); } From 9faf1656b6bf2c2a40def7f0af134b5b75ae8a5f Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 23:12:37 +0700 Subject: [PATCH 17/30] test(editor): cover cut-region preview skipping --- .../videoPlayback/videoEventHandlers.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts index 44414c53..fd5d1322 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts @@ -123,6 +123,35 @@ describe("createVideoEventHandlers", () => { expect(onTimeUpdate).toHaveBeenCalledWith(0.75); }); + it("skips removed footage when playback reaches a cut region", () => { + let animationFrameCallback: FrameRequestCallback | null = null; + requestAnimationFrameMock.mockImplementation((callback: FrameRequestCallback) => { + animationFrameCallback = callback; + return 29; + }); + const video = createMockVideo({ currentTime: 1.25, duration: 10 }); + const onTimeUpdate = vi.fn(); + const handlers = createVideoEventHandlers({ + video, + isSeekingRef: createMutableRef(false), + isPlayingRef: createMutableRef(false), + allowPlaybackRef: createMutableRef(true), + currentTimeRef: createMutableRef(0), + timeUpdateAnimationRef: createMutableRef(null), + onPlayStateChange: vi.fn(), + onTimeUpdate, + trimRegionsRef: createMutableRef([{ id: "trim-1", startMs: 1000, endMs: 2000 }]), + speedRegionsRef: createMutableRef([]), + }); + + handlers.handlePlay(); + animationFrameCallback?.(0); + + expect(video.currentTime).toBe(2); + expect(video.pause).not.toHaveBeenCalled(); + expect(onTimeUpdate).toHaveBeenLastCalledWith(2); + }); + it("cancels a pending requestVideoFrameCallback on pause and dispose", () => { const cancelVideoFrameCallback = vi.fn(); const video = createMockVideo({ From f3fcd4bfda5bb930ae5dc84702d17a76e0362c87 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 23:27:35 +0700 Subject: [PATCH 18/30] fix(recording): stabilize WGC resize frames --- .../bin/win32-x64/helpers-manifest.json | 6 +- electron/native/bin/win32-x64/wgc-capture.exe | Bin 105984 -> 108032 bytes electron/native/wgc-capture/src/main.cpp | 9 ++- .../native/wgc-capture/src/mf_encoder.cpp | 62 +++++++++++++++++- electron/native/wgc-capture/src/mf_encoder.h | 2 + .../native/wgc-capture/src/wgc_session.cpp | 48 ++++++++++++++ electron/native/wgc-capture/src/wgc_session.h | 6 ++ 7 files changed, 128 insertions(+), 5 deletions(-) diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index be08a2b3..896af05e 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -5,10 +5,10 @@ "helpers": { "wgc-capture": { "binaryName": "wgc-capture.exe", - "binarySha256": "88226bd499c820081a27264b40f37875d0949d9f921920738e05b7eaeb1a9e7d", + "binarySha256": "ee24c7ce2fcd86eb513663416ad0aca673ac2a9b90b73532bc457e021b7b78a6", "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "7190e148ede1fb1c573cc1866405d6345b0c35378a7e32499580bffacf1cc6d2", - "updatedAt": "2026-05-07T15:21:44.354Z" + "sourceFingerprint": "c5a28a1999758f5b98dc79c247507eacb672a188d81d19aedc2dd656ee92a737", + "updatedAt": "2026-05-08T16:48:17.850Z" }, "cursor-monitor": { "binaryName": "cursor-monitor.exe", diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe index e55df5063462f4c641ad974cb86fe8c8555882e4..add5c5db741c1477f2fdcdaa0953148e092321f4 100644 GIT binary patch delta 43348 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 delta 41496 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 diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 2c433b3b..d037fa75 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -397,7 +397,7 @@ int main(int argc, char* argv[]) { } // Wait for stop signal while pausing/resuming audio tracks in lockstep. - while (!g_stopRequested) { + while (!g_stopRequested && !session.hasFatalError()) { if (g_pauseRequested) { if (audioActive) loopback.pause(); if (micActive) micCapture.pause(); @@ -416,6 +416,13 @@ int main(int argc, char* argv[]) { if (audioActive) loopback.stop(); if (micActive) micCapture.stop(); + if (session.hasFatalError()) { + std::cerr << "ERROR: WGC capture session failed during recording" << std::endl; + encoder.finalize(); + DeleteFileW(outputPathW.c_str()); + return 1; + } + if (audioActive) { writeCompanionAudioTimingMetadata( config.audioOutputPath, diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 30969e39..1a89b21e 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -116,6 +117,31 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height return false; } + // WGC window captures can change frame size while recording. Keep the muxer + // output dimensions stable by compositing resized frames into this fixed + // BGRA surface before CPU readback. + D3D11_TEXTURE2D_DESC compositeDesc = {}; + compositeDesc.Width = width_; + compositeDesc.Height = height_; + compositeDesc.MipLevels = 1; + compositeDesc.ArraySize = 1; + compositeDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + compositeDesc.SampleDesc.Count = 1; + compositeDesc.Usage = D3D11_USAGE_DEFAULT; + compositeDesc.BindFlags = D3D11_BIND_RENDER_TARGET; + + hr = device_->CreateTexture2D(&compositeDesc, nullptr, &resizeCompositeTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create resize composite texture: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = device_->CreateRenderTargetView(resizeCompositeTexture_.Get(), nullptr, &resizeCompositeView_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create resize composite view: 0x" << std::hex << hr << std::endl; + return false; + } + // Pre-allocate NV12 buffer const int ySize = width_ * height_; const int uvSize = (width_ / 2) * (height_ / 2) * 2; @@ -132,7 +158,39 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { if (!initialized_ || !sinkWriter_) return false; - context_->CopyResource(stagingTexture_.Get(), texture); + D3D11_TEXTURE2D_DESC sourceDesc = {}; + texture->GetDesc(&sourceDesc); + + if (sourceDesc.Width == static_cast(width_) && + sourceDesc.Height == static_cast(height_)) { + context_->CopyResource(stagingTexture_.Get(), texture); + } else { + if (!resizeCompositeTexture_ || !resizeCompositeView_) return false; + + const FLOAT clearColor[4] = {0.0f, 0.0f, 0.0f, 1.0f}; + context_->ClearRenderTargetView(resizeCompositeView_.Get(), clearColor); + + D3D11_BOX sourceBox = {}; + sourceBox.left = 0; + sourceBox.top = 0; + sourceBox.front = 0; + sourceBox.right = (std::min)(sourceDesc.Width, static_cast(width_)); + sourceBox.bottom = (std::min)(sourceDesc.Height, static_cast(height_)); + sourceBox.back = 1; + + if (sourceBox.right == 0 || sourceBox.bottom == 0) return false; + + context_->CopySubresourceRegion( + resizeCompositeTexture_.Get(), + 0, + 0, + 0, + 0, + texture, + 0, + &sourceBox); + context_->CopyResource(stagingTexture_.Get(), resizeCompositeTexture_.Get()); + } D3D11_MAPPED_SUBRESOURCE mapped; HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); @@ -242,6 +300,8 @@ bool MFEncoder::finalize() { initialized_ = false; sinkWriter_.Reset(); stagingTexture_.Reset(); + resizeCompositeView_.Reset(); + resizeCompositeTexture_.Reset(); nv12Buffer_.clear(); lastFrameBuffer_.clear(); nv12Buffer_.shrink_to_fit(); diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 21f49032..48fdeaad 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -30,6 +30,8 @@ private: ID3D11Device* device_ = nullptr; ID3D11DeviceContext* context_ = nullptr; ComPtr stagingTexture_; + ComPtr resizeCompositeTexture_; + ComPtr resizeCompositeView_; std::vector nv12Buffer_; std::vector lastFrameBuffer_; DWORD streamIndex_ = 0; diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index 987c649b..c9fb0040 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -24,6 +24,12 @@ extern "C" { IInspectable** graphicsDevice); } +static int normalizeFramePoolExtent(int value) { + int normalized = value < 2 ? 2 : value; + if ((normalized % 2) != 0) ++normalized; + return normalized; +} + WgcSession::WgcSession() {} WgcSession::~WgcSession() { @@ -114,6 +120,8 @@ bool WgcSession::initializeWithItem(int fps) { auto size = captureItem_.Size(); captureWidth_ = size.Width; captureHeight_ = size.Height; + framePoolWidth_ = size.Width; + framePoolHeight_ = size.Height; framePool_ = winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::CreateFreeThreaded( winrtDevice_, @@ -134,6 +142,40 @@ bool WgcSession::initializeWithItem(int fps) { return true; } +bool WgcSession::recreateFramePoolIfNeeded( + winrt::Windows::Graphics::SizeInt32 const& contentSize) { + if (!framePool_) return false; + + const int normalizedWidth = normalizeFramePoolExtent(contentSize.Width); + const int normalizedHeight = normalizeFramePoolExtent(contentSize.Height); + if (normalizedWidth == framePoolWidth_ && normalizedHeight == framePoolHeight_) { + return false; + } + + winrt::Windows::Graphics::SizeInt32 normalizedSize{ + normalizedWidth, + normalizedHeight, + }; + + try { + framePool_.Recreate( + winrtDevice_, + winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized, + 2, + normalizedSize); + framePoolWidth_ = normalizedWidth; + framePoolHeight_ = normalizedHeight; + std::cerr << "INFO: Recreated WGC frame pool for resized content " + << framePoolWidth_ << "x" << framePoolHeight_ << std::endl; + } catch (winrt::hresult_error const& e) { + fatalError_ = true; + std::cerr << "ERROR: Failed to recreate WGC frame pool after resize: 0x" + << std::hex << e.code() << std::dec << std::endl; + } + + return true; +} + bool WgcSession::initialize(HMONITOR monitor, int fps) { fps_ = fps; frameIntervalHns_ = 10000000LL / fps_; @@ -174,6 +216,7 @@ bool WgcSession::startCapture() { if (!session_ || !framePool_) return false; capturing_ = true; + fatalError_ = false; lastFrameTimeHns_ = 0; frameArrivedRevoker_ = framePool_.FrameArrived( @@ -209,6 +252,11 @@ void WgcSession::onFrameArrived( auto frame = sender.TryGetNextFrame(); if (!frame) return; + auto contentSize = frame.ContentSize(); + if (recreateFramePoolIfNeeded(contentSize)) { + frame.Close(); + return; + } auto timestamp = frame.SystemRelativeTime(); int64_t frameTimeHns = std::chrono::duration_cast>>(timestamp).count(); diff --git a/electron/native/wgc-capture/src/wgc_session.h b/electron/native/wgc-capture/src/wgc_session.h index 5ca6be34..1755f4b1 100644 --- a/electron/native/wgc-capture/src/wgc_session.h +++ b/electron/native/wgc-capture/src/wgc_session.h @@ -28,6 +28,7 @@ public: void setFrameCallback(FrameCallback callback); bool startCapture(); void stopCapture(); + bool hasFatalError() const { return fatalError_.load(); } int captureWidth() const { return captureWidth_; } int captureHeight() const { return captureHeight_; } @@ -45,9 +46,12 @@ private: FrameCallback frameCallback_; std::atomic capturing_{false}; + std::atomic fatalError_{false}; int fps_ = 60; int captureWidth_ = 0; int captureHeight_ = 0; + int framePoolWidth_ = 0; + int framePoolHeight_ = 0; int64_t frameIntervalHns_ = 0; int64_t lastFrameTimeHns_ = 0; @@ -56,6 +60,8 @@ private: winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForMonitor(HMONITOR monitor); winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForWindow(HWND hwnd); bool initializeWithItem(int fps); + bool recreateFramePoolIfNeeded( + winrt::Windows::Graphics::SizeInt32 const& contentSize); void onFrameArrived( winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, winrt::Windows::Foundation::IInspectable const& args); From fe294b71d52c3ac219872ff2c66565904e7c58fc Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 07:49:54 +0700 Subject: [PATCH 19/30] fix(recording): fail cleanly on WGC resize errors --- .../bin/win32-x64/helpers-manifest.json | 6 +++--- electron/native/bin/win32-x64/wgc-capture.exe | Bin 108032 -> 109568 bytes electron/native/wgc-capture/src/main.cpp | 12 ++++++++++++ .../native/wgc-capture/src/wgc_session.cpp | 5 +++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index 896af05e..bbcef557 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -5,10 +5,10 @@ "helpers": { "wgc-capture": { "binaryName": "wgc-capture.exe", - "binarySha256": "ee24c7ce2fcd86eb513663416ad0aca673ac2a9b90b73532bc457e021b7b78a6", + "binarySha256": "4f8873abbff58add37672114184c154dee838939ee8f2b7df45d658d078af28c", "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "c5a28a1999758f5b98dc79c247507eacb672a188d81d19aedc2dd656ee92a737", - "updatedAt": "2026-05-08T16:48:17.850Z" + "sourceFingerprint": "6f725fad6eb515d81b2d553ec45ddbf7c681d2725bba48f0c0722ce00166d967", + "updatedAt": "2026-05-09T00:49:28.306Z" }, "cursor-monitor": { "binaryName": "cursor-monitor.exe", diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe index add5c5db741c1477f2fdcdaa0953148e092321f4..37de4c9c730e3afe13b2ba44f1cdb2302604917a 100644 GIT binary patch delta 31233 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 Wwx65JQ};y{%46kyKI`OrZvPKu1EPrl delta 29909 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 diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index d037fa75..0f007826 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -420,6 +420,18 @@ int main(int argc, char* argv[]) { std::cerr << "ERROR: WGC capture session failed during recording" << std::endl; encoder.finalize(); DeleteFileW(outputPathW.c_str()); + if (!config.audioOutputPath.empty()) { + const std::wstring audioPathW = utf8ToWide(config.audioOutputPath); + const std::wstring audioMetadataPathW = utf8ToWide(config.audioOutputPath + ".json"); + DeleteFileW(audioPathW.c_str()); + DeleteFileW(audioMetadataPathW.c_str()); + } + if (!config.micOutputPath.empty()) { + const std::wstring micPathW = utf8ToWide(config.micOutputPath); + const std::wstring micMetadataPathW = utf8ToWide(config.micOutputPath + ".json"); + DeleteFileW(micPathW.c_str()); + DeleteFileW(micMetadataPathW.c_str()); + } return 1; } diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index c9fb0040..898a9fdc 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -136,7 +136,7 @@ bool WgcSession::initializeWithItem(int fps) { // IsBorderRequired is only available on Windows 11+ (build 22000). propagating an hresult_error results in Native Windows capture failure try { session_.IsBorderRequired(false); - } catch (winrt::hresult_error const& e) { + } catch (winrt::hresult_error const&) { } return true; @@ -169,6 +169,7 @@ bool WgcSession::recreateFramePoolIfNeeded( << framePoolWidth_ << "x" << framePoolHeight_ << std::endl; } catch (winrt::hresult_error const& e) { fatalError_ = true; + capturing_ = false; std::cerr << "ERROR: Failed to recreate WGC frame pool after resize: 0x" << std::hex << e.code() << std::dec << std::endl; } @@ -248,7 +249,7 @@ void WgcSession::onFrameArrived( winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, winrt::Windows::Foundation::IInspectable const&) { - if (!capturing_) return; + if (!capturing_ || fatalError_) return; auto frame = sender.TryGetNextFrame(); if (!frame) return; From 2f17894fae7d2f3ab8c41a653a754c05781f2f98 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 08:10:00 +0700 Subject: [PATCH 20/30] fix(export): fallback native lightning errors --- .../modernVideoExporter.fallback.test.ts | 120 ++++++++++++++++++ src/lib/exporter/modernVideoExporter.ts | 19 ++- 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 src/lib/exporter/modernVideoExporter.fallback.test.ts diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts new file mode 100644 index 00000000..80125266 --- /dev/null +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const videoInfo = { + width: 1920, + height: 1080, + duration: 1, + streamDuration: 1, + frameRate: 30, + codec: "h264", + hasAudio: false, + audioCodec: null, + audioSampleRate: null, + }; + + return { + videoInfo, + streamingDecoderDestroy: vi.fn(), + streamingDecoderCancel: vi.fn(), + streamingDecoderDecodeAll: vi.fn(async () => {}), + streamingDecoderGetDemuxer: vi.fn(() => null), + streamingDecoderGetEffectiveDuration: vi.fn(() => 0), + streamingDecoderLoadMetadata: vi.fn(async () => videoInfo), + frameRendererDestroy: vi.fn(), + frameRendererGetBackend: vi.fn(() => "webgl"), + frameRendererInitialize: vi.fn(async () => {}), + muxerDestroy: vi.fn(), + muxerFinalize: vi.fn(async () => ({ + mode: "buffer" as const, + blob: new Blob([], { type: "video/mp4" }), + })), + muxerInitialize: vi.fn(async () => {}), + }; +}); + +vi.mock("./streamingDecoder", () => ({ + StreamingVideoDecoder: vi.fn().mockImplementation(function () { + return { + cancel: mocks.streamingDecoderCancel, + decodeAll: mocks.streamingDecoderDecodeAll, + destroy: mocks.streamingDecoderDestroy, + getDemuxer: mocks.streamingDecoderGetDemuxer, + getEffectiveDuration: mocks.streamingDecoderGetEffectiveDuration, + loadMetadata: mocks.streamingDecoderLoadMetadata, + }; + }), +})); + +vi.mock("./modernFrameRenderer", () => ({ + FrameRenderer: vi.fn().mockImplementation(function () { + return { + destroy: mocks.frameRendererDestroy, + getRendererBackend: mocks.frameRendererGetBackend, + initialize: mocks.frameRendererInitialize, + }; + }), +})); + +vi.mock("./muxer", () => ({ + VideoMuxer: vi.fn().mockImplementation(function () { + return { + destroy: mocks.muxerDestroy, + finalize: mocks.muxerFinalize, + initialize: mocks.muxerInitialize, + }; + }), +})); + +describe("ModernVideoExporter native fallback routing", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("falls back to WebCodecs instead of surfacing a native error when Breeze is unavailable", async () => { + const { ModernVideoExporter } = await import("./modernVideoExporter"); + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + experimentalNativeExport: true, + backendPreference: "breeze", + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + initializeEncoder: () => Promise; + loadNativeStaticLayoutVideoInfo: () => Promise; + tryExportNativeStaticLayout: () => Promise; + tryStartNativeVideoExport: () => Promise; + lastNativeExportError: string | null; + }; + + vi.spyOn(exporter, "loadNativeStaticLayoutVideoInfo").mockResolvedValue(mocks.videoInfo); + vi.spyOn(exporter, "tryExportNativeStaticLayout").mockResolvedValue(null); + vi.spyOn(exporter, "tryStartNativeVideoExport").mockImplementation(async () => { + exporter.lastNativeExportError = "Breeze native encoder unavailable"; + return false; + }); + const initializeEncoder = vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640034", + hardwareAcceleration: "prefer-hardware", + }); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.blob).toBeInstanceOf(Blob); + expect(initializeEncoder).toHaveBeenCalledTimes(1); + expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1); + }, 15_000); +}); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 501dd2b9..ce8b3eae 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -346,7 +346,7 @@ export class ModernVideoExporter { const runtimePlatform = this.getRuntimePlatform(); let useNativeEncoder = false; let triedNativeStaticLayoutWithProbe = false; - const shouldDeferNativeEncoderStart = backendPreference === "breeze"; + let shouldDeferNativeEncoderStart = backendPreference === "breeze"; this.lastNativeExportError = null; let stageStartedAt = this.getNowMs(); @@ -512,10 +512,23 @@ export class ModernVideoExporter { useNativeEncoder = await this.tryStartNativeVideoExport(); this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt; if (!useNativeEncoder) { - throw new Error( + const nativeFailure = this.lastNativeExportError ?? - `${NATIVE_EXPORT_ENGINE_NAME} export is unavailable for this output profile on this system.`, + `${NATIVE_EXPORT_ENGINE_NAME} export is unavailable for this output profile on this system.`; + console.warn( + `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} native export unavailable after static-layout fallback; falling back to WebCodecs.`, + nativeFailure, ); + shouldDeferNativeEncoderStart = false; + this.backpressureProfile = getExportBackpressureProfile({ + encodeBackend: "webcodecs", + width: this.config.width, + height: this.config.height, + frameRate: this.config.frameRate, + encodingMode: this.config.encodingMode, + }); + this.maxNativeWriteInFlight = 1; + await this.initializeEncoder(); } } From 0105cc415c20ae3567800969685b9b392624fed2 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 08:29:39 +0700 Subject: [PATCH 21/30] fix(export): use portrait target dimensions --- src/components/video-editor/VideoEditor.tsx | 74 +------------------ .../video-editor/exportDimensions.test.ts | 51 +++++++++++++ .../video-editor/exportDimensions.ts | 68 +++++++++++++++++ 3 files changed, 123 insertions(+), 70 deletions(-) create mode 100644 src/components/video-editor/exportDimensions.test.ts create mode 100644 src/components/video-editor/exportDimensions.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 189f54bf..59adbddb 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -97,6 +97,10 @@ import { getAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { ExtensionIcon } from "./ExtensionIcon"; +import { + calculateMp4ExportDimensions, + calculateMp4SourceDimensions, +} from "./exportDimensions"; const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => ( @@ -517,76 +521,6 @@ function areDeepEqual(left: unknown, right: unknown): boolean { return true; } -function calculateMp4SourceDimensions( - sourceWidth: number, - sourceHeight: number, - aspectRatio: AspectRatio, -): { width: number; height: number } { - const safeSourceWidth = Math.max(2, Math.floor(sourceWidth / 2) * 2); - const safeSourceHeight = Math.max(2, Math.floor(sourceHeight / 2) * 2); - const sourceAspectRatio = safeSourceHeight > 0 ? safeSourceWidth / safeSourceHeight : 16 / 9; - const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio); - - if (aspectRatio === "native") { - return { width: safeSourceWidth, height: safeSourceHeight }; - } - - if (aspectRatioValue === 1) { - const baseDimension = Math.max( - 2, - Math.floor(Math.min(safeSourceWidth, safeSourceHeight) / 2) * 2, - ); - return { width: baseDimension, height: baseDimension }; - } - - if (aspectRatioValue > 1) { - const baseWidth = safeSourceWidth; - for (let width = baseWidth; width >= 100; width -= 2) { - const height = Math.round(width / aspectRatioValue); - if (height % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { - return { width, height }; - } - } - - return { - width: baseWidth, - height: Math.max(2, Math.floor(baseWidth / aspectRatioValue / 2) * 2), - }; - } - - const baseHeight = safeSourceHeight; - for (let height = baseHeight; height >= 100; height -= 2) { - const width = Math.round(height * aspectRatioValue); - if (width % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { - return { width, height }; - } - } - - return { - height: baseHeight, - width: Math.max(2, Math.floor((baseHeight * aspectRatioValue) / 2) * 2), - }; -} - -function calculateMp4ExportDimensions( - baseWidth: number, - baseHeight: number, - quality: ExportQuality, -): { width: number; height: number } { - if (quality === "source") { - return { - width: Math.max(2, Math.floor(baseWidth / 2) * 2), - height: Math.max(2, Math.floor(baseHeight / 2) * 2), - }; - } - - const qualityScale = quality === "medium" ? 0.6 : quality === "good" ? 0.75 : 0.9; - return { - width: Math.max(2, Math.floor((baseWidth * qualityScale) / 2) * 2), - height: Math.max(2, Math.floor((baseHeight * qualityScale) / 2) * 2), - }; -} - function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; diff --git a/src/components/video-editor/exportDimensions.test.ts b/src/components/video-editor/exportDimensions.test.ts new file mode 100644 index 00000000..a3404943 --- /dev/null +++ b/src/components/video-editor/exportDimensions.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions"; + +describe("calculateMp4SourceDimensions", () => { + it("keeps native exports at the source dimensions", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "native")).toEqual({ + width: 1920, + height: 1080, + }); + }); + + it("uses the rotated source bounds for 9:16 original exports", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "9:16")).toEqual({ + width: 1080, + height: 1920, + }); + }); + + it("uses the rotated source bounds for portrait social ratios", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "4:5")).toEqual({ + width: 1080, + height: 1350, + }); + }); + + it("keeps landscape aspect-ratio exports inside the source bounds", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "4:3")).toEqual({ + width: 1440, + height: 1080, + }); + }); +}); + +describe("calculateMp4ExportDimensions", () => { + it("scales portrait output dimensions from the aspect target", () => { + const sourceDimensions = calculateMp4SourceDimensions(1920, 1080, "9:16"); + + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "source"), + ).toEqual({ + width: 1080, + height: 1920, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "high"), + ).toEqual({ + width: 972, + height: 1728, + }); + }); +}); diff --git a/src/components/video-editor/exportDimensions.ts b/src/components/video-editor/exportDimensions.ts new file mode 100644 index 00000000..8c36d895 --- /dev/null +++ b/src/components/video-editor/exportDimensions.ts @@ -0,0 +1,68 @@ +import type { ExportQuality } from "@/lib/exporter"; +import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; + +function normalizeEvenDimension(value: number): number { + return Math.max(2, Math.floor(value / 2) * 2); +} + +function fitAspectRatioWithinBounds( + maxWidth: number, + maxHeight: number, + aspectRatioValue: number, +): { width: number; height: number } { + const safeMaxWidth = normalizeEvenDimension(maxWidth); + const safeMaxHeight = normalizeEvenDimension(maxHeight); + const safeAspectRatio = + Number.isFinite(aspectRatioValue) && aspectRatioValue > 0 ? aspectRatioValue : 16 / 9; + + if (safeMaxWidth / safeMaxHeight > safeAspectRatio) { + const height = safeMaxHeight; + const width = normalizeEvenDimension(height * safeAspectRatio); + return { width: Math.min(width, safeMaxWidth), height }; + } + + const width = safeMaxWidth; + const height = normalizeEvenDimension(width / safeAspectRatio); + return { width, height: Math.min(height, safeMaxHeight) }; +} + +export function calculateMp4SourceDimensions( + sourceWidth: number, + sourceHeight: number, + aspectRatio: AspectRatio, +): { width: number; height: number } { + const safeSourceWidth = normalizeEvenDimension(sourceWidth); + const safeSourceHeight = normalizeEvenDimension(sourceHeight); + const sourceAspectRatio = safeSourceHeight > 0 ? safeSourceWidth / safeSourceHeight : 16 / 9; + const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio); + + if (aspectRatio === "native") { + return { width: safeSourceWidth, height: safeSourceHeight }; + } + + const longSide = Math.max(safeSourceWidth, safeSourceHeight); + const shortSide = Math.min(safeSourceWidth, safeSourceHeight); + const maxWidth = aspectRatioValue >= 1 ? longSide : shortSide; + const maxHeight = aspectRatioValue >= 1 ? shortSide : longSide; + + return fitAspectRatioWithinBounds(maxWidth, maxHeight, aspectRatioValue); +} + +export function calculateMp4ExportDimensions( + baseWidth: number, + baseHeight: number, + quality: ExportQuality, +): { width: number; height: number } { + if (quality === "source") { + return { + width: normalizeEvenDimension(baseWidth), + height: normalizeEvenDimension(baseHeight), + }; + } + + const qualityScale = quality === "medium" ? 0.6 : quality === "good" ? 0.75 : 0.9; + return { + width: normalizeEvenDimension(baseWidth * qualityScale), + height: normalizeEvenDimension(baseHeight * qualityScale), + }; +} From ea747c101747382b9ce17e0182e2d7766d680ac9 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 08:36:12 +0700 Subject: [PATCH 22/30] test(export): cover odd mp4 dimensions --- .../video-editor/exportDimensions.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/components/video-editor/exportDimensions.test.ts b/src/components/video-editor/exportDimensions.test.ts index a3404943..7c79f4ed 100644 --- a/src/components/video-editor/exportDimensions.test.ts +++ b/src/components/video-editor/exportDimensions.test.ts @@ -32,6 +32,27 @@ describe("calculateMp4SourceDimensions", () => { }); describe("calculateMp4ExportDimensions", () => { + it("normalizes odd source dimensions to even export dimensions", () => { + const sourceDimensions = calculateMp4SourceDimensions(1919, 1079, "native"); + + expect(sourceDimensions).toEqual({ + width: 1918, + height: 1078, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "source"), + ).toEqual({ + width: 1918, + height: 1078, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "high"), + ).toEqual({ + width: 1726, + height: 970, + }); + }); + it("scales portrait output dimensions from the aspect target", () => { const sourceDimensions = calculateMp4SourceDimensions(1920, 1080, "9:16"); From 6ebd15edef09350e1c1bde30d60b0e9a3742b9a5 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 08:53:53 +0700 Subject: [PATCH 23/30] fix(recording): stop browser recorder before cleanup --- src/hooks/useScreenRecorder.test.ts | 30 +++++++++++++++++++++++++++++ src/hooks/useScreenRecorder.ts | 6 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index e98554ad..34424903 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -132,6 +132,11 @@ function stopRecording( if (webcamRecorder && webcamRecorder.state !== "inactive") { webcamRecorder.stop(); } + try { + recorder.requestData(); + } catch { + // Stopping should continue even if the browser refuses an explicit flush. + } recorder.stop(); return { stopped: true, wasNative: false }; } @@ -328,6 +333,31 @@ describe("useScreenRecorder state machine", () => { expect(callOrder).toEqual(["resume", "stop"]); }); + it("flushes the current recorder data before stopping", () => { + const callOrder: string[] = []; + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["requestData", "stop"]); + }); + + it("still stops when the explicit data flush fails", () => { + recorder.requestData.mockImplementation(() => { + throw new Error("flush failed"); + }); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.stop).toHaveBeenCalled(); + }); + it("still stops when resume throws from paused state", () => { recorder.pause(); recorder.resume.mockImplementation(() => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 3d7933ee..69591478 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1092,7 +1092,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } pendingWebcamPathPromise.current = stopWebcamRecorder(); - cleanupCapturedMedia(); + try { + recorder.requestData(); + } catch (error) { + console.warn("Failed to flush recorder before stopping:", error); + } recorder.stop(); setRecording(false); setFinalizing(true); From 0fce11792d39367edd5d0a6181ad453a9d72b3bf Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 09:01:39 +0700 Subject: [PATCH 24/30] test(recording): cover paused recorder flush order --- src/hooks/useScreenRecorder.test.ts | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 34424903..7cf480cf 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -347,6 +347,24 @@ describe("useScreenRecorder state machine", () => { expect(callOrder).toEqual(["requestData", "stop"]); }); + it("resumes, flushes, then stops from paused state", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["resume", "requestData", "stop"]); + }); + it("still stops when the explicit data flush fails", () => { recorder.requestData.mockImplementation(() => { throw new Error("flush failed"); @@ -358,6 +376,26 @@ describe("useScreenRecorder state machine", () => { expect(recorder.stop).toHaveBeenCalled(); }); + it("still stops from paused state when the explicit data flush fails", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + throw new Error("flush failed"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(callOrder).toEqual(["resume", "requestData", "stop"]); + }); + it("still stops when resume throws from paused state", () => { recorder.pause(); recorder.resume.mockImplementation(() => { From 61a7913d1170498972ea3269eb7ed94907b191d9 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 09:53:03 +0700 Subject: [PATCH 25/30] fix(editor): keep cursor size stable during zoom --- src/components/video-editor/VideoPlayback.tsx | 1 + .../videoPlayback/cursorRenderer.test.ts | 27 +++++++++++++++++++ .../videoPlayback/cursorRenderer.ts | 19 ++++++++++--- src/lib/exporter/frameRenderer.ts | 18 +++++++------ src/lib/exporter/modernFrameRenderer.ts | 10 ++++--- 5 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer.test.ts diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 836af7d5..b53636e8 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -2182,6 +2182,7 @@ const VideoPlayback = forwardRef( baseMaskRef.current, showCursorRef.current, !isPlayingRef.current || isSeekingRef.current, + animationStateRef.current.appliedScale || 1, ); smoothedCursorForHooks = mapSmoothedCursorToCanvasNormalized( diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.test.ts b/src/components/video-editor/videoPlayback/cursorRenderer.test.ts new file mode 100644 index 00000000..97fd3594 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorRenderer.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { resolveCursorDrawHeight } from "./cursorRenderer"; + +describe("resolveCursorDrawHeight", () => { + const viewport = { + x: 100, + y: 50, + width: 960, + height: 540, + }; + + it("keeps cursor output size stable when the zoom parent scales up", () => { + const baseHeight = resolveCursorDrawHeight(viewport, 70, 1); + const zoomedLocalHeight = resolveCursorDrawHeight(viewport, 70, 2); + + expect(zoomedLocalHeight).toBeCloseTo(baseHeight / 2, 6); + expect(zoomedLocalHeight * 2).toBeCloseTo(baseHeight, 6); + }); + + it("falls back to unscaled sizing for invalid parent scale values", () => { + const baseHeight = resolveCursorDrawHeight(viewport, 70, 1); + + expect(resolveCursorDrawHeight(viewport, 70, 0)).toBe(baseHeight); + expect(resolveCursorDrawHeight(viewport, 70, Number.NaN)).toBe(baseHeight); + }); +}); diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 0402c412..1a06d003 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -1,8 +1,8 @@ import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { getRenderableAssetUrl } from "@/lib/assetPath"; import { extensionHost } from "@/lib/extensions"; -import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { type CursorStyle, type CursorTelemetryPoint, @@ -12,11 +12,11 @@ import { import { computeCursorSwayRotation } from "./cursorSway"; import { type CursorViewportRect, projectCursorPositionToViewport } from "./cursorViewport"; import { + type CursorSpringTuning, createSpringState, getCursorSpringConfig, resetSpringState, stepSpringValue, - type CursorSpringTuning, } from "./motionSmoothing"; import { cursorSetAssets, getCursorStyleSizeMultiplier } from "./uploadedCursorAssets"; @@ -745,6 +745,15 @@ function getCursorViewportScale(viewport: CursorViewportRect) { return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); } +export function resolveCursorDrawHeight( + viewport: CursorViewportRect, + dotRadius: number, + parentScale = 1, +) { + const safeParentScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1; + return (dotRadius * getCursorViewportScale(viewport)) / safeParentScale; +} + function getCursorSwaySpringConfig( smoothingFactor: number, springTuning: CursorSpringTuning, @@ -1061,6 +1070,7 @@ export class PixiCursorOverlay { viewport: CursorViewportRect, visible: boolean, freeze = false, + parentScale = 1, ): void { if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) { this.container.visible = false; @@ -1107,7 +1117,7 @@ export class PixiCursorOverlay { const px = viewport.x + this.state.x * viewport.width; const py = viewport.y + this.state.y * viewport.height; - const h = this.config.dotRadius * getCursorViewportScale(viewport); + const h = resolveCursorDrawHeight(viewport, this.config.dotRadius, parentScale); const { cursorType, clickBounceProgress } = getCursorVisualState( samples, timeMs, @@ -1303,6 +1313,7 @@ export function drawCursorOnCanvas( viewport: CursorViewportRect, smoothedState: SmoothedCursorState, config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG, + parentScale = 1, ): void { if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return; @@ -1316,7 +1327,7 @@ 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 h = resolveCursorDrawHeight(viewport, config.dotRadius, parentScale); const { cursorType, clickBounceProgress } = getCursorVisualState( samples, timeMs, diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 3a01403e..f390414e 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1626,6 +1626,12 @@ export class FrameRenderer { const timeMs = this.currentVideoTime * 1000; const cursorTimeMs = cursorTimestamp / 1000; + const TICKS_PER_FRAME = 1; + + for (let i = 0; i < TICKS_PER_FRAME; i++) { + this.updateAnimationState(timeMs); + } + if (this.cursorOverlay) { this.cursorOverlay.update( this.config.cursorTelemetry ?? [], @@ -1633,6 +1639,7 @@ export class FrameRenderer { layoutCache.maskRect, this.config.showCursor ?? true, false, + this.animationState.appliedScale || 1, ); } @@ -1655,12 +1662,6 @@ export class FrameRenderer { : null, ); - const TICKS_PER_FRAME = 1; - - for (let i = 0; i < TICKS_PER_FRAME; i++) { - this.updateAnimationState(timeMs); - } - applyZoomTransform({ cameraContainer: this.cameraContainer, zoomBlurFilter: this.zoomBlurFilter, @@ -2110,6 +2111,8 @@ export class FrameRenderer { const timeMs = this.currentVideoTime * 1000; const cursorTimeMs = cursorTimestamp / 1000; + this.updateAnimationState(timeMs); + if (this.cursorOverlay) { this.cursorOverlay.update( this.config.cursorTelemetry ?? [], @@ -2117,6 +2120,7 @@ export class FrameRenderer { layoutCache.maskRect, this.config.showCursor ?? true, false, + this.animationState.appliedScale || 1, ); } @@ -2129,8 +2133,6 @@ export class FrameRenderer { }, ); - this.updateAnimationState(timeMs); - applyZoomTransform({ cameraContainer: this.cameraContainer, zoomBlurFilter: this.zoomBlurFilter, diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index cf52faea..20517938 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -2885,6 +2885,8 @@ export class FrameRenderer { const timeMs = this.currentVideoTime * 1000; const cursorTimeMs = cursorTimestamp / 1000; + this.updateAnimationState(timeMs); + if (this.cursorOverlay) { this.cursorOverlay.update( this.config.cursorTelemetry ?? [], @@ -2892,11 +2894,10 @@ export class FrameRenderer { layoutCache.maskRect, this.config.showCursor ?? true, false, + this.animationState.appliedScale || 1, ); } - this.updateAnimationState(timeMs); - applyZoomTransform({ cameraContainer: this.cameraContainer, zoomBlurFilter: this.zoomBlurFilter, @@ -3132,6 +3133,8 @@ export class FrameRenderer { const timeMs = this.currentVideoTime * 1000; const cursorTimeMs = cursorTimestamp / 1000; + this.updateAnimationState(timeMs); + if (this.cursorOverlay) { this.cursorOverlay.update( this.config.cursorTelemetry ?? [], @@ -3139,11 +3142,10 @@ export class FrameRenderer { layoutCache.maskRect, this.config.showCursor ?? true, false, + this.animationState.appliedScale || 1, ); } - this.updateAnimationState(timeMs); - applyZoomTransform({ cameraContainer: this.cameraContainer, zoomBlurFilter: this.zoomBlurFilter, From 70b46ecb86d4209a3e6beedce92ff09760fb05e2 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 10:07:45 +0700 Subject: [PATCH 26/30] Revert "Merge pull request #465 from webadderallorg/fix/issue395-zoom-independent-cursor" This reverts commit ea443f4e0e7b5a8ce4964ac43578ec99f6c48fb1, reversing changes made to ee4db6b3addbc140aa427ba7b9ce5d735c1c5601. --- src/components/video-editor/VideoPlayback.tsx | 1 - .../videoPlayback/cursorRenderer.test.ts | 27 ------------------- .../videoPlayback/cursorRenderer.ts | 15 ++--------- src/lib/exporter/frameRenderer.ts | 18 ++++++------- src/lib/exporter/modernFrameRenderer.ts | 10 +++---- 5 files changed, 14 insertions(+), 57 deletions(-) delete mode 100644 src/components/video-editor/videoPlayback/cursorRenderer.test.ts diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index b53636e8..836af7d5 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -2182,7 +2182,6 @@ const VideoPlayback = forwardRef( baseMaskRef.current, showCursorRef.current, !isPlayingRef.current || isSeekingRef.current, - animationStateRef.current.appliedScale || 1, ); smoothedCursorForHooks = mapSmoothedCursorToCanvasNormalized( diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.test.ts b/src/components/video-editor/videoPlayback/cursorRenderer.test.ts deleted file mode 100644 index 97fd3594..00000000 --- a/src/components/video-editor/videoPlayback/cursorRenderer.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveCursorDrawHeight } from "./cursorRenderer"; - -describe("resolveCursorDrawHeight", () => { - const viewport = { - x: 100, - y: 50, - width: 960, - height: 540, - }; - - it("keeps cursor output size stable when the zoom parent scales up", () => { - const baseHeight = resolveCursorDrawHeight(viewport, 70, 1); - const zoomedLocalHeight = resolveCursorDrawHeight(viewport, 70, 2); - - expect(zoomedLocalHeight).toBeCloseTo(baseHeight / 2, 6); - expect(zoomedLocalHeight * 2).toBeCloseTo(baseHeight, 6); - }); - - it("falls back to unscaled sizing for invalid parent scale values", () => { - const baseHeight = resolveCursorDrawHeight(viewport, 70, 1); - - expect(resolveCursorDrawHeight(viewport, 70, 0)).toBe(baseHeight); - expect(resolveCursorDrawHeight(viewport, 70, Number.NaN)).toBe(baseHeight); - }); -}); diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 1a06d003..644cf899 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -745,15 +745,6 @@ function getCursorViewportScale(viewport: CursorViewportRect) { return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); } -export function resolveCursorDrawHeight( - viewport: CursorViewportRect, - dotRadius: number, - parentScale = 1, -) { - const safeParentScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1; - return (dotRadius * getCursorViewportScale(viewport)) / safeParentScale; -} - function getCursorSwaySpringConfig( smoothingFactor: number, springTuning: CursorSpringTuning, @@ -1070,7 +1061,6 @@ export class PixiCursorOverlay { viewport: CursorViewportRect, visible: boolean, freeze = false, - parentScale = 1, ): void { if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) { this.container.visible = false; @@ -1117,7 +1107,7 @@ export class PixiCursorOverlay { const px = viewport.x + this.state.x * viewport.width; const py = viewport.y + this.state.y * viewport.height; - const h = resolveCursorDrawHeight(viewport, this.config.dotRadius, parentScale); + const h = this.config.dotRadius * getCursorViewportScale(viewport); const { cursorType, clickBounceProgress } = getCursorVisualState( samples, timeMs, @@ -1313,7 +1303,6 @@ export function drawCursorOnCanvas( viewport: CursorViewportRect, smoothedState: SmoothedCursorState, config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG, - parentScale = 1, ): void { if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return; @@ -1327,7 +1316,7 @@ export function drawCursorOnCanvas( const px = viewport.x + smoothedState.x * viewport.width; const py = viewport.y + smoothedState.y * viewport.height; - const h = resolveCursorDrawHeight(viewport, config.dotRadius, parentScale); + const h = config.dotRadius * getCursorViewportScale(viewport); const { cursorType, clickBounceProgress } = getCursorVisualState( samples, timeMs, diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index f390414e..3a01403e 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1626,12 +1626,6 @@ export class FrameRenderer { const timeMs = this.currentVideoTime * 1000; const cursorTimeMs = cursorTimestamp / 1000; - const TICKS_PER_FRAME = 1; - - for (let i = 0; i < TICKS_PER_FRAME; i++) { - this.updateAnimationState(timeMs); - } - if (this.cursorOverlay) { this.cursorOverlay.update( this.config.cursorTelemetry ?? [], @@ -1639,7 +1633,6 @@ export class FrameRenderer { layoutCache.maskRect, this.config.showCursor ?? true, false, - this.animationState.appliedScale || 1, ); } @@ -1662,6 +1655,12 @@ export class FrameRenderer { : null, ); + const TICKS_PER_FRAME = 1; + + for (let i = 0; i < TICKS_PER_FRAME; i++) { + this.updateAnimationState(timeMs); + } + applyZoomTransform({ cameraContainer: this.cameraContainer, zoomBlurFilter: this.zoomBlurFilter, @@ -2111,8 +2110,6 @@ export class FrameRenderer { const timeMs = this.currentVideoTime * 1000; const cursorTimeMs = cursorTimestamp / 1000; - this.updateAnimationState(timeMs); - if (this.cursorOverlay) { this.cursorOverlay.update( this.config.cursorTelemetry ?? [], @@ -2120,7 +2117,6 @@ export class FrameRenderer { layoutCache.maskRect, this.config.showCursor ?? true, false, - this.animationState.appliedScale || 1, ); } @@ -2133,6 +2129,8 @@ export class FrameRenderer { }, ); + this.updateAnimationState(timeMs); + applyZoomTransform({ cameraContainer: this.cameraContainer, zoomBlurFilter: this.zoomBlurFilter, diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 20517938..cf52faea 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -2885,8 +2885,6 @@ export class FrameRenderer { const timeMs = this.currentVideoTime * 1000; const cursorTimeMs = cursorTimestamp / 1000; - this.updateAnimationState(timeMs); - if (this.cursorOverlay) { this.cursorOverlay.update( this.config.cursorTelemetry ?? [], @@ -2894,10 +2892,11 @@ export class FrameRenderer { layoutCache.maskRect, this.config.showCursor ?? true, false, - this.animationState.appliedScale || 1, ); } + this.updateAnimationState(timeMs); + applyZoomTransform({ cameraContainer: this.cameraContainer, zoomBlurFilter: this.zoomBlurFilter, @@ -3133,8 +3132,6 @@ export class FrameRenderer { const timeMs = this.currentVideoTime * 1000; const cursorTimeMs = cursorTimestamp / 1000; - this.updateAnimationState(timeMs); - if (this.cursorOverlay) { this.cursorOverlay.update( this.config.cursorTelemetry ?? [], @@ -3142,10 +3139,11 @@ export class FrameRenderer { layoutCache.maskRect, this.config.showCursor ?? true, false, - this.animationState.appliedScale || 1, ); } + this.updateAnimationState(timeMs); + applyZoomTransform({ cameraContainer: this.cameraContainer, zoomBlurFilter: this.zoomBlurFilter, From 92f7476c642e6ca8ae63bbd6e402d3abc4f484b0 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 10:51:22 +0700 Subject: [PATCH 27/30] fix(electron): force main bundle cjs output --- electron/mainCjsNormalize.test.mjs | 46 ++++++++++++++ scripts/normalize-electron-main-cjs.mjs | 82 +++++++++++++++++++++++++ vite.config.ts | 36 ++++++++++- 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 electron/mainCjsNormalize.test.mjs diff --git a/electron/mainCjsNormalize.test.mjs b/electron/mainCjsNormalize.test.mjs new file mode 100644 index 00000000..7b68badb --- /dev/null +++ b/electron/mainCjsNormalize.test.mjs @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + findElectronMainCjsEsmSyntax, + normalizeElectronMainCjsSource, +} from "../scripts/normalize-electron-main-cjs.mjs"; + +describe("Electron main CJS normalizer", () => { + it("converts Rollup named export blocks to CommonJS assignments", () => { + const source = [ + 'const MAIN_DIST = "dist-electron";', + 'const RENDERER_DIST = "dist";', + "const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;", + "export {", + " MAIN_DIST,", + " RENDERER_DIST,", + " VITE_DEV_SERVER_URL", + "};", + ].join("\n"); + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toContain("exports.MAIN_DIST = MAIN_DIST;"); + expect(result.source).toContain("exports.RENDERER_DIST = RENDERER_DIST;"); + expect(result.source).toContain("exports.VITE_DEV_SERVER_URL = VITE_DEV_SERVER_URL;"); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([]); + }); + + it("converts single-line aliased named exports to CommonJS assignments", () => { + const source = "export { VITE_DEV_SERVER_URL as devServerUrl };"; + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toBe("exports.devServerUrl = VITE_DEV_SERVER_URL;"); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([]); + }); + + it("reports unsupported ESM export syntax that cannot be normalized safely", () => { + const source = "export default MAIN_DIST;"; + + expect(findElectronMainCjsEsmSyntax(source)).toEqual([ + { line: 1, text: "export default MAIN_DIST;" }, + ]); + }); +}); diff --git a/scripts/normalize-electron-main-cjs.mjs b/scripts/normalize-electron-main-cjs.mjs index 3d3b186c..a62171ee 100644 --- a/scripts/normalize-electron-main-cjs.mjs +++ b/scripts/normalize-electron-main-cjs.mjs @@ -49,6 +49,49 @@ function convertImportLine(line) { return null; } +function convertNamedExports(namedSpec, indent = "") { + const statements = []; + for (const rawSpecifier of namedSpec.split(",")) { + const specifier = rawSpecifier.trim(); + if (!specifier) { + continue; + } + + const aliasMatch = specifier.match( + /^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$/, + ); + const localName = aliasMatch ? aliasMatch[1] : specifier; + const exportName = aliasMatch ? aliasMatch[2] : specifier; + if ( + !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName) || + !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(exportName) + ) { + return null; + } + + statements.push(`${indent}exports.${exportName} = ${localName};`); + } + + return statements; +} + +function convertExportLine(line) { + const singleLineMatch = line.match( + /^([ \t]*)export\s*\{\s*([^}]*)\s*\}\s*;?[ \t]*$/, + ); + if (singleLineMatch) { + const [, indent, namedSpec] = singleLineMatch; + return convertNamedExports(namedSpec, indent); + } + + const blockStartMatch = line.match(/^([ \t]*)export\s*\{\s*$/); + if (blockStartMatch) { + return { indent: blockStartMatch[1], specifiers: [] }; + } + + return null; +} + function updateLexicalState(line, state) { let mode = state.mode; let escaped = false; @@ -296,8 +339,29 @@ export function normalizeElectronMainCjsSource(source) { const lines = source.split(/\r?\n/); const normalizedLines = []; let state = { mode: null }; + let exportBlock = null; for (const line of lines) { + if (exportBlock) { + if (/^[ \t]*\}\s*;?[ \t]*$/.test(line)) { + const statements = convertNamedExports( + exportBlock.specifiers.join(","), + exportBlock.indent, + ); + if (statements === null) { + normalizedLines.push(`export {\n${exportBlock.specifiers.join("\n")}\n};`); + } else { + normalizedLines.push(...statements); + changed = true; + } + exportBlock = null; + continue; + } + + exportBlock.specifiers.push(line.trim().replace(/,$/, "")); + continue; + } + if (state.mode === null) { const converted = convertImportLine(line); if (converted !== null) { @@ -305,6 +369,17 @@ export function normalizeElectronMainCjsSource(source) { changed = true; continue; } + + const convertedExport = convertExportLine(line); + if (convertedExport !== null) { + if (Array.isArray(convertedExport)) { + normalizedLines.push(...convertedExport); + changed = true; + } else { + exportBlock = convertedExport; + } + continue; + } } const normalized = replaceImportMetaUrlInCode(line, state); @@ -348,6 +423,13 @@ export function findElectronMainCjsEsmSyntax(source) { }); continue; } + if (/^[ \t]*export\b/.test(line)) { + matches.push({ + line: index + 1, + text: line.trim(), + }); + continue; + } } state = updateLexicalState(line, state); diff --git a/vite.config.ts b/vite.config.ts index 64614cdb..3dd36633 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,36 @@ import react from "@vitejs/plugin-react"; import { defineConfig, type Plugin } from "vite"; import electron from "vite-plugin-electron/simple"; +function electronMainCjsOutputPlugin(): Plugin { + return { + name: "recordly-electron-main-cjs-output", + enforce: "post", + config(config) { + // Vite mergeConfig concatenates lib.formats with the plugin's ESM default. + config.build ??= {}; + const build = config.build; + const lib = build.lib; + if (lib && typeof lib === "object") { + lib.formats = ["cjs"]; + lib.fileName = (_format, entryName) => `${entryName}.cjs`; + } + + build.rollupOptions ??= {}; + const rollupOptions = build.rollupOptions; + const cjsOutput = { + format: "cjs" as const, + inlineDynamicImports: true, + entryFileNames: "[name].cjs", + chunkFileNames: "[name]-[hash].cjs", + }; + + rollupOptions.output = Array.isArray(rollupOptions.output) + ? rollupOptions.output.map((output) => ({ ...output, ...cjsOutput })) + : { ...(rollupOptions.output ?? {}), ...cjsOutput }; + }, + }; +} + function electronMainCjsGuardPlugin(): Plugin { return { name: "recordly-electron-main-cjs-guard", @@ -37,17 +67,19 @@ export default defineConfig({ lib: { entry: "electron/main.ts", formats: ["cjs"], + fileName: (_format, entryName) => `${entryName}.cjs`, }, rollupOptions: { external: ["ffmpeg-static", "uiohook-napi"], output: { format: "cjs", + inlineDynamicImports: true, entryFileNames: "[name].cjs", - chunkFileNames: "[name].cjs", + chunkFileNames: "[name]-[hash].cjs", }, }, }, - plugins: [electronMainCjsGuardPlugin()], + plugins: [electronMainCjsOutputPlugin(), electronMainCjsGuardPlugin()], }, }, preload: { From 513c576c2708281877549c4c663cf5d25cf90c37 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 11:23:01 +0700 Subject: [PATCH 28/30] fix(electron): preserve unsupported export blocks --- electron/mainCjsNormalize.test.mjs | 24 ++++++++++++++++++++++++ scripts/normalize-electron-main-cjs.mjs | 5 +++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/electron/mainCjsNormalize.test.mjs b/electron/mainCjsNormalize.test.mjs index 7b68badb..9ccf91da 100644 --- a/electron/mainCjsNormalize.test.mjs +++ b/electron/mainCjsNormalize.test.mjs @@ -43,4 +43,28 @@ describe("Electron main CJS normalizer", () => { { line: 1, text: "export default MAIN_DIST;" }, ]); }); + + it("preserves an unsupported export block exactly while normalizing other syntax", () => { + const source = [ + 'import fs from "node:fs";', + "export {", + ' MAIN_DIST as "main-dist",', + "};", + ].join("\n"); + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toBe( + [ + 'const fs = require("node:fs");', + "export {", + ' MAIN_DIST as "main-dist",', + "};", + ].join("\n"), + ); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([ + { line: 2, text: "export {" }, + ]); + }); }); diff --git a/scripts/normalize-electron-main-cjs.mjs b/scripts/normalize-electron-main-cjs.mjs index a62171ee..fcbca31f 100644 --- a/scripts/normalize-electron-main-cjs.mjs +++ b/scripts/normalize-electron-main-cjs.mjs @@ -86,7 +86,7 @@ function convertExportLine(line) { const blockStartMatch = line.match(/^([ \t]*)export\s*\{\s*$/); if (blockStartMatch) { - return { indent: blockStartMatch[1], specifiers: [] }; + return { indent: blockStartMatch[1], specifiers: [], rawLines: [line] }; } return null; @@ -349,7 +349,7 @@ export function normalizeElectronMainCjsSource(source) { exportBlock.indent, ); if (statements === null) { - normalizedLines.push(`export {\n${exportBlock.specifiers.join("\n")}\n};`); + normalizedLines.push(...exportBlock.rawLines, line); } else { normalizedLines.push(...statements); changed = true; @@ -359,6 +359,7 @@ export function normalizeElectronMainCjsSource(source) { } exportBlock.specifiers.push(line.trim().replace(/,$/, "")); + exportBlock.rawLines.push(line); continue; } From 4f0361e43aad5b59b00c95aa82c04e5b3938a27f Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 12:57:05 +0700 Subject: [PATCH 29/30] fix(recording): clamp cursor samples at pause boundary --- electron/electron-env.d.ts | 4 ++-- electron/ipc/cursor/telemetry.test.ts | 19 ++++++++++++++- electron/ipc/cursor/telemetry.ts | 33 +++++++++++++++++++-------- electron/ipc/register/recording.ts | 20 +++++++++++----- electron/preload.ts | 8 +++---- src/hooks/useScreenRecorder.ts | 8 +++---- 6 files changed, 65 insertions(+), 27 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 5836b3e4..cdd90881 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -254,12 +254,12 @@ interface Window { message?: string; error?: string; }>; - pauseCursorCapture: () => Promise<{ + pauseCursorCapture: (pausedAtMs?: number) => Promise<{ success: boolean; message?: string; error?: string; }>; - resumeCursorCapture: () => Promise<{ + resumeCursorCapture: (resumedAtMs?: number) => Promise<{ success: boolean; message?: string; error?: string; diff --git a/electron/ipc/cursor/telemetry.test.ts b/electron/ipc/cursor/telemetry.test.ts index 8c9b06b8..03613859 100644 --- a/electron/ipc/cursor/telemetry.test.ts +++ b/electron/ipc/cursor/telemetry.test.ts @@ -29,21 +29,24 @@ vi.mock("../utils", () => ({ })), })); +import { activeCursorSamples, setActiveCursorSamples, setCursorCaptureStartTimeMs } from "../state"; import { getCursorCaptureElapsedMs, normalizeCursorTelemetrySamples, pauseCursorCapture, + pauseCursorCaptureAtBoundary, + pushCursorSample, resetCursorCaptureClock, resumeCursorCapture, writeCursorTelemetry, } from "./telemetry"; -import { setCursorCaptureStartTimeMs } from "../state"; describe("cursor telemetry pause clock", () => { beforeEach(() => { writeFile.mockReset(); rm.mockReset(); setCursorCaptureStartTimeMs(1_000); + setActiveCursorSamples([]); resetCursorCaptureClock(); }); @@ -66,6 +69,20 @@ describe("cursor telemetry pause clock", () => { expect(getCursorCaptureElapsedMs(1_900)).toBe(550); }); + it("drops cursor samples captured after the renderer pause boundary", () => { + pushCursorSample(0.1, 0.1, 120, "move"); + pushCursorSample(0.2, 0.2, 205, "move"); + pushCursorSample(0.3, 0.3, 260, "move"); + + pauseCursorCaptureAtBoundary(1_200); + + expect(getCursorCaptureElapsedMs(1_500)).toBe(200); + expect(activeCursorSamples.map((sample) => sample.timeMs)).toEqual([120]); + + resumeCursorCapture(1_700); + expect(getCursorCaptureElapsedMs(1_900)).toBe(400); + }); + it("normalizes cursor telemetry samples before persisting them", async () => { const samples = normalizeCursorTelemetrySamples([ { timeMs: 30, cx: 2, cy: -1, interactionType: "click", cursorType: "pointer" }, diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 2c14705a..ebedfe72 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -1,28 +1,29 @@ import fs from "node:fs/promises"; -import { getTelemetryPathForVideo, getScreen } from "../utils"; import { + CURSOR_SAMPLE_INTERVAL_MS, CURSOR_TELEMETRY_VERSION, MAX_CURSOR_SAMPLES, - CURSOR_SAMPLE_INTERVAL_MS, } from "../constants"; -import type { CursorVisualType, CursorInteractionType, CursorTelemetryPoint } from "../types"; import { - cursorCaptureInterval, - setCursorCaptureInterval, + activeCursorSamples, + currentCursorVisualType, cursorCaptureAccumulatedPausedMs, + cursorCaptureInterval, cursorCapturePauseStartedAtMs, cursorCaptureStartTimeMs, - activeCursorSamples, - pendingCursorSamples, - setPendingCursorSamples, isCursorCaptureActive, - currentCursorVisualType, linuxCursorScreenPoint, + pendingCursorSamples, selectedSource, selectedWindowBounds, + setActiveCursorSamples, setCursorCaptureAccumulatedPausedMs, + setCursorCaptureInterval, setCursorCapturePauseStartedAtMs, + setPendingCursorSamples, } from "../state"; +import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; +import { getScreen, getTelemetryPathForVideo } from "../utils"; export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -125,6 +126,18 @@ export function pauseCursorCapture(pausedAtMs: number) { setCursorCapturePauseStartedAtMs(pausedAtMs); } +export function pauseCursorCaptureAtBoundary(pausedAtMs: number) { + if (cursorCapturePauseStartedAtMs !== null) { + return; + } + + const pausedElapsedMs = getCursorCaptureElapsedMs(pausedAtMs); + setActiveCursorSamples( + activeCursorSamples.filter((sample) => sample.timeMs <= pausedElapsedMs), + ); + setCursorCapturePauseStartedAtMs(pausedAtMs); +} + export function resumeCursorCapture(resumedAtMs: number) { if (cursorCapturePauseStartedAtMs === null) { return; @@ -310,6 +323,6 @@ export function startCursorSampling() { setCursorCaptureInterval(setTimeout(tick, CURSOR_SAMPLE_INTERVAL_MS)); } +export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; // Re-export for consumers that use it from this module export { getTelemetryPathForVideo } from "../utils"; -export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 69ed08cb..41785fd2 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -19,7 +19,7 @@ import { startInteractionCapture, stopInteractionCapture } from "../cursor/inter import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor"; import { normalizeCursorTelemetrySamples, - pauseCursorCapture, + pauseCursorCaptureAtBoundary, resetCursorCaptureClock, resumeCursorCapture, sampleCursorPoint, @@ -192,6 +192,15 @@ function pickPrimitiveRecord(value: unknown) { return entries.length > 0 ? Object.fromEntries(entries) : null; } +function normalizeRendererTimestampMs(value: unknown) { + const nowMs = Date.now(); + if (typeof value !== "number" || !Number.isFinite(value)) { + return nowMs; + } + + return Math.min(Math.max(0, Math.round(value)), nowMs + 1000); +} + function pickMicrophoneChunkEvents(value: unknown): MicrophoneChunkTimingEvent[] | null { if (!Array.isArray(value)) { return null; @@ -1715,14 +1724,13 @@ export function registerRecordingHandlers( } }); - ipcMain.handle("pause-cursor-capture", () => { - sampleCursorPoint(); - pauseCursorCapture(Date.now()); + ipcMain.handle("pause-cursor-capture", (_, pausedAtMs?: unknown) => { + pauseCursorCaptureAtBoundary(normalizeRendererTimestampMs(pausedAtMs)); return { success: true }; }); - ipcMain.handle("resume-cursor-capture", () => { - resumeCursorCapture(Date.now()); + ipcMain.handle("resume-cursor-capture", (_, resumedAtMs?: unknown) => { + resumeCursorCapture(normalizeRendererTimestampMs(resumedAtMs)); sampleCursorPoint(); return { success: true }; }); diff --git a/electron/preload.ts b/electron/preload.ts index 478cb018..cfb8015f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -493,11 +493,11 @@ contextBridge.exposeInMainWorld("electronAPI", { resumeNativeScreenRecording: () => { return ipcRenderer.invoke("resume-native-screen-recording"); }, - pauseCursorCapture: () => { - return ipcRenderer.invoke("pause-cursor-capture"); + pauseCursorCapture: (pausedAtMs?: number) => { + return ipcRenderer.invoke("pause-cursor-capture", pausedAtMs); }, - resumeCursorCapture: () => { - return ipcRenderer.invoke("resume-cursor-capture"); + resumeCursorCapture: (resumedAtMs?: number) => { + return ipcRenderer.invoke("resume-cursor-capture", resumedAtMs); }, startFfmpegRecording: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("start-ffmpeg-recording", source); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 69591478..40628d47 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1781,7 +1781,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { markRecordingPaused(boundaryMs); setPaused(true); try { - await window.electronAPI.pauseCursorCapture(); + await window.electronAPI.pauseCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to pause cursor capture:", error); } @@ -1798,7 +1798,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { markRecordingPaused(boundaryMs); setPaused(true); try { - await window.electronAPI.pauseCursorCapture(); + await window.electronAPI.pauseCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to pause cursor capture:", error); } @@ -1827,7 +1827,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { markRecordingResumed(boundaryMs); setPaused(false); try { - await window.electronAPI.resumeCursorCapture(); + await window.electronAPI.resumeCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to resume cursor capture:", error); } @@ -1844,7 +1844,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { markRecordingResumed(boundaryMs); setPaused(false); try { - await window.electronAPI.resumeCursorCapture(); + await window.electronAPI.resumeCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to resume cursor capture:", error); } From 9515f64efb2e4b92650dede7370e732c3a05cd69 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 9 May 2026 13:10:20 +0700 Subject: [PATCH 30/30] fix(recording): keep cursor boundary timestamps monotonic --- electron/ipc/register/recording.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 41785fd2..f2cb6660 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -198,7 +198,7 @@ function normalizeRendererTimestampMs(value: unknown) { return nowMs; } - return Math.min(Math.max(0, Math.round(value)), nowMs + 1000); + return Math.min(Math.max(0, Math.round(value)), nowMs); } function pickMicrophoneChunkEvents(value: unknown): MicrophoneChunkTimingEvent[] | null {