From 613ed008fc68a283e734e0754df6199e22a664cc Mon Sep 17 00:00:00 2001 From: Sunwood-ai-labs <108736814+Sunwood-ai-labs@users.noreply.github.com> Date: Sun, 3 May 2026 14:55:21 +0900 Subject: [PATCH 01/94] Allow PNG custom background uploads --- src/components/video-editor/SettingsPanel.tsx | 7 +++---- .../backgroundImageUpload.test.ts | 20 +++++++++++++++++++ .../video-editor/backgroundImageUpload.ts | 20 +++++++++++++++++++ src/i18n/locales/en/settings.json | 2 +- src/i18n/locales/es/settings.json | 2 +- src/i18n/locales/fr/settings.json | 2 +- src/i18n/locales/ja-JP/settings.json | 2 +- src/i18n/locales/ko-KR/settings.json | 2 +- src/i18n/locales/tr/settings.json | 2 +- src/i18n/locales/zh-CN/settings.json | 2 +- src/i18n/locales/zh-TW/settings.json | 2 +- 11 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 src/components/video-editor/backgroundImageUpload.test.ts create mode 100644 src/components/video-editor/backgroundImageUpload.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 82e106c5..5ac59796 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -43,6 +43,7 @@ import { getTestId } from "@/utils/getTestId"; import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; +import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { @@ -459,9 +460,7 @@ export function SettingsPanel({ const file = files[0]; - // Validate file type - only allow JPG/JPEG - const validTypes = ["image/jpeg", "image/jpg"]; - if (!validTypes.includes(file.type)) { + if (!isSupportedBackgroundImageType(file.type, file.name)) { toast.error(t("imageUpload.invalidFileType"), { description: t("imageUpload.jpgOnly"), }); @@ -1041,7 +1040,7 @@ export function SettingsPanel({ type="file" ref={fileInputRef} onChange={handleImageUpload} - accept=".jpg,.jpeg,image/jpeg" + accept={BACKGROUND_IMAGE_ACCEPT} className="hidden" /> - - +
+ {sourceDimensions && ( +
+ {t("exportQuality.title")} + + Source {sourceDimensions.width}x{sourceDimensions.height} + +
+ )} +
+ + + +
)} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 464370df..d4116f9e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -18,6 +18,8 @@ import { type Locale } from "@/i18n/config"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor"; import { + calculateEffectiveSourceDimensions, + calculateMp4ExportSettings, calculateOutputDimensions, type ExportFormat, type ExportProgress, @@ -1574,6 +1576,11 @@ export default function VideoEditor() { const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; + const effectiveSourceDimensions = calculateEffectiveSourceDimensions( + sourceWidth, + sourceHeight, + cropRegion, + ); const aspectRatioValue = aspectRatio === "native" ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) @@ -1667,83 +1674,16 @@ export default function VideoEditor() { } else { // MP4 Export const quality = settings.quality || exportQuality; - let exportWidth: number; - let exportHeight: number; - let bitrate: number; - - if (quality === "source") { - exportWidth = sourceWidth; - exportHeight = sourceHeight; - - // Use the source's longer dimension as the long axis of the export so - // a landscape recording can still fill a portrait target (and vice versa). - const sourceLongDim = Math.max(sourceWidth, sourceHeight); - - if (aspectRatioValue === 1) { - const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; - exportWidth = baseDimension; - exportHeight = baseDimension; - } else if (aspectRatioValue > 1) { - const baseWidth = Math.floor(sourceLongDim / 2) * 2; - let found = false; - for (let w = baseWidth; w >= 100 && !found; w -= 2) { - const h = Math.round(w / aspectRatioValue); - if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { - exportWidth = w; - exportHeight = h; - found = true; - } - } - if (!found) { - exportWidth = baseWidth; - exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2; - } - } else { - const baseHeight = Math.floor(sourceLongDim / 2) * 2; - let found = false; - for (let h = baseHeight; h >= 100 && !found; h -= 2) { - const w = Math.round(h * aspectRatioValue); - if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) { - exportWidth = w; - exportHeight = h; - found = true; - } - } - if (!found) { - exportHeight = baseHeight; - exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2; - } - } - - const totalPixels = exportWidth * exportHeight; - bitrate = 30_000_000; - if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { - bitrate = 50_000_000; - } else if (totalPixels > 2560 * 1440) { - bitrate = 80_000_000; - } - } else { - // Quality presets target the SHORT side; the long side derives from the - // aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080. - const targetShortDim = quality === "medium" ? 720 : 1080; - - if (aspectRatioValue >= 1) { - exportHeight = Math.floor(targetShortDim / 2) * 2; - exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; - } else { - exportWidth = Math.floor(targetShortDim / 2) * 2; - exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2; - } - - const totalPixels = exportWidth * exportHeight; - if (totalPixels <= 1280 * 720) { - bitrate = 10_000_000; - } else if (totalPixels <= 1920 * 1080) { - bitrate = 20_000_000; - } else { - bitrate = 30_000_000; - } - } + const { + width: exportWidth, + height: exportHeight, + bitrate, + } = calculateMp4ExportSettings({ + quality, + sourceWidth: effectiveSourceDimensions.width, + sourceHeight: effectiveSourceDimensions.height, + aspectRatioValue, + }); const exporter = new VideoExporter({ videoUrl: videoPath, @@ -1903,13 +1843,18 @@ export default function VideoEditor() { // Build export settings from current state const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; + const effectiveSourceDimensions = calculateEffectiveSourceDimensions( + sourceWidth, + sourceHeight, + cropRegion, + ); const aspectRatioValue = aspectRatio === "native" ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) : getAspectRatioValue(aspectRatio); const gifDimensions = calculateOutputDimensions( - sourceWidth, - sourceHeight, + effectiveSourceDimensions.width, + effectiveSourceDimensions.height, gifSizePreset, GIF_SIZE_PRESETS, aspectRatioValue, @@ -2266,8 +2211,16 @@ export default function VideoEditor() { gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} gifOutputDimensions={calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + calculateEffectiveSourceDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ).width, + calculateEffectiveSourceDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ).height, gifSizePreset, GIF_SIZE_PRESETS, aspectRatio === "native" diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 47fdca49..8c11cda1 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -69,10 +69,10 @@ "gifDescription": "صورة متحركة للمشاركة" }, "exportQuality": { - "title": "جودة التصدير", - "low": "منخفضة", - "medium": "متوسطة", - "high": "عالية" + "title": "دقة التصدير", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "معدل إطارات GIF", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9d417361..c7af9601 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -85,10 +85,10 @@ "gifDescription": "Animated image for sharing" }, "exportQuality": { - "title": "Export Quality", - "low": "Low", - "medium": "Medium", - "high": "High" + "title": "Export resolution", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF Frame Rate", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 20a9ec39..189a3098 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -76,10 +76,10 @@ "gifDescription": "Imagen animada para compartir" }, "exportQuality": { - "title": "Calidad de exportación", - "low": "Baja", - "medium": "Media", - "high": "Alta" + "title": "Resolución de exportación", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Velocidad de cuadros del GIF", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 66a6d74c..2c33340c 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -83,10 +83,10 @@ "gifDescription": "Image animée pour le partage" }, "exportQuality": { - "title": "Qualité d'export", - "low": "Faible", - "medium": "Moyenne", - "high": "Haute" + "title": "Résolution d'export", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Fréquence d'images GIF", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 1ccc3815..9a9b7bdd 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -76,10 +76,10 @@ "gifDescription": "共有用のアニメーション画像" }, "exportQuality": { - "title": "エクスポート品質", - "low": "低画質", - "medium": "中画質", - "high": "高画質" + "title": "書き出し解像度", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF フレームレート", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 268ba57c..76e912c1 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -76,10 +76,10 @@ "gifDescription": "공유용 애니메이션 이미지" }, "exportQuality": { - "title": "내보내기 품질", - "low": "낮음", - "medium": "보통", - "high": "높음" + "title": "내보내기 해상도", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 프레임 속도", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index dc15c3f5..708a9c95 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -77,10 +77,10 @@ "gifDescription": "Анимированное изображение для обмена" }, "exportQuality": { - "title": "Качество экспорта", - "low": "Низкое", - "medium": "Среднее", - "high": "Высокое" + "title": "Разрешение экспорта", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Частота кадров GIF", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index f6395582..af5a6f25 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -76,10 +76,10 @@ "gifDescription": "Paylaşım için hareketli görüntü" }, "exportQuality": { - "title": "Dışa Aktarım Kalitesi", - "low": "Düşük", - "medium": "Orta", - "high": "Yüksek" + "title": "Dışa aktarma çözünürlüğü", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF Kare Hızı", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index e6a897d8..149a3853 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -66,10 +66,10 @@ "gifDescription": "Hình ảnh động để chia sẻ" }, "exportQuality": { - "title": "Chất lượng xuất", - "low": "Thấp", - "medium": "Trung bình", - "high": "Cao" + "title": "Độ phân giải xuất", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "Tốc độ khung hình GIF", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ff157dcd..e9d729ed 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -76,10 +76,10 @@ "gifDescription": "可分享的动态图片" }, "exportQuality": { - "title": "导出质量", - "low": "低", - "medium": "中", - "high": "高" + "title": "导出分辨率", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 帧率", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 50ca00cb..44b64228 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -83,10 +83,10 @@ "gifDescription": "可分享的動態圖片" }, "exportQuality": { - "title": "匯出品質", - "low": "低", - "medium": "中", - "high": "高" + "title": "匯出解析度", + "low": "720p", + "medium": "1080p", + "high": "Source" }, "gifSettings": { "frameRate": "GIF 影格率", diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 596eb755..65cdfd57 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -17,6 +17,21 @@ describe("computeCompositeLayout", () => { expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2); }); + it("scales small screen content up to the export canvas when no padding is applied", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 1280, height: 720 }, + screenSize: { width: 854, height: 480 }, + }); + + expect(layout).not.toBeNull(); + expect(layout!.screenRect).toEqual({ + x: 0, + y: 0, + width: 1280, + height: 720, + }); + }); + it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => { const layout = computeCompositeLayout({ canvasSize: { width: 1280, height: 720 }, @@ -128,6 +143,21 @@ describe("computeCompositeLayout", () => { expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height); }); + it("snaps rounding-only source aspect gaps to the full canvas", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 319, height: 199 }, + maxContentSize: { width: 319, height: 199 }, + screenSize: { width: 1680, height: 1050 }, + }); + + expect(layout?.screenRect).toEqual({ + x: 0, + y: 0, + width: 319, + height: 199, + }); + }); + it("centers the combined screen and webcam stack in vertical stack mode", () => { const layout = computeCompositeLayout({ canvasSize: { width: 1920, height: 1080 }, diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 93161c06..839a7e0d 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -401,10 +401,24 @@ function centerRectInBounds(params: { bounds: RenderRect; size: Size; maxSize: S const { x: boundsX, y: boundsY, width: boundsWidth, height: boundsHeight } = bounds; const { width, height } = size; const { width: maxWidth, height: maxHeight } = maxSize; - const scale = Math.min(maxWidth / width, maxHeight / height, 1); + const scale = Math.min(maxWidth / width, maxHeight / height); const resolvedWidth = Math.round(width * scale); const resolvedHeight = Math.round(height * scale); + if ( + maxWidth >= boundsWidth && + maxHeight >= boundsHeight && + Math.abs(boundsWidth - resolvedWidth) <= 1 && + Math.abs(boundsHeight - resolvedHeight) <= 1 + ) { + return { + x: boundsX, + y: boundsY, + width: boundsWidth, + height: boundsHeight, + }; + } + return { x: boundsX + Math.max(0, Math.floor((boundsWidth - resolvedWidth) / 2)), y: boundsY + Math.max(0, Math.floor((boundsHeight - resolvedHeight) / 2)), diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts index e93166c7..8ac404fa 100644 --- a/src/lib/exporter/index.ts +++ b/src/lib/exporter/index.ts @@ -1,5 +1,10 @@ export { FrameRenderer } from "./frameRenderer"; export { calculateOutputDimensions, GifExporter } from "./gifExporter"; +export { + calculateEffectiveSourceDimensions, + calculateMp4ExportSettings, + type Mp4ExportSettings, +} from "./mp4ExportSettings"; export { VideoMuxer } from "./muxer"; export { StreamingVideoDecoder } from "./streamingDecoder"; export type { diff --git a/src/lib/exporter/mp4ExportSettings.test.ts b/src/lib/exporter/mp4ExportSettings.test.ts new file mode 100644 index 00000000..2a941547 --- /dev/null +++ b/src/lib/exporter/mp4ExportSettings.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { + calculateEffectiveSourceDimensions, + calculateMp4ExportSettings, +} from "./mp4ExportSettings"; + +describe("calculateMp4ExportSettings", () => { + it("keeps 1080p explicit even when it upscales short native captures", () => { + const aspectRatioValue = 1920 / 1032; + + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue, + }), + ).toMatchObject({ + width: 2008, + height: 1080, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue, + }), + ).toMatchObject({ + width: 1920, + height: 1032, + bitrate: 30_000_000, + }); + }); + + it("keeps lower quality presets below the source size when downscaling is useful", () => { + expect( + calculateMp4ExportSettings({ + quality: "medium", + sourceWidth: 1920, + sourceHeight: 1032, + aspectRatioValue: 1920 / 1032, + }), + ).toMatchObject({ + width: 1338, + height: 720, + bitrate: 20_000_000, + }); + }); + + it("keeps 1080p explicit even for 720p source dimensions", () => { + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1280, + sourceHeight: 720, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 20_000_000, + }); + }); + + it("preserves source-sized High exports when the source is already 1080p or larger", () => { + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 1920, + sourceHeight: 1080, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: 3840, + sourceHeight: 2160, + aspectRatioValue: 16 / 9, + }), + ).toMatchObject({ + width: 3840, + height: 2160, + bitrate: 80_000_000, + }); + }); + + it("keeps portrait presets on the short side", () => { + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: 1080, + sourceHeight: 1920, + aspectRatioValue: 9 / 16, + }), + ).toMatchObject({ + width: 1080, + height: 1920, + bitrate: 20_000_000, + }); + }); + + it("uses the cropped area as the effective source size", () => { + const effectiveSource = calculateEffectiveSourceDimensions(3840, 2160, { + width: 854 / 3840, + height: 480 / 2160, + }); + + expect(effectiveSource).toEqual({ + width: 854, + height: 480, + }); + + expect( + calculateMp4ExportSettings({ + quality: "source", + sourceWidth: effectiveSource.width, + sourceHeight: effectiveSource.height, + aspectRatioValue: effectiveSource.width / effectiveSource.height, + }), + ).toMatchObject({ + width: 854, + height: 480, + bitrate: 30_000_000, + }); + + expect( + calculateMp4ExportSettings({ + quality: "good", + sourceWidth: effectiveSource.width, + sourceHeight: effectiveSource.height, + aspectRatioValue: effectiveSource.width / effectiveSource.height, + }), + ).toMatchObject({ + width: 1920, + height: 1080, + bitrate: 20_000_000, + }); + }); +}); diff --git a/src/lib/exporter/mp4ExportSettings.ts b/src/lib/exporter/mp4ExportSettings.ts new file mode 100644 index 00000000..b75193fc --- /dev/null +++ b/src/lib/exporter/mp4ExportSettings.ts @@ -0,0 +1,143 @@ +import type { ExportQuality } from "./types"; + +export interface Mp4ExportSettings { + width: number; + height: number; + bitrate: number; +} + +interface SourceCropRegion { + width: number; + height: number; +} + +const MEDIUM_SHORT_SIDE = 720; +const HIGH_SHORT_SIDE = 1080; + +function even(value: number) { + return Math.floor(value / 2) * 2; +} + +function atLeastEven(value: number) { + return Math.max(2, even(value)); +} + +export function calculateEffectiveSourceDimensions( + sourceWidth: number, + sourceHeight: number, + cropRegion?: SourceCropRegion, +) { + const cropWidth = cropRegion?.width ?? 1; + const cropHeight = cropRegion?.height ?? 1; + + return { + width: atLeastEven(Math.round(sourceWidth * cropWidth)), + height: atLeastEven(Math.round(sourceHeight * cropHeight)), + }; +} + +function calculateDimensionsForShortSide(targetShortSide: number, aspectRatioValue: number) { + if (aspectRatioValue >= 1) { + const height = even(targetShortSide); + return { + width: even(height * aspectRatioValue), + height, + }; + } + + const width = even(targetShortSide); + return { + width, + height: even(width / aspectRatioValue), + }; +} + +function calculateSourceDimensions( + sourceWidth: number, + sourceHeight: number, + aspectRatioValue: number, +) { + const sourceLongDim = Math.max(sourceWidth, sourceHeight); + + if (aspectRatioValue === 1) { + const baseDimension = even(Math.min(sourceWidth, sourceHeight)); + return { + width: baseDimension, + height: baseDimension, + }; + } + + if (aspectRatioValue > 1) { + const baseWidth = even(sourceLongDim); + 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: even(baseWidth / aspectRatioValue), + }; + } + + const baseHeight = even(sourceLongDim); + 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 { + width: even(baseHeight * aspectRatioValue), + height: baseHeight, + }; +} + +function calculateBitrate(width: number, height: number, quality: ExportQuality) { + const totalPixels = width * height; + + if (quality === "source") { + if (totalPixels > 2560 * 1440) return 80_000_000; + if (totalPixels > 1920 * 1080) return 50_000_000; + return 30_000_000; + } + + if (totalPixels <= 1280 * 720) return 10_000_000; + if (totalPixels <= 1920 * 1080) return 20_000_000; + return 30_000_000; +} + +export function calculateMp4ExportSettings({ + quality, + sourceWidth, + sourceHeight, + aspectRatioValue, +}: { + quality: ExportQuality; + sourceWidth: number; + sourceHeight: number; + aspectRatioValue: number; +}): Mp4ExportSettings { + if (quality === "medium") { + const dimensions = calculateDimensionsForShortSide(MEDIUM_SHORT_SIDE, aspectRatioValue); + return { + ...dimensions, + bitrate: calculateBitrate(dimensions.width, dimensions.height, quality), + }; + } + + if (quality === "good") { + const dimensions = calculateDimensionsForShortSide(HIGH_SHORT_SIDE, aspectRatioValue); + return { + ...dimensions, + bitrate: calculateBitrate(dimensions.width, dimensions.height, quality), + }; + } + + const sourceDimensions = calculateSourceDimensions(sourceWidth, sourceHeight, aspectRatioValue); + return { + ...sourceDimensions, + bitrate: calculateBitrate(sourceDimensions.width, sourceDimensions.height, quality), + }; +} From fbdc7d56976d1b924090a862b46924d553c09bd5 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 11 May 2026 10:29:38 +0200 Subject: [PATCH 09/94] feat: scaffold macOS native capture pipeline --- .../macos-native-recorder-roadmap.md | 208 ++++++++++++++++++ electron/native/README.md | 27 +++ package.json | 1 + .../build-macos-screencapturekit-helper.mjs | 13 ++ src/lib/nativeMacRecording.test.ts | 26 +++ src/lib/nativeMacRecording.ts | 82 +++++++ 6 files changed, 357 insertions(+) create mode 100644 docs/engineering/macos-native-recorder-roadmap.md create mode 100644 scripts/build-macos-screencapturekit-helper.mjs create mode 100644 src/lib/nativeMacRecording.test.ts create mode 100644 src/lib/nativeMacRecording.ts diff --git a/docs/engineering/macos-native-recorder-roadmap.md b/docs/engineering/macos-native-recorder-roadmap.md new file mode 100644 index 00000000..469401bb --- /dev/null +++ b/docs/engineering/macos-native-recorder-roadmap.md @@ -0,0 +1,208 @@ +# macOS Native Recorder Roadmap + +OpenScreen's macOS recorder should follow the same architecture boundaries as the Windows native recorder: Electron owns session orchestration and persistence, while a platform-native helper owns capture, timing, encoding, and platform-specific permissions. + +This work is intentionally scoped as a macOS-only port. Windows native capture remains owned by the WGC helper, and Linux remains on the existing Electron path. + +## Goals + +- Capture displays and windows through ScreenCaptureKit. +- Exclude the real system cursor during capture when using the editable OpenScreen cursor overlay. +- Preserve the current high-quality cursor overlay path in preview and export. +- Capture macOS system audio through ScreenCaptureKit on supported macOS versions. +- Capture microphone audio through the same native timing domain where the OS supports it, or through an explicit companion path until it can be moved into the helper. +- Mix system audio and microphone audio into the primary MP4 without renderer-side track assembly. +- Capture webcam video natively and compose it into the helper-owned MP4 during the native-recording migration. +- Keep screen video, audio, webcam, and cursor aligned to one native timing origin. +- Package per-architecture helper binaries with macOS builds. + +## Non-Goals + +- Replacing the editor/export pipeline. +- Changing Windows native capture behavior. +- Adding Linux native capture. +- Shipping a silent fallback from native macOS capture to Electron capture when the user explicitly requested a native-only feature. + +## Architecture + +The renderer keeps the existing recording controls. On macOS, `useScreenRecorder` should eventually send a complete recording request to Electron instead of assembling display, audio, microphone, webcam, and cursor streams in the browser. + +Electron owns the native recording session: + +- resolves the selected display/window source; +- resolves output paths; +- starts cursor telemetry capture when editable cursor mode is selected; +- starts the ScreenCaptureKit helper process; +- sends pause/resume/stop/cancel commands; +- writes `RecordingSession` manifests; +- reports explicit errors when a macOS-native capability is unavailable. + +The helper owns macOS media capture: + +- ScreenCaptureKit display/window frames; +- ScreenCaptureKit system audio where supported; +- microphone capture or helper-owned companion audio capture; +- webcam capture and initial picture-in-picture composition; +- AVFoundation/VideoToolbox encoding and muxing; +- stream timestamp normalization. + +## Helper Contract V1 + +The helper receives a single JSON argument: + +```json +{ + "schemaVersion": 1, + "recordingId": 1234567890, + "source": { + "type": "display", + "sourceId": "screen:0:0", + "displayId": 1, + "windowId": null, + "bounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 } + }, + "video": { + "fps": 60, + "width": 1920, + "height": 1080, + "bitrate": 18000000, + "hideSystemCursor": true + }, + "audio": { + "system": { "enabled": true }, + "microphone": { + "enabled": true, + "deviceId": "default", + "deviceName": "MacBook Pro Microphone", + "gain": 1.4 + } + }, + "webcam": { + "enabled": true, + "deviceId": "default", + "deviceName": "FaceTime HD Camera", + "width": 1280, + "height": 720, + "fps": 30 + }, + "cursor": { + "mode": "editable-overlay" + }, + "outputs": { + "screenPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.mp4", + "manifestPath": "/Users/me/Library/Application Support/openscreen/recordings/recording-123.session.json" + } +} +``` + +The helper emits newline-delimited JSON events to stdout: + +```json +{ "event": "ready", "schemaVersion": 1 } +{ "event": "recording-started", "timestampMs": 1234567890 } +{ "event": "warning", "code": "microphone-unavailable", "message": "..." } +{ "event": "recording-stopped", "screenPath": "..." } +{ "event": "error", "code": "screen-permission-denied", "message": "..." } +``` + +## Implementation Phases + +### 1. Native Session Boundary + +- Add a structured macOS native recording request type. +- Add a macOS helper resolver and build script placeholders. +- Keep the helper contract process-based, matching the Windows helper boundary. +- Do not route production macOS recording through this helper until the helper is available and validated. + +Acceptance: + +- TypeScript build passes. +- The macOS helper path and request contract are documented and testable without affecting Windows/Linux behavior. + +### 2. ScreenCaptureKit Display Capture + +- Implement a Swift helper using ScreenCaptureKit. +- Select display captures by `displayId`. +- Encode H.264 MP4 through AVFoundation/VideoToolbox. +- Set `showsCursor = false` when editable cursor overlay mode is selected. + +Acceptance: + +- Display-only recording produces a valid MP4. +- The real cursor is not baked into editable-cursor recordings. + +### 3. ScreenCaptureKit Window Capture + +- Resolve Electron `window:*` selections to ScreenCaptureKit window ids. +- Capture `SCContentFilter(desktopIndependentWindow:)`. +- Handle closed/minimized/protected windows with explicit errors. +- Keep window selection and capture source resolution in Electron/main, not the renderer. + +Acceptance: + +- Capturing a normal app window works with cursor/audio/webcam disabled. +- Unsupported windows return clear native errors. + +### 4. System Audio + +- Enable ScreenCaptureKit system audio on supported macOS versions. +- Keep audio format and timing owned by the helper. +- Encode or mux AAC audio into the primary MP4. + +Acceptance: + +- System-audio-only recordings produce a valid AAC track. +- Unsupported macOS versions return an explicit capability error. + +### 5. Microphone + +- Resolve the selected microphone device from the renderer-provided browser `deviceId` and user-visible label. +- Capture microphone audio in the helper timing domain. +- Apply OpenScreen microphone gain policy. +- Mix system and microphone audio before final AAC output. + +Acceptance: + +- Mic-only and mic-plus-system recordings produce a valid, balanced AAC track. +- Device selection honors the selected microphone, not only the default device. + +### 6. Webcam Composition + +- Capture the selected camera natively through AVFoundation. +- Match browser device id first where possible, then user-visible label. +- Compose an initial picture-in-picture overlay into the primary MP4. +- Hide webcam output until the first usable frame to avoid black startup flashes. + +Acceptance: + +- Native display/window recordings can include webcam without returning to Electron capture. +- Selected camera is honored. + +### 7. Runtime Controls + +- Add pause/resume commands to the helper. +- Add cancel command that removes partial outputs. +- Keep restart as stop-discard-start until the helper exposes a native restart operation. + +Acceptance: + +- Pause/resume keeps output duration coherent. +- Cancel leaves no stale media/session files. + +### 8. Test Pipeline + +- `npm run build:native:mac`: builds Swift helper binaries on macOS. +- `npm run test:sck-helper:mac`: display-only helper smoke test. +- `npm run test:sck-window:mac`: window capture smoke test. +- `npm run test:sck-audio:mac`: system audio smoke test when supported. +- `npm run test:sck-mic:mac`: microphone smoke test. +- `npm run test:sck-webcam:mac`: webcam smoke test when a webcam is available. +- Packaging check: confirms helpers are available under `electron/native/bin/darwin-${arch}` in packaged builds. + +## SSOT Rules + +- `src/lib/nativeMacRecording.ts` is the renderer/main TypeScript request contract. +- This document is the feature-level contract and phase checklist. +- The Swift helper owns ScreenCaptureKit/AVFoundation media timing. +- Electron owns output paths, session manifests, and selected source/device resolution. +- Renderer code must use existing hooks/client APIs and should not bind directly to helper process details. diff --git a/electron/native/README.md b/electron/native/README.md index 659829cd..8d6ea465 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -1,5 +1,32 @@ # Native capture helpers +## macOS + +macOS native recording will use a ScreenCaptureKit helper with the same process boundary as the Windows WGC helper: + +1. Electron resolves the selected source, output paths, and user-selected devices. +2. The helper receives one structured JSON request. +3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing. +4. Electron persists the resulting media/session manifest and reports helper errors explicitly. + +Expected development helper locations: + +1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics. +2. `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, for locally built Swift output. +3. `electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper` or `electron/native/bin/darwin-x64/openscreen-screencapturekit-helper`, for packaged prebuilt helpers. + +The current macOS helper script is a placeholder: + +```bash +npm run build:native:mac +``` + +On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it fails until the Swift ScreenCaptureKit helper lands. + +See `docs/engineering/macos-native-recorder-roadmap.md` for the contract, rollout phases, and SSOT rules. + +## Windows + Windows native recording is resolved from one of these locations: 1. `OPENSCREEN_WGC_CAPTURE_EXE`, for local development and diagnostics. diff --git a/package.json b/package.json index 9388bcd3..12d02d71 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "format": "biome format --write .", "i18n:check": "node scripts/i18n-check.mjs", "preview": "vite preview", + "build:native:mac": "node scripts/build-macos-screencapturekit-helper.mjs", "build:mac": "tsc && vite build && electron-builder --mac", "build:native:win": "node scripts/build-windows-wgc-helper.mjs", "build:win": "npm run build:native:win && tsc && vite build && electron-builder --win --config.npmRebuild=false", diff --git a/scripts/build-macos-screencapturekit-helper.mjs b/scripts/build-macos-screencapturekit-helper.mjs new file mode 100644 index 00000000..5e9292f6 --- /dev/null +++ b/scripts/build-macos-screencapturekit-helper.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +import process from "node:process"; + +if (process.platform !== "darwin") { + console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS."); + process.exit(0); +} + +console.error( + "macOS ScreenCaptureKit helper sources are not implemented yet. See docs/engineering/macos-native-recorder-roadmap.md.", +); +process.exit(1); diff --git a/src/lib/nativeMacRecording.test.ts b/src/lib/nativeMacRecording.test.ts new file mode 100644 index 00000000..fce88f6a --- /dev/null +++ b/src/lib/nativeMacRecording.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parseMacDisplayIdFromSourceId, parseMacWindowIdFromSourceId } from "./nativeMacRecording"; + +describe("nativeMacRecording source parsing", () => { + it("parses Electron window source ids into ScreenCaptureKit window ids", () => { + expect(parseMacWindowIdFromSourceId("window:12345:0")).toBe(12345); + expect(parseMacWindowIdFromSourceId("window:987")).toBe(987); + }); + + it("rejects non-window source ids for window parsing", () => { + expect(parseMacWindowIdFromSourceId("screen:1:0")).toBeNull(); + expect(parseMacWindowIdFromSourceId("window:not-a-number:0")).toBeNull(); + expect(parseMacWindowIdFromSourceId(null)).toBeNull(); + }); + + it("parses Electron display source ids into ScreenCaptureKit display ids", () => { + expect(parseMacDisplayIdFromSourceId("screen:1:0")).toBe(1); + expect(parseMacDisplayIdFromSourceId("screen:69733248")).toBe(69733248); + }); + + it("rejects non-display source ids for display parsing", () => { + expect(parseMacDisplayIdFromSourceId("window:123:0")).toBeNull(); + expect(parseMacDisplayIdFromSourceId("screen:not-a-number:0")).toBeNull(); + expect(parseMacDisplayIdFromSourceId(undefined)).toBeNull(); + }); +}); diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts new file mode 100644 index 00000000..0e596c6d --- /dev/null +++ b/src/lib/nativeMacRecording.ts @@ -0,0 +1,82 @@ +import type { Rectangle } from "electron"; +import type { CursorCaptureMode } from "./recordingSession"; + +export type NativeMacSourceType = "display" | "window"; + +export type NativeMacRecordingRequest = { + recordingId?: number; + source: { + type: NativeMacSourceType; + sourceId: string; + displayId?: number; + windowId?: number; + bounds?: Rectangle; + }; + video: { + fps: number; + width: number; + height: number; + bitrate?: number; + hideSystemCursor: boolean; + }; + audio: { + system: { + enabled: boolean; + }; + microphone: { + enabled: boolean; + deviceId?: string; + deviceName?: string; + gain: number; + }; + }; + webcam: { + enabled: boolean; + deviceId?: string; + deviceName?: string; + width: number; + height: number; + fps: number; + }; + cursor: { + mode: CursorCaptureMode; + }; + outputs: { + screenPath: string; + manifestPath?: string; + }; +}; + +export type NativeMacRecordingStartResult = { + success: boolean; + recordingId?: number; + path?: string; + helperPath?: string; + error?: string; +}; + +export function parseMacWindowIdFromSourceId(sourceId?: string | null) { + if (!sourceId?.startsWith("window:")) { + return null; + } + + const windowIdPart = sourceId.split(":")[1]; + if (!windowIdPart || !/^\d+$/.test(windowIdPart)) { + return null; + } + + return Number(windowIdPart); +} + +export function parseMacDisplayIdFromSourceId(sourceId?: string | null) { + if (!sourceId?.startsWith("screen:")) { + return null; + } + + const displayIdPart = sourceId.split(":")[1]; + if (!displayIdPart || !/^\d+$/.test(displayIdPart)) { + return null; + } + + return Number(displayIdPart); +} From 7102110de5391878bd4a27b7fa688a32159735e0 Mon Sep 17 00:00:00 2001 From: Etienne Date: Tue, 12 May 2026 08:14:54 +0200 Subject: [PATCH 10/94] chore: ignore macos native build outputs --- .gitignore | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 84a5a1ce..0861e392 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,16 @@ dist-ssr # Native helper build outputs /electron/native/wgc-capture/build/ +/electron/native/screencapturekit/build/ +/electron/native/screencapturekit/.build/ +/electron/native/screencapturekit/.swiftpm/ /electron/native/bin/ +# Native macOS generated files +DerivedData/ +*.xcuserstate +xcuserdata/ + # Editor directories and files .vscode/* .zed/ @@ -49,4 +57,4 @@ result-* .direnv/ #kilocode -.kilo/ \ No newline at end of file +.kilo/ From b9e213474993e23237235bcc6cf6caab828543b2 Mon Sep 17 00:00:00 2001 From: Etienne Date: Tue, 12 May 2026 08:33:18 +0200 Subject: [PATCH 11/94] feat: add macos screencapturekit helper --- electron/electron-env.d.ts | 18 + electron/ipc/handlers.ts | 365 ++++++++++++++++++ electron/native/README.md | 10 +- .../native/screencapturekit/Package.swift | 22 ++ .../main.swift | 355 +++++++++++++++++ electron/preload.ts | 10 + .../build-macos-screencapturekit-helper.mjs | 68 +++- src/components/launch/LaunchWindow.tsx | 8 +- src/hooks/useScreenRecorder.ts | 179 ++++++++- src/lib/nativeMacRecording.ts | 35 ++ vite.config.ts | 5 + 11 files changed, 1064 insertions(+), 11 deletions(-) create mode 100644 electron/native/screencapturekit/Package.swift create mode 100644 electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e92ce19f..7ff73bcc 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -78,6 +78,13 @@ interface Window { reason?: string; error?: string; }>; + isNativeMacCaptureAvailable: () => Promise<{ + success: boolean; + available: boolean; + helperPath?: string; + reason?: "unsupported-platform" | "missing-helper" | string; + error?: string; + }>; startNativeWindowsRecording: ( request: import("../src/lib/nativeWindowsRecording").NativeWindowsRecordingRequest, ) => Promise; @@ -89,6 +96,17 @@ interface Window { discarded?: boolean; error?: string; }>; + startNativeMacRecording: ( + request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest, + ) => Promise; + stopNativeMacRecording: (discard?: boolean) => Promise<{ + success: boolean; + path?: string; + session?: import("../src/lib/recordingSession").RecordingSession; + message?: string; + discarded?: boolean; + error?: string; + }>; discardCursorTelemetry: (recordingId: number) => Promise; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9797c95c..68d60776 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -15,6 +15,7 @@ import { shell, systemPreferences, } from "electron"; +import type { NativeMacRecordingRequest } from "../../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../../src/lib/nativeWindowsRecording"; import { type CursorCaptureMode, @@ -276,6 +277,12 @@ let nativeWindowsCaptureRecordingId: number | null = null; let nativeWindowsCursorOffsetMs = 0; let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay"; const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000; +let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null; +let nativeMacCaptureOutput = ""; +let nativeMacCaptureTargetPath: string | null = null; +let nativeMacCaptureRecordingId: number | null = null; +let nativeMacCursorOffsetMs = 0; +let nativeMacCursorCaptureMode: CursorCaptureMode = "editable-overlay"; function normalizeCursorSample(sample: unknown): CursorRecordingSample | null { if (!sample || typeof sample !== "object") { @@ -499,6 +506,35 @@ async function findNativeWindowsCaptureHelperPath() { return null; } +function getNativeMacCaptureHelperCandidates() { + const envPath = process.env.OPENSCREEN_SCK_CAPTURE_EXE?.trim(); + const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + const helperName = "openscreen-screencapturekit-helper"; + return [ + envPath, + resolveUnpackedAppPath("electron", "native", "screencapturekit", "build", helperName), + resolveUnpackedAppPath("electron", "native", "bin", archTag, helperName), + resolvePackagedResourcePath("electron", "native", "bin", archTag, helperName), + ].filter((candidate): candidate is string => Boolean(candidate)); +} + +async function findNativeMacCaptureHelperPath() { + if (process.platform !== "darwin") { + return null; + } + + for (const candidate of getNativeMacCaptureHelperCandidates()) { + try { + await fs.access(candidate, fsConstants.X_OK); + return candidate; + } catch { + // Try the next configured helper location. + } + } + + return null; +} + function isWindowsGraphicsCaptureOsSupported() { if (process.platform !== "win32") { return false; @@ -785,6 +821,134 @@ function readNativeWindowsWebcamFormat(output: string) { } } +function tryParseNativeHelperEvent(line: string) { + try { + const parsed = JSON.parse(line); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for native macOS capture to start")); + }, 10_000); + + const inspect = (chunk: Buffer) => { + nativeMacCaptureOutput += chunk.toString(); + for (const line of nativeMacCaptureOutput.split(/\r?\n/)) { + const event = tryParseNativeHelperEvent(line.trim()); + if (!event) continue; + if (event.event === "recording-started") { + cleanup(); + resolve(); + return; + } + if (event.event === "error") { + cleanup(); + reject(new Error(event.message ?? event.code ?? "Native macOS capture failed")); + return; + } + } + }; + + const onOutput = (chunk: Buffer) => inspect(chunk); + const onClose = (code: number | null) => { + cleanup(); + reject( + new Error( + nativeMacCaptureOutput.trim() || + `Native macOS capture exited before recording started (code=${code ?? "unknown"})`, + ), + ); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + clearTimeout(timer); + proc.stdout.off("data", onOutput); + proc.stderr.off("data", onOutput); + proc.off("close", onClose); + proc.off("error", onError); + }; + + proc.stdout.on("data", onOutput); + proc.stderr.on("data", onOutput); + proc.once("close", onClose); + proc.once("error", onError); + }); +} + +function waitForNativeMacCaptureStop(proc: ChildProcessWithoutNullStreams) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject( + new Error( + `Timed out waiting for native macOS capture to stop. Output path: ${ + nativeMacCaptureTargetPath ?? "unknown" + }. Output: ${nativeMacCaptureOutput.trim()}`, + ), + ); + }, 30_000); + + const inspect = (chunk: Buffer) => { + nativeMacCaptureOutput += chunk.toString(); + for (const line of nativeMacCaptureOutput.split(/\r?\n/)) { + const event = tryParseNativeHelperEvent(line.trim()); + if (!event) continue; + if (event.event === "recording-stopped") { + cleanup(); + resolve(event.screenPath ?? nativeMacCaptureTargetPath ?? ""); + return; + } + if (event.event === "error") { + cleanup(); + reject(new Error(event.message ?? event.code ?? "Native macOS capture failed")); + return; + } + } + }; + + const onOutput = (chunk: Buffer) => inspect(chunk); + const onClose = (code: number | null) => { + if (code === 0 && nativeMacCaptureTargetPath) { + cleanup(); + resolve(nativeMacCaptureTargetPath); + return; + } + cleanup(); + reject( + new Error( + nativeMacCaptureOutput.trim() || + `Native macOS capture exited with code=${code ?? "unknown"}`, + ), + ); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + clearTimeout(timer); + proc.stdout.off("data", onOutput); + proc.stderr.off("data", onOutput); + proc.off("close", onClose); + proc.off("error", onError); + }; + + proc.stdout.on("data", onOutput); + proc.stderr.on("data", onOutput); + proc.once("close", onClose); + proc.once("error", onError); + }); +} + function setCurrentRecordingSessionState(session: RecordingSession | null) { currentRecordingSession = session; currentVideoPath = session?.screenVideoPath ?? null; @@ -1041,6 +1205,17 @@ export function registerIpcHandlers( : { success: true, available: false, reason: "missing-helper" }; }); + ipcMain.handle("is-native-mac-capture-available", async () => { + if (process.platform !== "darwin") { + return { success: true, available: false, reason: "unsupported-platform" }; + } + + const helperPath = await findNativeMacCaptureHelperPath(); + return helperPath + ? { success: true, available: true, helperPath } + : { success: true, available: false, reason: "missing-helper" }; + }); + ipcMain.handle( "start-native-windows-recording", async (_, request: NativeWindowsRecordingRequest) => { @@ -1217,6 +1392,121 @@ export function registerIpcHandlers( }, ); + ipcMain.handle("start-native-mac-recording", async (_, request: NativeMacRecordingRequest) => { + try { + if (process.platform !== "darwin") { + return { success: false, error: "Native macOS capture requires macOS." }; + } + if (nativeMacCaptureProcess) { + return { success: false, error: "Native macOS capture is already running." }; + } + + const helperPath = await findNativeMacCaptureHelperPath(); + if (!helperPath) { + return { success: false, error: "Native macOS capture helper is not available." }; + } + + if (!request?.source?.sourceId) { + return { success: false, error: "Native macOS capture request is missing a source." }; + } + + const recordingId = + typeof request.recordingId === "number" && Number.isFinite(request.recordingId) + ? request.recordingId + : Date.now(); + const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + const cursorCaptureMode = + normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay"; + const sourceDisplay = + request.source.type === "display" && typeof request.source.displayId === "number" + ? (screen.getAllDisplays().find((display) => display.id === request.source.displayId) ?? + null) + : getSelectedDisplay(); + const bounds = request.source.bounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds(); + const config: NativeMacRecordingRequest = { + ...request, + schemaVersion: 1, + recordingId, + source: { + ...request.source, + bounds, + }, + video: { + ...request.video, + hideSystemCursor: cursorCaptureMode === "editable-overlay", + }, + cursor: { + mode: cursorCaptureMode, + }, + outputs: { + screenPath: outputPath, + manifestPath: path.join( + RECORDINGS_DIR, + `${RECORDING_FILE_PREFIX}${recordingId}${RECORDING_SESSION_SUFFIX}`, + ), + }, + }; + + console.info("[native-sck] starting macOS capture", { + helperPath, + source: config.source, + audio: config.audio, + webcam: config.webcam, + cursor: config.cursor, + outputPath, + }); + + await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + nativeMacCaptureOutput = ""; + nativeMacCaptureTargetPath = outputPath; + nativeMacCaptureRecordingId = recordingId; + nativeMacCursorOffsetMs = 0; + nativeMacCursorCaptureMode = cursorCaptureMode; + + const cursorStartTimeMs = Date.now(); + if (cursorCaptureMode === "editable-overlay") { + await startCursorRecording(cursorStartTimeMs); + } else { + pendingCursorRecordingData = null; + } + + const proc = spawn(helperPath, [JSON.stringify(config)], { + cwd: RECORDINGS_DIR, + stdio: ["pipe", "pipe", "pipe"], + }); + nativeMacCaptureProcess = proc; + + await waitForNativeMacCaptureStart(proc); + const captureStartedAtMs = Date.now(); + nativeMacCursorOffsetMs = + cursorCaptureMode === "editable-overlay" + ? Math.max(0, captureStartedAtMs - cursorStartTimeMs) + : 0; + + const source = selectedSource || { name: "Screen" }; + if (onRecordingStateChange) { + onRecordingStateChange(true, source.name); + } + + return { + success: true, + recordingId, + path: outputPath, + helperPath, + }; + } catch (error) { + console.error("Failed to start native macOS recording:", error); + nativeMacCaptureProcess?.kill(); + nativeMacCaptureProcess = null; + nativeMacCaptureTargetPath = null; + nativeMacCaptureRecordingId = null; + nativeMacCursorOffsetMs = 0; + nativeMacCursorCaptureMode = "editable-overlay"; + await stopCursorRecording(); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => { const proc = nativeWindowsCaptureProcess; const preferredPath = nativeWindowsCaptureTargetPath; @@ -1301,6 +1591,81 @@ export function registerIpcHandlers( } }); + ipcMain.handle("stop-native-mac-recording", async (_, discard?: boolean) => { + const proc = nativeMacCaptureProcess; + const preferredPath = nativeMacCaptureTargetPath; + const recordingId = nativeMacCaptureRecordingId ?? Date.now(); + const cursorCaptureMode = nativeMacCursorCaptureMode; + + if (!proc) { + return { success: false, error: "Native macOS capture is not running." }; + } + + try { + const stoppedPathPromise = waitForNativeMacCaptureStop(proc); + proc.stdin.write("stop\n"); + const stoppedPath = await stoppedPathPromise; + const screenVideoPath = stoppedPath || preferredPath; + if (!screenVideoPath) { + throw new Error("Native macOS capture did not return an output path."); + } + + if (cursorCaptureMode === "editable-overlay") { + await stopCursorRecording(); + } else { + pendingCursorRecordingData = null; + } + if (discard) { + pendingCursorRecordingData = null; + await Promise.all([ + fs.rm(screenVideoPath, { force: true }), + fs.rm(`${screenVideoPath}.cursor.json`, { force: true }), + ]); + return { success: true, discarded: true }; + } + + if (cursorCaptureMode === "editable-overlay") { + shiftPendingCursorTelemetry(nativeMacCursorOffsetMs); + await writePendingCursorTelemetry(screenVideoPath); + } + + const session: RecordingSession = { + screenVideoPath, + createdAt: recordingId, + cursorCaptureMode, + }; + setCurrentRecordingSessionState(session); + currentProjectPath = null; + + const sessionManifestPath = path.join( + RECORDINGS_DIR, + `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, + ); + await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + + return { + success: true, + path: screenVideoPath, + session, + message: "Native macOS recording session stored successfully", + }; + } catch (error) { + console.error("Failed to stop native macOS recording:", error); + await stopCursorRecording(); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + nativeMacCaptureProcess = null; + nativeMacCaptureTargetPath = null; + nativeMacCaptureRecordingId = null; + nativeMacCursorOffsetMs = 0; + nativeMacCursorCaptureMode = "editable-overlay"; + const source = selectedSource || { name: "Screen" }; + if (onRecordingStateChange) { + onRecordingStateChange(false, source.name); + } + } + }); + ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { return await storeRecordedSessionFiles(payload); diff --git a/electron/native/README.md b/electron/native/README.md index 8d6ea465..2810f6b8 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -9,19 +9,23 @@ macOS native recording will use a ScreenCaptureKit helper with the same process 3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing. 4. Electron persists the resulting media/session manifest and reports helper errors explicitly. -Expected development helper locations: +Helper locations: 1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics. 2. `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, for locally built Swift output. 3. `electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper` or `electron/native/bin/darwin-x64/openscreen-screencapturekit-helper`, for packaged prebuilt helpers. -The current macOS helper script is a placeholder: +Build the macOS helper with: ```bash npm run build:native:mac ``` -On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it fails until the Swift ScreenCaptureKit helper lands. +On non-macOS hosts this command exits successfully and does not affect Windows/Linux development. On macOS it builds the Swift package at `electron/native/screencapturekit`, writes the development binary to `electron/native/screencapturekit/build/openscreen-screencapturekit-helper`, and copies the redistributable binary to `electron/native/bin/darwin-${arch}/openscreen-screencapturekit-helper`. + +The current helper implementation supports the first native media slice: display/window ScreenCaptureKit video capture, cursor exclusion through `SCStreamConfiguration.showsCursor`, H.264 encoding, and MP4 muxing. System audio, microphone capture, webcam composition, and runtime controls are intentionally left as explicit roadmap phases. + +Electron exposes `is-native-mac-capture-available` for capability probing. It resolves the same helper locations listed above and reports `missing-helper` until a Swift helper binary is present; production recording is not routed through the macOS helper yet. See `docs/engineering/macos-native-recorder-roadmap.md` for the contract, rollout phases, and SSOT rules. diff --git a/electron/native/screencapturekit/Package.swift b/electron/native/screencapturekit/Package.swift new file mode 100644 index 00000000..f040dd25 --- /dev/null +++ b/electron/native/screencapturekit/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "OpenScreenScreenCaptureKitHelper", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable( + name: "openscreen-screencapturekit-helper", + targets: ["OpenScreenScreenCaptureKitHelper"] + ) + ], + targets: [ + .executableTarget( + name: "OpenScreenScreenCaptureKitHelper", + path: "Sources/OpenScreenScreenCaptureKitHelper" + ) + ] +) diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift new file mode 100644 index 00000000..cf83eec5 --- /dev/null +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift @@ -0,0 +1,355 @@ +import AVFoundation +import CoreMedia +import Foundation +import ScreenCaptureKit + +struct Rectangle: Decodable { + let x: Double + let y: Double + let width: Double + let height: Double +} + +struct RecordingRequest: Decodable { + struct Source: Decodable { + let type: String + let sourceId: String + let displayId: UInt32? + let windowId: UInt32? + let bounds: Rectangle? + } + + struct Video: Decodable { + let fps: Int + let width: Int + let height: Int + let bitrate: Int? + let hideSystemCursor: Bool + } + + struct Audio: Decodable { + struct SystemAudio: Decodable { + let enabled: Bool + } + + struct Microphone: Decodable { + let enabled: Bool + let deviceId: String? + let deviceName: String? + let gain: Double + } + + let system: SystemAudio + let microphone: Microphone + } + + struct Webcam: Decodable { + let enabled: Bool + let deviceId: String? + let deviceName: String? + let width: Int + let height: Int + let fps: Int + } + + struct Cursor: Decodable { + let mode: String + } + + struct Outputs: Decodable { + let screenPath: String + let manifestPath: String? + } + + let schemaVersion: Int? + let recordingId: Int? + let source: Source + let video: Video + let audio: Audio + let webcam: Webcam + let cursor: Cursor + let outputs: Outputs +} + +enum HelperError: Error, CustomStringConvertible { + case invalidArguments + case unsupportedMacOS + case unsupportedFeature(String) + case sourceNotFound(String) + case invalidSourceType(String) + case writerSetupFailed(String) + + var description: String { + switch self { + case .invalidArguments: + return "Expected one JSON recording request argument." + case .unsupportedMacOS: + return "ScreenCaptureKit recording requires macOS 13 or newer." + case .unsupportedFeature(let message): + return message + case .sourceNotFound(let message): + return message + case .invalidSourceType(let sourceType): + return "Unsupported source type: \(sourceType)." + case .writerSetupFailed(let message): + return message + } + } +} + +func emit(_ fields: [String: Any]) { + if let data = try? JSONSerialization.data(withJSONObject: fields, options: []), + let line = String(data: data, encoding: .utf8) + { + print(line) + fflush(stdout) + } +} + +func emitError(code: String, message: String) { + emit([ + "event": "error", + "code": code, + "message": message, + ]) +} + +@available(macOS 13.0, *) +final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { + private let request: RecordingRequest + private let sampleQueue = DispatchQueue(label: "app.openscreen.sck-helper.samples") + private let stateQueue = DispatchQueue(label: "app.openscreen.sck-helper.state") + private var stream: SCStream? + private var writer: AVAssetWriter? + private var videoInput: AVAssetWriterInput? + private var didStartWriting = false + private var isStopping = false + + init(request: RecordingRequest) { + self.request = request + } + + func start() async throws { + try rejectUnsupportedPhaseFeatures() + + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: true + ) + let filter = try makeContentFilter(from: content) + let configuration = makeStreamConfiguration() + let stream = SCStream(filter: filter, configuration: configuration, delegate: self) + + try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: sampleQueue) + try setupWriter() + + self.stream = stream + emit(["event": "ready", "schemaVersion": 1]) + try await stream.startCapture() + emit([ + "event": "recording-started", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ]) + } + + func stop() async { + let shouldStop = stateQueue.sync { + if isStopping { + return false + } + isStopping = true + return true + } + if !shouldStop { + return + } + + do { + try await stream?.stopCapture() + } catch { + emit([ + "event": "warning", + "code": "stop-capture-failed", + "message": "\(error)", + ]) + } + + await finishWriter() + } + + func stream(_ stream: SCStream, didStopWithError error: Error) { + emitError(code: "capture-stopped-with-error", message: "\(error)") + Task { + await stop() + } + } + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .screen else { + return + } + guard CMSampleBufferDataIsReady(sampleBuffer) else { + return + } + guard let videoInput, let writer else { + return + } + + let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + if !didStartWriting { + writer.startWriting() + writer.startSession(atSourceTime: presentationTime) + didStartWriting = true + } + + if videoInput.isReadyForMoreMediaData { + videoInput.append(sampleBuffer) + } + } + + private func rejectUnsupportedPhaseFeatures() throws { + if request.audio.system.enabled { + throw HelperError.unsupportedFeature( + "System audio capture is planned for the roadmap system-audio phase." + ) + } + if request.audio.microphone.enabled { + throw HelperError.unsupportedFeature( + "Microphone capture is planned for the roadmap microphone phase." + ) + } + if request.webcam.enabled { + throw HelperError.unsupportedFeature( + "Webcam composition is planned for the roadmap webcam phase." + ) + } + } + + private func makeContentFilter(from content: SCShareableContent) throws -> SCContentFilter { + switch request.source.type { + case "display": + guard let displayId = request.source.displayId else { + throw HelperError.sourceNotFound("Display capture requires source.displayId.") + } + guard let display = content.displays.first(where: { $0.displayID == displayId }) else { + throw HelperError.sourceNotFound("No ScreenCaptureKit display found for id \(displayId).") + } + return SCContentFilter(display: display, excludingWindows: []) + case "window": + guard let windowId = request.source.windowId else { + throw HelperError.sourceNotFound("Window capture requires source.windowId.") + } + guard let window = content.windows.first(where: { $0.windowID == windowId }) else { + throw HelperError.sourceNotFound("No ScreenCaptureKit window found for id \(windowId).") + } + return SCContentFilter(desktopIndependentWindow: window) + default: + throw HelperError.invalidSourceType(request.source.type) + } + } + + private func makeStreamConfiguration() -> SCStreamConfiguration { + let configuration = SCStreamConfiguration() + configuration.width = request.video.width + configuration.height = request.video.height + configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, request.video.fps))) + configuration.queueDepth = 6 + configuration.showsCursor = !request.video.hideSystemCursor + configuration.pixelFormat = kCVPixelFormatType_32BGRA + return configuration + } + + private func setupWriter() throws { + let outputUrl = URL(fileURLWithPath: request.outputs.screenPath) + try? FileManager.default.removeItem(at: outputUrl) + try FileManager.default.createDirectory( + at: outputUrl.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let writer = try AVAssetWriter(outputURL: outputUrl, fileType: .mp4) + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: request.video.width, + AVVideoHeightKey: request.video.height, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000, + AVVideoExpectedSourceFrameRateKey: request.video.fps, + ], + ] + let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + input.expectsMediaDataInRealTime = true + + guard writer.canAdd(input) else { + throw HelperError.writerSetupFailed("Unable to add H.264 video input to AVAssetWriter.") + } + + writer.add(input) + self.writer = writer + self.videoInput = input + } + + private func finishWriter() async { + guard let writer else { + return + } + + videoInput?.markAsFinished() + + await withCheckedContinuation { continuation in + writer.finishWriting { + continuation.resume() + } + } + + if writer.status == .completed { + emit([ + "event": "recording-stopped", + "screenPath": request.outputs.screenPath, + ]) + } else { + emitError( + code: "writer-failed", + message: writer.error.map { "\($0)" } ?? "AVAssetWriter failed with status \(writer.status.rawValue)." + ) + } + } +} + +@main +struct OpenScreenScreenCaptureKitHelper { + static func main() async { + do { + guard CommandLine.arguments.count == 2 else { + throw HelperError.invalidArguments + } + + guard #available(macOS 13.0, *) else { + throw HelperError.unsupportedMacOS + } + + let requestData = Data(CommandLine.arguments[1].utf8) + let decoder = JSONDecoder() + let request = try decoder.decode(RecordingRequest.self, from: requestData) + let recorder = ScreenCaptureRecorder(request: request) + let stopTask = Task.detached { + while let line = readLine() { + let command = line.trimmingCharacters(in: .whitespacesAndNewlines) + if command == "stop" { + await recorder.stop() + exit(0) + } + } + } + + try await recorder.start() + await stopTask.value + } catch let error as HelperError { + emitError(code: "helper-error", message: error.description) + exit(1) + } catch { + emitError(code: "helper-error", message: "\(error)") + exit(1) + } + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 8302b959..e50e8b3b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; +import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; @@ -68,12 +69,21 @@ contextBridge.exposeInMainWorld("electronAPI", { isNativeWindowsCaptureAvailable: () => { return ipcRenderer.invoke("is-native-windows-capture-available"); }, + isNativeMacCaptureAvailable: () => { + return ipcRenderer.invoke("is-native-mac-capture-available"); + }, startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => { return ipcRenderer.invoke("start-native-windows-recording", request); }, stopNativeWindowsRecording: (discard?: boolean) => { return ipcRenderer.invoke("stop-native-windows-recording", discard); }, + startNativeMacRecording: (request: NativeMacRecordingRequest) => { + return ipcRenderer.invoke("start-native-mac-recording", request); + }, + stopNativeMacRecording: (discard?: boolean) => { + return ipcRenderer.invoke("stop-native-mac-recording", discard); + }, getCursorTelemetry: (videoPath?: string) => { return ipcRenderer.invoke("get-cursor-telemetry", videoPath); }, diff --git a/scripts/build-macos-screencapturekit-helper.mjs b/scripts/build-macos-screencapturekit-helper.mjs index 5e9292f6..f5f0c82b 100644 --- a/scripts/build-macos-screencapturekit-helper.mjs +++ b/scripts/build-macos-screencapturekit-helper.mjs @@ -1,13 +1,75 @@ #!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; import process from "node:process"; +import { fileURLToPath } from "node:url"; if (process.platform !== "darwin") { console.log("Skipping macOS ScreenCaptureKit helper build: host platform is not macOS."); process.exit(0); } -console.error( - "macOS ScreenCaptureKit helper sources are not implemented yet. See docs/engineering/macos-native-recorder-roadmap.md.", +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const helperName = "openscreen-screencapturekit-helper"; +const packageDir = path.join(root, "electron", "native", "screencapturekit"); +const buildDir = path.join(packageDir, "build"); +const swiftBuildDir = path.join(buildDir, "swiftpm"); +const builtHelperPath = path.join(swiftBuildDir, "release", helperName); +const localHelperPath = path.join(buildDir, helperName); +const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; +const distributableDir = path.join(root, "electron", "native", "bin", archTag); +const distributablePath = path.join(distributableDir, helperName); + +const xcodebuildVersion = spawnSync("xcodebuild", ["-version"], { + cwd: root, + encoding: "utf8", +}); + +if (xcodebuildVersion.status !== 0) { + const message = `${xcodebuildVersion.stderr ?? ""}${xcodebuildVersion.stdout ?? ""}`.trim(); + console.error( + [ + "Unable to build the macOS ScreenCaptureKit helper because full Xcode is not active.", + "", + message, + "", + "Install Xcode from the App Store or Apple Developer downloads, then run:", + " sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer", + " sudo xcodebuild -license accept", + "", + "Command Line Tools alone may not include the Swift SDK/platform metadata required by SwiftPM.", + ].join("\n"), + ); + process.exit(1); +} + +const result = spawnSync( + "swift", + ["build", "-c", "release", "--package-path", packageDir, "--build-path", swiftBuildDir], + { + cwd: root, + stdio: "inherit", + }, ); -process.exit(1); + +if (result.error) { + console.error(`Failed to start Swift build: ${result.error.message}`); + process.exit(1); +} + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +fs.mkdirSync(buildDir, { recursive: true }); +fs.mkdirSync(distributableDir, { recursive: true }); +fs.copyFileSync(builtHelperPath, localHelperPath); +fs.copyFileSync(builtHelperPath, distributablePath); +fs.chmodSync(localHelperPath, 0o755); +fs.chmodSync(distributablePath, 0o755); + +console.log(`Built macOS ScreenCaptureKit helper: ${localHelperPath}`); +console.log(`Copied redistributable helper: ${distributablePath}`); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 056a4fd8..d4fa9289 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -127,7 +127,7 @@ export function LaunchWindow() { const [isWebcamFocused, setIsWebcamFocused] = useState(false); const webcamExpanded = isWebcamHovered || isWebcamFocused; const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); - const [isWindows, setIsWindows] = useState(false); + const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); const languageTriggerRef = useRef(null); const languageMenuPanelRef = useRef(null); const [languageMenuStyle, setLanguageMenuStyle] = useState<{ @@ -192,12 +192,12 @@ export function LaunchWindow() { .getPlatform() .then((platform) => { if (!cancelled) { - setIsWindows(platform === "win32"); + setSupportsCursorModeToggle(platform === "win32" || platform === "darwin"); } }) .catch(() => { if (!cancelled) { - setIsWindows(false); + setSupportsCursorModeToggle(false); } }); @@ -609,7 +609,7 @@ export function LaunchWindow() { ? getIcon("webcamOn", "text-green-400") : getIcon("webcamOff", "text-white/40")} - {isWindows && ( + {supportsCursorModeToggle && ( - + {canPauseRecording && ( + + + + )} + )} {zoomEnabled && selectedZoomFocusMode !== "auto" && selectedZoomFocus && diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 15d582d3..818f663c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -194,6 +194,7 @@ export default function VideoEditor() { const durationRef = useRef(duration); durationRef.current = duration; const [selectedZoomId, setSelectedZoomId] = useState(null); + const [isPreviewingZoom, setIsPreviewingZoom] = useState(false); const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); @@ -2112,6 +2113,7 @@ export default function VideoEditor() { cursorMotionBlur={cursorMotionBlur} cursorClickBounce={cursorClickBounce} cursorClipToBounds={cursorClipToBounds} + isPreviewingZoom={isPreviewingZoom} /> @@ -2147,6 +2149,8 @@ export default function VideoEditor() { } onZoomCustomScaleChange={handleZoomCustomScaleChange} onZoomCustomScaleCommit={handleZoomCustomScaleCommit} + onZoomPreviewStart={() => setIsPreviewingZoom(true)} + onZoomPreviewEnd={() => setIsPreviewingZoom(false)} selectedZoomFocusMode={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 45ca9dfd..0c58572f 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -149,6 +149,9 @@ interface VideoPlaybackProps { cursorMotionBlur?: number; cursorClickBounce?: number; cursorClipToBounds?: boolean; + // When true, render the selected zoom at the playhead even while paused — + // lets the editor preview the zoom effect without leaving the focus-edit view. + isPreviewingZoom?: boolean; } export interface VideoPlaybackRef { @@ -270,6 +273,7 @@ const VideoPlayback = forwardRef( cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, cursorClipToBounds = false, + isPreviewingZoom = false, }, ref, ) => { @@ -341,6 +345,7 @@ const VideoPlayback = forwardRef( const cursorMotionBlurRef = useRef(cursorMotionBlur); const cursorClickBounceRef = useRef(cursorClickBounce); const cursorClipToBoundsRef = useRef(cursorClipToBounds); + const isPreviewingZoomRef = useRef(isPreviewingZoom); const motionBlurStateRef = useRef(createMotionBlurState()); const onTimeUpdateRef = useRef(onTimeUpdate); const onPlayStateChangeRef = useRef(onPlayStateChange); @@ -832,6 +837,10 @@ const VideoPlayback = forwardRef( cursorClipToBoundsRef.current = cursorClipToBounds; }, [cursorClipToBounds]); + useEffect(() => { + isPreviewingZoomRef.current = isPreviewingZoom; + }, [isPreviewingZoom]); + // Sync cursor overlay config when settings change useEffect(() => { const overlay = cursorOverlayRef.current; @@ -1309,7 +1318,8 @@ const VideoPlayback = forwardRef( // If a zoom is selected but video is not playing, show default unzoomed view const selectedId = selectedZoomIdRef.current; const hasSelectedZoom = selectedId !== null; - const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; + const shouldShowUnzoomedView = + hasSelectedZoom && !isPlayingRef.current && !isPreviewingZoomRef.current; if (region && strength > 0 && !shouldShowUnzoomedView) { const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 1b4907c1..45789272 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "اضغط مع الاستمرار للمعاينة", "level": "مستوى التكبير", "selectRegion": "حدد منطقة التكبير للتعديل", "deleteZoom": "حذف التكبير", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9877d211..b5fc2db7 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Hold to preview", "level": "Zoom Level", "customScale": "Custom Zoom", "selectRegion": "Select a zoom region to adjust", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index d03c0c72..6401db8a 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Mantener para previsualizar", "level": "Nivel de zoom", "selectRegion": "Selecciona una región de zoom para ajustar", "deleteZoom": "Eliminar zoom", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 8a53d44f..5af86913 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Maintenir pour prévisualiser", "level": "Niveau de zoom", "selectRegion": "Sélectionnez une région de zoom à ajuster", "deleteZoom": "Supprimer le zoom", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index e7e15fab..5e2ee8b5 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "押している間プレビュー", "level": "ズーム倍率", "selectRegion": "ズーム範囲を選択して調整", "deleteZoom": "ズームを削除", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index c56df1dc..5e0c3769 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "누르고 있으면 미리보기", "level": "줌 레벨", "selectRegion": "조정할 줌 구간을 선택하세요", "deleteZoom": "줌 삭제", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 3e77999c..f781ce5b 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Удерживайте для предпросмотра", "level": "Уровень масштабирования", "selectRegion": "Выберите область масштабирования для настройки", "deleteZoom": "Удалить масштабирование", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 8d998c30..a5fb9f67 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Önizlemek için basılı tutun", "level": "Yakınlaştırma Seviyesi", "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin", "deleteZoom": "Yakınlaştırmayı Sil", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 84497621..76e27087 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Giữ để xem trước", "level": "Mức độ thu phóng", "selectRegion": "Chọn vùng thu phóng để điều chỉnh", "deleteZoom": "Xóa thu phóng", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 62dcae7c..4c5ea2da 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "按住预览", "level": "缩放级别", "selectRegion": "选择要调整的缩放区域", "deleteZoom": "删除缩放", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 100c7564..decda1bc 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "按住預覽", "level": "縮放級別", "selectRegion": "選擇要調整的縮放區域", "deleteZoom": "刪除縮放", From 24ce67b5a7297efb360b73cd3e42571682e06ae8 Mon Sep 17 00:00:00 2001 From: auberginewly <3127221787@qq.com> Date: Tue, 19 May 2026 10:51:22 +0800 Subject: [PATCH 28/94] i18n(zoom): clarify previewHold wording to "preview zoom effect" Make the label explicit about what holding previews (the zoom effect), across all 11 locales. --- src/i18n/locales/ar/settings.json | 2 +- src/i18n/locales/en/settings.json | 2 +- src/i18n/locales/es/settings.json | 2 +- src/i18n/locales/fr/settings.json | 2 +- src/i18n/locales/ja-JP/settings.json | 2 +- src/i18n/locales/ko-KR/settings.json | 2 +- src/i18n/locales/ru/settings.json | 2 +- src/i18n/locales/tr/settings.json | 2 +- src/i18n/locales/vi/settings.json | 2 +- src/i18n/locales/zh-CN/settings.json | 2 +- src/i18n/locales/zh-TW/settings.json | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 45789272..76729d84 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "اضغط مع الاستمرار للمعاينة", + "previewHold": "اضغط مع الاستمرار لمعاينة تأثير التكبير", "level": "مستوى التكبير", "selectRegion": "حدد منطقة التكبير للتعديل", "deleteZoom": "حذف التكبير", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index b5fc2db7..477b4598 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "Hold to preview", + "previewHold": "Hold to preview zoom effect", "level": "Zoom Level", "customScale": "Custom Zoom", "selectRegion": "Select a zoom region to adjust", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 6401db8a..09fabad4 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "Mantener para previsualizar", + "previewHold": "Mantener para previsualizar el efecto de zoom", "level": "Nivel de zoom", "selectRegion": "Selecciona una región de zoom para ajustar", "deleteZoom": "Eliminar zoom", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 5af86913..6009765f 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "Maintenir pour prévisualiser", + "previewHold": "Maintenir pour prévisualiser l’effet de zoom", "level": "Niveau de zoom", "selectRegion": "Sélectionnez une région de zoom à ajuster", "deleteZoom": "Supprimer le zoom", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 5e2ee8b5..f1a604c9 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "押している間プレビュー", + "previewHold": "押している間ズーム効果をプレビュー", "level": "ズーム倍率", "selectRegion": "ズーム範囲を選択して調整", "deleteZoom": "ズームを削除", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 5e0c3769..86fde766 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "누르고 있으면 미리보기", + "previewHold": "누르고 있으면 줌 효과 미리보기", "level": "줌 레벨", "selectRegion": "조정할 줌 구간을 선택하세요", "deleteZoom": "줌 삭제", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index f781ce5b..1ca59865 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "Удерживайте для предпросмотра", + "previewHold": "Удерживайте для предпросмотра эффекта зума", "level": "Уровень масштабирования", "selectRegion": "Выберите область масштабирования для настройки", "deleteZoom": "Удалить масштабирование", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index a5fb9f67..5adc2420 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "Önizlemek için basılı tutun", + "previewHold": "Yakınlaştırma efektini önizlemek için basılı tutun", "level": "Yakınlaştırma Seviyesi", "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin", "deleteZoom": "Yakınlaştırmayı Sil", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 76e27087..ba690465 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "Giữ để xem trước", + "previewHold": "Giữ để xem trước hiệu ứng phóng to", "level": "Mức độ thu phóng", "selectRegion": "Chọn vùng thu phóng để điều chỉnh", "deleteZoom": "Xóa thu phóng", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 4c5ea2da..642d78f5 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "按住预览", + "previewHold": "按住预览放大效果", "level": "缩放级别", "selectRegion": "选择要调整的缩放区域", "deleteZoom": "删除缩放", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index decda1bc..825169dc 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -1,6 +1,6 @@ { "zoom": { - "previewHold": "按住預覽", + "previewHold": "按住預覽放大效果", "level": "縮放級別", "selectRegion": "選擇要調整的縮放區域", "deleteZoom": "刪除縮放", From a686fa012a3c132e918bbdd5aece7411ee574696 Mon Sep 17 00:00:00 2001 From: auberginewly <3127221787@qq.com> Date: Tue, 19 May 2026 11:02:28 +0800 Subject: [PATCH 29/94] =?UTF-8?q?fix(zoom):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20preview=20selected=20zoom=20+=20keyboard=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VideoPlayback: while holding Preview, render the SELECTED zoom at full strength regardless of the playhead, instead of whatever findDominantRegion returns at currentTime (which is none/another zoom when the playhead is outside the selection). Uses getZoomScale/getRotation3D for the region's configured scale and 3D preset. - SettingsPanel: require both onZoomPreviewStart && onZoomPreviewEnd to render the button (full lifecycle), and add keyboard support — Space/Enter keydown (repeat-guarded) starts preview, keyup/blur ends it. --- src/components/video-editor/SettingsPanel.tsx | 23 +++++++++++++++---- src/components/video-editor/VideoPlayback.tsx | 19 ++++++++++++++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1b37499c..383fc48c 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -944,13 +944,26 @@ export function SettingsPanel({ )} - {zoomEnabled && (onZoomPreviewStart || onZoomPreviewEnd) && ( + {zoomEnabled && onZoomPreviewStart && onZoomPreviewEnd && ( {onSaveDiagnostic && ( )} ); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 1b4907c1..c16ada49 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -104,8 +104,9 @@ "gifButton": "تصدير GIF", "chooseSaveLocation": "اختيار موقع الحفظ" }, - "links": { + "support": { "reportBug": "الإبلاغ عن خطأ", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "إعطاء نجمة على GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9877d211..fae2f2ca 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -104,8 +104,9 @@ "gifButton": "Export GIF", "chooseSaveLocation": "Choose Save Location" }, - "links": { + "support": { "reportBug": "Report Bug", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "Star on GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index d03c0c72..e4d377d0 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -104,8 +104,9 @@ "gifButton": "Exportar GIF", "chooseSaveLocation": "Elegir ubicación de guardado" }, - "links": { + "support": { "reportBug": "Reportar error", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "Dar estrella en GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 8a53d44f..3d7347ba 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -105,8 +105,9 @@ "gifButton": "Exporter le GIF", "chooseSaveLocation": "Choisir l'emplacement d'enregistrement" }, - "links": { + "support": { "reportBug": "Signaler un bug", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "Étoile sur GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index e7e15fab..9cd3e7d3 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -104,8 +104,9 @@ "gifButton": "GIF をエクスポート", "chooseSaveLocation": "保存場所を選択" }, - "links": { + "support": { "reportBug": "バグを報告", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "GitHub でスターを付ける" }, "imageUpload": { diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 8cf79a82..cdc57f3c 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -104,8 +104,9 @@ "gifButton": "GIF 내보내기", "chooseSaveLocation": "저장 위치 선택" }, - "links": { + "support": { "reportBug": "버그 신고", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "GitHub에 Star 남기기" }, "imageUpload": { diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 3e77999c..bd059ea6 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -104,8 +104,9 @@ "gifButton": "Экспорт GIF", "chooseSaveLocation": "Выбрать место сохранения" }, - "links": { + "support": { "reportBug": "Сообщить об ошибке", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "Звезда на GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 8d998c30..f062879f 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -104,8 +104,9 @@ "gifButton": "GIF Olarak Dışa Aktar", "chooseSaveLocation": "Kayıt Konumu Seç" }, - "links": { + "support": { "reportBug": "Hata Bildir", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "GitHub'da Yıldızla" }, "imageUpload": { diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 84497621..a4064c82 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -104,8 +104,9 @@ "gifButton": "Xuất GIF", "chooseSaveLocation": "Chọn vị trí lưu" }, - "links": { + "support": { "reportBug": "Báo cáo lỗi", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "Đánh giá sao trên GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 62dcae7c..195f4ad6 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -104,8 +104,9 @@ "gifButton": "导出 GIF", "chooseSaveLocation": "选择保存位置" }, - "links": { + "support": { "reportBug": "报告错误", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "在 GitHub 上加星" }, "imageUpload": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 100c7564..44dcf379 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -105,8 +105,9 @@ "gifButton": "匯出 GIF", "chooseSaveLocation": "選擇儲存位置" }, - "links": { + "support": { "reportBug": "回報錯誤", + "saveDiagnostics": "Save Diagnostics", "starOnGithub": "在 GitHub 上加星" }, "imageUpload": { From a9181e6782cf81591a267a69c3c512783c212d9e Mon Sep 17 00:00:00 2001 From: Marc Diaz Date: Tue, 19 May 2026 23:22:50 -0400 Subject: [PATCH 42/94] fix: update translations --- src/i18n/locales/ar/settings.json | 2 +- src/i18n/locales/es/settings.json | 2 +- src/i18n/locales/fr/settings.json | 2 +- src/i18n/locales/ja-JP/settings.json | 2 +- src/i18n/locales/ru/settings.json | 2 +- src/i18n/locales/tr/settings.json | 2 +- src/i18n/locales/vi/settings.json | 2 +- src/i18n/locales/zh-CN/settings.json | 2 +- src/i18n/locales/zh-TW/settings.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index c16ada49..0b7bf8b1 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -106,7 +106,7 @@ }, "support": { "reportBug": "الإبلاغ عن خطأ", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "حفظ التشخيصات", "starOnGithub": "إعطاء نجمة على GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index e4d377d0..60a4def5 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -106,7 +106,7 @@ }, "support": { "reportBug": "Reportar error", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "Guardar diagnósticos", "starOnGithub": "Dar estrella en GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 3d7347ba..df98a2df 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -107,7 +107,7 @@ }, "support": { "reportBug": "Signaler un bug", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "Enregistrer les diagnostics", "starOnGithub": "Étoile sur GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 9cd3e7d3..7b729c3e 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -106,7 +106,7 @@ }, "support": { "reportBug": "バグを報告", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "診断情報を保存", "starOnGithub": "GitHub でスターを付ける" }, "imageUpload": { diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index bd059ea6..02e4fbae 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -106,7 +106,7 @@ }, "support": { "reportBug": "Сообщить об ошибке", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "Сохранить диагностику", "starOnGithub": "Звезда на GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index f062879f..6bebd4e7 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -106,7 +106,7 @@ }, "support": { "reportBug": "Hata Bildir", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "Teşhis Verilerini Kaydet", "starOnGithub": "GitHub'da Yıldızla" }, "imageUpload": { diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index a4064c82..c0bf09f8 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -106,7 +106,7 @@ }, "support": { "reportBug": "Báo cáo lỗi", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "Lưu thông tin chẩn đoán", "starOnGithub": "Đánh giá sao trên GitHub" }, "imageUpload": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 195f4ad6..77f41b71 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -106,7 +106,7 @@ }, "support": { "reportBug": "报告错误", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "保存诊断信息", "starOnGithub": "在 GitHub 上加星" }, "imageUpload": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 44dcf379..72d7a205 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -107,7 +107,7 @@ }, "support": { "reportBug": "回報錯誤", - "saveDiagnostics": "Save Diagnostics", + "saveDiagnostics": "儲存診斷資料", "starOnGithub": "在 GitHub 上加星" }, "imageUpload": { From 57eed2c8292b615677c3ed71e423d4dce88dbabe Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Wed, 20 May 2026 16:38:14 +0800 Subject: [PATCH 43/94] test: tighten export e2e guards --- tests/e2e/gif-export.spec.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/e2e/gif-export.spec.ts b/tests/e2e/gif-export.spec.ts index c00dc400..18277ccb 100644 --- a/tests/e2e/gif-export.spec.ts +++ b/tests/e2e/gif-export.spec.ts @@ -48,6 +48,12 @@ async function exportFromLoadedVideo(format: "gif" | "mp4"): Promise { ipcMain.handle( "write-export-to-path", (_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer, filePath: string) => { + if (filePath !== targetPath) { + return { + success: false, + error: `Unexpected export path: ${filePath}`, + }; + } (globalThis as Record)["__testExportData"] = Buffer.from(buffer).toString("base64"); return { success: true, path: filePath }; @@ -63,13 +69,19 @@ async function exportFromLoadedVideo(format: "gif" | "mp4"): Promise { fs.mkdirSync(recordingsDir, { recursive: true }); fs.copyFileSync(TEST_VIDEO, testVideoInRecordings); + await hudWindow.evaluate( + (videoPath: string) => window.electronAPI.setCurrentVideoPath(videoPath), + testVideoInRecordings, + ); try { - await hudWindow.evaluate((videoPath: string) => { - window.electronAPI.setCurrentVideoPath(videoPath); - window.electronAPI.switchToEditor(); - }, testVideoInRecordings); - } catch { - // switchToEditor closes the HUD window before evaluate can resolve. + await hudWindow.evaluate(() => window.electronAPI.switchToEditor()); + } catch (error) { + if ( + !(error instanceof Error) || + !/closed|destroyed|target page|target closed/i.test(error.message) + ) { + throw error; + } } const editorWindow = await app.waitForEvent("window", { From 7826cc44e3685b6d0d8a87926fa41653b638e1fb Mon Sep 17 00:00:00 2001 From: Luca Fontanot Date: Wed, 20 May 2026 11:49:23 +0200 Subject: [PATCH 44/94] fix: Add include for std::max --- electron/native/wgc-capture/src/cursor-sampler.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/electron/native/wgc-capture/src/cursor-sampler.cpp b/electron/native/wgc-capture/src/cursor-sampler.cpp index 531a97c6..0a773921 100644 --- a/electron/native/wgc-capture/src/cursor-sampler.cpp +++ b/electron/native/wgc-capture/src/cursor-sampler.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include From cfe6b9e5946452858de40d16aa2b6f01c2d0d275 Mon Sep 17 00:00:00 2001 From: Luca Fontanot Date: Wed, 20 May 2026 11:53:50 +0200 Subject: [PATCH 45/94] fix: Thread detach before teardown is race-prone. --- electron/native/wgc-capture/src/cursor-sampler.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/electron/native/wgc-capture/src/cursor-sampler.cpp b/electron/native/wgc-capture/src/cursor-sampler.cpp index 0a773921..21558c79 100644 --- a/electron/native/wgc-capture/src/cursor-sampler.cpp +++ b/electron/native/wgc-capture/src/cursor-sampler.cpp @@ -22,6 +22,7 @@ static HHOOK g_mouseHook = nullptr; static DWORD g_mainThreadId = 0; static std::atomic g_leftDownCount{0}; static std::atomic g_leftUpCount{0}; +static std::atomic g_stop{false}; static std::mutex g_stdoutMtx; static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { @@ -314,7 +315,7 @@ static std::string buildAssetJson( static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) { HCURSOR lastCursor = nullptr; - while (true) { + while (!g_stop.load(std::memory_order_relaxed)) { const int downCount = g_leftDownCount.exchange(0, std::memory_order_relaxed); const int upCount = g_leftUpCount.exchange(0, std::memory_order_relaxed); @@ -473,7 +474,8 @@ int main(int argc, char* argv[]) { DispatchMessage(&msg); } - sampler.detach(); + g_stop.store(true, std::memory_order_relaxed); + if (sampler.joinable()) sampler.join(); UnhookWindowsHookEx(g_mouseHook); Gdiplus::GdiplusShutdown(gdipToken); return 0; From ef5855f1f44a2b77796649295e47c3f63aa269c5 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 22 May 2026 11:20:35 +0200 Subject: [PATCH 46/94] Fix native Windows webcam sidecar capture Record browser webcam sidecar when native Windows capture is active. Add native webcam sidecar output and DirectShow NV12/YUY2 fallback. Sample exported webcam frames by source timestamp. --- .../wgc-capture/src/dshow_webcam_capture.cpp | 158 ++++++++++++++--- .../wgc-capture/src/dshow_webcam_capture.h | 19 +- electron/native/wgc-capture/src/main.cpp | 80 +++++++-- .../native/wgc-capture/src/mf_encoder.cpp | 82 +++++++++ electron/native/wgc-capture/src/mf_encoder.h | 2 + .../native/wgc-capture/src/webcam_capture.cpp | 12 +- .../native/wgc-capture/src/webcam_capture.h | 3 +- scripts/test-windows-wgc-helper.mjs | 27 ++- src/hooks/useScreenRecorder.ts | 163 ++++++++++++------ src/lib/exporter/gifExporter.ts | 14 +- .../timestampedVideoFrameQueue.test.ts | 50 ++++++ .../exporter/timestampedVideoFrameQueue.ts | 105 +++++++++++ src/lib/exporter/videoExporter.ts | 14 +- 13 files changed, 618 insertions(+), 111 deletions(-) create mode 100644 src/lib/exporter/timestampedVideoFrameQueue.test.ts create mode 100644 src/lib/exporter/timestampedVideoFrameQueue.ts diff --git a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp index 14cb888c..436bdeac 100644 --- a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp +++ b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp @@ -5,22 +5,18 @@ #include #include +#include #include #include +#include #include +#include namespace { const CLSID CLSID_SampleGrabberLocal = {0xC1F400A0, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}}; const CLSID CLSID_NullRendererLocal = {0xC1F400A4, 0x3F08, 0x11D3, {0x9F, 0x0B, 0x00, 0x60, 0x08, 0x03, 0x9E, 0x37}}; -MIDL_INTERFACE("0579154A-2B53-4994-B0D0-E773148EFF85") -ISampleGrabberCB : public IUnknown { -public: - virtual HRESULT STDMETHODCALLTYPE SampleCB(double sampleTime, IMediaSample* sample) = 0; - virtual HRESULT STDMETHODCALLTYPE BufferCB(double sampleTime, BYTE* buffer, long bufferLength) = 0; -}; - MIDL_INTERFACE("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F") ISampleGrabber : public IUnknown { public: @@ -30,7 +26,7 @@ public: virtual HRESULT STDMETHODCALLTYPE SetBufferSamples(BOOL bufferThem) = 0; virtual HRESULT STDMETHODCALLTYPE GetCurrentBuffer(long* bufferSize, long* buffer) = 0; virtual HRESULT STDMETHODCALLTYPE GetCurrentSample(IMediaSample** sample) = 0; - virtual HRESULT STDMETHODCALLTYPE SetCallback(ISampleGrabberCB* callback, long whichMethodToCallback) = 0; + virtual HRESULT STDMETHODCALLTYPE SetCallback(IUnknown* callback, long whichMethodToCallback) = 0; }; bool succeeded(HRESULT hr, const char* label) { @@ -43,6 +39,34 @@ bool succeeded(HRESULT hr, const char* label) { return false; } +std::string guidToString(const GUID& guid) { + if (guid == MEDIASUBTYPE_RGB32) { + return "RGB32"; + } + if (guid == MEDIASUBTYPE_YUY2) { + return "YUY2"; + } + if (guid == MEDIASUBTYPE_NV12) { + return "NV12"; + } + + std::ostringstream stream; + stream << std::hex << std::setfill('0') + << '{' << std::setw(8) << guid.Data1 + << '-' << std::setw(4) << guid.Data2 + << '-' << std::setw(4) << guid.Data3 + << '-'; + for (int index = 0; index < 2; index += 1) { + stream << std::setw(2) << static_cast(guid.Data4[index]); + } + stream << '-'; + for (int index = 2; index < 8; index += 1) { + stream << std::setw(2) << static_cast(guid.Data4[index]); + } + stream << '}'; + return stream.str(); +} + void freeMediaType(AM_MEDIA_TYPE& type) { if (type.cbFormat != 0) { CoTaskMemFree(type.pbFormat); @@ -55,6 +79,20 @@ void freeMediaType(AM_MEDIA_TYPE& type) { } } +BYTE clampToByte(int value) { + return static_cast(std::clamp(value, 0, 255)); +} + +std::array yuvToBgr(int y, int u, int v) { + const int c = y - 16; + const int d = u - 128; + const int e = v - 128; + const int blue = (298 * c + 516 * d + 128) >> 8; + const int green = (298 * c - 100 * d - 208 * e + 128) >> 8; + const int red = (298 * c + 409 * e + 128) >> 8; + return {clampToByte(blue), clampToByte(green), clampToByte(red)}; +} + } // namespace struct DirectShowWebcamCapture::Impl { @@ -137,9 +175,8 @@ bool DirectShowWebcamCapture::initialize( AM_MEDIA_TYPE requestedType{}; requestedType.majortype = MEDIATYPE_Video; - requestedType.subtype = MEDIASUBTYPE_RGB32; requestedType.formattype = FORMAT_VideoInfo; - if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow RGB32)")) { + if (!succeeded(impl_->sampleGrabber->SetMediaType(&requestedType), "SetMediaType(DirectShow video)")) { return false; } @@ -170,17 +207,40 @@ bool DirectShowWebcamCapture::initialize( if (!succeeded(impl_->sampleGrabber->GetConnectedMediaType(&connectedType), "GetConnectedMediaType(DirectShow webcam)")) { return false; } + if (connectedType.subtype == MEDIASUBTYPE_YUY2) { + pixelFormat_ = PixelFormat::Yuy2; + } else if (connectedType.subtype == MEDIASUBTYPE_NV12) { + pixelFormat_ = PixelFormat::Nv12; + } else if (connectedType.subtype == MEDIASUBTYPE_RGB32) { + pixelFormat_ = PixelFormat::Bgra; + } else { + std::cerr << "ERROR: Unsupported DirectShow webcam media subtype " + << guidToString(connectedType.subtype) << std::endl; + freeMediaType(connectedType); + return false; + } if (connectedType.formattype == FORMAT_VideoInfo && connectedType.pbFormat) { const auto* videoInfo = reinterpret_cast(connectedType.pbFormat); width_ = std::abs(videoInfo->bmiHeader.biWidth); height_ = std::abs(videoInfo->bmiHeader.biHeight); - sourceTopDown_ = videoInfo->bmiHeader.biHeight < 0; + const int bitsPerPixel = videoInfo->bmiHeader.biBitCount > 0 ? videoInfo->bmiHeader.biBitCount : 16; + if (pixelFormat_ == PixelFormat::Nv12) { + sourceStride_ = ((width_ + 3) / 4) * 4; + } else { + sourceStride_ = ((width_ * bitsPerPixel + 31) / 32) * 4; + } + sourceTopDown_ = pixelFormat_ != PixelFormat::Bgra || videoInfo->bmiHeader.biHeight < 0; } + std::cerr << "INFO: DirectShow webcam connected subtype " << guidToString(connectedType.subtype) + << " " << width_ << "x" << height_ << " stride=" << sourceStride_ << std::endl; freeMediaType(connectedType); if (width_ <= 0 || height_ <= 0) { width_ = requestedWidth > 0 ? requestedWidth : 1280; height_ = requestedHeight > 0 ? requestedHeight : 720; } + if (sourceStride_ <= 0) { + sourceStride_ = pixelFormat_ == PixelFormat::Bgra ? width_ * 4 : ((width_ + 3) / 4) * 4; + } impl_->sampleGrabber->SetBufferSamples(TRUE); impl_->sampleGrabber->SetOneShot(FALSE); @@ -262,36 +322,88 @@ void DirectShowWebcamCapture::captureLoop() { } void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) { - const int stride = width_ * 4; - const int expectedLength = stride * height_; + const int destinationStride = width_ * 4; + const int sourceStride = sourceStride_ > 0 ? sourceStride_ : destinationStride; + const int expectedLength = pixelFormat_ == PixelFormat::Nv12 + ? sourceStride * height_ + sourceStride * ((height_ + 1) / 2) + : sourceStride * height_; if (!buffer || length < expectedLength || width_ <= 0 || height_ <= 0) { return; } - std::vector frame(static_cast(expectedLength)); + std::vector frame(static_cast(destinationStride * height_)); for (int y = 0; y < height_; y += 1) { const int sourceY = sourceTopDown_ ? y : height_ - 1 - y; - const BYTE* source = buffer + sourceY * stride; - BYTE* destination = frame.data() + y * stride; - std::copy(source, source + stride, destination); - for (int x = 0; x < width_; x += 1) { - destination[x * 4 + 3] = 255; + const BYTE* source = buffer + sourceY * sourceStride; + BYTE* destination = frame.data() + y * destinationStride; + if (pixelFormat_ == PixelFormat::Bgra) { + std::copy(source, source + destinationStride, destination); + for (int x = 0; x < width_; x += 1) { + destination[x * 4 + 3] = 255; + } + continue; + } + + if (pixelFormat_ == PixelFormat::Nv12) { + const BYTE* yPlane = buffer + sourceY * sourceStride; + const BYTE* uvPlane = buffer + sourceStride * height_ + (sourceY / 2) * sourceStride; + for (int x = 0; x < width_; x += 1) { + const int uvX = (x / 2) * 2; + const auto color = yuvToBgr(yPlane[x], uvPlane[uvX], uvPlane[uvX + 1]); + BYTE* pixel = destination + x * 4; + pixel[0] = color[0]; + pixel[1] = color[1]; + pixel[2] = color[2]; + pixel[3] = 255; + } + continue; + } + + for (int x = 0; x + 1 < width_; x += 2) { + const BYTE y0 = source[x * 2]; + const BYTE u = source[x * 2 + 1]; + const BYTE y1 = source[x * 2 + 2]; + const BYTE v = source[x * 2 + 3]; + const auto first = yuvToBgr(y0, u, v); + const auto second = yuvToBgr(y1, u, v); + BYTE* firstPixel = destination + x * 4; + BYTE* secondPixel = firstPixel + 4; + firstPixel[0] = first[0]; + firstPixel[1] = first[1]; + firstPixel[2] = first[2]; + firstPixel[3] = 255; + secondPixel[0] = second[0]; + secondPixel[1] = second[1]; + secondPixel[2] = second[2]; + secondPixel[3] = 255; + } + if (width_ % 2 == 1) { + const int x = width_ - 1; + const BYTE* pair = source + (x - 1) * 2; + const auto color = yuvToBgr(pair[2], pair[1], pair[3]); + BYTE* pixel = destination + x * 4; + pixel[0] = color[0]; + pixel[1] = color[1]; + pixel[2] = color[2]; + pixel[3] = 255; } } std::scoped_lock lock(frameMutex_); latestFrame_ = std::move(frame); + latestFrameSequence_ += 1; } -bool DirectShowWebcamCapture::copyLatestFrame(std::vector& destination, int& width, int& height) { +bool DirectShowWebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) { std::scoped_lock lock(frameMutex_); if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) { return false; } - destination = latestFrame_; - width = width_; - height = height_; + destination.data = latestFrame_; + destination.width = width_; + destination.height = height_; + destination.sequence = latestFrameSequence_; return true; } diff --git a/electron/native/wgc-capture/src/dshow_webcam_capture.h b/electron/native/wgc-capture/src/dshow_webcam_capture.h index 906da8fa..3debcbe0 100644 --- a/electron/native/wgc-capture/src/dshow_webcam_capture.h +++ b/electron/native/wgc-capture/src/dshow_webcam_capture.h @@ -3,11 +3,19 @@ #include #include +#include #include #include #include #include +struct WebcamFrameSnapshot { + std::vector data; + int width = 0; + int height = 0; + uint64_t sequence = 0; +}; + class DirectShowWebcamCapture { public: DirectShowWebcamCapture() = default; @@ -25,7 +33,7 @@ public: int requestedFps); bool start(); void stop(); - bool copyLatestFrame(std::vector& destination, int& width, int& height); + bool copyLatestFrame(WebcamFrameSnapshot& destination); int width() const; int height() const; @@ -34,6 +42,12 @@ public: void storeFrame(const BYTE* buffer, long length); private: + enum class PixelFormat { + Bgra, + Nv12, + Yuy2, + }; + struct Impl; void captureLoop(); @@ -42,9 +56,12 @@ private: std::atomic stopRequested_ = false; std::mutex frameMutex_; std::vector latestFrame_; + uint64_t latestFrameSequence_ = 0; int width_ = 0; int height_ = 0; int fps_ = 30; + int sourceStride_ = 0; bool sourceTopDown_ = false; + PixelFormat pixelFormat_ = PixelFormat::Bgra; std::wstring selectedDeviceName_; }; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index f8f56cd0..7968d94a 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -29,6 +29,7 @@ struct CaptureConfig { std::string sourceId; std::string windowHandle; std::string outputPath; + std::string webcamOutputPath; int fps = 60; int width = 0; int height = 0; @@ -311,6 +312,7 @@ bool parseConfig(const std::string& json, CaptureConfig& config) { config.webcamDeviceId = findString(json, "webcamDeviceId"); config.webcamDeviceName = findString(json, "webcamDeviceName"); config.webcamDirectShowClsid = findString(json, "webcamDirectShowClsid"); + config.webcamOutputPath = findString(json, "webcamPath"); config.webcamWidth = findInt(json, "webcamWidth", 0); config.webcamHeight = findInt(json, "webcamHeight", 0); config.webcamFps = findInt(json, "webcamFps", 0); @@ -389,6 +391,7 @@ int main(int argc, char* argv[]) { WebcamCapture webcamCapture; bool webcamActive = false; + bool writeSeparateWebcam = false; if (config.webcamEnabled) { if (!webcamCapture.initialize( utf8ToWide(config.webcamDeviceId), @@ -405,6 +408,7 @@ int main(int argc, char* argv[]) { << ",\"fps\":" << webcamCapture.fps() << ",\"deviceName\":\"" << jsonEscape(wideToUtf8(webcamCapture.selectedDeviceName())) << "\"}" << std::endl; + writeSeparateWebcam = !config.webcamOutputPath.empty(); } WasapiLoopbackCapture loopbackCapture; @@ -466,6 +470,24 @@ int main(int argc, char* argv[]) { return 1; } + MFEncoder webcamEncoder; + if (writeSeparateWebcam) { + const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height()); + const int webcamBitrate = webcamPixels >= 1280 * 720 ? 8'000'000 : 4'000'000; + if (!webcamEncoder.initialize( + utf8ToWide(config.webcamOutputPath), + webcamCapture.width(), + webcamCapture.height(), + webcamCapture.fps(), + webcamBitrate, + session.device(), + session.context(), + nullptr)) { + std::cerr << "ERROR: Failed to initialize native webcam encoder" << std::endl; + return 1; + } + } + std::mutex mutex; std::condition_variable cv; std::atomic stopRequested = false; @@ -477,6 +499,7 @@ int main(int argc, char* argv[]) { std::vector latestWebcamFrame; int latestWebcamWidth = 0; int latestWebcamHeight = 0; + uint64_t latestWebcamSequence = 0; bool hasVisibleWebcamFrame = false; session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { @@ -509,20 +532,22 @@ int main(int argc, char* argv[]) { auto writeVideoFrames = [&]() { const auto startedAt = std::chrono::steady_clock::now(); uint64_t frameIndex = 0; + uint64_t lastWrittenWebcamSequence = 0; + uint64_t webcamOutputFrameIndex = 0; int64_t lastEncodedVideoTimestampHns = -1; while (!stopRequested && !encodeFailed) { { std::scoped_lock lock(mutex); if (webcamActive) { - std::vector candidateWebcamFrame; - int candidateWebcamWidth = 0; - int candidateWebcamHeight = 0; - if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) && - hasVisibleBgraContent(candidateWebcamFrame)) { - latestWebcamFrame = std::move(candidateWebcamFrame); - latestWebcamWidth = candidateWebcamWidth; - latestWebcamHeight = candidateWebcamHeight; + WebcamFrameSnapshot candidateWebcamFrame; + if (webcamCapture.copyLatestFrame(candidateWebcamFrame) && + candidateWebcamFrame.sequence != latestWebcamSequence && + hasVisibleBgraContent(candidateWebcamFrame.data)) { + latestWebcamFrame = std::move(candidateWebcamFrame.data); + latestWebcamWidth = candidateWebcamFrame.width; + latestWebcamHeight = candidateWebcamFrame.height; + latestWebcamSequence = candidateWebcamFrame.sequence; hasVisibleWebcamFrame = true; } } @@ -545,10 +570,23 @@ int main(int argc, char* argv[]) { frameTimestampHns = lastEncodedVideoTimestampHns + static_cast(10'000'000ULL / config.fps); } + if (writeSeparateWebcam && webcamFrame.data && + latestWebcamSequence != lastWrittenWebcamSequence) { + const int64_t webcamTimestampHns = static_cast( + (webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps())); + if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) { + encodeFailed = true; + stopRequested = true; + cv.notify_all(); + return; + } + lastWrittenWebcamSequence = latestWebcamSequence; + webcamOutputFrameIndex += 1; + } if (latestFrameTexture && !encoder.writeFrame( latestFrameTexture.Get(), frameTimestampHns, - webcamFrame.data ? &webcamFrame : nullptr)) { + !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) { encodeFailed = true; stopRequested = true; cv.notify_all(); @@ -659,14 +697,13 @@ int main(int argc, char* argv[]) { webcamActive = true; const auto webcamDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); while (std::chrono::steady_clock::now() < webcamDeadline && !hasVisibleWebcamFrame) { - std::vector candidateWebcamFrame; - int candidateWebcamWidth = 0; - int candidateWebcamHeight = 0; - if (webcamCapture.copyLatestFrame(candidateWebcamFrame, candidateWebcamWidth, candidateWebcamHeight) && - hasVisibleBgraContent(candidateWebcamFrame)) { - latestWebcamFrame = std::move(candidateWebcamFrame); - latestWebcamWidth = candidateWebcamWidth; - latestWebcamHeight = candidateWebcamHeight; + WebcamFrameSnapshot candidateWebcamFrame; + if (webcamCapture.copyLatestFrame(candidateWebcamFrame) && + hasVisibleBgraContent(candidateWebcamFrame.data)) { + latestWebcamFrame = std::move(candidateWebcamFrame.data); + latestWebcamWidth = candidateWebcamFrame.width; + latestWebcamHeight = candidateWebcamFrame.height; + latestWebcamSequence = candidateWebcamFrame.sequence; hasVisibleWebcamFrame = true; break; } @@ -740,6 +777,9 @@ int main(int argc, char* argv[]) { { std::scoped_lock lock(mutex); encoder.finalize(); + if (writeSeparateWebcam) { + webcamEncoder.finalize(); + } } if (stdinThread.joinable()) { @@ -752,7 +792,11 @@ int main(int argc, char* argv[]) { } std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\"" - << jsonEscape(config.outputPath) << "\"}" << std::endl; + << jsonEscape(config.outputPath) << "\""; + if (writeSeparateWebcam) { + std::cout << ",\"webcamPath\":\"" << jsonEscape(config.webcamOutputPath) << "\""; + } + std::cout << "}" << std::endl; std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; return 0; } diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index b56386ef..c04353af 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -254,6 +254,40 @@ bool MFEncoder::copyFrameToBuffer( return true; } +bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize) { + if (!frame.data || frame.width <= 0 || frame.height <= 0) { + return false; + } + + const DWORD rowBytes = static_cast(width_ * 4); + const DWORD requiredBytes = rowBytes * static_cast(height_); + if (destinationSize < requiredBytes) { + std::cerr << "ERROR: Media Foundation webcam buffer is too small" << std::endl; + return false; + } + + if (frame.width == width_ && frame.height == height_) { + std::memcpy(destination, frame.data, requiredBytes); + return true; + } + + for (int y = 0; y < height_; y += 1) { + const int sourceY = static_cast((static_cast(y) * frame.height) / height_); + BYTE* destinationRow = destination + rowBytes * y; + for (int x = 0; x < width_; x += 1) { + const int sourceX = static_cast((static_cast(x) * frame.width) / width_); + const BYTE* source = frame.data + (sourceY * frame.width + sourceX) * 4; + BYTE* target = destinationRow + x * 4; + target[0] = source[0]; + target[1] = source[1]; + target[2] = source[2]; + target[3] = 255; + } + } + + return true; +} + bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) { std::scoped_lock writerLock(writerMutex_); if (!sinkWriter_ || finalized_) { @@ -302,6 +336,54 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample"); } +bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } + + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + int64_t sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + } + const int64_t sampleDuration = 10'000'000LL / fps_; + lastTimestampHns_ = sampleTime; + + Microsoft::WRL::ComPtr buffer; + const DWORD frameBytes = static_cast(width_ * height_ * 4); + if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer(webcam)")) { + return false; + } + + BYTE* data = nullptr; + DWORD maxLength = 0; + DWORD currentLength = 0; + if (!succeeded(buffer->Lock(&data, &maxLength, ¤tLength), "IMFMediaBuffer::Lock(webcam)")) { + return false; + } + + const bool copied = copyBgraFrameToBuffer(frame, data, maxLength); + buffer->Unlock(); + if (!copied) { + return false; + } + buffer->SetCurrentLength(frameBytes); + + Microsoft::WRL::ComPtr sample; + if (!succeeded(MFCreateSample(&sample), "MFCreateSample(webcam)")) { + return false; + } + sample->AddBuffer(buffer.Get()); + sample->SetSampleTime(sampleTime); + sample->SetSampleDuration(sampleDuration); + + return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)"); +} + bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { std::scoped_lock writerLock(writerMutex_); if (!sinkWriter_ || finalized_ || !hasAudioStream_) { diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index a82a9402..e7821e91 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -44,6 +44,7 @@ public: ID3D11DeviceContext* context, const AudioInputFormat* audioFormat = nullptr); bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr); + bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns); bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); @@ -54,6 +55,7 @@ private: BYTE* destination, DWORD destinationSize, const BgraFrameView* webcamFrame); + bool copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destination, DWORD destinationSize); bool configureAudioStream(const AudioInputFormat& audioFormat); Microsoft::WRL::ComPtr sinkWriter_; diff --git a/electron/native/wgc-capture/src/webcam_capture.cpp b/electron/native/wgc-capture/src/webcam_capture.cpp index aff9fdbf..783b854a 100644 --- a/electron/native/wgc-capture/src/webcam_capture.cpp +++ b/electron/native/wgc-capture/src/webcam_capture.cpp @@ -365,6 +365,7 @@ void WebcamCapture::captureLoop() { if (currentLength >= expectedLength && expectedLength > 0) { std::scoped_lock lock(frameMutex_); latestFrame_.assign(data, data + expectedLength); + latestFrameSequence_ += 1; } buffer->Unlock(); @@ -373,18 +374,19 @@ void WebcamCapture::captureLoop() { CoUninitialize(); } -bool WebcamCapture::copyLatestFrame(std::vector& destination, int& width, int& height) { +bool WebcamCapture::copyLatestFrame(WebcamFrameSnapshot& destination) { if (usingDirectShow_) { - return directShowCapture_.copyLatestFrame(destination, width, height); + return directShowCapture_.copyLatestFrame(destination); } std::scoped_lock lock(frameMutex_); if (latestFrame_.empty() || width_ <= 0 || height_ <= 0) { return false; } - destination = latestFrame_; - width = width_; - height = height_; + destination.data = latestFrame_; + destination.width = width_; + destination.height = height_; + destination.sequence = latestFrameSequence_; return true; } diff --git a/electron/native/wgc-capture/src/webcam_capture.h b/electron/native/wgc-capture/src/webcam_capture.h index c539d025..5b61aa6b 100644 --- a/electron/native/wgc-capture/src/webcam_capture.h +++ b/electron/native/wgc-capture/src/webcam_capture.h @@ -31,7 +31,7 @@ public: int requestedFps); bool start(); void stop(); - bool copyLatestFrame(std::vector& destination, int& width, int& height); + bool copyLatestFrame(WebcamFrameSnapshot& destination); int width() const; int height() const; @@ -50,6 +50,7 @@ private: std::atomic stopRequested_ = false; std::mutex frameMutex_; std::vector latestFrame_; + uint64_t latestFrameSequence_ = 0; int width_ = 0; int height_ = 0; int fps_ = 30; diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index 53cea19a..5dd2dccf 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -230,6 +230,7 @@ const outputPath = path.join( os.tmpdir(), `openscreen-wgc-helper-${WITH_WEBCAM ? "webcam" : WITH_WINDOW ? "window" : WITH_SYSTEM_AUDIO || WITH_MICROPHONE ? "audio" : "video"}-${process.pid}-${Date.now()}-${randomUUID()}.mp4`, ); +const webcamOutputPath = WITH_WEBCAM ? outputPath.replace(/\.mp4$/i, "-webcam.mp4") : null; const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null; @@ -263,7 +264,10 @@ const config = { webcamWidth: 640, webcamHeight: 360, webcamFps: 30, - outputs: { screenPath: outputPath }, + outputs: { + screenPath: outputPath, + ...(webcamOutputPath ? { webcamPath: webcamOutputPath } : {}), + }, }; let result; @@ -289,8 +293,13 @@ if (result.code !== 0) { if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) { throw new Error(`WGC helper did not produce a video at ${outputPath}`); } +if (WITH_WEBCAM && (!fs.existsSync(webcamOutputPath) || fs.statSync(webcamOutputPath).size === 0)) { + throw new Error(`WGC helper did not produce a webcam video at ${webcamOutputPath}`); +} const streams = probeStreams(outputPath); +const webcamStreams = + webcamOutputPath && fs.existsSync(webcamOutputPath) ? probeStreams(webcamOutputPath) : []; const hasVideo = streams.some((stream) => stream.codec_type === "video"); const hasAudio = streams.some((stream) => stream.codec_type === "audio"); const webcamFormatLine = result.stdout @@ -318,6 +327,9 @@ const nativeMicrophoneDiagnostics = result.stderr if (!hasVideo) { throw new Error(`WGC helper output has no video stream: ${outputPath}`); } +if (WITH_WEBCAM && !webcamStreams.some((stream) => stream.codec_type === "video")) { + throw new Error(`WGC helper webcam output has no video stream: ${webcamOutputPath}`); +} if ( (CAPTURE_CURSOR && !cursorCapture) || (cursorCapture && @@ -342,13 +354,26 @@ console.log( { success: true, outputPath, + webcamOutputPath, bytes: fs.statSync(outputPath).size, + webcamBytes: + webcamOutputPath && fs.existsSync(webcamOutputPath) + ? fs.statSync(webcamOutputPath).size + : undefined, streams: streams.map((stream) => ({ index: stream.index, codecType: stream.codec_type, codecName: stream.codec_name, duration: stream.duration, })), + webcamStreams: webcamStreams.map((stream) => ({ + index: stream.index, + codecType: stream.codec_type, + codecName: stream.codec_name, + width: stream.width, + height: stream.height, + duration: stream.duration, + })), cursorCapture, selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName, selectedWebcamDeviceName: webcamFormat?.deviceName, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 9941dc41..f64eec44 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -84,6 +84,7 @@ type RecorderHandle = { type NativeWindowsRecordingHandle = { recordingId: number; finalizing: boolean; + webcamRecorder: RecorderHandle | null; }; type NativeMacRecordingHandle = { @@ -422,58 +423,105 @@ export function useScreenRecorder(): UseScreenRecorderReturn { [cursorCaptureMode, teardownMedia], ); - const finalizeNativeWindowsRecording = useCallback(async (discard = false) => { - const activeNativeRecording = nativeWindowsRecording.current; - if (!activeNativeRecording || activeNativeRecording.finalizing) { - return false; - } - - activeNativeRecording.finalizing = true; - - const clearNativeRecordingState = () => { - nativeWindowsRecording.current = null; - setRecording(false); - setPaused(false); - setElapsedSeconds(0); - accumulatedDurationMs.current = 0; - segmentStartedAt.current = null; - }; - - try { - const result = await window.electronAPI.stopNativeWindowsRecording(discard); - if (discard || result.discarded) { - clearNativeRecordingState(); - return true; + const finalizeNativeWindowsRecording = useCallback( + async (discard = false) => { + const activeNativeRecording = nativeWindowsRecording.current; + if (!activeNativeRecording || activeNativeRecording.finalizing) { + return false; } - if (!result.success) { - console.error("Failed to stop native Windows recording:", result.error); - toast.error(result.error ?? "Failed to stop native Windows recording"); + + activeNativeRecording.finalizing = true; + const activeWebcamRecorder = activeNativeRecording.webcamRecorder; + const duration = Math.max(0, getRecordingDurationMs()); + if ( + activeWebcamRecorder?.recorder.state === "recording" || + activeWebcamRecorder?.recorder.state === "paused" + ) { + try { + activeWebcamRecorder.recorder.stop(); + } catch { + // Recorder may already be stopping. + } + } + if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) { + webcamRecorder.current = null; + } + + const clearNativeRecordingState = () => { + nativeWindowsRecording.current = null; + setRecording(false); + setPaused(false); + setElapsedSeconds(0); + accumulatedDurationMs.current = 0; + segmentStartedAt.current = null; + }; + + try { + const result = await window.electronAPI.stopNativeWindowsRecording(discard); + if (discard || result.discarded) { + clearNativeRecordingState(); + return true; + } + if (!result.success) { + console.error("Failed to stop native Windows recording:", result.error); + toast.error(result.error ?? "Failed to stop native Windows recording"); + activeNativeRecording.finalizing = false; + return true; + } + + const nativeScreenPath = result.session?.screenVideoPath ?? result.path; + let storedSession = result.session; + if (activeWebcamRecorder && nativeScreenPath) { + const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); + const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath); + if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) { + const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); + const nativeScreenFileName = + nativeScreenPath.split(/[\\/]/).pop() ?? + `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; + const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + const stored = await window.electronAPI.storeRecordedSession({ + screen: { + videoData: screenRead.data, + fileName: nativeScreenFileName, + }, + webcam: { + videoData: await fixedWebcamBlob.arrayBuffer(), + fileName: webcamFileName, + }, + createdAt: activeNativeRecording.recordingId, + cursorCaptureMode, + }); + if (stored.success && stored.session) { + storedSession = stored.session; + } + } + } + + clearNativeRecordingState(); + if (storedSession) { + await window.electronAPI.setCurrentRecordingSession(storedSession); + } else if (result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + } + + await window.electronAPI.switchToEditor(); + return true; + } catch (error) { + console.error("Error saving native Windows recording:", error); + toast.error( + error instanceof Error ? error.message : "Failed to save native Windows recording", + ); activeNativeRecording.finalizing = false; return true; + } finally { + if (discardRecordingId.current === activeNativeRecording.recordingId) { + discardRecordingId.current = null; + } } - - clearNativeRecordingState(); - if (result.session) { - await window.electronAPI.setCurrentRecordingSession(result.session); - } else if (result.path) { - await window.electronAPI.setCurrentVideoPath(result.path); - } - - await window.electronAPI.switchToEditor(); - return true; - } catch (error) { - console.error("Error saving native Windows recording:", error); - toast.error( - error instanceof Error ? error.message : "Failed to save native Windows recording", - ); - activeNativeRecording.finalizing = false; - return true; - } finally { - if (discardRecordingId.current === activeNativeRecording.recordingId) { - discardRecordingId.current = null; - } - } - }, []); + }, + [cursorCaptureMode, getRecordingDurationMs], + ); const finalizeNativeMacRecording = useCallback( async (discard = false) => { @@ -747,7 +795,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const displayId = Number(selectedSource.display_id); const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display"; const windowHandle = parseWindowHandleFromSourceId(selectedSource.id); - if (webcamEnabled) { + const browserWebcamRecorder = + webcamEnabled && webcamStream.current + ? createRecorderHandle(webcamStream.current, { + mimeType: selectMimeType(), + videoBitsPerSecond: BITRATE_BASE, + }) + : null; + if (webcamEnabled && !browserWebcamRecorder) { stopWebcamPreviewStream(); } const request: NativeWindowsRecordingRequest = { @@ -775,7 +830,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, }, webcam: { - enabled: webcamEnabled, + enabled: webcamEnabled && !browserWebcamRecorder, deviceId: webcamDeviceId, deviceName: webcamDeviceName, width: WEBCAM_TARGET_WIDTH, @@ -788,6 +843,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const result = await window.electronAPI.startNativeWindowsRecording(request); if (!result.success || !result.recordingId) { + if ( + browserWebcamRecorder?.recorder.state === "recording" || + browserWebcamRecorder?.recorder.state === "paused" + ) { + browserWebcamRecorder.recorder.stop(); + } throw new Error(result.error ?? "Native Windows capture failed."); } @@ -795,7 +856,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { nativeWindowsRecording.current = { recordingId: result.recordingId, finalizing: false, + webcamRecorder: browserWebcamRecorder, }; + webcamRecorder.current = browserWebcamRecorder; accumulatedDurationMs.current = 0; segmentStartedAt.current = Date.now(); allowAutoFinalize.current = true; diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 0d2402f3..7c0d2a66 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -11,9 +11,9 @@ import type { import { BackgroundLoadError } from "@/lib/wallpaper"; import type { CursorRecordingData } from "@/native/contracts"; import { getPlatform } from "@/utils/platformUtils"; -import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { FrameRenderer } from "./frameRenderer"; import { StreamingVideoDecoder } from "./streamingDecoder"; +import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue"; import type { ExportProgress, ExportResult, @@ -124,7 +124,7 @@ export class GifExporter { } async export(): Promise { - let webcamFrameQueue: AsyncVideoFrameQueue | null = null; + let webcamFrameQueue: TimestampedVideoFrameQueue | null = null; const warnings: string[] = []; const onWarning = (message: string) => warnings.push(message); @@ -216,7 +216,7 @@ export class GifExporter { console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)"); let frameIndex = 0; - webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null; + webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null; let stopWebcamDecode = false; let webcamDecodeError: Error | null = null; const webcamDecodePromise = @@ -228,7 +228,7 @@ export class GifExporter { this.config.frameRate, this.config.trimRegions, this.config.speedRegions, - async (webcamFrame) => { + async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => { while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) { await new Promise((resolve) => setTimeout(resolve, 2)); } @@ -236,7 +236,7 @@ export class GifExporter { webcamFrame.close(); return; } - queue.enqueue(webcamFrame); + queue.enqueue(webcamFrame, webcamSourceTimestampMs); }, onWarning, ) @@ -266,7 +266,9 @@ export class GifExporter { return; } - webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null; + webcamFrame = webcamFrameQueue + ? await webcamFrameQueue.frameAt(sourceTimestampMs) + : null; const renderer = this.renderer; if (this.cancelled || !renderer) { return; diff --git a/src/lib/exporter/timestampedVideoFrameQueue.test.ts b/src/lib/exporter/timestampedVideoFrameQueue.test.ts new file mode 100644 index 00000000..24d625de --- /dev/null +++ b/src/lib/exporter/timestampedVideoFrameQueue.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue"; + +class MockVideoFrame { + timestamp: number; + closed = false; + + constructor(source: MockVideoFrame | number) { + this.timestamp = typeof source === "number" ? source : source.timestamp; + } + + close() { + this.closed = true; + } +} + +describe("TimestampedVideoFrameQueue", () => { + it("samples the latest webcam frame at or before the requested source timestamp", async () => { + const originalVideoFrame = globalThis.VideoFrame; + vi.stubGlobal("VideoFrame", MockVideoFrame); + try { + const queue = new TimestampedVideoFrameQueue(); + const frame0 = new MockVideoFrame(0) as unknown as VideoFrame; + const frame33 = new MockVideoFrame(33_000) as unknown as VideoFrame; + const frame66 = new MockVideoFrame(66_000) as unknown as VideoFrame; + + queue.enqueue(frame0, 0); + queue.enqueue(frame33, 33); + queue.enqueue(frame66, 66); + + const sampled0 = await queue.frameAt(0); + const sampled20 = await queue.frameAt(20); + const sampled40 = await queue.frameAt(40); + const sampled80 = await queue.frameAt(80); + + expect(sampled0?.timestamp).toBe(0); + expect(sampled20?.timestamp).toBe(0); + expect(sampled40?.timestamp).toBe(33_000); + expect(sampled80?.timestamp).toBe(66_000); + + sampled0?.close(); + sampled20?.close(); + sampled40?.close(); + sampled80?.close(); + queue.destroy(); + } finally { + vi.stubGlobal("VideoFrame", originalVideoFrame); + } + }); +}); diff --git a/src/lib/exporter/timestampedVideoFrameQueue.ts b/src/lib/exporter/timestampedVideoFrameQueue.ts new file mode 100644 index 00000000..336b053b --- /dev/null +++ b/src/lib/exporter/timestampedVideoFrameQueue.ts @@ -0,0 +1,105 @@ +type TimestampedVideoFrame = { + frame: VideoFrame; + sourceTimestampMs: number; +}; + +type PendingConsumer = { + resolve: () => void; + reject: (error: Error) => void; +}; + +const TIMESTAMP_EPSILON_MS = 0.5; + +export class TimestampedVideoFrameQueue { + private frames: TimestampedVideoFrame[] = []; + private consumers: PendingConsumer[] = []; + private error: Error | null = null; + private closed = false; + private heldFrame: TimestampedVideoFrame | null = null; + + get length() { + return this.frames.length; + } + + enqueue(frame: VideoFrame, sourceTimestampMs: number) { + if (this.closed) { + frame.close(); + return; + } + + this.frames.push({ frame, sourceTimestampMs }); + const consumers = this.consumers.splice(0); + for (const consumer of consumers) { + consumer.resolve(); + } + } + + fail(error: Error) { + this.error = error; + this.closed = true; + const consumers = this.consumers.splice(0); + for (const consumer of consumers) { + consumer.reject(error); + } + this.closeOwnedFrames(); + } + + close() { + this.closed = true; + const consumers = this.consumers.splice(0); + for (const consumer of consumers) { + consumer.resolve(); + } + } + + async frameAt(sourceTimestampMs: number): Promise { + for (;;) { + if (this.error) { + throw this.error; + } + + const next = this.frames[0] ?? null; + if (next && next.sourceTimestampMs <= sourceTimestampMs + TIMESTAMP_EPSILON_MS) { + this.replaceHeldFrame(this.frames.shift() ?? null); + continue; + } + + if (this.heldFrame) { + return new VideoFrame(this.heldFrame.frame, { + timestamp: this.heldFrame.frame.timestamp, + }); + } + + if (next || this.closed) { + return null; + } + + await new Promise((resolve, reject) => { + this.consumers.push({ resolve, reject }); + }); + } + } + + destroy() { + this.close(); + this.closeOwnedFrames(); + } + + private replaceHeldFrame(frame: TimestampedVideoFrame | null) { + if (this.heldFrame) { + this.heldFrame.frame.close(); + } + this.heldFrame = frame; + } + + private closeOwnedFrames() { + if (this.heldFrame) { + this.heldFrame.frame.close(); + this.heldFrame = null; + } + for (const item of this.frames) { + item.frame.close(); + } + this.frames = []; + } +} diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 346953b4..35c3d559 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -10,11 +10,11 @@ import type { import { BackgroundLoadError } from "@/lib/wallpaper"; import type { CursorRecordingData } from "@/native/contracts"; import { getPlatform } from "@/utils/platformUtils"; -import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue"; import { AudioProcessor } from "./audioEncoder"; import { FrameRenderer } from "./frameRenderer"; import { VideoMuxer } from "./muxer"; import { StreamingVideoDecoder } from "./streamingDecoder"; +import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue"; import type { ExportConfig, ExportProgress, ExportResult } from "./types"; const ENCODER_STALL_TIMEOUT_MS = 15_000; @@ -195,7 +195,7 @@ export class VideoExporter { private async exportWithEncoderPreference( encoderPreference: HardwareAcceleration, ): Promise { - let webcamFrameQueue: AsyncVideoFrameQueue | null = null; + let webcamFrameQueue: TimestampedVideoFrameQueue | null = null; let stopWebcamDecode = false; let webcamDecodeError: Error | null = null; let webcamDecodePromise: Promise | null = null; @@ -290,7 +290,7 @@ export class VideoExporter { ? Math.min(this.MAX_ENCODE_QUEUE, 32) : this.MAX_ENCODE_QUEUE; - webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null; + webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null; webcamDecodePromise = webcamDecoder && webcamFrameQueue ? (() => { @@ -300,7 +300,7 @@ export class VideoExporter { this.config.frameRate, this.config.trimRegions, this.config.speedRegions, - async (webcamFrame) => { + async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => { while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) { await new Promise((resolve) => setTimeout(resolve, 2)); } @@ -308,7 +308,7 @@ export class VideoExporter { webcamFrame.close(); return; } - queue.enqueue(webcamFrame); + queue.enqueue(webcamFrame, webcamSourceTimestampMs); }, onWarning, ) @@ -342,7 +342,9 @@ export class VideoExporter { } const timestamp = frameIndex * frameDuration; - webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null; + webcamFrame = webcamFrameQueue + ? await webcamFrameQueue.frameAt(sourceTimestampMs) + : null; if (this.cancelled) { return; } From ca826d90880685bf08a30bba4021f7f3e925b3d0 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 22 May 2026 09:53:22 +0200 Subject: [PATCH 47/94] Fix native Windows recording pause --- electron/electron-env.d.ts | 8 ++ electron/ipc/handlers.ts | 75 +++++++++++ .../wgc-capture/src/audio_sample_utils.cpp | 23 +++- .../wgc-capture/src/audio_sample_utils.h | 2 + electron/native/wgc-capture/src/main.cpp | 121 +++++++++++++----- electron/preload.ts | 6 + scripts/build-windows-wgc-helper.mjs | 21 ++- src/hooks/useScreenRecorder.ts | 39 +++++- 8 files changed, 257 insertions(+), 38 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index abb688d1..1b829922 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -117,6 +117,14 @@ interface Window { discarded?: boolean; error?: string; }>; + pauseNativeWindowsRecording: () => Promise<{ + success: boolean; + error?: string; + }>; + resumeNativeWindowsRecording: () => Promise<{ + success: boolean; + error?: string; + }>; startNativeMacRecording: ( request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest, ) => Promise; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 009ade60..669f1dd7 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -384,6 +384,10 @@ let nativeWindowsCaptureWebcamTargetPath: string | null = null; let nativeWindowsCaptureRecordingId: number | null = null; let nativeWindowsCursorOffsetMs = 0; let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay"; +let nativeWindowsCursorRecordingStartMs = 0; +let nativeWindowsPauseStartedAtMs: number | null = null; +let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = []; +let nativeWindowsIsPaused = false; const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000; let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null; let nativeMacCaptureOutput = ""; @@ -873,6 +877,18 @@ function completeNativeMacCursorPauseRange(endMs = Date.now()) { nativeMacPauseStartedAtMs = null; } +function completeNativeWindowsCursorPauseRange(endMs = Date.now()) { + if (nativeWindowsPauseStartedAtMs === null || nativeWindowsCursorRecordingStartMs <= 0) { + return; + } + + nativeWindowsPauseRanges.push({ + startMs: Math.max(0, nativeWindowsPauseStartedAtMs - nativeWindowsCursorRecordingStartMs), + endMs: Math.max(0, endMs - nativeWindowsCursorRecordingStartMs), + }); + nativeWindowsPauseStartedAtMs = null; +} + function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1583,9 +1599,14 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = recordingId; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = cursorCaptureMode; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { + nativeWindowsCursorRecordingStartMs = cursorStartTimeMs; await startCursorRecording(cursorStartTimeMs); console.info("[native-wgc] cursor sampler ready", { cursorStartTimeMs, @@ -1635,6 +1656,10 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; await stopCursorRecording(); return { success: false, error: String(error) }; } @@ -1836,6 +1861,50 @@ export function registerIpcHandlers( } }); + ipcMain.handle("pause-native-windows-recording", async () => { + const proc = nativeWindowsCaptureProcess; + if (!proc) { + return { success: false, error: "Native Windows capture is not running." }; + } + if (nativeWindowsIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native Windows capture command channel is closed." }; + } + + try { + proc.stdin.write("pause\n"); + nativeWindowsIsPaused = true; + nativeWindowsPauseStartedAtMs = Date.now(); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle("resume-native-windows-recording", async () => { + const proc = nativeWindowsCaptureProcess; + if (!proc) { + return { success: false, error: "Native Windows capture is not running." }; + } + if (!nativeWindowsIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native Windows capture command channel is closed." }; + } + + try { + proc.stdin.write("resume\n"); + completeNativeWindowsCursorPauseRange(); + nativeWindowsIsPaused = false; + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => { const proc = nativeWindowsCaptureProcess; const preferredPath = nativeWindowsCaptureTargetPath; @@ -1848,6 +1917,7 @@ export function registerIpcHandlers( } try { + completeNativeWindowsCursorPauseRange(); const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc); proc.stdin.write("stop\n"); const stoppedPath = await stoppedPathPromise; @@ -1872,6 +1942,7 @@ export function registerIpcHandlers( } if (cursorCaptureMode === "editable-overlay") { + compactPendingCursorTelemetryPauseRanges(nativeWindowsPauseRanges); shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs); await writePendingCursorTelemetry(screenVideoPath); } @@ -1913,6 +1984,10 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; const source = selectedSource || { name: "Screen" }; if (onRecordingStateChange) { onRecordingStateChange(false, source.name); diff --git a/electron/native/wgc-capture/src/audio_sample_utils.cpp b/electron/native/wgc-capture/src/audio_sample_utils.cpp index c816fead..6b503250 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils.cpp @@ -279,6 +279,7 @@ bool AudioMixer::start() { stopRequested_ = false; emittedFrames_ = 0; timelineStarted_ = false; + paused_ = false; thread_ = std::thread([this] { mixLoop(); }); @@ -296,6 +297,18 @@ void AudioMixer::beginTimeline() { cv_.notify_all(); } +void AudioMixer::setPaused(bool paused) { + { + std::scoped_lock lock(mutex_); + paused_ = paused; + if (paused_) { + systemQueue_.clear(); + microphoneQueue_.clear(); + } + } + cv_.notify_all(); +} + void AudioMixer::stop() { stopRequested_ = true; cv_.notify_all(); @@ -311,6 +324,9 @@ void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) { { std::scoped_lock lock(mutex_); + if (paused_) { + return; + } append(systemQueue_, data, byteCount, systemFormat_, 1.0); } cv_.notify_all(); @@ -323,6 +339,9 @@ void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) { { std::scoped_lock lock(mutex_); + if (paused_) { + return; + } append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_); } cv_.notify_all(); @@ -371,13 +390,13 @@ void AudioMixer::mixLoop() { const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes; const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty(); return stopRequested_.load() || - (timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource); + (timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource); }); if (stopRequested_) { break; } - if (!timelineStarted_) { + if (!timelineStarted_ || paused_) { continue; } diff --git a/electron/native/wgc-capture/src/audio_sample_utils.h b/electron/native/wgc-capture/src/audio_sample_utils.h index 4e61252c..0bdbc080 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.h +++ b/electron/native/wgc-capture/src/audio_sample_utils.h @@ -52,6 +52,7 @@ public: bool start(); void beginTimeline(); + void setPaused(bool paused); void stop(); void pushSystem(const BYTE* data, DWORD byteCount); void pushMicrophone(const BYTE* data, DWORD byteCount); @@ -81,5 +82,6 @@ private: std::thread thread_; std::atomic stopRequested_ = false; bool timelineStarted_ = false; + bool paused_ = false; uint64_t emittedFrames_ = 0; }; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index f8f56cd0..24bab84d 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -49,6 +50,37 @@ struct CaptureConfig { int webcamFps = 0; }; +struct CaptureControl { + std::atomic stopRequested = false; + std::atomic paused = false; + std::mutex mutex; + std::condition_variable cv; + std::chrono::steady_clock::time_point pauseStartedAt; + std::chrono::steady_clock::duration totalPausedDuration{}; + + int64_t pausedDurationHns() { + std::scoped_lock lock(mutex); + auto total = totalPausedDuration; + if (paused.load()) { + total += std::chrono::steady_clock::now() - pauseStartedAt; + } + return std::chrono::duration_cast(total).count() / 100; + } + + void setPaused(bool nextPaused) { + std::scoped_lock lock(mutex); + if (nextPaused == paused.load()) { + return; + } + if (nextPaused) { + pauseStartedAt = std::chrono::steady_clock::now(); + } else { + totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt; + } + paused = nextPaused; + } +}; + std::wstring utf8ToWide(const std::string& value) { if (value.empty()) { return {}; @@ -317,17 +349,31 @@ bool parseConfig(const std::string& json, CaptureConfig& config) { return true; } -void readStopCommands(std::atomic& stopRequested, std::condition_variable& cv) { +void readCaptureCommands(CaptureControl& control, const std::function& onPauseChanged) { std::string line; while (std::getline(std::cin, line)) { if (line == "stop" || line == "q" || line == "quit") { - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } + if (line == "pause") { + control.setPaused(true); + onPauseChanged(true); + std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl; + control.cv.notify_all(); + continue; + } + if (line == "resume") { + control.setPaused(false); + onPauseChanged(false); + std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl; + control.cv.notify_all(); + continue; + } } - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); } } // namespace @@ -467,8 +513,7 @@ int main(int argc, char* argv[]) { } std::mutex mutex; - std::condition_variable cv; - std::atomic stopRequested = false; + CaptureControl control; std::atomic firstFrameWritten = false; std::atomic encodeFailed = false; Microsoft::WRL::ComPtr latestFrameTexture; @@ -480,7 +525,7 @@ int main(int argc, char* argv[]) { bool hasVisibleWebcamFrame = false; session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { - if (stopRequested) { + if (control.stopRequested || control.paused) { return; } @@ -493,8 +538,8 @@ int main(int argc, char* argv[]) { desc.MiscFlags = 0; if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } } @@ -502,18 +547,27 @@ int main(int argc, char* argv[]) { session.context()->CopyResource(latestFrameTexture.Get(), texture); latestFrameTimestampHns = timestampHns; if (!firstFrameWritten.exchange(true)) { - cv.notify_all(); + control.cv.notify_all(); } }); auto writeVideoFrames = [&]() { - const auto startedAt = std::chrono::steady_clock::now(); + const auto frameDuration = std::chrono::duration_cast( + std::chrono::duration(1.0 / config.fps)); uint64_t frameIndex = 0; int64_t lastEncodedVideoTimestampHns = -1; - while (!stopRequested && !encodeFailed) { + while (!control.stopRequested && !encodeFailed) { { - std::scoped_lock lock(mutex); + std::unique_lock lock(mutex); + control.cv.wait(lock, [&] { + return control.stopRequested.load() || + encodeFailed.load() || + (!control.paused.load() && latestFrameTexture); + }); + if (control.stopRequested || encodeFailed) { + break; + } if (webcamActive) { std::vector candidateWebcamFrame; int candidateWebcamWidth = 0; @@ -539,7 +593,9 @@ int main(int argc, char* argv[]) { firstFrameTimestampHns = sourceTimestampHns; } int64_t frameTimestampHns = - std::max(0, sourceTimestampHns - firstFrameTimestampHns); + std::max( + 0, + sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns()); if (lastEncodedVideoTimestampHns >= 0 && frameTimestampHns <= lastEncodedVideoTimestampHns) { frameTimestampHns = @@ -550,8 +606,8 @@ int main(int argc, char* argv[]) { frameTimestampHns, webcamFrame.data ? &webcamFrame : nullptr)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } if (latestFrameTexture) { @@ -560,10 +616,7 @@ int main(int argc, char* argv[]) { } frameIndex += 1; - const auto nextDeadline = startedAt + - std::chrono::duration_cast( - std::chrono::duration(static_cast(frameIndex) / config.fps)); - std::this_thread::sleep_until(nextDeadline); + std::this_thread::sleep_for(frameDuration); } }; @@ -595,8 +648,8 @@ int main(int argc, char* argv[]) { [&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return false; } return true; @@ -611,7 +664,7 @@ int main(int argc, char* argv[]) { if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { (void)timestampHns; (void)durationHns; - if (stopRequested || !audioMixer) { + if (control.stopRequested || !audioMixer) { return; } @@ -627,7 +680,7 @@ int main(int argc, char* argv[]) { if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { (void)timestampHns; (void)durationHns; - if (stopRequested || !audioMixer) { + if (control.stopRequested || !audioMixer) { return; } @@ -689,16 +742,20 @@ int main(int argc, char* argv[]) { return 1; } - std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv)); + std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) { + if (audioMixer) { + audioMixer->setPaused(isPaused); + } + }); { std::unique_lock lock(mutex); - const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] { - return firstFrameWritten.load() || stopRequested.load(); + const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] { + return firstFrameWritten.load() || control.stopRequested.load(); }); if (!started || !firstFrameWritten) { - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); if (stdinThread.joinable()) { stdinThread.detach(); } @@ -724,8 +781,8 @@ int main(int argc, char* argv[]) { { std::unique_lock lock(mutex); - cv.wait(lock, [&] { - return stopRequested.load(); + control.cv.wait(lock, [&] { + return control.stopRequested.load(); }); } diff --git a/electron/preload.ts b/electron/preload.ts index 361eb18d..933ce9dc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -87,6 +87,12 @@ contextBridge.exposeInMainWorld("electronAPI", { stopNativeWindowsRecording: (discard?: boolean) => { return ipcRenderer.invoke("stop-native-windows-recording", discard); }, + pauseNativeWindowsRecording: () => { + return ipcRenderer.invoke("pause-native-windows-recording"); + }, + resumeNativeWindowsRecording: () => { + return ipcRenderer.invoke("resume-native-windows-recording"); + }, startNativeMacRecording: (request: NativeMacRecordingRequest) => { return ipcRenderer.invoke("start-native-mac-recording", request); }, diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 46a1f054..29df4d84 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -37,6 +37,21 @@ function findVcVarsAll() { return null; } +function findWindowsSdkUmLibDir() { + const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib"; + if (!fs.existsSync(sdkLibRoot)) { + return null; + } + + return fs + .readdirSync(sdkLibRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64")) + .filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib"))) + .sort() + .at(-1); +} + function run(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -64,6 +79,8 @@ async function runInVsEnv(command) { ); } + const sdkUmLibDir = findWindowsSdkUmLibDir(); + const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`); fs.writeFileSync( cmdPath, @@ -72,9 +89,9 @@ async function runInVsEnv(command) { `call "${vcvarsAll}" x64`, "if errorlevel 1 exit /b %errorlevel%", `if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`, - `for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, + `for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, "if errorlevel 1 exit /b %errorlevel%", - `set "LIB=${COMPAT_LIB_DIR};%LIB%"`, + `set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`, command, "exit /b %errorlevel%", "", diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 9941dc41..7abed3ac 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -84,6 +84,7 @@ type RecorderHandle = { type NativeWindowsRecordingHandle = { recordingId: number; finalizing: boolean; + paused: boolean; }; type NativeMacRecordingHandle = { @@ -149,9 +150,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const webcamAcquireId = useRef(0); const canPauseRecording = recording && - !nativeWindowsRecording.current && Boolean( - (nativeMacRecording.current && !nativeMacRecording.current.finalizing) || + (nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) || + (nativeMacRecording.current && !nativeMacRecording.current.finalizing) || (screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"), ); @@ -795,6 +796,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { nativeWindowsRecording.current = { recordingId: result.recordingId, finalizing: false, + paused: false, }; accumulatedDurationMs.current = 0; segmentStartedAt.current = Date.now(); @@ -1321,6 +1323,39 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const togglePaused = () => { + const activeNativeWindowsRecording = nativeWindowsRecording.current; + if (activeNativeWindowsRecording && !activeNativeWindowsRecording.finalizing) { + void (async () => { + try { + if (activeNativeWindowsRecording.paused) { + const result = await window.electronAPI.resumeNativeWindowsRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to resume native Windows recording"); + } + activeNativeWindowsRecording.paused = false; + segmentStartedAt.current = Date.now(); + setPaused(false); + return; + } + + const pausedAtMs = getRecordingDurationMs(); + const result = await window.electronAPI.pauseNativeWindowsRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to pause native Windows recording"); + } + activeNativeWindowsRecording.paused = true; + accumulatedDurationMs.current = pausedAtMs; + segmentStartedAt.current = null; + setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); + setPaused(true); + } catch (error) { + console.error("Failed to toggle native Windows pause state:", error); + toast.error(error instanceof Error ? error.message : "Failed to toggle pause state"); + } + })(); + return; + } + const activeNativeMacRecording = nativeMacRecording.current; if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) { void (async () => { From b36a32d44be103fd5c822dd3c97e08c25ddce81a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 18 May 2026 14:45:33 +0200 Subject: [PATCH 48/94] refactor: centralize editor defaults --- src/components/video-editor/CropControl.tsx | 5 +- src/components/video-editor/SettingsPanel.tsx | 45 +++++---- src/components/video-editor/VideoEditor.tsx | 75 +++++++++------ src/components/video-editor/VideoPlayback.tsx | 25 ++--- .../video-editor/editorDefaults.test.ts | 55 +++++++++++ src/components/video-editor/editorDefaults.ts | 95 +++++++++++++++++++ .../video-editor/projectPersistence.ts | 72 ++++++++------ src/components/video-editor/types.ts | 1 + src/hooks/useEditorHistory.ts | 36 ++++--- src/lib/userPreferences.ts | 14 ++- 10 files changed, 307 insertions(+), 116 deletions(-) create mode 100644 src/components/video-editor/editorDefaults.test.ts create mode 100644 src/components/video-editor/editorDefaults.ts diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 07e769d6..faedf1cf 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { DEFAULT_SOURCE_DIMENSIONS } from "./editorDefaults"; interface CropRegion { x: number; // 0-1 normalized @@ -32,8 +33,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont const ctx = canvas.getContext("2d", { alpha: false }); if (!ctx) return; - canvas.width = videoElement.videoWidth || 1920; - canvas.height = videoElement.videoHeight || 1080; + canvas.width = videoElement.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; + canvas.height = videoElement.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const draw = () => { if (videoElement.readyState >= 2) { diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b0b46dfd..b69350a4 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,6 +53,14 @@ import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; +import { + DEFAULT_CURSOR_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_SOURCE_DIMENSIONS, + DEFAULT_WEBCAM_SETTINGS, +} from "./editorDefaults"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, @@ -70,7 +78,6 @@ import type { ZoomFocusMode, } from "./types"; import { - DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, MAX_ZOOM_SCALE, MIN_ZOOM_SCALE, @@ -382,24 +389,24 @@ export function SettingsPanel({ borderRadius = 0, onBorderRadiusChange, onBorderRadiusCommit, - padding = 50, + padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, onPaddingChange, onPaddingCommit, cropRegion, onCropChange, aspectRatio, videoElement, - exportQuality = "good", + exportQuality = DEFAULT_EXPORT_SETTINGS.quality, onExportQualityChange, - exportFormat = "mp4", + exportFormat = DEFAULT_EXPORT_SETTINGS.format, onExportFormatChange, - gifFrameRate = 15, + gifFrameRate = DEFAULT_GIF_SETTINGS.frameRate, onGifFrameRateChange, - gifLoop = true, + gifLoop = DEFAULT_GIF_SETTINGS.loop, onGifLoopChange, - gifSizePreset = "medium", + gifSizePreset = DEFAULT_GIF_SETTINGS.sizePreset, onGifSizePresetChange, - gifOutputDimensions = { width: 1280, height: 720 }, + gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions, onExport, unsavedExport, onSaveUnsavedExport, @@ -421,25 +428,25 @@ export function SettingsPanel({ onSpeedChange, onSpeedDelete, hasWebcam = false, - webcamLayoutPreset = "picture-in-picture", + webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset, onWebcamLayoutPresetChange, - webcamMaskShape = "rectangle", + webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape, onWebcamMaskShapeChange, - webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET, + webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset, onWebcamSizePresetChange, onWebcamSizePresetCommit, onSaveDiagnostic, - showCursor = true, + showCursor = DEFAULT_CURSOR_SETTINGS.show, onShowCursorChange, - cursorSize = 3.0, + cursorSize = DEFAULT_CURSOR_SETTINGS.size, onCursorSizeChange, - cursorSmoothing = 0.67, + cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing, onCursorSmoothingChange, - cursorMotionBlur = 0.35, + cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur, onCursorMotionBlurChange, - cursorClickBounce = 2.5, + cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce, onCursorClickBounceChange, - cursorClipToBounds = false, + cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds, onCursorClipToBoundsChange, hasCursorData = false, showCursorSettings = true, @@ -478,8 +485,8 @@ export function SettingsPanel({ const [cropAspectRatio, setCropAspectRatio] = useState(""); const isPortraitCanvas = isPortraitAspectRatio(aspectRatio); - const videoWidth = videoElement?.videoWidth || 1920; - const videoHeight = videoElement?.videoHeight || 1080; + const videoWidth = videoElement?.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; + const videoHeight = videoElement?.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const handleCropNumericChange = useCallback( (field: "x" | "y" | "width" | "height", pixelValue: number) => { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ce6314f1..b44d9b63 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -49,6 +49,12 @@ import { isPortraitAspectRatio, } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; +import { + DEFAULT_CURSOR_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_SOURCE_DIMENSIONS, +} from "./editorDefaults"; import PlaybackControls from "./PlaybackControls"; import { createProjectData, @@ -71,11 +77,6 @@ import { DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_BLUR_DATA, - DEFAULT_CURSOR_CLICK_BOUNCE, - DEFAULT_CURSOR_CLIP_TO_BOUNDS, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, @@ -203,11 +204,15 @@ export default function VideoEditor() { const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false); - const [exportQuality, setExportQuality] = useState("good"); - const [exportFormat, setExportFormat] = useState("mp4"); - const [gifFrameRate, setGifFrameRate] = useState(15); - const [gifLoop, setGifLoop] = useState(true); - const [gifSizePreset, setGifSizePreset] = useState("medium"); + const [exportQuality, setExportQuality] = useState( + DEFAULT_EXPORT_SETTINGS.quality, + ); + const [exportFormat, setExportFormat] = useState(DEFAULT_EXPORT_SETTINGS.format); + const [gifFrameRate, setGifFrameRate] = useState(DEFAULT_GIF_SETTINGS.frameRate); + const [gifLoop, setGifLoop] = useState(DEFAULT_GIF_SETTINGS.loop); + const [gifSizePreset, setGifSizePreset] = useState( + DEFAULT_GIF_SETTINGS.sizePreset, + ); const [exportedFilePath, setExportedFilePath] = useState(null); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); const [unsavedExport, setUnsavedExport] = useState<{ @@ -238,12 +243,14 @@ export default function VideoEditor() { }, [cursorRecordingData, cursorTelemetry]); // Cursor & motion blur visual settings (non-undoable preferences) - const [showCursor, setShowCursor] = useState(true); - const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE); - const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING); - const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR); - const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE); - const [cursorClipToBounds, setCursorClipToBounds] = useState(DEFAULT_CURSOR_CLIP_TO_BOUNDS); + const [showCursor, setShowCursor] = useState(DEFAULT_CURSOR_SETTINGS.show); + const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SETTINGS.size); + const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SETTINGS.smoothing); + const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_SETTINGS.motionBlur); + const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_SETTINGS.clickBounce); + const [cursorClipToBounds, setCursorClipToBounds] = useState( + DEFAULT_CURSOR_SETTINGS.clipToBounds, + ); const [nativePlatform, setNativePlatform] = useState(null); const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] = useState(null); @@ -1575,8 +1582,8 @@ export default function VideoEditor() { videoPlaybackRef.current?.pause(); } - const sourceWidth = video.videoWidth || 1920; - const sourceHeight = video.videoHeight || 1080; + const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; + const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const effectiveSourceDimensions = calculateEffectiveSourceDimensions( sourceWidth, sourceHeight, @@ -1590,8 +1597,8 @@ export default function VideoEditor() { // Get preview CONTAINER dimensions for scaling const playbackRef = videoPlaybackRef.current; const containerElement = playbackRef?.containerRef?.current; - const previewWidth = containerElement?.clientWidth || 1920; - const previewHeight = containerElement?.clientHeight || 1080; + const previewWidth = containerElement?.clientWidth || DEFAULT_SOURCE_DIMENSIONS.width; + const previewHeight = containerElement?.clientHeight || DEFAULT_SOURCE_DIMENSIONS.height; if (settings.format === "gif" && settings.gifConfig) { // GIF Export @@ -1845,8 +1852,8 @@ export default function VideoEditor() { } // Build export settings from current state - const sourceWidth = video.videoWidth || 1920; - const sourceHeight = video.videoHeight || 1080; + const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width; + const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height; const effectiveSourceDimensions = calculateEffectiveSourceDimensions( sourceWidth, sourceHeight, @@ -2050,8 +2057,10 @@ export default function VideoEditor() { aspectRatio: aspectRatio === "native" ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ) : getAspectRatioValue(aspectRatio), @@ -2217,21 +2226,27 @@ export default function VideoEditor() { onGifSizePresetChange={setGifSizePreset} gifOutputDimensions={calculateOutputDimensions( calculateEffectiveSourceDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ).width, calculateEffectiveSourceDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ).height, gifSizePreset, GIF_SIZE_PRESETS, aspectRatio === "native" ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, + videoPlaybackRef.current?.video?.videoWidth || + DEFAULT_SOURCE_DIMENSIONS.width, + videoPlaybackRef.current?.video?.videoHeight || + DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ) : getAspectRatioValue(aspectRatio), diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 45ca9dfd..5b25ced3 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -49,15 +49,16 @@ import { getNativeAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; +import { + DEFAULT_CURSOR_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_SOURCE_DIMENSIONS, +} from "./editorDefaults"; import { type AnnotationRegion, type BlurData, type CursorTelemetryPoint, computeRotation3DContainScale, - DEFAULT_CURSOR_CLICK_BOUNCE, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, DEFAULT_ROTATION_3D, isRotation3DIdentity, lerpRotation3D, @@ -244,7 +245,7 @@ const VideoPlayback = forwardRef( showBlur, motionBlurAmount = 0, borderRadius = 0, - padding = 50, + padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, cropRegion, trimRegions = [], speedRegions = [], @@ -265,11 +266,11 @@ const VideoPlayback = forwardRef( cursorTelemetry = [], cursorClickTimestamps = [], showCursor = false, - cursorSize = DEFAULT_CURSOR_SIZE, - cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, - cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, - cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, - cursorClipToBounds = false, + cursorSize = DEFAULT_CURSOR_SETTINGS.size, + cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing, + cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur, + cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce, + cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds, }, ref, ) => { @@ -1824,8 +1825,8 @@ const VideoPlayback = forwardRef( aspectRatio, aspectRatio === "native" ? getNativeAspectRatioValue( - lockedVideoDimensionsRef.current?.width || 1920, - lockedVideoDimensionsRef.current?.height || 1080, + lockedVideoDimensionsRef.current?.width || DEFAULT_SOURCE_DIMENSIONS.width, + lockedVideoDimensionsRef.current?.height || DEFAULT_SOURCE_DIMENSIONS.height, cropRegion, ) : undefined, diff --git a/src/components/video-editor/editorDefaults.test.ts b/src/components/video-editor/editorDefaults.test.ts new file mode 100644 index 00000000..8b515f1c --- /dev/null +++ b/src/components/video-editor/editorDefaults.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory"; +import { DEFAULT_PREFS } from "@/lib/userPreferences"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "./editorDefaults"; +import { normalizeProjectEditor } from "./projectPersistence"; + +describe("editor defaults SSOT", () => { + it("keeps history defaults aligned with editor defaults", () => { + expect(INITIAL_EDITOR_STATE).toMatchObject({ + ...DEFAULT_EDITOR_APPEARANCE_SETTINGS, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, + }); + }); + + it("keeps user preference defaults aligned with editor and export defaults", () => { + expect(DEFAULT_PREFS).toMatchObject({ + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, + }); + }); + + it("keeps project fallback normalization aligned with editor defaults", () => { + expect(normalizeProjectEditor({})).toMatchObject({ + ...DEFAULT_EDITOR_APPEARANCE_SETTINGS, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, + gifFrameRate: DEFAULT_GIF_SETTINGS.frameRate, + gifLoop: DEFAULT_GIF_SETTINGS.loop, + gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset, + }); + }); +}); diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts new file mode 100644 index 00000000..ac6ab614 --- /dev/null +++ b/src/components/video-editor/editorDefaults.ts @@ -0,0 +1,95 @@ +import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import { + type CursorVisualSettings, + DEFAULT_CROP_REGION, + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_CLIP_TO_BOUNDS, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SMOOTHING, + DEFAULT_WEBCAM_LAYOUT_PRESET, + DEFAULT_WEBCAM_MASK_SHAPE, + DEFAULT_WEBCAM_POSITION, + DEFAULT_WEBCAM_SIZE_PRESET, + type WebcamLayoutPreset, + type WebcamMaskShape, + type WebcamPosition, + type WebcamSizePreset, +} from "./types"; + +export const DEFAULT_SOURCE_DIMENSIONS = { + width: 1920, + height: 1080, +} as const; + +export const DEFAULT_GIF_OUTPUT_DIMENSIONS = { + width: 1280, + height: 720, +} as const; + +export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { + shadowIntensity: number; + showBlur: boolean; + motionBlurAmount: number; + borderRadius: number; +} = { + shadowIntensity: 0, + showBlur: false, + motionBlurAmount: 0, + borderRadius: 0, +}; + +export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { + padding: number; + aspectRatio: AspectRatio; + cropRegion: typeof DEFAULT_CROP_REGION; + wallpaper: string; +} = { + padding: 50, + aspectRatio: "16:9", + cropRegion: DEFAULT_CROP_REGION, + wallpaper: DEFAULT_WALLPAPER, +}; + +export const DEFAULT_WEBCAM_SETTINGS = { + layoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET, + maskShape: DEFAULT_WEBCAM_MASK_SHAPE, + sizePreset: DEFAULT_WEBCAM_SIZE_PRESET, + position: DEFAULT_WEBCAM_POSITION, +} as const satisfies { + layoutPreset: WebcamLayoutPreset; + maskShape: WebcamMaskShape; + sizePreset: WebcamSizePreset; + position: WebcamPosition | null; +}; + +export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean } = { + show: true, + size: DEFAULT_CURSOR_SIZE, + smoothing: DEFAULT_CURSOR_SMOOTHING, + motionBlur: DEFAULT_CURSOR_MOTION_BLUR, + clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE, + clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS, +}; + +export const DEFAULT_EXPORT_SETTINGS: { + quality: ExportQuality; + format: ExportFormat; +} = { + quality: "good", + format: "mp4", +}; + +export const DEFAULT_GIF_SETTINGS: { + frameRate: GifFrameRate; + loop: boolean; + sizePreset: GifSizePreset; + outputDimensions: typeof DEFAULT_GIF_OUTPUT_DIMENSIONS; +} = { + frameRate: 15, + loop: true, + sizePreset: "medium", + outputDimensions: DEFAULT_GIF_OUTPUT_DIMENSIONS, +}; diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 7360142b..f16d29ea 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -4,6 +4,13 @@ import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, + DEFAULT_GIF_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "./editorDefaults"; import { type AnnotationRegion, type CropRegion, @@ -15,14 +22,10 @@ import { DEFAULT_BLUR_DATA, DEFAULT_BLUR_FREEHAND_POINTS, DEFAULT_BLUR_INTENSITY, - DEFAULT_CROP_REGION, DEFAULT_FIGURE_DATA, DEFAULT_PLAYBACK_SPEED, - DEFAULT_WEBCAM_LAYOUT_PRESET, - DEFAULT_WEBCAM_MASK_SHAPE, - DEFAULT_WEBCAM_POSITION, - DEFAULT_WEBCAM_SIZE_PRESET, DEFAULT_ZOOM_DEPTH, + DEFAULT_ZOOM_MOTION_BLUR, MAX_BLUR_BLOCK_SIZE, MAX_BLUR_INTENSITY, MAX_PLAYBACK_SPEED, @@ -104,13 +107,13 @@ function computeNormalizedWebcamLayoutPreset( case "vertical-stack": return isPortraitAspectRatio(normalizedAspectRatio) ? webcamLayoutPreset - : DEFAULT_WEBCAM_LAYOUT_PRESET; + : DEFAULT_WEBCAM_SETTINGS.layoutPreset; case "dual-frame": return isPortraitAspectRatio(normalizedAspectRatio) - ? DEFAULT_WEBCAM_LAYOUT_PRESET + ? DEFAULT_WEBCAM_SETTINGS.layoutPreset : webcamLayoutPreset; default: - return DEFAULT_WEBCAM_LAYOUT_PRESET; + return DEFAULT_WEBCAM_SETTINGS.layoutPreset; } } @@ -211,7 +214,7 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.aspectRatio as AspectRatio, ) ? (editor.aspectRatio as AspectRatio) - : "16:9"; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio; const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset( editor.webcamLayoutPreset, normalizedAspectRatio, @@ -226,7 +229,7 @@ export function normalizeProjectEditor(editor: Partial): Pro cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1), cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1), } - : DEFAULT_WEBCAM_POSITION; + : DEFAULT_WEBCAM_SETTINGS.position; const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) ? editor.zoomRegions @@ -413,16 +416,16 @@ export function normalizeProjectEditor(editor: Partial): Pro const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x - : DEFAULT_CROP_REGION.x; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.x; const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y - : DEFAULT_CROP_REGION.y; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.y; const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width - : DEFAULT_CROP_REGION.width; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.width; const rawCropHeight = isFiniteNumber(editor.cropRegion?.height) ? editor.cropRegion.height - : DEFAULT_CROP_REGION.height; + : DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.height; const cropX = clamp(rawCropX, 0, 1); const cropY = clamp(rawCropY, 0, 1); @@ -433,18 +436,29 @@ export function normalizeProjectEditor(editor: Partial): Pro wallpaper: typeof editor.wallpaper === "string" ? normalizeWallpaperValue(editor.wallpaper) - : DEFAULT_WALLPAPER, - shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0, - showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false, + : DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + shadowIntensity: + typeof editor.shadowIntensity === "number" + ? editor.shadowIntensity + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, + showBlur: + typeof editor.showBlur === "boolean" + ? editor.showBlur + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) ? clamp(editor.motionBlurAmount, 0, 1) : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" ? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled - ? 0.35 - : 0 - : 0, - borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0, - padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50, + ? DEFAULT_ZOOM_MOTION_BLUR + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, + borderRadius: + typeof editor.borderRadius === "number" + ? editor.borderRadius + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, + padding: isFiniteNumber(editor.padding) + ? clamp(editor.padding, 0, 100) + : DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, cropRegion: { x: cropX, y: cropY, @@ -463,31 +477,31 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.webcamMaskShape === "square" || editor.webcamMaskShape === "rounded" ? editor.webcamMaskShape - : DEFAULT_WEBCAM_MASK_SHAPE, + : DEFAULT_WEBCAM_SETTINGS.maskShape, webcamSizePreset: typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset) ? Math.max(10, Math.min(50, editor.webcamSizePreset)) - : DEFAULT_WEBCAM_SIZE_PRESET, + : DEFAULT_WEBCAM_SETTINGS.sizePreset, webcamPosition: normalizedWebcamPosition, exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality - : "good", - exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", + : DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: editor.exportFormat === "gif" ? "gif" : DEFAULT_EXPORT_SETTINGS.format, gifFrameRate: editor.gifFrameRate === 15 || editor.gifFrameRate === 20 || editor.gifFrameRate === 25 || editor.gifFrameRate === 30 ? editor.gifFrameRate - : 15, - gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true, + : DEFAULT_GIF_SETTINGS.frameRate, + gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : DEFAULT_GIF_SETTINGS.loop, gifSizePreset: editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original" ? editor.gifSizePreset - : "medium", + : DEFAULT_GIF_SETTINGS.sizePreset, }; } diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index cf426d0b..fce4a199 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -188,6 +188,7 @@ export interface CursorVisualSettings { smoothing: number; motionBlur: number; clickBounce: number; + clipToBounds: boolean; } export const DEFAULT_CURSOR_SIZE = 3.0; diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index bd410dae..b6525c10 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -1,4 +1,9 @@ import { useCallback, useRef, useState } from "react"; +import { + DEFAULT_EDITOR_APPEARANCE_SETTINGS, + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_WEBCAM_SETTINGS, +} from "@/components/video-editor/editorDefaults"; import type { AnnotationRegion, CropRegion, @@ -10,14 +15,7 @@ import type { WebcamSizePreset, ZoomRegion, } from "@/components/video-editor/types"; -import { - DEFAULT_CROP_REGION, - DEFAULT_WEBCAM_LAYOUT_PRESET, - DEFAULT_WEBCAM_MASK_SHAPE, - DEFAULT_WEBCAM_POSITION, - DEFAULT_WEBCAM_SIZE_PRESET, -} from "@/components/video-editor/types"; -import { DEFAULT_WALLPAPER } from "@/lib/wallpaper"; +import { DEFAULT_CROP_REGION } from "@/components/video-editor/types"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; // Undoable state — selection IDs are intentionally excluded (undoing a @@ -47,17 +45,17 @@ export const INITIAL_EDITOR_STATE: EditorState = { speedRegions: [], annotationRegions: [], cropRegion: DEFAULT_CROP_REGION, - wallpaper: DEFAULT_WALLPAPER, - shadowIntensity: 0, - showBlur: false, - motionBlurAmount: 0, - borderRadius: 0, - padding: 50, - aspectRatio: "16:9", - webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET, - webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE, - webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET, - webcamPosition: DEFAULT_WEBCAM_POSITION, + wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, + shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, + showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, + borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset, + webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape, + webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset, + webcamPosition: DEFAULT_WEBCAM_SETTINGS.position, }; type StateUpdate = Partial | ((prev: EditorState) => Partial); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 5b7bc86d..28a45068 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_EDITOR_LAYOUT_SETTINGS, + DEFAULT_EXPORT_SETTINGS, +} from "@/components/video-editor/editorDefaults"; import type { ExportFormat, ExportQuality } from "@/lib/exporter"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; @@ -27,11 +31,11 @@ export interface UserPreferences { exportFolder: string | null; } -const DEFAULT_PREFS: UserPreferences = { - padding: 50, - aspectRatio: "16:9", - exportQuality: "good", - exportFormat: "mp4", +export const DEFAULT_PREFS: UserPreferences = { + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, + aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio, + exportQuality: DEFAULT_EXPORT_SETTINGS.quality, + exportFormat: DEFAULT_EXPORT_SETTINGS.format, exportFolder: null, }; From 10614c2950fd750faf87de1da1217071773572f3 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 22 May 2026 21:20:51 +0200 Subject: [PATCH 49/94] Address webcam sidecar review feedback --- .../wgc-capture/src/dshow_webcam_capture.cpp | 7 ++- .../native/wgc-capture/src/mf_encoder.cpp | 7 ++- src/hooks/useScreenRecorder.ts | 25 +++++++++++ .../timestampedVideoFrameQueue.test.ts | 45 ++++++++++++++++++- .../exporter/timestampedVideoFrameQueue.ts | 7 ++- 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp index 436bdeac..7e3f8b7a 100644 --- a/electron/native/wgc-capture/src/dshow_webcam_capture.cpp +++ b/electron/native/wgc-capture/src/dshow_webcam_capture.cpp @@ -379,8 +379,11 @@ void DirectShowWebcamCapture::storeFrame(const BYTE* buffer, long length) { } if (width_ % 2 == 1) { const int x = width_ - 1; - const BYTE* pair = source + (x - 1) * 2; - const auto color = yuvToBgr(pair[2], pair[1], pair[3]); + const int previousPairStart = ((x - 1) / 2) * 4; + const BYTE y = source[x * 2]; + const BYTE u = source[previousPairStart + 1]; + const BYTE v = source[previousPairStart + 3]; + const auto color = yuvToBgr(y, u, v); BYTE* pixel = destination + x * 4; pixel[0] = color[0]; pixel[1] = color[1]; diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index c04353af..18bc4cca 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -267,7 +267,12 @@ bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destinat } if (frame.width == width_ && frame.height == height_) { - std::memcpy(destination, frame.data, requiredBytes); + for (DWORD i = 0; i < requiredBytes; i += 4) { + destination[i] = frame.data[i]; + destination[i + 1] = frame.data[i + 1]; + destination[i + 2] = frame.data[i + 2]; + destination[i + 3] = 255; + } return true; } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index f64eec44..cdd4088d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -764,6 +764,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const isCountdownRunActive = (runId?: number) => runId === undefined || countdownRunId.current === runId; + const waitForWebcamReady = async () => { + if (webcamReady.current) { + return; + } + + await new Promise((resolve) => { + const interval = setInterval(() => { + if (webcamReady.current) { + clearInterval(interval); + resolve(); + } + }, 50); + setTimeout(() => { + clearInterval(interval); + resolve(); + }, 5000); + }); + }; + const startNativeWindowsRecordingIfAvailable = async ( selectedSource: ProcessedDesktopSource, countdownRunToken?: number, @@ -795,6 +814,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const displayId = Number(selectedSource.display_id); const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display"; const windowHandle = parseWindowHandleFromSourceId(selectedSource.id); + if (webcamEnabled) { + await waitForWebcamReady(); + if (!isCountdownRunActive(countdownRunToken)) { + return true; + } + } const browserWebcamRecorder = webcamEnabled && webcamStream.current ? createRecorderHandle(webcamStream.current, { diff --git a/src/lib/exporter/timestampedVideoFrameQueue.test.ts b/src/lib/exporter/timestampedVideoFrameQueue.test.ts index 24d625de..9645c985 100644 --- a/src/lib/exporter/timestampedVideoFrameQueue.test.ts +++ b/src/lib/exporter/timestampedVideoFrameQueue.test.ts @@ -14,6 +14,15 @@ class MockVideoFrame { } } +function restoreVideoFrame(originalVideoFrame: typeof globalThis.VideoFrame | undefined) { + if (originalVideoFrame === undefined) { + delete (globalThis as { VideoFrame?: typeof globalThis.VideoFrame }).VideoFrame; + return; + } + + vi.stubGlobal("VideoFrame", originalVideoFrame); +} + describe("TimestampedVideoFrameQueue", () => { it("samples the latest webcam frame at or before the requested source timestamp", async () => { const originalVideoFrame = globalThis.VideoFrame; @@ -27,6 +36,7 @@ describe("TimestampedVideoFrameQueue", () => { queue.enqueue(frame0, 0); queue.enqueue(frame33, 33); queue.enqueue(frame66, 66); + queue.close(); const sampled0 = await queue.frameAt(0); const sampled20 = await queue.frameAt(20); @@ -44,7 +54,40 @@ describe("TimestampedVideoFrameQueue", () => { sampled80?.close(); queue.destroy(); } finally { - vi.stubGlobal("VideoFrame", originalVideoFrame); + restoreVideoFrame(originalVideoFrame); + } + }); + + it("waits for a newer frame before falling back to the held frame while open", async () => { + const originalVideoFrame = globalThis.VideoFrame; + vi.stubGlobal("VideoFrame", MockVideoFrame); + try { + const queue = new TimestampedVideoFrameQueue(); + const frame0 = new MockVideoFrame(0) as unknown as VideoFrame; + const frame33 = new MockVideoFrame(33_000) as unknown as VideoFrame; + + queue.enqueue(frame0, 0); + const sampled0 = await queue.frameAt(0); + let resolved = false; + const pending = queue.frameAt(33).then((frame) => { + resolved = true; + return frame; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + + queue.enqueue(frame33, 33); + const sampled33 = await pending; + + expect(sampled0?.timestamp).toBe(0); + expect(sampled33?.timestamp).toBe(33_000); + + sampled0?.close(); + sampled33?.close(); + queue.destroy(); + } finally { + restoreVideoFrame(originalVideoFrame); } }); }); diff --git a/src/lib/exporter/timestampedVideoFrameQueue.ts b/src/lib/exporter/timestampedVideoFrameQueue.ts index 336b053b..86c0fe91 100644 --- a/src/lib/exporter/timestampedVideoFrameQueue.ts +++ b/src/lib/exporter/timestampedVideoFrameQueue.ts @@ -64,7 +64,12 @@ export class TimestampedVideoFrameQueue { continue; } - if (this.heldFrame) { + if ( + this.heldFrame && + (next || + this.closed || + this.heldFrame.sourceTimestampMs >= sourceTimestampMs - TIMESTAMP_EPSILON_MS) + ) { return new VideoFrame(this.heldFrame.frame, { timestamp: this.heldFrame.frame.timestamp, }); From 0daf2295a37e73d5a99c3600de727736f01908e0 Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 23 May 2026 03:39:26 +0800 Subject: [PATCH 50/94] fix: accept decimal custom speeds --- src/components/video-editor/SettingsPanel.tsx | 33 ++++++------ .../video-editor/customPlaybackSpeed.test.ts | 50 +++++++++++++++++++ .../video-editor/customPlaybackSpeed.ts | 37 ++++++++++++++ src/i18n/locales/it/settings.json | 3 +- 4 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 src/components/video-editor/customPlaybackSpeed.test.ts create mode 100644 src/components/video-editor/customPlaybackSpeed.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b0b46dfd..3bd87500 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,6 +53,7 @@ import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; +import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, @@ -71,7 +72,6 @@ import type { } from "./types"; import { DEFAULT_WEBCAM_SIZE_PRESET, - MAX_PLAYBACK_SPEED, MAX_ZOOM_SCALE, MIN_ZOOM_SCALE, ROTATION_3D_PRESET_ORDER, @@ -90,37 +90,38 @@ function CustomSpeedInput({ onError: () => void; }) { const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); - const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value))); + const [draft, setDraft] = useState(isPreset ? "" : String(value)); const [isFocused, setIsFocused] = useState(false); const prevValue = useRef(value); if (!isFocused && prevValue.current !== value) { prevValue.current = value; - setDraft(isPreset ? "" : String(Math.round(value))); + setDraft(isPreset ? "" : String(value)); } const handleChange = useCallback( (e: React.ChangeEvent) => { - const digits = e.target.value.replace(/\D/g, ""); - if (digits === "") { - setDraft(""); - return; - } - const num = Number(digits); - if (num > MAX_PLAYBACK_SPEED) { + const result = parseCustomPlaybackSpeedInput(e.target.value); + if (result.status === "too-fast") { onError(); return; } - setDraft(digits); - if (num >= 1) onChange(num); + + setDraft(result.draft); + if (result.status === "valid") { + onChange(result.speed); + } }, [onChange, onError], ); const handleBlur = useCallback(() => { setIsFocused(false); - if (!draft || Number(draft) < 1) { - setDraft(isPreset ? "" : String(Math.round(value))); + const result = parseCustomPlaybackSpeedInput(draft); + if (result.status === "valid") { + setDraft(String(result.speed)); + } else { + setDraft(isPreset ? "" : String(value)); } }, [draft, isPreset, value]); @@ -128,8 +129,8 @@ function CustomSpeedInput({
setIsFocused(true)} diff --git a/src/components/video-editor/customPlaybackSpeed.test.ts b/src/components/video-editor/customPlaybackSpeed.test.ts new file mode 100644 index 00000000..223175cf --- /dev/null +++ b/src/components/video-editor/customPlaybackSpeed.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; + +describe("parseCustomPlaybackSpeedInput", () => { + it("accepts decimal playback speeds", () => { + expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({ + status: "valid", + draft: "1.1", + speed: 1.1, + }); + }); + + it("keeps a single decimal point while typing", () => { + expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({ + status: "valid", + draft: "1.23", + speed: 1.23, + }); + }); + + it("allows sub-1 custom speeds down to the editor minimum", () => { + expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({ + status: "valid", + draft: "0.1", + speed: 0.1, + }); + }); + + it("rejects speeds below the editor minimum", () => { + expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({ + status: "too-slow", + draft: "0.09", + }); + }); + + it("accepts comma decimal input by normalizing to a dot", () => { + expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({ + status: "valid", + draft: "1.1", + speed: 1.1, + }); + }); + + it("rejects speeds above the editor maximum", () => { + expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({ + status: "too-fast", + draft: "16.1", + }); + }); +}); diff --git a/src/components/video-editor/customPlaybackSpeed.ts b/src/components/video-editor/customPlaybackSpeed.ts new file mode 100644 index 00000000..64cc3e3e --- /dev/null +++ b/src/components/video-editor/customPlaybackSpeed.ts @@ -0,0 +1,37 @@ +import { + clampPlaybackSpeed, + MAX_PLAYBACK_SPEED, + MIN_PLAYBACK_SPEED, + type PlaybackSpeed, +} from "./types"; + +export type CustomPlaybackSpeedInputResult = + | { status: "empty"; draft: string } + | { status: "too-fast"; draft: string } + | { status: "too-slow"; draft: string } + | { status: "valid"; draft: string; speed: PlaybackSpeed }; + +export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult { + const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, ""); + const [whole = "", ...fractionParts] = decimalDraft.split("."); + const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole; + + if (draft === "" || draft === ".") { + return { status: "empty", draft }; + } + + const speed = Number(draft); + if (!Number.isFinite(speed)) { + return { status: "empty", draft }; + } + + if (speed > MAX_PLAYBACK_SPEED) { + return { status: "too-fast", draft }; + } + + if (speed < MIN_PLAYBACK_SPEED) { + return { status: "too-slow", draft }; + } + + return { status: "valid", draft, speed: clampPlaybackSpeed(speed) }; +} diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index e609d08d..0515a765 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -104,8 +104,9 @@ "gifButton": "Esporta GIF", "chooseSaveLocation": "Scegli posizione di salvataggio" }, - "links": { + "support": { "reportBug": "Segnala bug", + "saveDiagnostics": "Salva dati diagnostici", "starOnGithub": "Metti stella su GitHub" }, "imageUpload": { From 84b523df8353ff6ba679f5c31b17b24edd5aedd3 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 22 May 2026 19:54:13 -0700 Subject: [PATCH 51/94] fix: drop unused imports and reorder in SettingsPanel Removes MAX_PLAYBACK_SPEED and DEFAULT_WEBCAM_SIZE_PRESET (TS6133) and runs biome's organize-imports to satisfy the Lint check. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/video-editor/SettingsPanel.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 71635686..571b587d 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,6 +53,7 @@ import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; +import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; import { DEFAULT_CURSOR_SETTINGS, DEFAULT_EDITOR_LAYOUT_SETTINGS, @@ -61,7 +62,6 @@ import { DEFAULT_SOURCE_DIMENSIONS, DEFAULT_WEBCAM_SETTINGS, } from "./editorDefaults"; -import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, @@ -79,8 +79,6 @@ import type { ZoomFocusMode, } from "./types"; import { - MAX_PLAYBACK_SPEED, - DEFAULT_WEBCAM_SIZE_PRESET, MAX_ZOOM_SCALE, MIN_ZOOM_SCALE, ROTATION_3D_PRESET_ORDER, From 9eaae72af1ede47cc98fa2e765a789d403b00561 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 22 May 2026 20:02:25 -0700 Subject: [PATCH 52/94] fix: drop removed WEBCAM_TARGET width/height refs after main merge PR #600 (now on main) removed WEBCAM_TARGET_WIDTH/HEIGHT and switched this call site to width/height: 0 so the native helper picks the camera's native dimensions. Align this branch with that so CI's fresh PR-merge stops erroring on the undeclared identifiers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useScreenRecorder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 08d062ca..4ad11c63 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -989,8 +989,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { enabled: webcamEnabled, deviceId: webcamDeviceId, deviceName: webcamDeviceName, - width: WEBCAM_TARGET_WIDTH, - height: WEBCAM_TARGET_HEIGHT, + width: 0, + height: 0, fps: WEBCAM_TARGET_FRAME_RATE, }, cursor: { From 37eaacc07b5671f4dfacd3bf772fac8a7d53199a Mon Sep 17 00:00:00 2001 From: auberginewly <3127221787@qq.com> Date: Sat, 23 May 2026 16:13:38 +0800 Subject: [PATCH 53/94] fix(export): clear timeline selection when opening export panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a zoom/trim/speed region is selected, hasTimelineSelection is true and the export panel is gated behind !hasTimelineSelection. Clicking the Download button only switched activePanelMode locally in SettingsPanel without clearing the selection in VideoEditor, so the export panel never rendered. Add onExportPanelOpen callback prop to SettingsPanel and call it on Download button click to clear selectedZoomId, selectedTrimId, and selectedSpeedId — making hasTimelineSelection false and unblocking the export panel. Complements PR #611 which fixed the bulk suggest-zooms path; this covers the manual selection path. --- src/components/video-editor/SettingsPanel.tsx | 7 ++++++- src/components/video-editor/VideoEditor.tsx | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d3683976..ef597d22 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -284,6 +284,7 @@ interface SettingsPanelProps { onGifSizePresetChange?: (preset: GifSizePreset) => void; gifOutputDimensions?: { width: number; height: number }; onExport?: () => void; + onExportPanelOpen?: () => void; unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; @@ -414,6 +415,7 @@ export function SettingsPanel({ onGifSizePresetChange, gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions, onExport, + onExportPanelOpen, unsavedExport, onSaveUnsavedExport, selectedAnnotationId, @@ -822,7 +824,10 @@ export function SettingsPanel({ data-testid={getTestId("export-panel-button")} type="button" title={exportPanelMode.label} - onClick={() => setActivePanelMode(exportPanelMode.id)} + onClick={() => { + setActivePanelMode(exportPanelMode.id); + onExportPanelOpen?.(); + }} className={cn( "mt-auto flex h-8 w-8 items-center justify-center rounded-lg border transition-all", activePanelMode === "export" && !hasTimelineSelection diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b32621c9..bbd043a9 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2256,6 +2256,11 @@ export default function VideoEditor() { : getAspectRatioValue(aspectRatio), )} onExport={handleOpenExportDialog} + onExportPanelOpen={() => { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + }} selectedAnnotationId={selectedAnnotationId} annotationRegions={annotationOnlyRegions} onAnnotationContentChange={handleAnnotationContentChange} From d856a52316f3a161115a7ccad62935abbdad1aec Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 23 May 2026 18:32:52 +0800 Subject: [PATCH 54/94] fix stale speed selection in editor --- src/components/video-editor/VideoEditor.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b32621c9..a4e68955 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -808,6 +808,7 @@ export default function VideoEditor() { setSelectedZoomId(id); if (id) { setSelectedTrimId(null); + setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); } @@ -817,6 +818,7 @@ export default function VideoEditor() { setSelectedTrimId(id); if (id) { setSelectedZoomId(null); + setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); } @@ -827,6 +829,7 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedSpeedId(null); setSelectedBlurId(null); } }, []); @@ -855,6 +858,7 @@ export default function VideoEditor() { pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); setSelectedZoomId(id); setSelectedTrimId(null); + setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, @@ -891,6 +895,7 @@ export default function VideoEditor() { pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] })); setSelectedTrimId(id); setSelectedZoomId(null); + setSelectedSpeedId(null); setSelectedAnnotationId(null); setSelectedBlurId(null); }, @@ -1126,6 +1131,7 @@ export default function VideoEditor() { setSelectedAnnotationId(id); setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedSpeedId(null); setSelectedBlurId(null); }, [pushState], @@ -1199,6 +1205,8 @@ export default function VideoEditor() { setSelectedAnnotationId(duplicateId); setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedBlurId(null); }, [pushState], ); From 8413095aa93142bed5e8df3e2aa2239511aba5ad Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 12:53:56 +0200 Subject: [PATCH 55/94] feat: audio waveform on trim track + Timeline Settings toggle - Add RowWaveform component (refactored from TrimWaveform) that decodes audio via AudioContext, caches peaks per URL, and renders a faint waveform canvas behind any timeline row using useTimelineContext() for zoom/pan alignment - Wire RowWaveform as background of the trim row in TimelineEditor - Add showTrimWaveform boolean to EditorState / ProjectEditorState with default true; persists across saves/loads - Add Timeline Settings accordion section in SettingsPanel (always visible) with a Waveform toggle to enable/disable the visualization - Bump hint text opacity from 0.12 to 0.25 so "Press T to add trim" remains readable over the waveform background Co-Authored-By: Claude Sonnet 4.5 --- src/components/video-editor/SettingsPanel.tsx | 32 +++- src/components/video-editor/VideoEditor.tsx | 10 ++ src/components/video-editor/editorDefaults.ts | 2 + .../video-editor/projectPersistence.ts | 5 + src/components/video-editor/timeline/Row.tsx | 6 +- .../video-editor/timeline/RowWaveform.tsx | 169 ++++++++++++++++++ .../video-editor/timeline/TimelineEditor.tsx | 18 +- src/hooks/useEditorHistory.ts | 2 + 8 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 src/components/video-editor/timeline/RowWaveform.tsx diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d3683976..a32fd804 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,5 +1,6 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { + AudioWaveform, Bug, Crop, Download, @@ -258,6 +259,8 @@ interface SettingsPanelProps { onShadowCommit?: () => void; showBlur?: boolean; onBlurChange?: (showBlur: boolean) => void; + showTrimWaveform?: boolean; + onTrimWaveformChange?: (show: boolean) => void; motionBlurAmount?: number; onMotionBlurChange?: (amount: number) => void; onMotionBlurCommit?: () => void; @@ -389,6 +392,8 @@ export function SettingsPanel({ onShadowCommit, showBlur, onBlurChange, + showTrimWaveform = true, + onTrimWaveformChange, motionBlurAmount = 0, onMotionBlurChange, onMotionBlurCommit, @@ -1178,7 +1183,11 @@ export function SettingsPanel({ )} {!hasTimelineSelection && ( - + {hasWebcam && activePanelMode === "layout" && ( @@ -1696,6 +1705,27 @@ export function SettingsPanel({ )} + {/* Timeline Settings — always visible regardless of active panel mode */} + + +
+ + Timeline +
+
+ +
+
+
Waveform
+ +
+
+
+
)}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b32621c9..e645ec7c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -169,6 +169,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -355,6 +356,7 @@ export default function VideoEditor() { wallpaper: normalizedEditor.wallpaper, shadowIntensity: normalizedEditor.shadowIntensity, showBlur: normalizedEditor.showBlur, + showTrimWaveform: normalizedEditor.showTrimWaveform, motionBlurAmount: normalizedEditor.motionBlurAmount, borderRadius: normalizedEditor.borderRadius, padding: normalizedEditor.padding, @@ -426,6 +428,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -449,6 +452,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -572,6 +576,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -631,6 +636,7 @@ export default function VideoEditor() { wallpaper, shadowIntensity, showBlur, + showTrimWaveform, motionBlurAmount, borderRadius, padding, @@ -2192,6 +2198,8 @@ export default function VideoEditor() { onShadowCommit={commitState} showBlur={showBlur} onBlurChange={(v) => pushState({ showBlur: v })} + showTrimWaveform={showTrimWaveform} + onTrimWaveformChange={(v) => pushState({ showTrimWaveform: v })} motionBlurAmount={motionBlurAmount} onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} onMotionBlurCommit={commitState} @@ -2355,6 +2363,8 @@ export default function VideoEditor() { : webcamLayoutPreset, }) } + videoUrl={videoPath ?? undefined} + showTrimWaveform={showTrimWaveform} /> diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index ac6ab614..2e1e9510 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -34,11 +34,13 @@ export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { showBlur: boolean; motionBlurAmount: number; borderRadius: number; + showTrimWaveform: boolean; } = { shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, borderRadius: 0, + showTrimWaveform: true, }; export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index f16d29ea..46673290 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -65,6 +65,7 @@ export interface ProjectEditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; + showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -445,6 +446,10 @@ export function normalizeProjectEditor(editor: Partial): Pro typeof editor.showBlur === "boolean" ? editor.showBlur : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + showTrimWaveform: + typeof editor.showTrimWaveform === "boolean" + ? editor.showTrimWaveform + : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) ? clamp(editor.motionBlurAmount, 0, 1) : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 77fb52de..d104e92d 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -5,9 +5,10 @@ interface RowProps extends RowDefinition { children: React.ReactNode; hint?: string; isEmpty?: boolean; + background?: React.ReactNode; } -export default function Row({ id, children, hint, isEmpty }: RowProps) { +export default function Row({ id, children, hint, isEmpty, background }: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); return ( @@ -15,9 +16,10 @@ export default function Row({ id, children, hint, isEmpty }: RowProps) { className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden" style={{ ...rowWrapperStyle, minHeight: 36 }} > + {background} {isEmpty && hint && (
- {hint} + {hint}
)}
diff --git a/src/components/video-editor/timeline/RowWaveform.tsx b/src/components/video-editor/timeline/RowWaveform.tsx new file mode 100644 index 00000000..0ebce152 --- /dev/null +++ b/src/components/video-editor/timeline/RowWaveform.tsx @@ -0,0 +1,169 @@ +import { useTimelineContext } from "dnd-timeline"; +import { useEffect, useRef, useState } from "react"; + +export interface RowWaveformProps { + videoUrl?: string; + videoDurationMs: number; +} + +// Module-level cache keyed by URL — survives re-mounts within the same page session. +const peaksCache = new Map(); +let _audioCtx: AudioContext | null = null; + +function getAudioCtx(): AudioContext { + if (!_audioCtx) _audioCtx = new AudioContext(); + return _audioCtx; +} + +function computePeaks(audioBuffer: AudioBuffer): Float32Array { + const N = Math.min(24000, Math.ceil(audioBuffer.duration * 200)); + const nCh = audioBuffer.numberOfChannels; + const totalSamples = audioBuffer.length; + const blockSize = totalSamples / N; + const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] + + const channels: Float32Array[] = []; + for (let c = 0; c < nCh; c++) channels.push(audioBuffer.getChannelData(c)); + + for (let i = 0; i < N; i++) { + const start = Math.floor(i * blockSize); + const end = Math.floor((i + 1) * blockSize); + let minVal = 0; + let maxVal = 0; + for (let j = start; j < end; j++) { + let sample = 0; + for (let c = 0; c < nCh; c++) sample += channels[c][j]; + sample /= nCh; + if (sample < minVal) minVal = sample; + if (sample > maxVal) maxVal = sample; + } + peaks[i * 2] = minVal; + peaks[i * 2 + 1] = maxVal; + } + + return peaks; +} + +/** + * Renders a faint audio waveform on a `` element that fills its + * containing row. Designed to be passed as the `background` prop of ``. + * + * - Decodes audio from `videoUrl` once per URL (module-level cache). + * - Redraws whenever the timeline zoom/pan range changes. + * - `pointer-events: none` throughout — never blocks drag-to-create interactions. + * - Silent fallback when the file has no audio track. + */ +export default function RowWaveform({ videoUrl, videoDurationMs }: RowWaveformProps) { + const { range } = useTimelineContext(); + const canvasRef = useRef(null); + const wrapperRef = useRef(null); + const [peaks, setPeaks] = useState(null); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + + // Decode audio once per videoUrl, store peaks in module-level cache. + useEffect(() => { + if (!videoUrl) { + setPeaks(null); + return; + } + + const cached = peaksCache.get(videoUrl); + if (cached) { + setPeaks(cached); + return; + } + + let cancelled = false; + + (async () => { + try { + const response = await fetch(videoUrl); + if (cancelled) return; + const arrayBuffer = await response.arrayBuffer(); + if (cancelled) return; + const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); + if (cancelled) return; + const p = computePeaks(audioBuffer); + peaksCache.set(videoUrl, p); + setPeaks(p); + } catch { + // No audio track or unsupported format — silent degradation. + } + })(); + + return () => { + cancelled = true; + }; + }, [videoUrl]); + + // Track container dimensions via ResizeObserver. + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setCanvasSize({ w: width, h: height }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Redraw whenever peaks, range, or container size changes. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !peaks || canvasSize.w <= 0 || canvasSize.h <= 0) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvasSize.w * dpr); + canvas.height = Math.round(canvasSize.h * dpr); + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, canvasSize.w, canvasSize.h); + + const W = canvasSize.w; + const H = canvasSize.h; + const mid = H / 2; + const amp = mid * 0.9; + const rangeMs = range.end - range.start; + if (rangeMs <= 0 || videoDurationMs <= 0) return; + + const N = peaks.length / 2; + + ctx.beginPath(); + ctx.strokeStyle = "rgba(255, 255, 255, 0.12)"; + ctx.lineWidth = 1; + + for (let x = 0; x < W; x++) { + const startMs = range.start + (x / W) * rangeMs; + const endMs = range.start + ((x + 1) / W) * rangeMs; + const lo = Math.max(0, Math.floor((startMs / videoDurationMs) * N)); + const hi = Math.min(N - 1, Math.ceil((endMs / videoDurationMs) * N)); + + let minVal = 0; + let maxVal = 0; + for (let i = lo; i <= hi; i++) { + const mn = peaks[i * 2]; + const mx = peaks[i * 2 + 1]; + if (mn < minVal) minVal = mn; + if (mx > maxVal) maxVal = mx; + } + + ctx.moveTo(x + 0.5, mid - maxVal * amp); + ctx.lineTo(x + 0.5, mid - minVal * amp); + } + + ctx.stroke(); + }, [peaks, range, canvasSize, videoDurationMs]); + + return ( +
+ +
+ ); +} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 759fcbbe..76959a3e 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -38,6 +38,7 @@ import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; import TimelineWrapper from "./TimelineWrapper"; +import RowWaveform from "./RowWaveform"; import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; const ZOOM_ROW_ID = "row-zoom"; @@ -88,6 +89,8 @@ interface TimelineEditorProps { onSelectSpeed?: (id: string | null) => void; aspectRatio: AspectRatio; onAspectRatioChange: (aspectRatio: AspectRatio) => void; + videoUrl?: string; + showTrimWaveform?: boolean; } interface TimelineScaleConfig { @@ -567,6 +570,8 @@ function Timeline({ selectedBlurId, selectedSpeedId, keyframes = [], + videoUrl, + showTrimWaveform = true, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -584,6 +589,8 @@ function Timeline({ selectedBlurId?: string | null; selectedSpeedId?: string | null; keyframes?: { id: string; time: number }[]; + videoUrl?: string; + showTrimWaveform?: boolean; }) { const t = useScopedT("timeline"); const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); @@ -788,7 +795,12 @@ function Timeline({ ))}
- + : undefined} + > {trimItems.map((item) => ( Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); @@ -1700,6 +1714,8 @@ export default function TimelineEditor({ selectedBlurId={selectedBlurId} selectedSpeedId={selectedSpeedId} keyframes={keyframes} + videoUrl={videoUrl} + showTrimWaveform={showTrimWaveform} />
diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index b6525c10..bfa1a4be 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -29,6 +29,7 @@ export interface EditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; + showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -48,6 +49,7 @@ export const INITIAL_EDITOR_STATE: EditorState = { wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, + showTrimWaveform: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, From 70c7d20d3c2b8068c69d408d5eedacb6a07d2365 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 15:45:00 +0200 Subject: [PATCH 56/94] fix: timeline waveform in dedicated Timeline panel, disabled by default - Add "Timeline" as a dedicated SettingsPanelMode with AudioWaveform icon - Waveform toggle now lives exclusively in the Timeline panel (not in every panel) - Default showTrimWaveform to false so trim row starts with visible hint text - Guard AccordionItem render with activePanelMode === "timeline" to prevent bleed Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/SettingsPanel.tsx | 58 +++++++++---------- src/components/video-editor/editorDefaults.ts | 2 +- .../video-editor/timeline/TimelineEditor.tsx | 12 ++-- 3 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index a32fd804..2a33d6bd 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -346,7 +346,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; -type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export"; +type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export" | "timeline"; const MP4_EXPORT_SHORT_SIDES = { medium: 720, @@ -392,7 +392,7 @@ export function SettingsPanel({ onShadowCommit, showBlur, onBlurChange, - showTrimWaveform = true, + showTrimWaveform = false, onTrimWaveformChange, motionBlurAmount = 0, onMotionBlurChange, @@ -608,6 +608,7 @@ export function SettingsPanel({ { id: "background", label: t("background.title"), icon: Palette }, { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, + { id: "timeline", label: "Timeline", icon: AudioWaveform }, ...(hasCursorPanel ? [ { @@ -629,8 +630,10 @@ export function SettingsPanel({ : selectedSpeedId ? t("speed.playbackSpeed") : t("trim.deleteRegion") - : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? - t("background.title")); + : activePanelMode === "timeline" + ? "Timeline" + : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? + t("background.title")); const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { @@ -1183,11 +1186,7 @@ export function SettingsPanel({ )} {!hasTimelineSelection && ( - + {hasWebcam && activePanelMode === "layout" && ( @@ -1705,27 +1704,28 @@ export function SettingsPanel({ )} - {/* Timeline Settings — always visible regardless of active panel mode */} - - -
- - Timeline -
-
- -
-
-
Waveform
- + {activePanelMode === "timeline" && ( + + +
+ + Timeline
-
- - + + +
+
+
Waveform
+ +
+
+
+ + )} )}
diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index 2e1e9510..914ec769 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -40,7 +40,7 @@ export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { showBlur: false, motionBlurAmount: 0, borderRadius: 0, - showTrimWaveform: true, + showTrimWaveform: false, }; export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 76959a3e..d0026610 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -37,8 +37,8 @@ import type { import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; -import TimelineWrapper from "./TimelineWrapper"; import RowWaveform from "./RowWaveform"; +import TimelineWrapper from "./TimelineWrapper"; import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; const ZOOM_ROW_ID = "row-zoom"; @@ -571,7 +571,7 @@ function Timeline({ selectedSpeedId, keyframes = [], videoUrl, - showTrimWaveform = true, + showTrimWaveform = false, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -799,7 +799,11 @@ function Timeline({ id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")} - background={showTrimWaveform ? : undefined} + background={ + showTrimWaveform ? ( + + ) : undefined + } > {trimItems.map((item) => ( Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); From 5b4f31f661366600a1e8afed3a31bfddec73269e Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 16:57:19 +0200 Subject: [PATCH 57/94] chore: swap AudioWaveform icon for Brackets, improve toggle label - Replace AudioWaveform icon with Brackets on the Timeline panel tab and section header - Rename toggle label from "Waveform" to "Show Audio Waveform on Trim Track" - Make toggle full-width to accommodate the longer label Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/SettingsPanel.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 2a33d6bd..2f54c75f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,6 +1,6 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { - AudioWaveform, + Brackets, Bug, Crop, Download, @@ -608,7 +608,7 @@ export function SettingsPanel({ { id: "background", label: t("background.title"), icon: Palette }, { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, - { id: "timeline", label: "Timeline", icon: AudioWaveform }, + { id: "timeline", label: "Timeline", icon: Brackets }, ...(hasCursorPanel ? [ { @@ -1708,20 +1708,20 @@ export function SettingsPanel({
- + Timeline
-
-
-
Waveform
- +
+
+ Show Audio Waveform on Trim Track
+
From 46732fc76b0154c6f1c90728021f98e75c0ee561 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:08:38 +0200 Subject: [PATCH 58/94] revert: restore hint text opacity to 0.12 in Row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.25 bump was a workaround for hint visibility over the waveform background, but is now stale — waveform defaults to off and hints only appear on empty rows anyway. Restores consistency with all other row hints. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/timeline/Row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index d104e92d..f3d11bcf 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -19,7 +19,7 @@ export default function Row({ id, children, hint, isEmpty, background }: RowProp {background} {isEmpty && hint && (
- {hint} + {hint}
)}
From a72aa1a594af5aeaa90c573e71195e930671ccb6 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:14:20 +0200 Subject: [PATCH 59/94] refactor: extract audio peak decoding into useAudioPeaks hook Move fetch/decode/cache logic out of RowWaveform into a standalone useAudioPeaks hook. RowWaveform is now a pure canvas renderer that accepts pre-computed peaks as a prop. TimelineEditor calls the hook and passes peaks down, skipping decoding entirely when waveform is off. Co-Authored-By: Claude Sonnet 4.6 --- .../video-editor/timeline/RowWaveform.tsx | 83 +---------------- .../video-editor/timeline/TimelineEditor.tsx | 4 +- src/hooks/useAudioPeaks.ts | 90 +++++++++++++++++++ 3 files changed, 97 insertions(+), 80 deletions(-) create mode 100644 src/hooks/useAudioPeaks.ts diff --git a/src/components/video-editor/timeline/RowWaveform.tsx b/src/components/video-editor/timeline/RowWaveform.tsx index 0ebce152..a77ee2c8 100644 --- a/src/components/video-editor/timeline/RowWaveform.tsx +++ b/src/components/video-editor/timeline/RowWaveform.tsx @@ -2,100 +2,25 @@ import { useTimelineContext } from "dnd-timeline"; import { useEffect, useRef, useState } from "react"; export interface RowWaveformProps { - videoUrl?: string; + /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */ + peaks: Float32Array | null; videoDurationMs: number; } -// Module-level cache keyed by URL — survives re-mounts within the same page session. -const peaksCache = new Map(); -let _audioCtx: AudioContext | null = null; - -function getAudioCtx(): AudioContext { - if (!_audioCtx) _audioCtx = new AudioContext(); - return _audioCtx; -} - -function computePeaks(audioBuffer: AudioBuffer): Float32Array { - const N = Math.min(24000, Math.ceil(audioBuffer.duration * 200)); - const nCh = audioBuffer.numberOfChannels; - const totalSamples = audioBuffer.length; - const blockSize = totalSamples / N; - const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] - - const channels: Float32Array[] = []; - for (let c = 0; c < nCh; c++) channels.push(audioBuffer.getChannelData(c)); - - for (let i = 0; i < N; i++) { - const start = Math.floor(i * blockSize); - const end = Math.floor((i + 1) * blockSize); - let minVal = 0; - let maxVal = 0; - for (let j = start; j < end; j++) { - let sample = 0; - for (let c = 0; c < nCh; c++) sample += channels[c][j]; - sample /= nCh; - if (sample < minVal) minVal = sample; - if (sample > maxVal) maxVal = sample; - } - peaks[i * 2] = minVal; - peaks[i * 2 + 1] = maxVal; - } - - return peaks; -} - /** * Renders a faint audio waveform on a `` element that fills its * containing row. Designed to be passed as the `background` prop of ``. * - * - Decodes audio from `videoUrl` once per URL (module-level cache). + * - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`). * - Redraws whenever the timeline zoom/pan range changes. * - `pointer-events: none` throughout — never blocks drag-to-create interactions. - * - Silent fallback when the file has no audio track. */ -export default function RowWaveform({ videoUrl, videoDurationMs }: RowWaveformProps) { +export default function RowWaveform({ peaks, videoDurationMs }: RowWaveformProps) { const { range } = useTimelineContext(); const canvasRef = useRef(null); const wrapperRef = useRef(null); - const [peaks, setPeaks] = useState(null); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); - // Decode audio once per videoUrl, store peaks in module-level cache. - useEffect(() => { - if (!videoUrl) { - setPeaks(null); - return; - } - - const cached = peaksCache.get(videoUrl); - if (cached) { - setPeaks(cached); - return; - } - - let cancelled = false; - - (async () => { - try { - const response = await fetch(videoUrl); - if (cancelled) return; - const arrayBuffer = await response.arrayBuffer(); - if (cancelled) return; - const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); - if (cancelled) return; - const p = computePeaks(audioBuffer); - peaksCache.set(videoUrl, p); - setPeaks(p); - } catch { - // No audio track or unsupported format — silent degradation. - } - })(); - - return () => { - cancelled = true; - }; - }, [videoUrl]); - // Track container dimensions via ResizeObserver. useEffect(() => { const el = wrapperRef.current; diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index d0026610..3bb420d8 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { useAudioPeaks } from "@/hooks/useAudioPeaks"; import { matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; @@ -597,6 +598,7 @@ function Timeline({ const localTimelineRef = useRef(null); const isScrubbingTimelineRef = useRef(false); const scrubPointerIdRef = useRef(null); + const peaks = useAudioPeaks(showTrimWaveform ? videoUrl : undefined); const setRefs = useCallback( (node: HTMLDivElement | null) => { @@ -801,7 +803,7 @@ function Timeline({ hint={t("hints.pressTrim")} background={ showTrimWaveform ? ( - + ) : undefined } > diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts new file mode 100644 index 00000000..206ad1d1 --- /dev/null +++ b/src/hooks/useAudioPeaks.ts @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; + +// Module-level cache keyed by URL — survives re-mounts within the same page session. +const peaksCache = new Map(); + +let _audioCtx: AudioContext | null = null; +function getAudioCtx(): AudioContext { + if (!_audioCtx) _audioCtx = new AudioContext(); + return _audioCtx; +} + +function computePeaks(audioBuffer: AudioBuffer): Float32Array { + const N = Math.min(24000, Math.ceil(audioBuffer.duration * 200)); + const nCh = audioBuffer.numberOfChannels; + const totalSamples = audioBuffer.length; + const blockSize = totalSamples / N; + const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] + + const channels: Float32Array[] = []; + for (let c = 0; c < nCh; c++) channels.push(audioBuffer.getChannelData(c)); + + for (let i = 0; i < N; i++) { + const start = Math.floor(i * blockSize); + const end = Math.floor((i + 1) * blockSize); + let minVal = 0; + let maxVal = 0; + for (let j = start; j < end; j++) { + let sample = 0; + for (let c = 0; c < nCh; c++) sample += channels[c][j]; + sample /= nCh; + if (sample < minVal) minVal = sample; + if (sample > maxVal) maxVal = sample; + } + peaks[i * 2] = minVal; + peaks[i * 2 + 1] = maxVal; + } + + return peaks; +} + +/** + * Decodes audio from `videoUrl` and returns a Float32Array of paired + * [min, max] peak values (length = 2 * N blocks). Returns `null` while + * decoding is in progress, and stays `null` when the file has no audio + * track or decoding fails (silent degradation). + * + * Results are cached at module scope by URL so re-mounts are free. + */ +export function useAudioPeaks(videoUrl?: string): Float32Array | null { + const [peaks, setPeaks] = useState(() => + videoUrl ? (peaksCache.get(videoUrl) ?? null) : null, + ); + + useEffect(() => { + if (!videoUrl) { + setPeaks(null); + return; + } + + const cached = peaksCache.get(videoUrl); + if (cached) { + setPeaks(cached); + return; + } + + let cancelled = false; + + (async () => { + try { + const response = await fetch(videoUrl); + if (cancelled) return; + const arrayBuffer = await response.arrayBuffer(); + if (cancelled) return; + const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); + if (cancelled) return; + const p = computePeaks(audioBuffer); + peaksCache.set(videoUrl, p); + setPeaks(p); + } catch { + // No audio track or unsupported format — silent degradation. + } + })(); + + return () => { + cancelled = true; + }; + }, [videoUrl]); + + return peaks; +} From 514943c1429c93373804aba04c4762deb96348fc Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:26:54 +0200 Subject: [PATCH 60/94] refactor: extract loadFileAsArrayBuffer to eliminate duplicated file loading Add a single exported loadFileAsArrayBuffer in streamingDecoder.ts that owns the IPC-vs-fetch branching for local and remote video URLs. StreamingVideoDecoder.loadSourceFile and useAudioPeaks both delegate to it, removing the duplicated readBinaryFile/fetch logic. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAudioPeaks.ts | 5 +-- src/lib/exporter/streamingDecoder.ts | 60 +++++++++++++--------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 206ad1d1..00e60675 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; // Module-level cache keyed by URL — survives re-mounts within the same page session. const peaksCache = new Map(); @@ -67,9 +68,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { (async () => { try { - const response = await fetch(videoUrl); - if (cancelled) return; - const arrayBuffer = await response.arrayBuffer(); + const arrayBuffer = await loadFileAsArrayBuffer(videoUrl); if (cancelled) return; const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); if (cancelled) return; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index e8093df0..f7687233 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -137,6 +137,29 @@ export function shouldFailDecodeEndedEarly({ return true; } +/** + * Loads a video file as an ArrayBuffer, using the Electron IPC bridge for + * local paths and falling back to `fetch` for remote / blob / data URLs. + * This is the single canonical place for reading raw video bytes in the renderer. + */ +export async function loadFileAsArrayBuffer(videoUrl: string): Promise { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + + if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { + const result = await window.electronAPI.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + throw new Error(result.message ?? result.error ?? "Failed to read video file"); + } + return result.data; + } + + const response = await fetch(videoUrl); + if (!response.ok) { + throw new Error(`Failed to fetch video file: ${response.status} ${response.statusText}`); + } + return response.arrayBuffer(); +} + /** Caller must close the VideoFrame after use. */ type OnFrameCallback = ( frame: VideoFrame, @@ -158,43 +181,16 @@ export class StreamingVideoDecoder { private metadata: DecodedVideoInfo | null = null; private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { - const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); - - if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { - const result = await this.withTimeout( - window.electronAPI.readBinaryFile(videoUrl), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while loading the source video.", - ); - if (!result.success || !result.data) { - throw new Error(result.message || result.error || "Failed to read source video"); - } - - const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; - const blob = new Blob([result.data]); - return { - blob, - file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), - }; - } - - const response = await this.withTimeout( - fetch(videoUrl), + const buffer = await this.withTimeout( + loadFileAsArrayBuffer(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); - if (!response.ok) { - throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`); - } - const blob = await this.withTimeout( - response.blob(), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while reading the source video.", - ); - const filename = videoUrl.split("/").pop() || "video"; + const filename = videoUrl.split(/[\\/]/).pop() || "video"; + const blob = new Blob([buffer]); return { blob, - file: new File([blob], filename, { type: blob.type }), + file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), }; } From f67e997688cf77057b94a860463707342100431e Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:34:38 +0200 Subject: [PATCH 61/94] refactor: offload peak computation to Web Worker, replace module cache with useRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move computePeaks into audioPeaksWorker.ts — runs off the main thread with zero-copy channel buffer transfer - Replace module-level peaksCache Map with a useRef scoped to the hook instance — no global mutable state, toggle off/on still instant Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/audioPeaksWorker.ts | 40 +++++++++++++++++++ src/hooks/useAudioPeaks.ts | 73 +++++++++++++++++++---------------- 2 files changed, 80 insertions(+), 33 deletions(-) create mode 100644 src/hooks/audioPeaksWorker.ts diff --git a/src/hooks/audioPeaksWorker.ts b/src/hooks/audioPeaksWorker.ts new file mode 100644 index 00000000..27812fd9 --- /dev/null +++ b/src/hooks/audioPeaksWorker.ts @@ -0,0 +1,40 @@ +/** + * Web Worker: computes min/max peak pairs from raw audio channel data. + * + * Input message: { channels: Float32Array[]; duration: number } + * Output message: Float32Array of length 2*N — [min0, max0, min1, max1, …] + * + * Channel buffers are transferred (zero-copy) from the caller. + * The peaks buffer is transferred back. + */ +self.onmessage = (event: MessageEvent<{ channels: Float32Array[]; duration: number }>) => { + const { channels, duration } = event.data; + const nCh = channels.length; + if (nCh === 0) { + (self as unknown as Worker).postMessage(new Float32Array(0)); + return; + } + + const totalSamples = channels[0].length; + const N = Math.min(24000, Math.ceil(duration * 200)); + const blockSize = totalSamples / N; + const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] + + for (let i = 0; i < N; i++) { + const start = Math.floor(i * blockSize); + const end = Math.floor((i + 1) * blockSize); + let minVal = 0; + let maxVal = 0; + for (let j = start; j < end; j++) { + let sample = 0; + for (let c = 0; c < nCh; c++) sample += channels[c][j]; + sample /= nCh; + if (sample < minVal) minVal = sample; + if (sample > maxVal) maxVal = sample; + } + peaks[i * 2] = minVal; + peaks[i * 2 + 1] = maxVal; + } + + (self as unknown as Worker).postMessage(peaks, [peaks.buffer]); +}; diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 00e60675..18de90a5 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -1,42 +1,44 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; -// Module-level cache keyed by URL — survives re-mounts within the same page session. -const peaksCache = new Map(); - let _audioCtx: AudioContext | null = null; function getAudioCtx(): AudioContext { if (!_audioCtx) _audioCtx = new AudioContext(); return _audioCtx; } -function computePeaks(audioBuffer: AudioBuffer): Float32Array { - const N = Math.min(24000, Math.ceil(audioBuffer.duration * 200)); - const nCh = audioBuffer.numberOfChannels; - const totalSamples = audioBuffer.length; - const blockSize = totalSamples / N; - const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …] +/** + * Offloads peak computation to a Web Worker (zero-copy via Transferable). + * Returns a Promise that resolves with the peaks Float32Array. + */ +function computePeaksInWorker(audioBuffer: AudioBuffer): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL("./audioPeaksWorker.ts", import.meta.url), { + type: "module", + }); - const channels: Float32Array[] = []; - for (let c = 0; c < nCh; c++) channels.push(audioBuffer.getChannelData(c)); - - for (let i = 0; i < N; i++) { - const start = Math.floor(i * blockSize); - const end = Math.floor((i + 1) * blockSize); - let minVal = 0; - let maxVal = 0; - for (let j = start; j < end; j++) { - let sample = 0; - for (let c = 0; c < nCh; c++) sample += channels[c][j]; - sample /= nCh; - if (sample < minVal) minVal = sample; - if (sample > maxVal) maxVal = sample; + // slice() creates an owned copy so the transfer is safe and the + // AudioBuffer remains valid if anything else holds a reference. + const channels: Float32Array[] = []; + for (let c = 0; c < audioBuffer.numberOfChannels; c++) { + channels.push(audioBuffer.getChannelData(c).slice()); } - peaks[i * 2] = minVal; - peaks[i * 2 + 1] = maxVal; - } - return peaks; + worker.onmessage = (e: MessageEvent) => { + worker.terminate(); + resolve(e.data); + }; + + worker.onerror = (e) => { + worker.terminate(); + reject(e); + }; + + worker.postMessage( + { channels, duration: audioBuffer.duration }, + channels.map((ch) => ch.buffer), + ); + }); } /** @@ -45,11 +47,15 @@ function computePeaks(audioBuffer: AudioBuffer): Float32Array { * decoding is in progress, and stays `null` when the file has no audio * track or decoding fails (silent degradation). * - * Results are cached at module scope by URL so re-mounts are free. + * - File loading uses the Electron IPC bridge for local paths (same as the exporter). + * - Peak computation runs in a Web Worker to avoid blocking the main thread. + * - Results are cached in a ref scoped to the hook instance (survives re-renders + * and waveform toggle off/on, but not component unmount). */ export function useAudioPeaks(videoUrl?: string): Float32Array | null { + const cacheRef = useRef>(new Map()); const [peaks, setPeaks] = useState(() => - videoUrl ? (peaksCache.get(videoUrl) ?? null) : null, + videoUrl ? (cacheRef.current.get(videoUrl) ?? null) : null, ); useEffect(() => { @@ -58,7 +64,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { return; } - const cached = peaksCache.get(videoUrl); + const cached = cacheRef.current.get(videoUrl); if (cached) { setPeaks(cached); return; @@ -72,8 +78,9 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { if (cancelled) return; const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); if (cancelled) return; - const p = computePeaks(audioBuffer); - peaksCache.set(videoUrl, p); + const p = await computePeaksInWorker(audioBuffer); + if (cancelled) return; + cacheRef.current.set(videoUrl, p); setPeaks(p); } catch { // No audio track or unsupported format — silent degradation. From b7d7c08906ab80932748ee012c6f73f2a47a251f Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 17:43:13 +0200 Subject: [PATCH 62/94] i18n: add Timeline panel translations for all 12 locales Add timeline.title and timeline.waveform keys to every settings.json locale file. Replace all hardcoded "Timeline" and "Show Audio Waveform on Trim Track" strings in SettingsPanel with t() calls. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/SettingsPanel.tsx | 8 ++++---- src/i18n/locales/ar/settings.json | 4 ++++ src/i18n/locales/en/settings.json | 4 ++++ src/i18n/locales/es/settings.json | 4 ++++ src/i18n/locales/fr/settings.json | 4 ++++ src/i18n/locales/it/settings.json | 4 ++++ src/i18n/locales/ja-JP/settings.json | 4 ++++ src/i18n/locales/ko-KR/settings.json | 4 ++++ src/i18n/locales/ru/settings.json | 4 ++++ src/i18n/locales/tr/settings.json | 4 ++++ src/i18n/locales/vi/settings.json | 4 ++++ src/i18n/locales/zh-CN/settings.json | 4 ++++ src/i18n/locales/zh-TW/settings.json | 4 ++++ 13 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 2f54c75f..6534180f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -608,7 +608,7 @@ export function SettingsPanel({ { id: "background", label: t("background.title"), icon: Palette }, { id: "effects", label: t("effects.title"), icon: SlidersHorizontal }, { id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam }, - { id: "timeline", label: "Timeline", icon: Brackets }, + { id: "timeline", label: t("timeline.title"), icon: Brackets }, ...(hasCursorPanel ? [ { @@ -631,7 +631,7 @@ export function SettingsPanel({ ? t("speed.playbackSpeed") : t("trim.deleteRegion") : activePanelMode === "timeline" - ? "Timeline" + ? t("timeline.title") : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? t("background.title")); @@ -1709,13 +1709,13 @@ export function SettingsPanel({
- Timeline + {t("timeline.title")}
- Show Audio Waveform on Trim Track + {t("timeline.waveform")}
Date: Sat, 23 May 2026 18:06:28 +0200 Subject: [PATCH 63/94] refactor: rename RowWaveform to BackgroundWaveform, drop wrapper div MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename to BackgroundWaveform to reflect its visual role rather than structural placement. Remove the redundant wrapper div — Row already provides relative overflow-hidden, so the canvas can sit absolute inset-0 directly and observe itself via ResizeObserver. Co-Authored-By: Claude Sonnet 4.6 --- ...RowWaveform.tsx => BackgroundWaveform.tsx} | 32 ++++++++----------- .../video-editor/timeline/TimelineEditor.tsx | 4 +-- 2 files changed, 15 insertions(+), 21 deletions(-) rename src/components/video-editor/timeline/{RowWaveform.tsx => BackgroundWaveform.tsx} (72%) diff --git a/src/components/video-editor/timeline/RowWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx similarity index 72% rename from src/components/video-editor/timeline/RowWaveform.tsx rename to src/components/video-editor/timeline/BackgroundWaveform.tsx index a77ee2c8..45426ebf 100644 --- a/src/components/video-editor/timeline/RowWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -1,39 +1,40 @@ import { useTimelineContext } from "dnd-timeline"; import { useEffect, useRef, useState } from "react"; -export interface RowWaveformProps { +export interface BackgroundWaveformProps { /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */ peaks: Float32Array | null; videoDurationMs: number; } /** - * Renders a faint audio waveform on a `` element that fills its - * containing row. Designed to be passed as the `background` prop of ``. + * Renders a faint audio waveform on a `` that fills its containing + * block. Designed to be passed as the `background` prop of ``, which + * already provides `relative overflow-hidden` — no wrapper element needed. * * - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`). * - Redraws whenever the timeline zoom/pan range changes. - * - `pointer-events: none` throughout — never blocks drag-to-create interactions. + * - `pointer-events: none` — never blocks drag-to-create interactions. */ -export default function RowWaveform({ peaks, videoDurationMs }: RowWaveformProps) { +export default function BackgroundWaveform({ peaks, videoDurationMs }: BackgroundWaveformProps) { const { range } = useTimelineContext(); const canvasRef = useRef(null); - const wrapperRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); - // Track container dimensions via ResizeObserver. + // Observe the canvas itself — Row's `relative overflow-hidden` parent + // makes it fill the row exactly, so no wrapper div is needed. useEffect(() => { - const el = wrapperRef.current; - if (!el) return; + const canvas = canvasRef.current; + if (!canvas) return; const ro = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; setCanvasSize({ w: width, h: height }); }); - ro.observe(el); + ro.observe(canvas); return () => ro.disconnect(); }, []); - // Redraw whenever peaks, range, or container size changes. + // Redraw whenever peaks, range, or canvas size changes. useEffect(() => { const canvas = canvasRef.current; if (!canvas || !peaks || canvasSize.w <= 0 || canvasSize.h <= 0) return; @@ -83,12 +84,5 @@ export default function RowWaveform({ peaks, videoDurationMs }: RowWaveformProps ctx.stroke(); }, [peaks, range, canvasSize, videoDurationMs]); - return ( -
- -
- ); + return ; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 3bb420d8..734e6e1e 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -35,10 +35,10 @@ import type { ZoomFocus, ZoomRegion, } from "../types"; +import BackgroundWaveform from "./BackgroundWaveform"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; -import RowWaveform from "./RowWaveform"; import TimelineWrapper from "./TimelineWrapper"; import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; @@ -803,7 +803,7 @@ function Timeline({ hint={t("hints.pressTrim")} background={ showTrimWaveform ? ( - + ) : undefined } > From f19d1bc668873bc094b166b939d35b52f54de785 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 18:10:36 +0200 Subject: [PATCH 64/94] fix: clear stale peaks before decoding a new source Reset peaks to null immediately when an uncached URL is set, so the previous video's waveform is not shown during decode. Also clear on decode failure so stale data does not persist indefinitely. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAudioPeaks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 18de90a5..38875370 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -70,6 +70,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { return; } + setPeaks(null); let cancelled = false; (async () => { @@ -83,7 +84,8 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { cacheRef.current.set(videoUrl, p); setPeaks(p); } catch { - // No audio track or unsupported format — silent degradation. + // No audio track or unsupported format — clear stale data silently. + if (!cancelled) setPeaks(null); } })(); From 87f7268de7b3723920e429e46b5e5008b3b10bfd Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 18:11:44 +0200 Subject: [PATCH 65/94] i18n(zh-TW): align waveform label with existing trim terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use 剪輯 (editing/clip) instead of 修剪 (trimming) to match the term already used in the trim section throughout zh-TW strings. Co-Authored-By: Claude Sonnet 4.6 --- src/i18n/locales/zh-TW/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 202a4ed9..e579aeae 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -207,6 +207,6 @@ }, "timeline": { "title": "時間軸", - "waveform": "在修剪軌道上顯示音訊波形" + "waveform": "在剪輯軌道上顯示音訊波形" } } From 9f55611f72ca3ae1db6321115b5c04fcf60b273a Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 18:12:54 +0200 Subject: [PATCH 66/94] fix: always clear canvas before bailing on empty peaks Move the peaks null-check after clearRect so a previously drawn waveform is erased when peaks transitions to null (e.g. on URL change or decode failure) rather than lingering on screen. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/timeline/BackgroundWaveform.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index 45426ebf..05d8e650 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -37,7 +37,7 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun // Redraw whenever peaks, range, or canvas size changes. useEffect(() => { const canvas = canvasRef.current; - if (!canvas || !peaks || canvasSize.w <= 0 || canvasSize.h <= 0) return; + if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return; const dpr = window.devicePixelRatio || 1; canvas.width = Math.round(canvasSize.w * dpr); @@ -49,6 +49,8 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun ctx.scale(dpr, dpr); ctx.clearRect(0, 0, canvasSize.w, canvasSize.h); + if (!peaks || peaks.length === 0) return; + const W = canvasSize.w; const H = canvasSize.h; const mid = H / 2; From 9a72f31148aa8b0461fb92a42fac9e1deb1eaea2 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 18:33:27 +0200 Subject: [PATCH 67/94] docs: add missing JSDoc to satisfy docstring coverage threshold Add docstrings to all undocumented functions and methods across the files touched in this branch: getAudioCtx, Row component, and all StreamingVideoDecoder methods (loadSourceFile, computeSegments, getExportMetrics, splitBySpeed, getDemuxer, cancel, destroy, withTimeout). Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/timeline/Row.tsx | 5 +++++ src/hooks/useAudioPeaks.ts | 1 + src/lib/exporter/streamingDecoder.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index f3d11bcf..17a59e83 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -8,6 +8,11 @@ interface RowProps extends RowDefinition { background?: React.ReactNode; } +/** + * A single horizontal lane in the timeline. Wraps the dnd-timeline `useRow` + * hook and adds an optional `background` layer (e.g. `BackgroundWaveform`), + * an empty-state hint label, and a minimum height. + */ export default function Row({ id, children, hint, isEmpty, background }: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 38875370..d640e961 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder"; let _audioCtx: AudioContext | null = null; +/** Returns the shared AudioContext, creating it lazily on first call. */ function getAudioCtx(): AudioContext { if (!_audioCtx) _audioCtx = new AudioContext(); return _audioCtx; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index f7687233..fbe1453f 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -180,6 +180,7 @@ export class StreamingVideoDecoder { private cancelled = false; private metadata: DecodedVideoInfo | null = null; + /** Loads the video file and returns it as both a Blob and a File for WebDemuxer. */ private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const buffer = await this.withTimeout( loadFileAsArrayBuffer(videoUrl), @@ -616,6 +617,10 @@ export class StreamingVideoDecoder { } } + /** + * Converts trim regions into the segments that should be kept. + * Returns a single full-duration segment when no trim regions are present. + */ private computeSegments( totalDuration: number, trimRegions?: TrimRegion[], @@ -644,6 +649,11 @@ export class StreamingVideoDecoder { return segments; } + /** + * Calculates the effective output duration (in seconds) and total frame count + * for a given combination of trim and speed regions at the target frame rate. + * Requires `loadMetadata()` to have been called first. + */ getExportMetrics( targetFrameRate: number, trimRegions?: TrimRegion[], @@ -664,6 +674,10 @@ export class StreamingVideoDecoder { }; } + /** + * Splits keep-segments by overlapping speed regions, annotating each + * sub-segment with its playback speed multiplier (defaults to 1×). + */ private splitBySpeed( segments: Array<{ startSec: number; endSec: number }>, speedRegions?: SpeedRegion[], @@ -696,14 +710,17 @@ export class StreamingVideoDecoder { return result.filter((s) => s.endSec - s.startSec > 0.0001); } + /** Returns the underlying WebDemuxer instance, or null if not yet loaded. */ getDemuxer(): WebDemuxer | null { return this.demuxer; } + /** Signals the decoder to stop processing at the next cancellation checkpoint. */ cancel(): void { this.cancelled = true; } + /** Cancels decoding and releases the VideoDecoder and WebDemuxer resources. */ destroy(): void { this.cancelled = true; @@ -726,6 +743,7 @@ export class StreamingVideoDecoder { } } + /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { return new Promise((resolve, reject) => { const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); From 928cabc68df85909b3bd1164f0b031acb8c93c8a Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sat, 23 May 2026 19:04:35 +0200 Subject: [PATCH 68/94] fix: add AbortSignal to worker, fix data: URL filename in loadSourceFile - computePeaksInWorker now accepts an AbortSignal; terminates the worker immediately on abort and rejects with AbortError so orphaned workers cannot outlive the effect cleanup - useAudioPeaks wires an AbortController into the effect; controller.abort() fires alongside the existing cancelled flag on URL change or unmount - loadSourceFile handles data: URLs by parsing the MIME type, deriving a safe filename (e.g. video.mp4), and setting Blob type correctly Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAudioPeaks.ts | 29 ++++++++++++++++++++++++---- src/lib/exporter/streamingDecoder.ts | 17 ++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index d640e961..1538bc94 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -10,14 +10,29 @@ function getAudioCtx(): AudioContext { /** * Offloads peak computation to a Web Worker (zero-copy via Transferable). - * Returns a Promise that resolves with the peaks Float32Array. + * Accepts an optional AbortSignal — if aborted, the worker is terminated + * immediately and the promise rejects with an AbortError. */ -function computePeaksInWorker(audioBuffer: AudioBuffer): Promise { +function computePeaksInWorker( + audioBuffer: AudioBuffer, + signal?: AbortSignal, +): Promise { return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const worker = new Worker(new URL("./audioPeaksWorker.ts", import.meta.url), { type: "module", }); + const onAbort = () => { + worker.terminate(); + reject(new DOMException("Aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + // slice() creates an owned copy so the transfer is safe and the // AudioBuffer remains valid if anything else holds a reference. const channels: Float32Array[] = []; @@ -26,11 +41,13 @@ function computePeaksInWorker(audioBuffer: AudioBuffer): Promise { } worker.onmessage = (e: MessageEvent) => { + signal?.removeEventListener("abort", onAbort); worker.terminate(); resolve(e.data); }; worker.onerror = (e) => { + signal?.removeEventListener("abort", onAbort); worker.terminate(); reject(e); }; @@ -73,6 +90,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { setPeaks(null); let cancelled = false; + const controller = new AbortController(); (async () => { try { @@ -80,11 +98,13 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { if (cancelled) return; const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); if (cancelled) return; - const p = await computePeaksInWorker(audioBuffer); + const p = await computePeaksInWorker(audioBuffer, controller.signal); if (cancelled) return; cacheRef.current.set(videoUrl, p); setPeaks(p); - } catch { + } catch (err) { + // AbortError means the effect cleaned up — no state update needed. + if (err instanceof DOMException && err.name === "AbortError") return; // No audio track or unsupported format — clear stale data silently. if (!cancelled) setPeaks(null); } @@ -92,6 +112,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { return () => { cancelled = true; + controller.abort(); }; }, [videoUrl]); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index fbe1453f..71adfa12 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -187,8 +187,21 @@ export class StreamingVideoDecoder { SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); - const filename = videoUrl.split(/[\\/]/).pop() || "video"; - const blob = new Blob([buffer]); + + let filename: string; + let mime = ""; + + if (videoUrl.startsWith("data:")) { + // data:video/mp4;base64,... → mime="video/mp4", filename="video.mp4" + const mimeMatch = videoUrl.match(/^data:([^;,]+)/); + mime = mimeMatch?.[1] ?? ""; + const ext = mime.split("/")[1]?.replace(/[^a-z0-9]/gi, "") ?? ""; + filename = ext ? `video.${ext}` : "video"; + } else { + filename = videoUrl.split(/[\\/]/).pop() || "video"; + } + + const blob = new Blob([buffer], { type: mime }); return { blob, file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), From d06bf3747134248d2fb1fdcb725339909329f984 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 11:30:06 +0200 Subject: [PATCH 69/94] fix: propagate Content-Type from fetch through loadFileAsArrayBuffer Change return type to { data, contentType } so the fetched Content-Type header is available to callers. loadSourceFile uses it to set the Blob MIME type for https:/blob: URLs instead of always falling back to application/octet-stream. useAudioPeaks destructures only { data }. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAudioPeaks.ts | 2 +- src/lib/exporter/streamingDecoder.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/hooks/useAudioPeaks.ts b/src/hooks/useAudioPeaks.ts index 1538bc94..3be6ac61 100644 --- a/src/hooks/useAudioPeaks.ts +++ b/src/hooks/useAudioPeaks.ts @@ -94,7 +94,7 @@ export function useAudioPeaks(videoUrl?: string): Float32Array | null { (async () => { try { - const arrayBuffer = await loadFileAsArrayBuffer(videoUrl); + const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl); if (cancelled) return; const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer); if (cancelled) return; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 71adfa12..6f28127c 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -140,9 +140,13 @@ export function shouldFailDecodeEndedEarly({ /** * Loads a video file as an ArrayBuffer, using the Electron IPC bridge for * local paths and falling back to `fetch` for remote / blob / data URLs. + * Also returns the `contentType` from the response headers (empty string for + * local IPC reads where no Content-Type is available). * This is the single canonical place for reading raw video bytes in the renderer. */ -export async function loadFileAsArrayBuffer(videoUrl: string): Promise { +export async function loadFileAsArrayBuffer( + videoUrl: string, +): Promise<{ data: ArrayBuffer; contentType: string }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { @@ -150,14 +154,15 @@ export async function loadFileAsArrayBuffer(videoUrl: string): Promise { - const buffer = await this.withTimeout( + const { data: buffer, contentType } = await this.withTimeout( loadFileAsArrayBuffer(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", @@ -198,6 +203,7 @@ export class StreamingVideoDecoder { const ext = mime.split("/")[1]?.replace(/[^a-z0-9]/gi, "") ?? ""; filename = ext ? `video.${ext}` : "video"; } else { + mime = contentType; filename = videoUrl.split(/[\\/]/).pop() || "video"; } From 69d0aa4d30c5cb82eed2f0b0f473313916331570 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 11:43:22 +0200 Subject: [PATCH 70/94] refactor: split loadSourceFile into loadLocalSourceFile and loadRemoteSourceFile Restore loadSourceFile as a thin router (local vs remote) and extract the IPC and fetch paths into dedicated private methods, each containing the original logic from main. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/exporter/streamingDecoder.ts | 239 ++++++++++++++++++++------- 1 file changed, 181 insertions(+), 58 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 6f28127c..ac3dffb4 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -19,7 +19,11 @@ function buildAV1CodecString(description?: BufferSource): string { const bytes = description instanceof ArrayBuffer ? new Uint8Array(description) - : new Uint8Array(description.buffer, description.byteOffset, description.byteLength); + : new Uint8Array( + description.buffer, + description.byteOffset, + description.byteLength, + ); // AV1CodecConfigurationRecord layout (4+ bytes): // Byte 0: marker (1) | version (7) @@ -87,16 +91,24 @@ const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60; * @param containerDuration Duration from the container-level metadata * @param scannedDuration Duration derived from actual packet timestamps (ground truth) */ -export function validateDuration(containerDuration: number, scannedDuration: number): number { +export function validateDuration( + containerDuration: number, + scannedDuration: number, +): number { if (scannedDuration <= 0) { // Zero scanned duration means corrupted/empty file — fall back to container // (downstream shouldFailDecodeEndedEarly will catch truly empty files) - return Number.isFinite(containerDuration) ? Math.max(containerDuration, 0) : 0; + return Number.isFinite(containerDuration) + ? Math.max(containerDuration, 0) + : 0; } if (!Number.isFinite(containerDuration) || containerDuration <= 0) { return scannedDuration; } - if (Math.abs(containerDuration - scannedDuration) > DURATION_DIVERGENCE_THRESHOLD_SEC) { + if ( + Math.abs(containerDuration - scannedDuration) > + DURATION_DIVERGENCE_THRESHOLD_SEC + ) { return scannedDuration; } return containerDuration; @@ -121,16 +133,27 @@ export function shouldFailDecodeEndedEarly({ return false; } - if (typeof streamDurationSec !== "number" || !Number.isFinite(streamDurationSec)) { + if ( + typeof streamDurationSec !== "number" || + !Number.isFinite(streamDurationSec) + ) { return true; } const metadataTailSec = requiredEndSec - streamDurationSec; const decodedNearStreamEnd = - Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC; + Math.abs(lastDecodedFrameSec - streamDurationSec) <= + STREAM_DURATION_MATCH_TOLERANCE_SEC; - const maxTailSec = Math.max(METADATA_TAIL_TOLERANCE_SEC, requiredEndSec * 0.01); - if (decodedNearStreamEnd && metadataTailSec > 0 && metadataTailSec <= maxTailSec) { + const maxTailSec = Math.max( + METADATA_TAIL_TOLERANCE_SEC, + requiredEndSec * 0.01, + ); + if ( + decodedNearStreamEnd && + metadataTailSec > 0 && + metadataTailSec <= maxTailSec + ) { return false; } @@ -152,16 +175,21 @@ export async function loadFileAsArrayBuffer( if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { - throw new Error(result.message ?? result.error ?? "Failed to read video file"); + throw new Error( + result.message ?? result.error ?? "Failed to read video file", + ); } return { data: result.data, contentType: "" }; } const response = await fetch(videoUrl); if (!response.ok) { - throw new Error(`Failed to fetch video file: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to fetch video file: ${response.status} ${response.statusText}`, + ); } - const contentType = response.headers.get("content-type")?.split(";")[0].trim() ?? ""; + const contentType = + response.headers.get("content-type")?.split(";")[0].trim() ?? ""; return { data: await response.arrayBuffer(), contentType }; } @@ -185,32 +213,65 @@ export class StreamingVideoDecoder { private cancelled = false; private metadata: DecodedVideoInfo | null = null; - /** Loads the video file and returns it as both a Blob and a File for WebDemuxer. */ - private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { - const { data: buffer, contentType } = await this.withTimeout( - loadFileAsArrayBuffer(videoUrl), + /** Routes to the appropriate loader based on whether the source is local or remote. */ + private async loadSourceFile( + videoUrl: string, + ): Promise<{ file: File; blob: Blob }> { + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + if (!isRemoteUrl && window.electronAPI) { + return this.loadLocalSourceFile(videoUrl); + } + return this.loadRemoteSourceFile(videoUrl); + } + + /** Loads a local video file via the Electron IPC bridge. */ + private async loadLocalSourceFile( + videoUrl: string, + ): Promise<{ file: File; blob: Blob }> { + const result = await this.withTimeout( + window.electronAPI.readBinaryFile(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); - - let filename: string; - let mime = ""; - - if (videoUrl.startsWith("data:")) { - // data:video/mp4;base64,... → mime="video/mp4", filename="video.mp4" - const mimeMatch = videoUrl.match(/^data:([^;,]+)/); - mime = mimeMatch?.[1] ?? ""; - const ext = mime.split("/")[1]?.replace(/[^a-z0-9]/gi, "") ?? ""; - filename = ext ? `video.${ext}` : "video"; - } else { - mime = contentType; - filename = videoUrl.split(/[\\/]/).pop() || "video"; + if (!result.success || !result.data) { + throw new Error( + result.message || result.error || "Failed to read source video", + ); } - const blob = new Blob([buffer], { type: mime }); + const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; + const blob = new Blob([result.data]); return { blob, - file: new File([blob], filename, { type: blob.type || "application/octet-stream" }), + file: new File([blob], filename, { + type: blob.type || "application/octet-stream", + }), + }; + } + + /** Loads a remote or blob video URL via fetch. */ + private async loadRemoteSourceFile( + videoUrl: string, + ): Promise<{ file: File; blob: Blob }> { + const response = await this.withTimeout( + fetch(videoUrl), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while loading the source video.", + ); + if (!response.ok) { + throw new Error( + `Failed to fetch source video: ${response.status} ${response.statusText}`, + ); + } + const blob = await this.withTimeout( + response.blob(), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while reading the source video.", + ); + const filename = videoUrl.split("/").pop() || "video"; + return { + blob, + file: new File([blob], filename, { type: blob.type }), }; } @@ -218,7 +279,8 @@ export class StreamingVideoDecoder { const { file } = await this.loadSourceFile(videoUrl); // Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds - const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href) + .href; this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); await this.withTimeout( this.demuxer.load(file), @@ -231,7 +293,9 @@ export class StreamingVideoDecoder { SOURCE_LOAD_TIMEOUT_MS, "Timed out while reading video metadata.", ); - const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video"); + const videoStream = mediaInfo.streams.find( + (s) => s.codec_type_string === "video", + ); let frameRate = 60; if (videoStream?.avg_frame_rate) { @@ -243,7 +307,9 @@ export class StreamingVideoDecoder { } } - const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio"); + const audioStream = mediaInfo.streams.find( + (s) => s.codec_type_string === "audio", + ); // Scan video packets to find the true content boundary. // MediaRecorder (especially on Linux) writes unreliable container durations. @@ -251,14 +317,23 @@ export class StreamingVideoDecoder { // Pass explicit range because some containers are truncated without one. // Sanitize because mediaInfo.duration can be NaN/Infinity (Chromium Linux bug), // which would propagate into demuxer.read() as an invalid endpoint. - const containerDurationSec = Number.isFinite(mediaInfo.duration) ? mediaInfo.duration : 0; + const containerDurationSec = Number.isFinite(mediaInfo.duration) + ? mediaInfo.duration + : 0; const streamDurationSec = - typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) + typeof videoStream?.duration === "number" && + Number.isFinite(videoStream.duration) ? videoStream.duration : 0; - const hintedDurationSec = Math.max(containerDurationSec, streamDurationSec, 0); + const hintedDurationSec = Math.max( + containerDurationSec, + streamDurationSec, + 0, + ); const scanEndSec = - hintedDurationSec > 0 ? hintedDurationSec + 0.5 : SCAN_UNBOUNDED_FALLBACK_SEC; + hintedDurationSec > 0 + ? hintedDurationSec + 0.5 + : SCAN_UNBOUNDED_FALLBACK_SEC; let maxPacketEndUs = 0; const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader(); try { @@ -276,14 +351,18 @@ export class StreamingVideoDecoder { } } const scannedDuration = maxPacketEndUs / 1_000_000; - const validatedDuration = validateDuration(mediaInfo.duration, scannedDuration); + const validatedDuration = validateDuration( + mediaInfo.duration, + scannedDuration, + ); this.metadata = { width: videoStream?.width || 1920, height: videoStream?.height || 1080, duration: validatedDuration, streamDuration: - typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) + typeof videoStream?.duration === "number" && + Number.isFinite(videoStream.duration) ? videoStream.duration : undefined, frameRate, @@ -316,8 +395,14 @@ export class StreamingVideoDecoder { const decoderConfig = await this.demuxer.getDecoderConfig("video"); - console.log("[StreamingVideoDecoder] decoderConfig.codec:", decoderConfig.codec); - console.log("[StreamingVideoDecoder] decoderConfig.description:", decoderConfig.description); + console.log( + "[StreamingVideoDecoder] decoderConfig.codec:", + decoderConfig.codec, + ); + console.log( + "[StreamingVideoDecoder] decoderConfig.description:", + decoderConfig.description, + ); // web-demuxer may return bare four-character code strings ("av01", "vp08", // "vp09", "avc1") that WebCodecs rejects. Normalize them to the short or @@ -356,7 +441,8 @@ export class StreamingVideoDecoder { const segmentOutputFrameCounts = segments.map((segment) => Math.ceil( - ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate, + ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * + targetFrameRate, ), ); const frameDurationUs = 1_000_000 / targetFrameRate; @@ -398,7 +484,9 @@ export class StreamingVideoDecoder { : decoderConfig; try { - const support = await VideoDecoder.isConfigSupported(preferredDecoderConfig); + const support = await VideoDecoder.isConfigSupported( + preferredDecoderConfig, + ); console.log( `[StreamingVideoDecoder] isConfigSupported for "${preferredDecoderConfig.codec}":`, support.supported, @@ -424,7 +512,8 @@ export class StreamingVideoDecoder { const getNextFrame = (): Promise => { if (decodeError) throw decodeError; - if (pendingFrames.length > 0) return Promise.resolve(pendingFrames.shift()!); + if (pendingFrames.length > 0) + return Promise.resolve(pendingFrames.shift()!); if (decodeDone) return Promise.resolve(null); return new Promise((resolve) => { frameResolve = resolve; @@ -488,11 +577,18 @@ export class StreamingVideoDecoder { if (segmentFrameIndex >= segmentFrameCount) return false; const sourceTimeSec = - segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed; + segment.startSec + + (segmentFrameIndex / targetFrameRate) * segment.speed; if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false; - const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); - await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); + const clone = new VideoFrame(heldFrame, { + timestamp: heldFrame.timestamp, + }); + await onFrame( + clone, + exportFrameIndex * frameDurationUs, + sourceTimeSec * 1000, + ); segmentFrameIndex++; exportFrameIndex++; return true; @@ -555,7 +651,8 @@ export class StreamingVideoDecoder { } const sourceTimeSec = - currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed; + currentSegment.startSec + + (segmentFrameIndex / targetFrameRate) * currentSegment.speed; if (sourceTimeSec >= currentSegment.endSec - EPSILON_SEC) { break; } @@ -563,8 +660,14 @@ export class StreamingVideoDecoder { break; } - const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); - await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); + const clone = new VideoFrame(heldFrame, { + timestamp: heldFrame.timestamp, + }); + await onFrame( + clone, + exportFrameIndex * frameDurationUs, + sourceTimeSec * 1000, + ); segmentFrameIndex++; exportFrameIndex++; } @@ -629,7 +732,9 @@ export class StreamingVideoDecoder { }) ) { const decodedAtLabel = - lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`; + lastDecodedFrameSec === null + ? "no decoded frame" + : `${lastDecodedFrameSec.toFixed(3)}s`; const message = `Decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s) – export may be slightly shorter than expected.`; console.warn(`[StreamingVideoDecoder] ${message}`); onWarning?.(message); @@ -679,7 +784,10 @@ export class StreamingVideoDecoder { speedRegions?: SpeedRegion[], ): { effectiveDuration: number; totalFrames: number } { if (!this.metadata) throw new Error("Must call loadMetadata() first"); - const trimSegments = this.computeSegments(this.metadata.duration, trimRegions); + const trimSegments = this.computeSegments( + this.metadata.duration, + trimRegions, + ); const segments = this.splitBySpeed(trimSegments, speedRegions); return { effectiveDuration: segments.reduce( @@ -688,7 +796,9 @@ export class StreamingVideoDecoder { ), totalFrames: segments.reduce((sum, seg) => { const segDur = seg.endSec - seg.startSec - EPSILON_SEC; - return sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)); + return ( + sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)) + ); }, 0), }; } @@ -704,10 +814,15 @@ export class StreamingVideoDecoder { if (!speedRegions || speedRegions.length === 0) return segments.map((s) => ({ ...s, speed: 1 })); - const result: Array<{ startSec: number; endSec: number; speed: number }> = []; + const result: Array<{ startSec: number; endSec: number; speed: number }> = + []; for (const segment of segments) { const overlapping = speedRegions - .filter((sr) => sr.startMs / 1000 < segment.endSec && sr.endMs / 1000 > segment.startSec) + .filter( + (sr) => + sr.startMs / 1000 < segment.endSec && + sr.endMs / 1000 > segment.startSec, + ) .sort((a, b) => a.startMs - b.startMs); if (overlapping.length === 0) { @@ -719,7 +834,8 @@ export class StreamingVideoDecoder { for (const sr of overlapping) { const srStart = Math.max(sr.startMs / 1000, segment.startSec); const srEnd = Math.min(sr.endMs / 1000, segment.endSec); - if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 }); + if (cursor < srStart) + result.push({ startSec: cursor, endSec: srStart, speed: 1 }); result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed }); cursor = srEnd; } @@ -763,9 +879,16 @@ export class StreamingVideoDecoder { } /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ - private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + private withTimeout( + promise: Promise, + timeoutMs: number, + message: string, + ): Promise { return new Promise((resolve, reject) => { - const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); + const timer = window.setTimeout( + () => reject(new Error(message)), + timeoutMs, + ); promise.then( (value) => { window.clearTimeout(timer); From db1d6bfe96cde771d106997da9b260537ce7e4ba Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 11:47:51 +0200 Subject: [PATCH 71/94] refactor: rewrite loadFileAsArrayBuffer using static loadLocalSourceFile/loadRemoteSourceFile Make loadLocalSourceFile and loadRemoteSourceFile static so they can be called without an instance. loadSourceFile applies withTimeout around each static call. loadFileAsArrayBuffer delegates to the same static methods and extracts ArrayBuffer + contentType from the blob, eliminating the duplicated IPC/fetch branching logic. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/exporter/streamingDecoder.ts | 70 +++++++++++----------------- 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index ac3dffb4..1b686e50 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -161,36 +161,24 @@ export function shouldFailDecodeEndedEarly({ } /** - * Loads a video file as an ArrayBuffer, using the Electron IPC bridge for - * local paths and falling back to `fetch` for remote / blob / data URLs. - * Also returns the `contentType` from the response headers (empty string for - * local IPC reads where no Content-Type is available). - * This is the single canonical place for reading raw video bytes in the renderer. + * Loads a video file as an ArrayBuffer, delegating to + * `StreamingVideoDecoder.loadLocalSourceFile` for local paths (Electron IPC) + * and `StreamingVideoDecoder.loadRemoteSourceFile` for remote / blob / data URLs. + * Also returns the `contentType` derived from the blob (empty string for local + * IPC reads where no Content-Type is available). */ export async function loadFileAsArrayBuffer( videoUrl: string, ): Promise<{ data: ArrayBuffer; contentType: string }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); - if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { - const result = await window.electronAPI.readBinaryFile(videoUrl); - if (!result.success || !result.data) { - throw new Error( - result.message ?? result.error ?? "Failed to read video file", - ); - } - return { data: result.data, contentType: "" }; + if (!isRemoteUrl && window.electronAPI) { + const { blob } = await StreamingVideoDecoder.loadLocalSourceFile(videoUrl); + return { data: await blob.arrayBuffer(), contentType: "" }; } - const response = await fetch(videoUrl); - if (!response.ok) { - throw new Error( - `Failed to fetch video file: ${response.status} ${response.statusText}`, - ); - } - const contentType = - response.headers.get("content-type")?.split(";")[0].trim() ?? ""; - return { data: await response.arrayBuffer(), contentType }; + const { blob } = await StreamingVideoDecoder.loadRemoteSourceFile(videoUrl); + return { data: await blob.arrayBuffer(), contentType: blob.type }; } /** Caller must close the VideoFrame after use. */ @@ -219,20 +207,24 @@ export class StreamingVideoDecoder { ): Promise<{ file: File; blob: Blob }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { - return this.loadLocalSourceFile(videoUrl); + return this.withTimeout( + StreamingVideoDecoder.loadLocalSourceFile(videoUrl), + SOURCE_LOAD_TIMEOUT_MS, + "Timed out while loading the source video.", + ); } - return this.loadRemoteSourceFile(videoUrl); - } - - /** Loads a local video file via the Electron IPC bridge. */ - private async loadLocalSourceFile( - videoUrl: string, - ): Promise<{ file: File; blob: Blob }> { - const result = await this.withTimeout( - window.electronAPI.readBinaryFile(videoUrl), + return this.withTimeout( + StreamingVideoDecoder.loadRemoteSourceFile(videoUrl), SOURCE_LOAD_TIMEOUT_MS, "Timed out while loading the source video.", ); + } + + /** Loads a local video file via the Electron IPC bridge. */ + static async loadLocalSourceFile( + videoUrl: string, + ): Promise<{ file: File; blob: Blob }> { + const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { throw new Error( result.message || result.error || "Failed to read source video", @@ -250,24 +242,16 @@ export class StreamingVideoDecoder { } /** Loads a remote or blob video URL via fetch. */ - private async loadRemoteSourceFile( + static async loadRemoteSourceFile( videoUrl: string, ): Promise<{ file: File; blob: Blob }> { - const response = await this.withTimeout( - fetch(videoUrl), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while loading the source video.", - ); + const response = await fetch(videoUrl); if (!response.ok) { throw new Error( `Failed to fetch source video: ${response.status} ${response.statusText}`, ); } - const blob = await this.withTimeout( - response.blob(), - SOURCE_LOAD_TIMEOUT_MS, - "Timed out while reading the source video.", - ); + const blob = await response.blob(); const filename = videoUrl.split("/").pop() || "video"; return { blob, From c0b929d4b2e7b4dba5be11c5ac8d2dbcd0012abc Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 11:53:44 +0200 Subject: [PATCH 72/94] refactor: simplify function signatures and reduce line breaks for readability --- src/lib/exporter/streamingDecoder.ts | 169 +++++++-------------------- 1 file changed, 39 insertions(+), 130 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 1b686e50..0e429cb6 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -19,11 +19,7 @@ function buildAV1CodecString(description?: BufferSource): string { const bytes = description instanceof ArrayBuffer ? new Uint8Array(description) - : new Uint8Array( - description.buffer, - description.byteOffset, - description.byteLength, - ); + : new Uint8Array(description.buffer, description.byteOffset, description.byteLength); // AV1CodecConfigurationRecord layout (4+ bytes): // Byte 0: marker (1) | version (7) @@ -91,24 +87,16 @@ const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60; * @param containerDuration Duration from the container-level metadata * @param scannedDuration Duration derived from actual packet timestamps (ground truth) */ -export function validateDuration( - containerDuration: number, - scannedDuration: number, -): number { +export function validateDuration(containerDuration: number, scannedDuration: number): number { if (scannedDuration <= 0) { // Zero scanned duration means corrupted/empty file — fall back to container // (downstream shouldFailDecodeEndedEarly will catch truly empty files) - return Number.isFinite(containerDuration) - ? Math.max(containerDuration, 0) - : 0; + return Number.isFinite(containerDuration) ? Math.max(containerDuration, 0) : 0; } if (!Number.isFinite(containerDuration) || containerDuration <= 0) { return scannedDuration; } - if ( - Math.abs(containerDuration - scannedDuration) > - DURATION_DIVERGENCE_THRESHOLD_SEC - ) { + if (Math.abs(containerDuration - scannedDuration) > DURATION_DIVERGENCE_THRESHOLD_SEC) { return scannedDuration; } return containerDuration; @@ -133,27 +121,16 @@ export function shouldFailDecodeEndedEarly({ return false; } - if ( - typeof streamDurationSec !== "number" || - !Number.isFinite(streamDurationSec) - ) { + if (typeof streamDurationSec !== "number" || !Number.isFinite(streamDurationSec)) { return true; } const metadataTailSec = requiredEndSec - streamDurationSec; const decodedNearStreamEnd = - Math.abs(lastDecodedFrameSec - streamDurationSec) <= - STREAM_DURATION_MATCH_TOLERANCE_SEC; + Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC; - const maxTailSec = Math.max( - METADATA_TAIL_TOLERANCE_SEC, - requiredEndSec * 0.01, - ); - if ( - decodedNearStreamEnd && - metadataTailSec > 0 && - metadataTailSec <= maxTailSec - ) { + const maxTailSec = Math.max(METADATA_TAIL_TOLERANCE_SEC, requiredEndSec * 0.01); + if (decodedNearStreamEnd && metadataTailSec > 0 && metadataTailSec <= maxTailSec) { return false; } @@ -202,9 +179,7 @@ export class StreamingVideoDecoder { private metadata: DecodedVideoInfo | null = null; /** Routes to the appropriate loader based on whether the source is local or remote. */ - private async loadSourceFile( - videoUrl: string, - ): Promise<{ file: File; blob: Blob }> { + private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); if (!isRemoteUrl && window.electronAPI) { return this.withTimeout( @@ -221,14 +196,10 @@ export class StreamingVideoDecoder { } /** Loads a local video file via the Electron IPC bridge. */ - static async loadLocalSourceFile( - videoUrl: string, - ): Promise<{ file: File; blob: Blob }> { + static async loadLocalSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const result = await window.electronAPI.readBinaryFile(videoUrl); if (!result.success || !result.data) { - throw new Error( - result.message || result.error || "Failed to read source video", - ); + throw new Error(result.message || result.error || "Failed to read source video"); } const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video"; @@ -242,14 +213,10 @@ export class StreamingVideoDecoder { } /** Loads a remote or blob video URL via fetch. */ - static async loadRemoteSourceFile( - videoUrl: string, - ): Promise<{ file: File; blob: Blob }> { + static async loadRemoteSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> { const response = await fetch(videoUrl); if (!response.ok) { - throw new Error( - `Failed to fetch source video: ${response.status} ${response.statusText}`, - ); + throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`); } const blob = await response.blob(); const filename = videoUrl.split("/").pop() || "video"; @@ -263,8 +230,7 @@ export class StreamingVideoDecoder { const { file } = await this.loadSourceFile(videoUrl); // Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds - const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href) - .href; + const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href; this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl }); await this.withTimeout( this.demuxer.load(file), @@ -277,9 +243,7 @@ export class StreamingVideoDecoder { SOURCE_LOAD_TIMEOUT_MS, "Timed out while reading video metadata.", ); - const videoStream = mediaInfo.streams.find( - (s) => s.codec_type_string === "video", - ); + const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video"); let frameRate = 60; if (videoStream?.avg_frame_rate) { @@ -291,9 +255,7 @@ export class StreamingVideoDecoder { } } - const audioStream = mediaInfo.streams.find( - (s) => s.codec_type_string === "audio", - ); + const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio"); // Scan video packets to find the true content boundary. // MediaRecorder (especially on Linux) writes unreliable container durations. @@ -301,23 +263,14 @@ export class StreamingVideoDecoder { // Pass explicit range because some containers are truncated without one. // Sanitize because mediaInfo.duration can be NaN/Infinity (Chromium Linux bug), // which would propagate into demuxer.read() as an invalid endpoint. - const containerDurationSec = Number.isFinite(mediaInfo.duration) - ? mediaInfo.duration - : 0; + const containerDurationSec = Number.isFinite(mediaInfo.duration) ? mediaInfo.duration : 0; const streamDurationSec = - typeof videoStream?.duration === "number" && - Number.isFinite(videoStream.duration) + typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) ? videoStream.duration : 0; - const hintedDurationSec = Math.max( - containerDurationSec, - streamDurationSec, - 0, - ); + const hintedDurationSec = Math.max(containerDurationSec, streamDurationSec, 0); const scanEndSec = - hintedDurationSec > 0 - ? hintedDurationSec + 0.5 - : SCAN_UNBOUNDED_FALLBACK_SEC; + hintedDurationSec > 0 ? hintedDurationSec + 0.5 : SCAN_UNBOUNDED_FALLBACK_SEC; let maxPacketEndUs = 0; const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader(); try { @@ -335,18 +288,14 @@ export class StreamingVideoDecoder { } } const scannedDuration = maxPacketEndUs / 1_000_000; - const validatedDuration = validateDuration( - mediaInfo.duration, - scannedDuration, - ); + const validatedDuration = validateDuration(mediaInfo.duration, scannedDuration); this.metadata = { width: videoStream?.width || 1920, height: videoStream?.height || 1080, duration: validatedDuration, streamDuration: - typeof videoStream?.duration === "number" && - Number.isFinite(videoStream.duration) + typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration) ? videoStream.duration : undefined, frameRate, @@ -379,14 +328,8 @@ export class StreamingVideoDecoder { const decoderConfig = await this.demuxer.getDecoderConfig("video"); - console.log( - "[StreamingVideoDecoder] decoderConfig.codec:", - decoderConfig.codec, - ); - console.log( - "[StreamingVideoDecoder] decoderConfig.description:", - decoderConfig.description, - ); + console.log("[StreamingVideoDecoder] decoderConfig.codec:", decoderConfig.codec); + console.log("[StreamingVideoDecoder] decoderConfig.description:", decoderConfig.description); // web-demuxer may return bare four-character code strings ("av01", "vp08", // "vp09", "avc1") that WebCodecs rejects. Normalize them to the short or @@ -425,8 +368,7 @@ export class StreamingVideoDecoder { const segmentOutputFrameCounts = segments.map((segment) => Math.ceil( - ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * - targetFrameRate, + ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate, ), ); const frameDurationUs = 1_000_000 / targetFrameRate; @@ -468,9 +410,7 @@ export class StreamingVideoDecoder { : decoderConfig; try { - const support = await VideoDecoder.isConfigSupported( - preferredDecoderConfig, - ); + const support = await VideoDecoder.isConfigSupported(preferredDecoderConfig); console.log( `[StreamingVideoDecoder] isConfigSupported for "${preferredDecoderConfig.codec}":`, support.supported, @@ -496,8 +436,7 @@ export class StreamingVideoDecoder { const getNextFrame = (): Promise => { if (decodeError) throw decodeError; - if (pendingFrames.length > 0) - return Promise.resolve(pendingFrames.shift()!); + if (pendingFrames.length > 0) return Promise.resolve(pendingFrames.shift()!); if (decodeDone) return Promise.resolve(null); return new Promise((resolve) => { frameResolve = resolve; @@ -561,18 +500,13 @@ export class StreamingVideoDecoder { if (segmentFrameIndex >= segmentFrameCount) return false; const sourceTimeSec = - segment.startSec + - (segmentFrameIndex / targetFrameRate) * segment.speed; + segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed; if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false; const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp, }); - await onFrame( - clone, - exportFrameIndex * frameDurationUs, - sourceTimeSec * 1000, - ); + await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); segmentFrameIndex++; exportFrameIndex++; return true; @@ -635,8 +569,7 @@ export class StreamingVideoDecoder { } const sourceTimeSec = - currentSegment.startSec + - (segmentFrameIndex / targetFrameRate) * currentSegment.speed; + currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed; if (sourceTimeSec >= currentSegment.endSec - EPSILON_SEC) { break; } @@ -647,11 +580,7 @@ export class StreamingVideoDecoder { const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp, }); - await onFrame( - clone, - exportFrameIndex * frameDurationUs, - sourceTimeSec * 1000, - ); + await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); segmentFrameIndex++; exportFrameIndex++; } @@ -716,9 +645,7 @@ export class StreamingVideoDecoder { }) ) { const decodedAtLabel = - lastDecodedFrameSec === null - ? "no decoded frame" - : `${lastDecodedFrameSec.toFixed(3)}s`; + lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`; const message = `Decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s) – export may be slightly shorter than expected.`; console.warn(`[StreamingVideoDecoder] ${message}`); onWarning?.(message); @@ -768,10 +695,7 @@ export class StreamingVideoDecoder { speedRegions?: SpeedRegion[], ): { effectiveDuration: number; totalFrames: number } { if (!this.metadata) throw new Error("Must call loadMetadata() first"); - const trimSegments = this.computeSegments( - this.metadata.duration, - trimRegions, - ); + const trimSegments = this.computeSegments(this.metadata.duration, trimRegions); const segments = this.splitBySpeed(trimSegments, speedRegions); return { effectiveDuration: segments.reduce( @@ -780,9 +704,7 @@ export class StreamingVideoDecoder { ), totalFrames: segments.reduce((sum, seg) => { const segDur = seg.endSec - seg.startSec - EPSILON_SEC; - return ( - sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)) - ); + return sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate)); }, 0), }; } @@ -798,15 +720,10 @@ export class StreamingVideoDecoder { if (!speedRegions || speedRegions.length === 0) return segments.map((s) => ({ ...s, speed: 1 })); - const result: Array<{ startSec: number; endSec: number; speed: number }> = - []; + const result: Array<{ startSec: number; endSec: number; speed: number }> = []; for (const segment of segments) { const overlapping = speedRegions - .filter( - (sr) => - sr.startMs / 1000 < segment.endSec && - sr.endMs / 1000 > segment.startSec, - ) + .filter((sr) => sr.startMs / 1000 < segment.endSec && sr.endMs / 1000 > segment.startSec) .sort((a, b) => a.startMs - b.startMs); if (overlapping.length === 0) { @@ -818,8 +735,7 @@ export class StreamingVideoDecoder { for (const sr of overlapping) { const srStart = Math.max(sr.startMs / 1000, segment.startSec); const srEnd = Math.min(sr.endMs / 1000, segment.endSec); - if (cursor < srStart) - result.push({ startSec: cursor, endSec: srStart, speed: 1 }); + if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 }); result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed }); cursor = srEnd; } @@ -863,16 +779,9 @@ export class StreamingVideoDecoder { } /** Wraps a promise with a hard timeout, rejecting with `message` if it exceeds `timeoutMs`. */ - private withTimeout( - promise: Promise, - timeoutMs: number, - message: string, - ): Promise { + private withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { return new Promise((resolve, reject) => { - const timer = window.setTimeout( - () => reject(new Error(message)), - timeoutMs, - ); + const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs); promise.then( (value) => { window.clearTimeout(timer); From 3a2f90722c7e6224fd482cc1cc2aeb850f643ea9 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Sun, 24 May 2026 12:08:55 +0200 Subject: [PATCH 73/94] chore: restore original VideoFrame formatting in streamingDecoder.ts Biome had reformatted two pre-existing VideoFrame constructor calls from single-line to multi-line style when --write was run on the file. Restore them to match main so the diff only reflects our intentional refactoring. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/exporter/streamingDecoder.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 0e429cb6..752d5cd4 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -503,9 +503,7 @@ export class StreamingVideoDecoder { segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed; if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false; - const clone = new VideoFrame(heldFrame, { - timestamp: heldFrame.timestamp, - }); + const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); segmentFrameIndex++; exportFrameIndex++; @@ -577,9 +575,7 @@ export class StreamingVideoDecoder { break; } - const clone = new VideoFrame(heldFrame, { - timestamp: heldFrame.timestamp, - }); + const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp }); await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000); segmentFrameIndex++; exportFrameIndex++; From 3d129f85cdfcaf43559215e8fcddd8995fd1ac46 Mon Sep 17 00:00:00 2001 From: Ignacio Benito Herrero Date: Mon, 25 May 2026 10:24:45 +0200 Subject: [PATCH 74/94] Added .import files to .gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 0861e392..82fc468b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ release/** test-results playwright-report/ + # Vitest browser mode screenshots __screenshots__/ @@ -58,3 +59,7 @@ result-* #kilocode .kilo/ + +#others + +**/*.import From d32bb009598c9b94faa64d64c6636bf80937986b Mon Sep 17 00:00:00 2001 From: Ignacio Benito Herrero Date: Mon, 25 May 2026 10:42:43 +0200 Subject: [PATCH 75/94] Increased max rounding to 64 --- src/components/video-editor/SettingsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d3683976..1904e447 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1410,7 +1410,7 @@ export function SettingsPanel({ onValueChange={(values) => onBorderRadiusChange?.(values[0])} onValueCommit={() => onBorderRadiusCommit?.()} min={0} - max={16} + max={64} step={0.5} className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" /> From 727e395fcf0b7db2910b2e572e402234e8c8acbe Mon Sep 17 00:00:00 2001 From: neurot1cal Date: Mon, 25 May 2026 17:53:22 -0700 Subject: [PATCH 76/94] fix: stream long recordings to disk and patch WebM duration on save Recordings longer than ~10 minutes silently fail to save (#616). The renderer buffers the whole WebM as a Blob[], then on stop makes several in-memory copies (fixWebmDuration -> arrayBuffer -> Buffer.from) before writing. A long 1080p recording duplicates hundreds of MB several times in the renderer, exceeds Electron's memory limit, and the renderer crashes silently with no file saved. Two changes: 1. Stream chunks to disk (originally @Amanuel2x's contribution in #617). Open an fs.WriteStream in the main process at recording start and send each ~1s ondataavailable chunk straight to disk over two new IPC calls (open-recording-stream, append-recording-chunk), so the renderer never holds more than a single chunk. A full in-memory fallback is preserved for environments where the IPC stream cannot open. 2. Patch the WebM Duration header on disk after the stream closes. Browser MediaRecorder writes WebM with no Duration element, so streamed files save with duration=N/A and the editor's seek bar, timeline, and any scrub/trim break. A new electron/recording/webm-duration.ts module rewrites the Duration element, writing to a temp file and renaming in place so a crash mid-write cannot corrupt the recording. Streaming is opt-in: the screen recorder and the browser-only webcam recorder stream to disk; native-capture webcam sidecars (Windows, macOS) keep buffering in-memory, since their finalize path reads the recorder blob directly to attach the webcam track. Verified: tsc --noEmit clean; biome clean; vitest 166/166. Closes #616 Supersedes #617 Co-Authored-By: Amanuel Co-Authored-By: Claude Opus 4.7 (1M context) --- electron/electron-env.d.ts | 8 ++ electron/ipc/handlers.ts | 92 ++++++++++++++++- electron/preload.ts | 6 ++ electron/recording/webm-duration.ts | 97 ++++++++++++++++++ src/hooks/useScreenRecorder.ts | 148 +++++++++++++++++++++------- src/lib/recordingSession.ts | 8 ++ 6 files changed, 322 insertions(+), 37 deletions(-) create mode 100644 electron/recording/webm-duration.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1b829922..cbe43beb 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -81,6 +81,14 @@ interface Window { message?: string; error?: string; }>; + openRecordingStream: ( + recordingId: number, + fileName: string, + ) => Promise<{ success: boolean; error?: string }>; + appendRecordingChunk: ( + recordingId: number, + chunk: ArrayBuffer, + ) => Promise<{ success: boolean; error?: string }>; getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 669f1dd7..f10cbbc4 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,6 +1,6 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; -import { constants as fsConstants } from "node:fs"; +import { createWriteStream, constants as fsConstants, type WriteStream } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -40,6 +40,7 @@ import { RECORDINGS_DIR } from "../main"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; +import { patchWebmDurationOnDisk } from "../recording/webm-duration"; import { registerNativeBridgeHandlers } from "./nativeBridge"; const PROJECT_FILE_EXTENSION = "openscreen"; @@ -2141,6 +2142,47 @@ export function registerIpcHandlers( }, ); + // Streaming chunk writers — keyed by recordingId. Chunks are appended directly + // to disk as they arrive from ondataavailable so the renderer never holds the + // full video in memory. + const activeWriteStreams = new Map(); + + ipcMain.handle( + "open-recording-stream", + async ( + _, + recordingId: number, + fileName: string, + ): Promise<{ success: boolean; error?: string }> => { + try { + const filePath = resolveRecordingOutputPath(fileName); + const ws = createWriteStream(filePath, { flags: "w" }); + activeWriteStreams.set(recordingId, ws); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle( + "append-recording-chunk", + async ( + _, + recordingId: number, + chunk: ArrayBuffer, + ): Promise<{ success: boolean; error?: string }> => { + const ws = activeWriteStreams.get(recordingId); + if (!ws) return { success: false, error: "No active stream for recordingId " + recordingId }; + return new Promise((resolve) => { + ws.write(Buffer.from(chunk), (err) => { + if (err) resolve({ success: false, error: err.message }); + else resolve({ success: true }); + }); + }); + }, + ); + ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { return await storeRecordedSessionFiles(payload); @@ -2161,12 +2203,56 @@ export function registerIpcHandlers( : Date.now(); const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName); - await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); + + // Close the streaming write stream if one was used; otherwise fall back to + // writing the full buffer (short recordings that never opened a stream). + const screenWs = activeWriteStreams.get(createdAt); + let screenStreamed = false; + if (screenWs) { + await new Promise((resolve, reject) => + screenWs.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + activeWriteStreams.delete(createdAt); + screenStreamed = true; + } else if (payload.screen.videoData && payload.screen.videoData.byteLength > 0) { + await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); + } let webcamVideoPath: string | undefined; + let webcamStreamed = false; if (payload.webcam) { webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); - await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + const webcamWs = activeWriteStreams.get(createdAt + 1); // webcam stream keyed as recordingId+1 + if (webcamWs) { + await new Promise((resolve, reject) => + webcamWs.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + activeWriteStreams.delete(createdAt + 1); + webcamStreamed = true; + } else if (payload.webcam.videoData && payload.webcam.videoData.byteLength > 0) { + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + } + } + + // Streamed files lack the WebM Duration header (renderer no longer holds the + // blob to patch). Patch on disk so the editor's seek bar and timeline work. + // Best-effort: log on failure but don't block, since the file is still playable. + if ( + screenStreamed && + typeof payload.durationMs === "number" && + Number.isFinite(payload.durationMs) && + payload.durationMs > 0 + ) { + await patchWebmDurationOnDisk(screenVideoPath, payload.durationMs); + } + if ( + webcamStreamed && + webcamVideoPath && + typeof payload.durationMs === "number" && + Number.isFinite(payload.durationMs) && + payload.durationMs > 0 + ) { + await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs); } const session: RecordingSession = webcamVideoPath diff --git a/electron/preload.ts b/electron/preload.ts index 933ce9dc..a7283d81 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -64,6 +64,12 @@ contextBridge.exposeInMainWorld("electronAPI", { storeRecordedSession: (payload: StoreRecordedSessionInput) => { return ipcRenderer.invoke("store-recorded-session", payload); }, + openRecordingStream: (recordingId: number, fileName: string) => { + return ipcRenderer.invoke("open-recording-stream", recordingId, fileName); + }, + appendRecordingChunk: (recordingId: number, chunk: ArrayBuffer) => { + return ipcRenderer.invoke("append-recording-chunk", recordingId, chunk); + }, getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); diff --git a/electron/recording/webm-duration.ts b/electron/recording/webm-duration.ts new file mode 100644 index 00000000..5b2c197c --- /dev/null +++ b/electron/recording/webm-duration.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import { fixParsedWebmDuration } from "@fix-webm-duration/fix"; +import { WebmFile } from "@fix-webm-duration/parser"; + +export type DurationPatchResult = + | { patched: true } + | { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" }; + +/** + * Patch the WebM Duration header on a finalized recording file. + * + * Browser MediaRecorder writes WebM with no Duration EBML element. With the + * streaming-to-disk path the renderer never holds the blob, so the historical + * `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after + * `WriteStream.end()` produces an equivalent result: the editor's seek bar and + * timeline read a real duration instead of `N/A`. + * + * Atomic by design: writes the patched bytes to `.duration-patch.tmp` + * and renames in place. If the process crashes mid-rewrite, the original file + * survives intact, so the user never loses their recording to a partial write. + * + * Best-effort by intent: any failure (read, parse, write) logs and returns a + * non-`patched` result rather than throwing. The file is still playable without + * the patch (decoders walk frames sequentially); the only cost is that the + * editor's seek bar and timeline break until it is patched. + * + * Memory: reads the whole file into a main-process Buffer, the same footprint + * as the pre-streaming renderer path, just on the side without V8's heap cap. + */ +export async function patchWebmDurationOnDisk( + filePath: string, + durationMs: number, +): Promise { + try { + const fileBytes = await fs.readFile(filePath); + const webm = new WebmFile(new Uint8Array(fileBytes)); + + const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); + if (!patched) { + // fixParsedWebmDuration returns false for: missing Segment, missing + // Info, or a Duration that is already valid. The first two mean a + // malformed (most likely truncated) file; the third is a no-op. + const reason = inferUnpatchedReason(webm); + if (reason === "no-section") { + console.warn( + `[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`, + ); + } + return { patched: false, reason }; + } + + if (!webm.source) { + console.error(`[webm-duration] patched but source missing for ${filePath}`); + return { patched: false, reason: "internal" }; + } + + const tmpPath = `${filePath}.duration-patch.tmp`; + const patchedBytes = Buffer.from( + webm.source.buffer, + webm.source.byteOffset, + webm.source.byteLength, + ); + try { + await fs.writeFile(tmpPath, patchedBytes); + await fs.rename(tmpPath, filePath); + return { patched: true }; + } catch (writeError) { + console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError); + // Best-effort cleanup of the temp file; if unlink also fails, leave it. + // The original recording is untouched because the rename never ran. + await fs.unlink(tmpPath).catch(() => undefined); + return { patched: false, reason: "io-error" }; + } + } catch (error) { + console.error(`[webm-duration] failed to patch ${filePath}:`, error); + return { patched: false, reason: "io-error" }; + } +} + +/** + * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info + * present but Duration already valid" (patch unnecessary). + * + * The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser + * uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified + * against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML + * IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would + * never match. + */ +function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" { + const segment = webm.getSectionById?.(0x8538067); + if (!segment) return "no-section"; + const info = ( + segment as unknown as { getSectionById?: (id: number) => unknown } + ).getSectionById?.(0x549a966); + return info ? "already-valid" : "no-section"; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 14e55b35..665ae5e4 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -77,6 +77,7 @@ type UseScreenRecorderReturn = { type RecorderHandle = { recorder: MediaRecorder; recordedBlobPromise: Promise; + streaming: boolean; }; type NativeWindowsRecordingHandle = { @@ -92,26 +93,88 @@ type NativeMacRecordingHandle = { paused: boolean; }; -function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle { +function createRecorderHandle( + stream: MediaStream, + options: MediaRecorderOptions, + recordingId?: number, + fileName?: string, +): RecorderHandle { const recorder = new MediaRecorder(stream, options); - const chunks: Blob[] = []; const mimeType = options.mimeType || "video/webm"; + + // Stream chunks to disk only when a target (recordingId + fileName) is given. + // The main screen recorder and the browser-only webcam recorder pass a target + // so long recordings never buffer the whole video in the renderer (the #616 + // fix). Native-capture webcam sidecars omit the target and buffer in-memory, + // because their finalize path reads recordedBlobPromise directly to attach the + // webcam file; an empty streamed blob would silently drop their webcam track. + const streamTarget = + recordingId !== undefined && fileName !== undefined ? { recordingId, fileName } : null; + + const pendingChunks: ArrayBuffer[] = []; + let streamReady = false; + let streamFailed = streamTarget === null; + + if (streamTarget) { + const streamOpenPromise = + window.electronAPI?.openRecordingStream?.(streamTarget.recordingId, streamTarget.fileName) ?? + Promise.resolve({ success: false }); + + streamOpenPromise.then((result) => { + if (result.success) { + streamReady = true; + for (const chunk of pendingChunks) { + void window.electronAPI.appendRecordingChunk(streamTarget.recordingId, chunk); + } + pendingChunks.length = 0; + } else { + streamFailed = true; + } + }); + } + + const fallbackChunks: Blob[] = []; + const recordedBlobPromise = new Promise((resolve, reject) => { recorder.ondataavailable = (event: BlobEvent) => { - if (event.data && event.data.size > 0) { - chunks.push(event.data); + if (!event.data || event.data.size === 0) return; + + if (streamFailed) { + fallbackChunks.push(event.data); + return; } + + void event.data.arrayBuffer().then((buf) => { + if (streamFailed) { + fallbackChunks.push(new Blob([buf], { type: mimeType })); + return; + } + if (streamReady && streamTarget) { + void window.electronAPI.appendRecordingChunk(streamTarget.recordingId, buf); + } else { + pendingChunks.push(buf); + } + }); }; + recorder.onerror = () => { reject(new Error("Recording failed")); }; + recorder.onstop = () => { - resolve(new Blob(chunks, { type: mimeType })); + if (streamFailed) { + // Not streaming, or the stream failed to open — return the full + // in-memory blob (the buffered fallback). + resolve(new Blob(fallbackChunks, { type: mimeType })); + } else { + // Streaming succeeded — the main process already has the data on disk. + resolve(new Blob([], { type: mimeType })); + } }; }); recorder.start(RECORDER_TIMESLICE_MS); - return { recorder, recordedBlobPromise }; + return { recorder, recordedBlobPromise, streaming: !streamFailed }; } export function useScreenRecorder(): UseScreenRecorderReturn { @@ -361,34 +424,44 @@ export function useScreenRecorder(): UseScreenRecorderReturn { window.electronAPI?.discardCursorTelemetry(activeRecordingId); return; } - if (screenBlob.size === 0) { + // When streaming succeeded the blob is empty — the data is already on disk. + if (!activeScreenRecorder.streaming && screenBlob.size === 0) { return; } - const fixedScreenBlob = await fixWebmDuration(screenBlob, duration); - let fixedWebcamBlob: Blob | null = null; - if (activeWebcamRecorder) { - const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); - if (webcamBlob && webcamBlob.size > 0) { - fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); - } - } - const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`; const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + + // Only fix duration / convert to ArrayBuffer if we have in-memory data. + let screenVideoData: ArrayBuffer = new ArrayBuffer(0); + if (!activeScreenRecorder.streaming && screenBlob.size > 0) { + const fixedScreenBlob = await fixWebmDuration(screenBlob, duration); + screenVideoData = await fixedScreenBlob.arrayBuffer(); + } + + let webcamVideoData: ArrayBuffer | undefined; + if (activeWebcamRecorder) { + const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); + if (!activeWebcamRecorder.streaming && webcamBlob && webcamBlob.size > 0) { + const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); + webcamVideoData = await fixedWebcamBlob.arrayBuffer(); + } else if (activeWebcamRecorder.streaming) { + webcamVideoData = new ArrayBuffer(0); + } + } + const result = await window.electronAPI.storeRecordedSession({ screen: { - videoData: await fixedScreenBlob.arrayBuffer(), + videoData: screenVideoData, fileName: screenFileName, }, - webcam: fixedWebcamBlob - ? { - videoData: await fixedWebcamBlob.arrayBuffer(), - fileName: webcamFileName, - } - : undefined, + webcam: + webcamVideoData !== undefined + ? { videoData: webcamVideoData, fileName: webcamFileName } + : undefined, createdAt: activeRecordingId, cursorCaptureMode, + durationMs: duration, }); if (!result.success) { @@ -1336,13 +1409,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingId.current = Date.now(); const activeRecordingId = recordingId.current; - screenRecorder.current = createRecorderHandle(stream.current, { - mimeType, - videoBitsPerSecond, - ...(hasAudio - ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } - : {}), - }); + screenRecorder.current = createRecorderHandle( + stream.current, + { + mimeType, + videoBitsPerSecond, + ...(hasAudio + ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } + : {}), + }, + activeRecordingId, + `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`, + ); screenRecorder.current.recorder.addEventListener( "error", () => { @@ -1352,10 +1430,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); if (webcamStream.current) { - webcamRecorder.current = createRecorderHandle(webcamStream.current, { - mimeType, - videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE), - }); + webcamRecorder.current = createRecorderHandle( + webcamStream.current, + { mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) }, + activeRecordingId + 1, + `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, + ); } accumulatedDurationMs.current = 0; diff --git a/src/lib/recordingSession.ts b/src/lib/recordingSession.ts index f5ebf9ca..12a6afd2 100644 --- a/src/lib/recordingSession.ts +++ b/src/lib/recordingSession.ts @@ -20,6 +20,14 @@ export interface StoreRecordedSessionInput { webcam?: RecordedVideoAssetInput; createdAt?: number; cursorCaptureMode?: CursorCaptureMode; + /** + * Recording wall-clock duration in milliseconds. Used by the main process + * to patch the WebM Duration header on streamed recordings, since the + * renderer no longer holds the bytes. Browser MediaRecorder writes WebM + * with no/zero duration; without this patch, the editor's seek bar and + * timeline break for any recording that took the streaming path. + */ + durationMs?: number; } export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined { From 8c7ea939ab71553c50b45ddcf50bdbda8ed45825 Mon Sep 17 00:00:00 2001 From: aaravshirpurkar Date: Tue, 26 May 2026 13:43:57 +0530 Subject: [PATCH 77/94] fix: increase rounding snap tolerance to 4px to prevent background line bleed when padding is 0 --- src/lib/compositeLayout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 839a7e0d..abb6b0f7 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -408,8 +408,8 @@ function centerRectInBounds(params: { bounds: RenderRect; size: Size; maxSize: S if ( maxWidth >= boundsWidth && maxHeight >= boundsHeight && - Math.abs(boundsWidth - resolvedWidth) <= 1 && - Math.abs(boundsHeight - resolvedHeight) <= 1 + Math.abs(boundsWidth - resolvedWidth) <= 4 && + Math.abs(boundsHeight - resolvedHeight) <= 4 ) { return { x: boundsX, From f3c5b8a65d554216ba7e2182571f7b5c675705a2 Mon Sep 17 00:00:00 2001 From: neurot1cal Date: Tue, 26 May 2026 16:09:39 -0700 Subject: [PATCH 78/94] fix: harden streaming lifecycle and lift it out of the IPC god-module Addresses the review feedback on #658 (CodeRabbit + Codex) and the structural notes from the quality pass. Correctness: - Compute the recorder's streaming state at finalize time, not at construction. A stream that fails to open is now reported as not-streamed, so its buffered chunks are saved as a complete in-memory fallback instead of being dropped (was total data loss on open failure). - Await every in-flight chunk write before onstop resolves, so the main process never closes the write stream while a final chunk is still in flight (was truncating the tail of a recording under load). - Open the disk write stream by awaiting its 'open' event, so a bad path or permission error rejects up front instead of being acknowledged as success and then silently dropping bytes. - Close the stream and remove the partial file when a streamed recording is discarded or fails, so cancelled/failed runs don't leak descriptors or orphan partial recordings. - Surface a mid-stream write failure as a rejected recording rather than saving a silently truncated file. Structure: - Extract the streaming concern into electron/ipc/recordingStream.ts (RecordingStreamRegistry) and src/hooks/recorderHandle.ts, out of the 2.8k-line handlers.ts and the screen-recorder hook. - Key write streams by output file name, removing the implicit recordingId/+1 contract that spanned the IPC boundary. - Collapse the duplicated screen/webcam finalize blocks into one helper and the repeated duration-validity guard into one check; patch the screen and webcam durations in parallel. Adds unit tests for the registry (real temp-dir fs) and the recorder handle state machine (open-failure fallback, in-order writes awaited before stop, mid-stream failure). Extends the vitest include glob to collect electron-side tests. Verified: tsc --noEmit clean; biome clean; vitest 180/180. Co-Authored-By: Claude Opus 4.7 (1M context) --- electron/electron-env.d.ts | 8 +- electron/ipc/handlers.ts | 139 ++++++++---------- electron/ipc/recordingStream.test.ts | 84 +++++++++++ electron/ipc/recordingStream.ts | 147 ++++++++++++++++++++ electron/preload.ts | 11 +- src/hooks/recorderHandle.test.ts | 201 +++++++++++++++++++++++++++ src/hooks/recorderHandle.ts | 140 +++++++++++++++++++ src/hooks/useScreenRecorder.ts | 118 +++------------- vitest.config.ts | 2 +- 9 files changed, 658 insertions(+), 192 deletions(-) create mode 100644 electron/ipc/recordingStream.test.ts create mode 100644 electron/ipc/recordingStream.ts create mode 100644 src/hooks/recorderHandle.test.ts create mode 100644 src/hooks/recorderHandle.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index cbe43beb..b86c4ff2 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -81,14 +81,12 @@ interface Window { message?: string; error?: string; }>; - openRecordingStream: ( - recordingId: number, - fileName: string, - ) => Promise<{ success: boolean; error?: string }>; + openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>; appendRecordingChunk: ( - recordingId: number, + fileName: string, chunk: ArrayBuffer, ) => Promise<{ success: boolean; error?: string }>; + closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>; getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index f10cbbc4..d3677283 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,6 +1,6 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; -import { createWriteStream, constants as fsConstants, type WriteStream } from "node:fs"; +import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -42,6 +42,7 @@ import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/rec import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { patchWebmDurationOnDisk } from "../recording/webm-duration"; import { registerNativeBridgeHandlers } from "./nativeBridge"; +import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream"; const PROJECT_FILE_EXTENSION = "openscreen"; const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); @@ -266,6 +267,30 @@ function resolveRecordingOutputPath(fileName: string): string { return path.join(RECORDINGS_DIR, parsedPath.base); } +function isValidDurationMs(value: number | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +/** + * Finalize a single recording file: if it was streamed to disk, flush and close + * the stream; otherwise (a short recording, or the stream failed to open and the + * renderer fell back to in-memory buffering) write the buffered bytes. Returns + * whether the file was streamed, which the caller uses to decide whether the + * WebM duration needs patching on disk. + */ +async function finalizeRecordingFile( + registry: RecordingStreamRegistry, + fileName: string, + filePath: string, + videoData?: ArrayBuffer, +): Promise { + const streamed = await registry.finalize(fileName); + if (!streamed && videoData && videoData.byteLength > 0) { + await fs.writeFile(filePath, Buffer.from(videoData)); + } + return streamed; +} + async function getApprovedProjectSession( project: unknown, projectFilePath?: string, @@ -2142,46 +2167,11 @@ export function registerIpcHandlers( }, ); - // Streaming chunk writers — keyed by recordingId. Chunks are appended directly - // to disk as they arrive from ondataavailable so the renderer never holds the - // full video in memory. - const activeWriteStreams = new Map(); - - ipcMain.handle( - "open-recording-stream", - async ( - _, - recordingId: number, - fileName: string, - ): Promise<{ success: boolean; error?: string }> => { - try { - const filePath = resolveRecordingOutputPath(fileName); - const ws = createWriteStream(filePath, { flags: "w" }); - activeWriteStreams.set(recordingId, ws); - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } - }, - ); - - ipcMain.handle( - "append-recording-chunk", - async ( - _, - recordingId: number, - chunk: ArrayBuffer, - ): Promise<{ success: boolean; error?: string }> => { - const ws = activeWriteStreams.get(recordingId); - if (!ws) return { success: false, error: "No active stream for recordingId " + recordingId }; - return new Promise((resolve) => { - ws.write(Buffer.from(chunk), (err) => { - if (err) resolve({ success: false, error: err.message }); - else resolve({ success: true }); - }); - }); - }, - ); + // On-disk write streams for in-progress recordings, keyed by output file name. + // Chunks are appended as they arrive from ondataavailable so the renderer + // never buffers the full video in memory (the #616 fix). + const recordingStreams = new RecordingStreamRegistry(); + registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { @@ -2203,56 +2193,37 @@ export function registerIpcHandlers( : Date.now(); const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName); - - // Close the streaming write stream if one was used; otherwise fall back to - // writing the full buffer (short recordings that never opened a stream). - const screenWs = activeWriteStreams.get(createdAt); - let screenStreamed = false; - if (screenWs) { - await new Promise((resolve, reject) => - screenWs.end((err?: Error | null) => (err ? reject(err) : resolve())), - ); - activeWriteStreams.delete(createdAt); - screenStreamed = true; - } else if (payload.screen.videoData && payload.screen.videoData.byteLength > 0) { - await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); - } + const screenStreamed = await finalizeRecordingFile( + recordingStreams, + payload.screen.fileName, + screenVideoPath, + payload.screen.videoData, + ); let webcamVideoPath: string | undefined; let webcamStreamed = false; if (payload.webcam) { webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); - const webcamWs = activeWriteStreams.get(createdAt + 1); // webcam stream keyed as recordingId+1 - if (webcamWs) { - await new Promise((resolve, reject) => - webcamWs.end((err?: Error | null) => (err ? reject(err) : resolve())), - ); - activeWriteStreams.delete(createdAt + 1); - webcamStreamed = true; - } else if (payload.webcam.videoData && payload.webcam.videoData.byteLength > 0) { - await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); - } + webcamStreamed = await finalizeRecordingFile( + recordingStreams, + payload.webcam.fileName, + webcamVideoPath, + payload.webcam.videoData, + ); } - // Streamed files lack the WebM Duration header (renderer no longer holds the - // blob to patch). Patch on disk so the editor's seek bar and timeline work. - // Best-effort: log on failure but don't block, since the file is still playable. - if ( - screenStreamed && - typeof payload.durationMs === "number" && - Number.isFinite(payload.durationMs) && - payload.durationMs > 0 - ) { - await patchWebmDurationOnDisk(screenVideoPath, payload.durationMs); - } - if ( - webcamStreamed && - webcamVideoPath && - typeof payload.durationMs === "number" && - Number.isFinite(payload.durationMs) && - payload.durationMs > 0 - ) { - await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs); + // Streamed files lack the WebM Duration header (the renderer no longer holds + // the blob to patch). Patch on disk so the editor's seek bar and timeline + // work. Best-effort and independent per file, so the patches run together. + if (isValidDurationMs(payload.durationMs)) { + const patches: Promise[] = []; + if (screenStreamed) { + patches.push(patchWebmDurationOnDisk(screenVideoPath, payload.durationMs)); + } + if (webcamStreamed && webcamVideoPath) { + patches.push(patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs)); + } + await Promise.all(patches); } const session: RecordingSession = webcamVideoPath diff --git a/electron/ipc/recordingStream.test.ts b/electron/ipc/recordingStream.test.ts new file mode 100644 index 00000000..776fcf12 --- /dev/null +++ b/electron/ipc/recordingStream.test.ts @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { RecordingStreamRegistry } from "./recordingStream"; + +describe("RecordingStreamRegistry", () => { + let dir: string; + const pathFor = (name: string) => path.join(dir, name); + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("streams chunks to disk in order and reports streamed on finalize", async () => { + const registry = new RecordingStreamRegistry(); + await registry.open("rec.webm", pathFor("rec.webm")); + await registry.append("rec.webm", Buffer.from("hello ")); + await registry.append("rec.webm", Buffer.from("world")); + + const streamed = await registry.finalize("rec.webm"); + + expect(streamed).toBe(true); + expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world"); + // A second finalize has nothing to close. + expect(await registry.finalize("rec.webm")).toBe(false); + }); + + it("reports not-streamed when no stream was opened", async () => { + const registry = new RecordingStreamRegistry(); + expect(await registry.finalize("missing.webm")).toBe(false); + expect(registry.has("missing.webm")).toBe(false); + }); + + it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => { + const registry = new RecordingStreamRegistry(); + // Parent directory does not exist, so createWriteStream emits 'error' on open. + await expect( + registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")), + ).rejects.toThrow(); + // A failed open must not register a stream the renderer would treat as live. + expect(registry.has("rec.webm")).toBe(false); + }); + + it("rejects append when no stream is open", async () => { + const registry = new RecordingStreamRegistry(); + await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow( + /No active recording stream/, + ); + }); + + it("discard closes the stream and removes the partial file", async () => { + const registry = new RecordingStreamRegistry(); + await registry.open("rec.webm", pathFor("rec.webm")); + await registry.append("rec.webm", Buffer.from("partial")); + + await registry.discard("rec.webm", pathFor("rec.webm")); + + expect(registry.has("rec.webm")).toBe(false); + await expect(stat(pathFor("rec.webm"))).rejects.toThrow(); + // Nothing left to finalize after a discard. + expect(await registry.finalize("rec.webm")).toBe(false); + }); + + it("discard tolerates a missing file", async () => { + const registry = new RecordingStreamRegistry(); + await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined(); + }); + + it("opening the same file twice replaces the prior stream", async () => { + const registry = new RecordingStreamRegistry(); + await registry.open("rec.webm", pathFor("rec.webm")); + await registry.append("rec.webm", Buffer.from("first")); + await registry.open("rec.webm", pathFor("rec.webm")); + await registry.append("rec.webm", Buffer.from("second")); + await registry.finalize("rec.webm"); + + expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second"); + }); +}); diff --git a/electron/ipc/recordingStream.ts b/electron/ipc/recordingStream.ts new file mode 100644 index 00000000..3dce5b95 --- /dev/null +++ b/electron/ipc/recordingStream.ts @@ -0,0 +1,147 @@ +import { createWriteStream, type WriteStream } from "node:fs"; +import { unlink } from "node:fs/promises"; +import type { IpcMain } from "electron"; + +/** + * Owns the lifecycle of on-disk write streams for in-progress recordings, keyed + * by the recording's output file name. Browser MediaRecorder chunks are appended + * here as they arrive so a long recording never buffers the whole video in the + * renderer (the #616 fix). + * + * The file name is the key because it is the one value the renderer and main + * process already exchange and it is globally unique per recording, so there is + * no derived/offset key to keep in sync across the IPC boundary. + */ +export class RecordingStreamRegistry { + private readonly streams = new Map(); + + /** + * Open a write stream and resolve only once the OS confirms it is writable. + * Resolving on the `open` event (rather than on `createWriteStream` returning) + * means a bad path or permission error rejects here instead of surfacing as a + * silent chunk drop later, so the renderer's fallback can take over. + */ + async open(fileName: string, filePath: string): Promise { + await this.endStream(fileName); + + const ws = createWriteStream(filePath, { flags: "w" }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + ws.once("error", onError); + ws.once("open", () => { + ws.removeListener("error", onError); + resolve(); + }); + }); + // Keep a listener for the stream's lifetime so a late error logs rather + // than crashing the main process with an unhandled 'error' event. Per-write + // failures still surface through the `append` callback below. + ws.on("error", (error) => { + console.error(`[recording-stream] ${fileName}:`, error); + }); + + this.streams.set(fileName, ws); + } + + has(fileName: string): boolean { + return this.streams.has(fileName); + } + + /** Append a chunk; rejects if no stream is open or the write fails. */ + async append(fileName: string, chunk: Buffer): Promise { + const ws = this.streams.get(fileName); + if (!ws) { + throw new Error(`No active recording stream for ${fileName}`); + } + await new Promise((resolve, reject) => { + ws.write(chunk, (error) => (error ? reject(error) : resolve())); + }); + } + + /** + * Flush and close the stream, keeping the file. Returns whether a stream was + * open — i.e. whether the recording was streamed to disk (true) or needs its + * in-memory buffer written by the caller (false). + */ + async finalize(fileName: string): Promise { + const ws = this.streams.get(fileName); + if (!ws) { + return false; + } + this.streams.delete(fileName); + await new Promise((resolve, reject) => { + ws.end((error?: Error | null) => (error ? reject(error) : resolve())); + }); + return true; + } + + /** + * Close the stream (if any) and delete the partial file. Used when a streamed + * recording is discarded or fails before a successful save, so cancelled runs + * don't leak file descriptors or orphan partial recordings on disk. + */ + async discard(fileName: string, filePath: string): Promise { + await this.endStream(fileName); + await unlink(filePath).catch(() => undefined); + } + + private async endStream(fileName: string): Promise { + const ws = this.streams.get(fileName); + if (!ws) { + return; + } + this.streams.delete(fileName); + await new Promise((resolve) => ws.end(() => resolve())); + } +} + +/** + * Register the streaming IPC handlers. Thin wrappers that translate the + * registry's throw-on-failure contract into the `{ success, error }` shape the + * renderer expects. + */ +export function registerRecordingStreamHandlers( + ipcMain: IpcMain, + registry: RecordingStreamRegistry, + resolveRecordingOutputPath: (fileName: string) => string, +): void { + ipcMain.handle( + "open-recording-stream", + async (_, fileName: string): Promise<{ success: boolean; error?: string }> => { + try { + await registry.open(fileName, resolveRecordingOutputPath(fileName)); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle( + "append-recording-chunk", + async ( + _, + fileName: string, + chunk: ArrayBuffer, + ): Promise<{ success: boolean; error?: string }> => { + try { + await registry.append(fileName, Buffer.from(chunk)); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle( + "close-recording-stream", + async (_, fileName: string): Promise<{ success: boolean; error?: string }> => { + try { + await registry.discard(fileName, resolveRecordingOutputPath(fileName)); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); +} diff --git a/electron/preload.ts b/electron/preload.ts index a7283d81..4b697409 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -64,11 +64,14 @@ contextBridge.exposeInMainWorld("electronAPI", { storeRecordedSession: (payload: StoreRecordedSessionInput) => { return ipcRenderer.invoke("store-recorded-session", payload); }, - openRecordingStream: (recordingId: number, fileName: string) => { - return ipcRenderer.invoke("open-recording-stream", recordingId, fileName); + openRecordingStream: (fileName: string) => { + return ipcRenderer.invoke("open-recording-stream", fileName); }, - appendRecordingChunk: (recordingId: number, chunk: ArrayBuffer) => { - return ipcRenderer.invoke("append-recording-chunk", recordingId, chunk); + appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => { + return ipcRenderer.invoke("append-recording-chunk", fileName, chunk); + }, + closeRecordingStream: (fileName: string) => { + return ipcRenderer.invoke("close-recording-stream", fileName); }, getRecordedVideoPath: () => { diff --git a/src/hooks/recorderHandle.test.ts b/src/hooks/recorderHandle.test.ts new file mode 100644 index 00000000..3551b7da --- /dev/null +++ b/src/hooks/recorderHandle.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRecorderHandle } from "./recorderHandle"; + +type ElectronAPI = Window["electronAPI"]; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const decode = (buffer: ArrayBuffer) => new TextDecoder().decode(new Uint8Array(buffer)); + +/** Minimal MediaRecorder stand-in the tests can drive directly. */ +class FakeMediaRecorder { + ondataavailable: ((event: BlobEvent) => void) | null = null; + onstop: (() => void) | null = null; + onerror: (() => void) | null = null; + state: "inactive" | "recording" = "inactive"; + + start(): void { + this.state = "recording"; + } + + stop(): void { + this.state = "inactive"; + this.onstop?.(); + } + + emit(data: Blob): void { + this.ondataavailable?.({ data } as BlobEvent); + } +} + +function stubElectronAPI(api: Partial): void { + window.electronAPI = api as unknown as ElectronAPI; +} + +function driver(handle: { recorder: MediaRecorder }): FakeMediaRecorder { + return handle.recorder as unknown as FakeMediaRecorder; +} + +describe("createRecorderHandle", () => { + beforeEach(() => { + vi.stubGlobal("MediaRecorder", FakeMediaRecorder); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + window.electronAPI = undefined as unknown as ElectronAPI; + }); + + it("streams chunks to disk in arrival order and resolves an empty blob", async () => { + const appended: string[] = []; + const openRecordingStream = vi.fn(async () => ({ success: true })); + const appendRecordingChunk = vi.fn(async (_fileName: string, buffer: ArrayBuffer) => { + appended.push(decode(buffer)); + return { success: true }; + }); + stubElectronAPI({ openRecordingStream, appendRecordingChunk }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); // arrives before open resolves -> buffered + await tick(); // open resolves -> buffered chunk flushes, mode becomes streaming + fake.emit(new Blob(["b"])); + fake.emit(new Blob(["c"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(openRecordingStream).toHaveBeenCalledWith("rec.webm"); + expect(appended).toEqual(["a", "b", "c"]); + expect(blob.size).toBe(0); + expect(handle.isStreaming()).toBe(true); + }); + + it("falls back to a complete in-memory blob when the stream fails to open", async () => { + const openRecordingStream = vi.fn(async () => ({ success: false, error: "nope" })); + const appendRecordingChunk = vi.fn(async () => ({ success: true })); + stubElectronAPI({ openRecordingStream, appendRecordingChunk }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); + await tick(); // open resolves false -> buffering, keep everything in memory + fake.emit(new Blob(["bc"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(appendRecordingChunk).not.toHaveBeenCalled(); + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(3); + expect(decode(await blob.arrayBuffer())).toBe("abc"); + }); + + it("waits for in-flight chunk writes before stop resolves (no truncation)", async () => { + let releaseAppend: () => void = () => undefined; + const appendGate = new Promise((resolve) => { + releaseAppend = resolve; + }); + const appendRecordingChunk = vi.fn(async () => { + await appendGate; + return { success: true }; + }); + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk, + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + await tick(); // open resolves + fake.emit(new Blob(["a"])); // write blocks on the gate + fake.stop(); + + let resolved = false; + void handle.recordedBlobPromise.then(() => { + resolved = true; + }); + await tick(); + expect(resolved).toBe(false); // must not resolve while the write is in flight + + releaseAppend(); + await handle.recordedBlobPromise; + expect(resolved).toBe(true); + expect(appendRecordingChunk).toHaveBeenCalledTimes(1); + }); + + it("rejects when a chunk fails to write mid-stream", async () => { + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk: vi.fn(async () => ({ success: false, error: "disk full" })), + closeRecordingStream: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + await tick(); + fake.emit(new Blob(["a"])); + fake.stop(); + + await expect(handle.recordedBlobPromise).rejects.toThrow(/disk full/); + expect(handle.isStreaming()).toBe(false); + }); + + it("buffers in memory and never opens a stream when no file name is given", async () => { + const openRecordingStream = vi.fn(async () => ({ success: true })); + stubElectronAPI({ + openRecordingStream, + appendRecordingChunk: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }); + const fake = driver(handle); + + fake.emit(new Blob(["xy"])); + await tick(); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(openRecordingStream).not.toHaveBeenCalled(); + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(2); + }); + + it("discard closes the disk stream for a streamed recording", async () => { + const closeRecordingStream = vi.fn(async () => ({ success: true })); + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk: vi.fn(async () => ({ success: true })), + closeRecordingStream, + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + await tick(); + fake.emit(new Blob(["a"])); + fake.stop(); + await handle.recordedBlobPromise; + + await handle.discard(); + expect(closeRecordingStream).toHaveBeenCalledWith("rec.webm"); + }); + + it("discard is a no-op when the stream never opened", async () => { + const closeRecordingStream = vi.fn(async () => ({ success: true })); + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: false })), + appendRecordingChunk: vi.fn(async () => ({ success: true })), + closeRecordingStream, + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + await tick(); + fake.stop(); + await handle.recordedBlobPromise; + + await handle.discard(); + expect(closeRecordingStream).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts new file mode 100644 index 00000000..3c631204 --- /dev/null +++ b/src/hooks/recorderHandle.ts @@ -0,0 +1,140 @@ +const RECORDER_TIMESLICE_MS = 1000; + +export type RecorderHandle = { + recorder: MediaRecorder; + /** + * Resolves once the recording has fully drained. For a streamed recording the + * blob is empty (the bytes are already on disk); for an in-memory recording it + * holds the full WebM. Rejects if a chunk failed to write to disk mid-stream, + * so a truncated recording surfaces as an error instead of a silent partial save. + */ + recordedBlobPromise: Promise; + /** + * Whether the recording's bytes went to disk via the streaming path. Computed + * at finalize time rather than construction, so a stream that fails to open is + * correctly reported as not-streamed and its in-memory fallback is used. + */ + isStreaming: () => boolean; + /** + * Close the disk stream (if one opened) and delete its partial file. Called + * when a recording is discarded or fails before a successful save, so cancelled + * runs don't leak the stream or orphan a partial file. No-op for in-memory + * recorders. + */ + discard: () => Promise; +}; + +/** + * Wrap a MediaRecorder, optionally streaming its chunks to disk. + * + * When `fileName` is given, chunks are written to disk in arrival order through + * the main process as they arrive, so a long recording never buffers the whole + * video in the renderer (the #616 fix). Until the disk stream confirms it is + * open, chunks are held in memory; if the open fails, that buffer becomes a + * complete in-memory fallback so nothing is lost. Native-capture webcam sidecars + * omit `fileName` and always buffer in memory, since their finalize path reads + * the blob directly to attach the webcam track. + */ +export function createRecorderHandle( + stream: MediaStream, + options: MediaRecorderOptions, + fileName?: string, +): RecorderHandle { + const recorder = new MediaRecorder(stream, options); + const mimeType = options.mimeType || "video/webm"; + const api = window.electronAPI; + + // Chunks held in memory: everything before the stream opens, plus everything + // when not streaming at all. On a successful open these flush to disk and are + // dropped; on open failure they remain as the complete fallback recording. + const memoryChunks: Blob[] = []; + let mode: "pending" | "streaming" | "buffering" = fileName ? "pending" : "buffering"; + let streamOpened = false; + let appendError: Error | null = null; + + // Serialize chunk writes so they land on disk in arrival order, and so stop + // can await every in-flight write before the main process closes the stream + // (otherwise a late chunk arrives after close and truncates the recording). + let writeChain: Promise = Promise.resolve(); + const enqueueWrite = (chunk: Blob) => { + writeChain = writeChain.then(async () => { + if (appendError || !fileName || !api?.appendRecordingChunk) { + return; + } + const buffer = await chunk.arrayBuffer(); + const result = await api.appendRecordingChunk(fileName, buffer); + if (!result.success) { + appendError = new Error(result.error ?? "Failed to write recording chunk to disk"); + } + }); + }; + + const openPromise: Promise<{ success: boolean; error?: string }> = + fileName && api?.openRecordingStream + ? api.openRecordingStream(fileName) + : Promise.resolve({ success: false }); + + void openPromise.then((result) => { + if (result.success) { + streamOpened = true; + mode = "streaming"; + for (const chunk of memoryChunks) { + enqueueWrite(chunk); + } + memoryChunks.length = 0; + } else { + mode = "buffering"; + } + }); + + const recordedBlobPromise = new Promise((resolve, reject) => { + recorder.ondataavailable = (event: BlobEvent) => { + if (!event.data || event.data.size === 0) { + return; + } + if (mode === "streaming") { + enqueueWrite(event.data); + } else { + // "pending" (stream not open yet) or "buffering" (not streaming). + memoryChunks.push(event.data); + } + }; + + recorder.onerror = () => { + reject(new Error("Recording failed")); + }; + + recorder.onstop = () => { + resolve(finalizeBlob()); + }; + }); + + async function finalizeBlob(): Promise { + // Wait for the open attempt to settle so its flush (or fallback switch) has + // been applied, then for every queued write to land, so we never resolve + // while chunks are still in flight to the about-to-close disk stream. + await openPromise.catch(() => undefined); + await writeChain; + if (appendError) { + throw appendError; + } + if (mode === "streaming") { + return new Blob([], { type: mimeType }); + } + return new Blob(memoryChunks, { type: mimeType }); + } + + async function discard(): Promise { + if (streamOpened && fileName && api?.closeRecordingStream) { + await api.closeRecordingStream(fileName); + } + } + + recorder.start(RECORDER_TIMESLICE_MS); + return { + recorder, + recordedBlobPromise, + isStreaming: () => mode === "streaming" && !appendError, + discard, + }; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 665ae5e4..d6c03f12 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -13,6 +13,7 @@ import { } from "@/lib/nativeWindowsRecording"; import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession"; import { requestCameraAccess } from "@/lib/requestCameraAccess"; +import { createRecorderHandle, type RecorderHandle } from "./recorderHandle"; const TARGET_FRAME_RATE = 60; const MIN_FRAME_RATE = 30; @@ -34,7 +35,6 @@ const DEFAULT_HEIGHT = 1080; const CODEC_ALIGNMENT = 2; -const RECORDER_TIMESLICE_MS = 1000; const BITS_PER_MEGABIT = 1_000_000; const CHROME_MEDIA_SOURCE = "desktop"; const RECORDING_FILE_PREFIX = "recording-"; @@ -74,12 +74,6 @@ type UseScreenRecorderReturn = { setCursorCaptureMode: (mode: CursorCaptureMode) => void; }; -type RecorderHandle = { - recorder: MediaRecorder; - recordedBlobPromise: Promise; - streaming: boolean; -}; - type NativeWindowsRecordingHandle = { recordingId: number; finalizing: boolean; @@ -93,90 +87,6 @@ type NativeMacRecordingHandle = { paused: boolean; }; -function createRecorderHandle( - stream: MediaStream, - options: MediaRecorderOptions, - recordingId?: number, - fileName?: string, -): RecorderHandle { - const recorder = new MediaRecorder(stream, options); - const mimeType = options.mimeType || "video/webm"; - - // Stream chunks to disk only when a target (recordingId + fileName) is given. - // The main screen recorder and the browser-only webcam recorder pass a target - // so long recordings never buffer the whole video in the renderer (the #616 - // fix). Native-capture webcam sidecars omit the target and buffer in-memory, - // because their finalize path reads recordedBlobPromise directly to attach the - // webcam file; an empty streamed blob would silently drop their webcam track. - const streamTarget = - recordingId !== undefined && fileName !== undefined ? { recordingId, fileName } : null; - - const pendingChunks: ArrayBuffer[] = []; - let streamReady = false; - let streamFailed = streamTarget === null; - - if (streamTarget) { - const streamOpenPromise = - window.electronAPI?.openRecordingStream?.(streamTarget.recordingId, streamTarget.fileName) ?? - Promise.resolve({ success: false }); - - streamOpenPromise.then((result) => { - if (result.success) { - streamReady = true; - for (const chunk of pendingChunks) { - void window.electronAPI.appendRecordingChunk(streamTarget.recordingId, chunk); - } - pendingChunks.length = 0; - } else { - streamFailed = true; - } - }); - } - - const fallbackChunks: Blob[] = []; - - const recordedBlobPromise = new Promise((resolve, reject) => { - recorder.ondataavailable = (event: BlobEvent) => { - if (!event.data || event.data.size === 0) return; - - if (streamFailed) { - fallbackChunks.push(event.data); - return; - } - - void event.data.arrayBuffer().then((buf) => { - if (streamFailed) { - fallbackChunks.push(new Blob([buf], { type: mimeType })); - return; - } - if (streamReady && streamTarget) { - void window.electronAPI.appendRecordingChunk(streamTarget.recordingId, buf); - } else { - pendingChunks.push(buf); - } - }); - }; - - recorder.onerror = () => { - reject(new Error("Recording failed")); - }; - - recorder.onstop = () => { - if (streamFailed) { - // Not streaming, or the stream failed to open — return the full - // in-memory blob (the buffered fallback). - resolve(new Blob(fallbackChunks, { type: mimeType })); - } else { - // Streaming succeeded — the main process already has the data on disk. - resolve(new Blob([], { type: mimeType })); - } - }; - }); - - recorder.start(RECORDER_TIMESLICE_MS); - return { recorder, recordedBlobPromise, streaming: !streamFailed }; -} - export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); const [recording, setRecording] = useState(false); @@ -418,6 +328,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { window.electronAPI?.setRecordingState(false); void (async () => { + // Set once the recording is safely stored. Until then any disk stream + // is still open, so the finally block closes it and removes the partial + // file on the discard or error paths. + let savedToDisk = false; try { const screenBlob = await activeScreenRecorder.recordedBlobPromise; if (discardRecordingId.current === activeRecordingId) { @@ -425,16 +339,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } // When streaming succeeded the blob is empty — the data is already on disk. - if (!activeScreenRecorder.streaming && screenBlob.size === 0) { + if (!activeScreenRecorder.isStreaming() && screenBlob.size === 0) { return; } const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`; const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; - // Only fix duration / convert to ArrayBuffer if we have in-memory data. + // Only fix duration / convert to ArrayBuffer for in-memory data; + // streamed recordings are patched on disk by the main process. let screenVideoData: ArrayBuffer = new ArrayBuffer(0); - if (!activeScreenRecorder.streaming && screenBlob.size > 0) { + if (!activeScreenRecorder.isStreaming() && screenBlob.size > 0) { const fixedScreenBlob = await fixWebmDuration(screenBlob, duration); screenVideoData = await fixedScreenBlob.arrayBuffer(); } @@ -442,10 +357,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { let webcamVideoData: ArrayBuffer | undefined; if (activeWebcamRecorder) { const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); - if (!activeWebcamRecorder.streaming && webcamBlob && webcamBlob.size > 0) { + if (!activeWebcamRecorder.isStreaming() && webcamBlob && webcamBlob.size > 0) { const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); webcamVideoData = await fixedWebcamBlob.arrayBuffer(); - } else if (activeWebcamRecorder.streaming) { + } else if (activeWebcamRecorder.isStreaming()) { webcamVideoData = new ArrayBuffer(0); } } @@ -468,6 +383,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.error("Failed to store recording session:", result.message); return; } + // store-recorded-session has flushed and closed the disk streams. + savedToDisk = true; if (result.session) { await window.electronAPI.setCurrentRecordingSession(result.session); @@ -479,6 +396,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } catch (error) { console.error("Error saving recording:", error); } finally { + if (!savedToDisk) { + // Discarded, or failed before a successful save — close any + // dangling disk streams and remove their partial files so a + // cancelled or failed run doesn't leak a descriptor or orphan. + await activeScreenRecorder.discard().catch(() => undefined); + await activeWebcamRecorder?.discard().catch(() => undefined); + } if (finalizingRecordingId.current === activeRecordingId) { finalizingRecordingId.current = null; } @@ -1418,7 +1342,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } : {}), }, - activeRecordingId, `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`, ); screenRecorder.current.recorder.addEventListener( @@ -1433,7 +1356,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamRecorder.current = createRecorderHandle( webcamStream.current, { mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) }, - activeRecordingId + 1, `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, ); } diff --git a/vitest.config.ts b/vitest.config.ts index 9108f699..5a52a9be 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", - include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: ["{src,electron}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], exclude: ["src/**/*.browser.test.{ts,tsx}"], }, resolve: { From 36d7d2bdd034f410163a1c82b5b005a6b4c20961 Mon Sep 17 00:00:00 2001 From: neurot1cal Date: Tue, 26 May 2026 16:28:50 -0700 Subject: [PATCH 79/94] fix: tighten streaming failure handling from re-review Addresses the CodeRabbit + Codex re-review of the prior commit. - Normalize a rejected append (channel/handler error, not just a { success: false } result) into appendError, so the write queue never rejects and isStreaming() stays consistent after a failure (CodeRabbit). - Handle a rejected open-stream IPC the same as a failed open: fall back to in-memory buffering instead of leaving the recorder stuck "pending" with an unhandled rejection (CodeRabbit). - Discard a streamed webcam whose write failed even when the screen save succeeds. The cleanup gate is now per-recorder, so a webcam omitted from a successful screen-only save no longer leaks its stream and partial file (Codex). Adds tests for the rejected-append and rejected-open paths. Verified: tsc --noEmit clean; biome clean; vitest 182/182. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/recorderHandle.test.ts | 43 ++++++++++++++++++++++++++++++++ src/hooks/recorderHandle.ts | 42 ++++++++++++++++++++----------- src/hooks/useScreenRecorder.ts | 27 ++++++++++++-------- 3 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/hooks/recorderHandle.test.ts b/src/hooks/recorderHandle.test.ts index 3551b7da..adb53700 100644 --- a/src/hooks/recorderHandle.test.ts +++ b/src/hooks/recorderHandle.test.ts @@ -90,6 +90,29 @@ describe("createRecorderHandle", () => { expect(decode(await blob.arrayBuffer())).toBe("abc"); }); + it("falls back to in-memory buffering when the open IPC call rejects", async () => { + const openRecordingStream = vi.fn(async () => { + throw new Error("ipc channel closed"); + }); + stubElectronAPI({ + openRecordingStream, + appendRecordingChunk: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); + await tick(); // open rejects -> treated as a failed open, keep buffering + fake.emit(new Blob(["b"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(2); + expect(decode(await blob.arrayBuffer())).toBe("ab"); + }); + it("waits for in-flight chunk writes before stop resolves (no truncation)", async () => { let releaseAppend: () => void = () => undefined; const appendGate = new Promise((resolve) => { @@ -142,6 +165,26 @@ describe("createRecorderHandle", () => { expect(handle.isStreaming()).toBe(false); }); + it("treats a rejected append the same as a failed write", async () => { + stubElectronAPI({ + openRecordingStream: vi.fn(async () => ({ success: true })), + appendRecordingChunk: vi.fn(async () => { + throw new Error("kernel said no"); + }), + closeRecordingStream: vi.fn(async () => ({ success: true })), + }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + await tick(); + fake.emit(new Blob(["a"])); + fake.stop(); + + await expect(handle.recordedBlobPromise).rejects.toThrow(/kernel said no/); + expect(handle.isStreaming()).toBe(false); + }); + it("buffers in memory and never opens a stream when no file name is given", async () => { const openRecordingStream = vi.fn(async () => ({ success: true })); stubElectronAPI({ diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts index 3c631204..d547bf9e 100644 --- a/src/hooks/recorderHandle.ts +++ b/src/hooks/recorderHandle.ts @@ -61,10 +61,17 @@ export function createRecorderHandle( if (appendError || !fileName || !api?.appendRecordingChunk) { return; } - const buffer = await chunk.arrayBuffer(); - const result = await api.appendRecordingChunk(fileName, buffer); - if (!result.success) { - appendError = new Error(result.error ?? "Failed to write recording chunk to disk"); + // Capture both outcomes — a `{ success: false }` result and an outright + // rejection (channel/handler error) — into appendError, so writeChain + // never rejects and isStreaming() stays consistent after a failure. + try { + const buffer = await chunk.arrayBuffer(); + const result = await api.appendRecordingChunk(fileName, buffer); + if (!result.success) { + appendError = new Error(result.error ?? "Failed to write recording chunk to disk"); + } + } catch (error) { + appendError = error instanceof Error ? error : new Error(String(error)); } }); }; @@ -74,18 +81,25 @@ export function createRecorderHandle( ? api.openRecordingStream(fileName) : Promise.resolve({ success: false }); - void openPromise.then((result) => { - if (result.success) { - streamOpened = true; - mode = "streaming"; - for (const chunk of memoryChunks) { - enqueueWrite(chunk); + void openPromise.then( + (result) => { + if (result.success) { + streamOpened = true; + mode = "streaming"; + for (const chunk of memoryChunks) { + enqueueWrite(chunk); + } + memoryChunks.length = 0; + } else { + mode = "buffering"; } - memoryChunks.length = 0; - } else { + }, + () => { + // The IPC call itself rejected (channel or handler error). Treat it the + // same as a failed open: keep buffering in memory so nothing is lost. mode = "buffering"; - } - }); + }, + ); const recordedBlobPromise = new Promise((resolve, reject) => { recorder.ondataavailable = (event: BlobEvent) => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index d6c03f12..f5fb9203 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -328,10 +328,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { window.electronAPI?.setRecordingState(false); void (async () => { - // Set once the recording is safely stored. Until then any disk stream - // is still open, so the finally block closes it and removes the partial - // file on the discard or error paths. - let savedToDisk = false; + // Each disk stream must end up either saved or explicitly discarded. + // store-recorded-session finalizes the streams included in a successful + // save; the finally block discards everything else. + let storeSucceeded = false; + let webcamIncludedInSave = false; try { const screenBlob = await activeScreenRecorder.recordedBlobPromise; if (discardRecordingId.current === activeRecordingId) { @@ -364,6 +365,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamVideoData = new ArrayBuffer(0); } } + webcamIncludedInSave = webcamVideoData !== undefined; const result = await window.electronAPI.storeRecordedSession({ screen: { @@ -383,8 +385,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.error("Failed to store recording session:", result.message); return; } - // store-recorded-session has flushed and closed the disk streams. - savedToDisk = true; + // store-recorded-session has flushed and closed the saved streams. + storeSucceeded = true; if (result.session) { await window.electronAPI.setCurrentRecordingSession(result.session); @@ -396,12 +398,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } catch (error) { console.error("Error saving recording:", error); } finally { - if (!savedToDisk) { - // Discarded, or failed before a successful save — close any - // dangling disk streams and remove their partial files so a - // cancelled or failed run doesn't leak a descriptor or orphan. + // Discard any recorder whose data was not part of a successful save + // — a discarded run, a failed save, or a webcam whose disk write + // failed (so it was omitted while the screen still saved) — so no + // stream or partial file is left open or orphaned. + if (!storeSucceeded) { await activeScreenRecorder.discard().catch(() => undefined); - await activeWebcamRecorder?.discard().catch(() => undefined); + } + if (activeWebcamRecorder && !(storeSucceeded && webcamIncludedInSave)) { + await activeWebcamRecorder.discard().catch(() => undefined); } if (finalizingRecordingId.current === activeRecordingId) { finalizingRecordingId.current = null; From 5c5cab69034cb2108b3a7d78d06a035024d623ec Mon Sep 17 00:00:00 2001 From: neurot1cal Date: Tue, 26 May 2026 16:39:53 -0700 Subject: [PATCH 80/94] fix: don't stream when the append IPC is unavailable Codex re-review: if openRecordingStream exists but appendRecordingChunk does not (renderer/main version skew), the recorder would open the stream and switch to streaming mode, but every append silently no-ops and the save ends up empty. Require both IPC methods before streaming; otherwise fall back to in-memory buffering. Adds a regression test. Verified: tsc --noEmit clean; biome clean; vitest 183/183. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/recorderHandle.test.ts | 20 ++++++++++++++++++++ src/hooks/recorderHandle.ts | 8 +++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/hooks/recorderHandle.test.ts b/src/hooks/recorderHandle.test.ts index adb53700..3f16437e 100644 --- a/src/hooks/recorderHandle.test.ts +++ b/src/hooks/recorderHandle.test.ts @@ -205,6 +205,26 @@ describe("createRecorderHandle", () => { expect(blob.size).toBe(2); }); + it("buffers in memory when appendRecordingChunk is unavailable (version skew)", async () => { + const openRecordingStream = vi.fn(async () => ({ success: true })); + // appendRecordingChunk intentionally omitted to simulate renderer/main skew. + stubElectronAPI({ openRecordingStream }); + + const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm"); + const fake = driver(handle); + + fake.emit(new Blob(["a"])); + await tick(); + fake.emit(new Blob(["b"])); + fake.stop(); + + const blob = await handle.recordedBlobPromise; + // Never even attempts to open the stream when it can't append to it. + expect(openRecordingStream).not.toHaveBeenCalled(); + expect(handle.isStreaming()).toBe(false); + expect(blob.size).toBe(2); + }); + it("discard closes the disk stream for a streamed recording", async () => { const closeRecordingStream = vi.fn(async () => ({ success: true })); stubElectronAPI({ diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts index d547bf9e..e98000e6 100644 --- a/src/hooks/recorderHandle.ts +++ b/src/hooks/recorderHandle.ts @@ -76,8 +76,14 @@ export function createRecorderHandle( }); }; + // Require BOTH stream IPC methods before attempting to stream. If only + // openRecordingStream exists (renderer/main version skew), streaming would + // open but every append would silently no-op, saving an empty file — so in + // that case fall through to in-memory buffering instead. const openPromise: Promise<{ success: boolean; error?: string }> = - fileName && api?.openRecordingStream + fileName !== undefined && + typeof api?.openRecordingStream === "function" && + typeof api?.appendRecordingChunk === "function" ? api.openRecordingStream(fileName) : Promise.resolve({ success: false }); From 0d39f65af1e682e9188b06c399b69a70596c7ad4 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Thu, 28 May 2026 09:22:56 +0200 Subject: [PATCH 81/94] feat(timeline): green filled polygon waveform style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the trim-row waveform from faint white vertical strokes to a green filled polygon (upper max edge left→right, lower min edge right→left) with crisp edge strokes — matching the look of Final Cut Pro / DaVinci Resolve waveform displays. Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/BackgroundWaveform.tsx | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index 05d8e650..a4f56aea 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -60,10 +60,9 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun const N = peaks.length / 2; - ctx.beginPath(); - ctx.strokeStyle = "rgba(255, 255, 255, 0.12)"; - ctx.lineWidth = 1; - + // Pre-compute per-column peaks so we can draw a single filled polygon. + const colMax = new Float32Array(W); + const colMin = new Float32Array(W); for (let x = 0; x < W; x++) { const startMs = range.start + (x / W) * rangeMs; const endMs = range.start + ((x + 1) / W) * rangeMs; @@ -78,11 +77,41 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun if (mn < minVal) minVal = mn; if (mx > maxVal) maxVal = mx; } - - ctx.moveTo(x + 0.5, mid - maxVal * amp); - ctx.lineTo(x + 0.5, mid - minVal * amp); + colMax[x] = maxVal; + colMin[x] = minVal; } + // Filled waveform polygon: upper edge (max) left→right, lower edge (min) right→left. + ctx.beginPath(); + ctx.moveTo(0, mid - colMax[0] * amp); + for (let x = 1; x < W; x++) { + ctx.lineTo(x, mid - colMax[x] * amp); + } + for (let x = W - 1; x >= 0; x--) { + ctx.lineTo(x, mid - colMin[x] * amp); + } + ctx.closePath(); + ctx.fillStyle = "rgba(74, 222, 128, 0.55)"; + ctx.fill(); + + // Crisp top-edge stroke for the "pro app" sharp silhouette. + ctx.beginPath(); + ctx.moveTo(0, mid - colMax[0] * amp); + for (let x = 1; x < W; x++) { + ctx.lineTo(x, mid - colMax[x] * amp); + } + ctx.strokeStyle = "rgba(74, 222, 128, 0.80)"; + ctx.lineWidth = 1; + ctx.stroke(); + + // Mirror: bottom edge stroke. + ctx.beginPath(); + ctx.moveTo(0, mid - colMin[0] * amp); + for (let x = 1; x < W; x++) { + ctx.lineTo(x, mid - colMin[x] * amp); + } + ctx.strokeStyle = "rgba(74, 222, 128, 0.80)"; + ctx.lineWidth = 1; ctx.stroke(); }, [peaks, range, canvasSize, videoDurationMs]); From 60cbb449e8694326280cf21d543669fbb14c70c8 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Thu, 28 May 2026 09:30:30 +0200 Subject: [PATCH 82/94] feat(timeline): rectified (half) waveform style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bipolar ±center polygon with a rectified half-waveform: amplitude per column is max(|min|, |max|) and the filled shape rises from the bottom of the row upward, matching the single-sided waveform display common in audio/video editors. Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/BackgroundWaveform.tsx | 52 +++++++------------ 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index a4f56aea..ada5ee77 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -53,64 +53,48 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun const W = canvasSize.w; const H = canvasSize.h; - const mid = H / 2; - const amp = mid * 0.9; const rangeMs = range.end - range.start; if (rangeMs <= 0 || videoDurationMs <= 0) return; const N = peaks.length / 2; + const amp = H * 0.9; // full-height amplitude for rectified display - // Pre-compute per-column peaks so we can draw a single filled polygon. - const colMax = new Float32Array(W); - const colMin = new Float32Array(W); + // Rectified (half-wave): amplitude = max(|min|, |max|), drawn from the bottom up. + const colAmp = new Float32Array(W); for (let x = 0; x < W; x++) { const startMs = range.start + (x / W) * rangeMs; const endMs = range.start + ((x + 1) / W) * rangeMs; const lo = Math.max(0, Math.floor((startMs / videoDurationMs) * N)); const hi = Math.min(N - 1, Math.ceil((endMs / videoDurationMs) * N)); - let minVal = 0; - let maxVal = 0; + let absMax = 0; for (let i = lo; i <= hi; i++) { - const mn = peaks[i * 2]; - const mx = peaks[i * 2 + 1]; - if (mn < minVal) minVal = mn; - if (mx > maxVal) maxVal = mx; + const a = Math.abs(peaks[i * 2]); + const b = Math.abs(peaks[i * 2 + 1]); + if (a > absMax) absMax = a; + if (b > absMax) absMax = b; } - colMax[x] = maxVal; - colMin[x] = minVal; + colAmp[x] = absMax; } - // Filled waveform polygon: upper edge (max) left→right, lower edge (min) right→left. + // Filled polygon: bottom-left → top silhouette → bottom-right. ctx.beginPath(); - ctx.moveTo(0, mid - colMax[0] * amp); - for (let x = 1; x < W; x++) { - ctx.lineTo(x, mid - colMax[x] * amp); - } - for (let x = W - 1; x >= 0; x--) { - ctx.lineTo(x, mid - colMin[x] * amp); + ctx.moveTo(0, H); + for (let x = 0; x < W; x++) { + ctx.lineTo(x, H - colAmp[x] * amp); } + ctx.lineTo(W, H); ctx.closePath(); ctx.fillStyle = "rgba(74, 222, 128, 0.55)"; ctx.fill(); - // Crisp top-edge stroke for the "pro app" sharp silhouette. + // Crisp top-edge stroke for the sharp silhouette. ctx.beginPath(); - ctx.moveTo(0, mid - colMax[0] * amp); + ctx.moveTo(0, H - colAmp[0] * amp); for (let x = 1; x < W; x++) { - ctx.lineTo(x, mid - colMax[x] * amp); + ctx.lineTo(x, H - colAmp[x] * amp); } - ctx.strokeStyle = "rgba(74, 222, 128, 0.80)"; - ctx.lineWidth = 1; - ctx.stroke(); - - // Mirror: bottom edge stroke. - ctx.beginPath(); - ctx.moveTo(0, mid - colMin[0] * amp); - for (let x = 1; x < W; x++) { - ctx.lineTo(x, mid - colMin[x] * amp); - } - ctx.strokeStyle = "rgba(74, 222, 128, 0.80)"; + ctx.strokeStyle = "rgba(74, 222, 128, 0.85)"; ctx.lineWidth = 1; ctx.stroke(); }, [peaks, range, canvasSize, videoDurationMs]); From 489f157eef9ab06052478fb61ae17e1742a93c35 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Thu, 28 May 2026 09:33:59 +0200 Subject: [PATCH 83/94] fix(timeline): align waveform bounds with trim box height The trim item content is 30 px tall inside a 36 px row, leaving 3 px of vertical padding top and bottom. Previously the canvas used `inset-0` so the waveform filled the full row height. A new `verticalInset` prop (defaulting to 0) offsets the canvas top/bottom edges; the trim row passes `verticalInset={3}` so the waveform top and bottom align exactly with the trim box bounds. Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/BackgroundWaveform.tsx | 31 +++++++++++++++---- .../video-editor/timeline/TimelineEditor.tsx | 2 +- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index ada5ee77..91f40fb7 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -5,18 +5,29 @@ export interface BackgroundWaveformProps { /** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */ peaks: Float32Array | null; videoDurationMs: number; + /** + * Vertical inset in CSS pixels applied to the top and bottom of the canvas + * so the waveform aligns with the item content height rather than the full + * row height. Defaults to 0. + */ + verticalInset?: number; } /** - * Renders a faint audio waveform on a `` that fills its containing - * block. Designed to be passed as the `background` prop of ``, which - * already provides `relative overflow-hidden` — no wrapper element needed. + * Renders a rectified (half-wave) audio waveform on a `` that fills + * its containing block. Designed to be passed as the `background` prop of + * ``, which already provides `relative overflow-hidden` — no wrapper + * element needed. * * - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`). * - Redraws whenever the timeline zoom/pan range changes. * - `pointer-events: none` — never blocks drag-to-create interactions. */ -export default function BackgroundWaveform({ peaks, videoDurationMs }: BackgroundWaveformProps) { +export default function BackgroundWaveform({ + peaks, + videoDurationMs, + verticalInset = 0, +}: BackgroundWaveformProps) { const { range } = useTimelineContext(); const canvasRef = useRef(null); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); @@ -57,7 +68,9 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun if (rangeMs <= 0 || videoDurationMs <= 0) return; const N = peaks.length / 2; - const amp = H * 0.9; // full-height amplitude for rectified display + // Amplitude scaled to fill the canvas height minus the vertical inset on + // each side, so the waveform top/bottom aligns with the item content bounds. + const amp = H * 0.9; // Rectified (half-wave): amplitude = max(|min|, |max|), drawn from the bottom up. const colAmp = new Float32Array(W); @@ -99,5 +112,11 @@ export default function BackgroundWaveform({ peaks, videoDurationMs }: Backgroun ctx.stroke(); }, [peaks, range, canvasSize, videoDurationMs]); - return ; + return ( + + ); } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 734e6e1e..53c7e1fd 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -803,7 +803,7 @@ function Timeline({ hint={t("hints.pressTrim")} background={ showTrimWaveform ? ( - + ) : undefined } > From d8ab206b806d4d0ff013fa00377160e9fede33d5 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Thu, 28 May 2026 09:38:06 +0200 Subject: [PATCH 84/94] fix(timeline): draw waveform within item bounds, not CSS canvas bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single `verticalInset` CSS prop (which tried to shrink the canvas element) with `topInset`/`bottomInset` drawing-space offsets. The canvas remains `absolute inset-0` (full row height); the polygon baseline is drawn at `H - bottomInset` and the amplitude is scaled to `drawHeight * 0.9` — so the waveform is guaranteed to stay within [topInset, H - bottomInset] regardless of sub-pixel CSS rounding. Trim row: topInset=0, bottomInset=6 (row minHeight 36 − item height 30) eliminates the faint green line that was visible below the trim box. Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/BackgroundWaveform.tsx | 55 +++++++++++-------- .../video-editor/timeline/TimelineEditor.tsx | 7 ++- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index 91f40fb7..815b472d 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -6,11 +6,15 @@ export interface BackgroundWaveformProps { peaks: Float32Array | null; videoDurationMs: number; /** - * Vertical inset in CSS pixels applied to the top and bottom of the canvas - * so the waveform aligns with the item content height rather than the full - * row height. Defaults to 0. + * Pixels to inset the drawn waveform from the top of the canvas row, + * so it aligns with the item content top edge. Defaults to 0. */ - verticalInset?: number; + topInset?: number; + /** + * Pixels to inset the drawn waveform from the bottom of the canvas row, + * so it aligns with the item content bottom edge. Defaults to 0. + */ + bottomInset?: number; } /** @@ -19,6 +23,11 @@ export interface BackgroundWaveformProps { * ``, which already provides `relative overflow-hidden` — no wrapper * element needed. * + * The canvas always uses `inset-0` (full row height). Vertical alignment with + * the item content is achieved via `topInset`/`bottomInset` in the draw calls + * rather than CSS positioning, so the result is immune to sub-pixel CSS layout + * differences. + * * - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`). * - Redraws whenever the timeline zoom/pan range changes. * - `pointer-events: none` — never blocks drag-to-create interactions. @@ -26,7 +35,8 @@ export interface BackgroundWaveformProps { export default function BackgroundWaveform({ peaks, videoDurationMs, - verticalInset = 0, + topInset = 0, + bottomInset = 0, }: BackgroundWaveformProps) { const { range } = useTimelineContext(); const canvasRef = useRef(null); @@ -67,12 +77,17 @@ export default function BackgroundWaveform({ const rangeMs = range.end - range.start; if (rangeMs <= 0 || videoDurationMs <= 0) return; - const N = peaks.length / 2; - // Amplitude scaled to fill the canvas height minus the vertical inset on - // each side, so the waveform top/bottom aligns with the item content bounds. - const amp = H * 0.9; + // Draw within [topY, bottomY] so the waveform aligns with item bounds + // regardless of CSS sub-pixel rounding on the canvas element itself. + const topY = topInset; + const bottomY = H - bottomInset; + const drawHeight = bottomY - topY; + if (drawHeight <= 0) return; - // Rectified (half-wave): amplitude = max(|min|, |max|), drawn from the bottom up. + const N = peaks.length / 2; + const amp = drawHeight * 0.9; + + // Rectified (half-wave): amplitude = max(|min|, |max|), drawn upward from bottomY. const colAmp = new Float32Array(W); for (let x = 0; x < W; x++) { const startMs = range.start + (x / W) * rangeMs; @@ -92,31 +107,25 @@ export default function BackgroundWaveform({ // Filled polygon: bottom-left → top silhouette → bottom-right. ctx.beginPath(); - ctx.moveTo(0, H); + ctx.moveTo(0, bottomY); for (let x = 0; x < W; x++) { - ctx.lineTo(x, H - colAmp[x] * amp); + ctx.lineTo(x, bottomY - colAmp[x] * amp); } - ctx.lineTo(W, H); + ctx.lineTo(W, bottomY); ctx.closePath(); ctx.fillStyle = "rgba(74, 222, 128, 0.55)"; ctx.fill(); // Crisp top-edge stroke for the sharp silhouette. ctx.beginPath(); - ctx.moveTo(0, H - colAmp[0] * amp); + ctx.moveTo(0, bottomY - colAmp[0] * amp); for (let x = 1; x < W; x++) { - ctx.lineTo(x, H - colAmp[x] * amp); + ctx.lineTo(x, bottomY - colAmp[x] * amp); } ctx.strokeStyle = "rgba(74, 222, 128, 0.85)"; ctx.lineWidth = 1; ctx.stroke(); - }, [peaks, range, canvasSize, videoDurationMs]); + }, [peaks, range, canvasSize, videoDurationMs, topInset, bottomInset]); - return ( - - ); + return ; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 53c7e1fd..2bca08a8 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -803,7 +803,12 @@ function Timeline({ hint={t("hints.pressTrim")} background={ showTrimWaveform ? ( - + ) : undefined } > From abb4413932bd68b618b8643aa0c45328378ad4eb Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Thu, 28 May 2026 09:45:14 +0200 Subject: [PATCH 85/94] fix(timeline): correct waveform vertical alignment and silent baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. topInset/bottomInset both set to 3 — items are center-aligned in the 36 px row (3 px gap top and bottom), so the drawing zone must match. topInset=0 was drawing 3 px above the trim box top. 2. Replace closed polygon with per-column fillRect so silent sections (barH < 0.5 px) produce no pixels instead of a flat green baseline. The top-edge stroke lifts the pen across silent gaps. Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/BackgroundWaveform.tsx | 39 +++++++++++-------- .../video-editor/timeline/TimelineEditor.tsx | 4 +- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index 815b472d..970a83d3 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -105,25 +105,32 @@ export default function BackgroundWaveform({ colAmp[x] = absMax; } - // Filled polygon: bottom-left → top silhouette → bottom-right. - ctx.beginPath(); - ctx.moveTo(0, bottomY); - for (let x = 0; x < W; x++) { - ctx.lineTo(x, bottomY - colAmp[x] * amp); - } - ctx.lineTo(W, bottomY); - ctx.closePath(); + // Draw one filled bar per column. Silent columns (height < 0.5 px) are + // skipped entirely so no flat baseline appears in quiet sections. ctx.fillStyle = "rgba(74, 222, 128, 0.55)"; - ctx.fill(); - - // Crisp top-edge stroke for the sharp silhouette. - ctx.beginPath(); - ctx.moveTo(0, bottomY - colAmp[0] * amp); - for (let x = 1; x < W; x++) { - ctx.lineTo(x, bottomY - colAmp[x] * amp); - } ctx.strokeStyle = "rgba(74, 222, 128, 0.85)"; ctx.lineWidth = 1; + + ctx.beginPath(); + let edgeOpen = false; + + for (let x = 0; x < W; x++) { + const barH = colAmp[x] * amp; + if (barH < 0.5) { + edgeOpen = false; + continue; + } + // Filled bar + ctx.fillRect(x, bottomY - barH, 1, barH); + // Top-edge stroke — lift pen on gaps, continue on runs + const topPx = bottomY - barH; + if (!edgeOpen) { + ctx.moveTo(x, topPx); + edgeOpen = true; + } else { + ctx.lineTo(x, topPx); + } + } ctx.stroke(); }, [peaks, range, canvasSize, videoDurationMs, topInset, bottomInset]); diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 2bca08a8..81cbf9d6 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -806,8 +806,8 @@ function Timeline({ ) : undefined } From fca17f901e383dfc0dc187621117051ec3f62173 Mon Sep 17 00:00:00 2001 From: Davide Mendolia Date: Thu, 28 May 2026 09:50:24 +0200 Subject: [PATCH 86/94] revert(timeline): restore polygon waveform, remove per-column bar approach The per-column fillRect skipped columns with amplitude < 0.5 px, causing the waveform to disappear entirely at certain zoom levels. Revert to the closed polygon + top-edge stroke which always renders something. Co-Authored-By: Claude Sonnet 4.6 --- .../timeline/BackgroundWaveform.tsx | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/src/components/video-editor/timeline/BackgroundWaveform.tsx b/src/components/video-editor/timeline/BackgroundWaveform.tsx index 970a83d3..815b472d 100644 --- a/src/components/video-editor/timeline/BackgroundWaveform.tsx +++ b/src/components/video-editor/timeline/BackgroundWaveform.tsx @@ -105,32 +105,25 @@ export default function BackgroundWaveform({ colAmp[x] = absMax; } - // Draw one filled bar per column. Silent columns (height < 0.5 px) are - // skipped entirely so no flat baseline appears in quiet sections. + // Filled polygon: bottom-left → top silhouette → bottom-right. + ctx.beginPath(); + ctx.moveTo(0, bottomY); + for (let x = 0; x < W; x++) { + ctx.lineTo(x, bottomY - colAmp[x] * amp); + } + ctx.lineTo(W, bottomY); + ctx.closePath(); ctx.fillStyle = "rgba(74, 222, 128, 0.55)"; + ctx.fill(); + + // Crisp top-edge stroke for the sharp silhouette. + ctx.beginPath(); + ctx.moveTo(0, bottomY - colAmp[0] * amp); + for (let x = 1; x < W; x++) { + ctx.lineTo(x, bottomY - colAmp[x] * amp); + } ctx.strokeStyle = "rgba(74, 222, 128, 0.85)"; ctx.lineWidth = 1; - - ctx.beginPath(); - let edgeOpen = false; - - for (let x = 0; x < W; x++) { - const barH = colAmp[x] * amp; - if (barH < 0.5) { - edgeOpen = false; - continue; - } - // Filled bar - ctx.fillRect(x, bottomY - barH, 1, barH); - // Top-edge stroke — lift pen on gaps, continue on runs - const topPx = bottomY - barH; - if (!edgeOpen) { - ctx.moveTo(x, topPx); - edgeOpen = true; - } else { - ctx.lineTo(x, topPx); - } - } ctx.stroke(); }, [peaks, range, canvasSize, videoDurationMs, topInset, bottomInset]); From 202c670090d5b1449063888caeaca8a343c3426c Mon Sep 17 00:00:00 2001 From: Justice Entsie Date: Thu, 28 May 2026 19:14:47 +0100 Subject: [PATCH 87/94] Add more built-in annotation font options --- .../video-editor/AnnotationSettingsPanel.tsx | 25 +++++++++++++++++-- src/i18n/locales/it/settings.json | 1 + src/index.css | 2 ++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 72e25a8b..5559b34d 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -50,7 +50,11 @@ interface AnnotationSettingsPanelProps { onDelete: () => void; } -const FONT_FAMILIES = [ +const FONT_FAMILIES: Array< + | { value: string; labelKey: string; name?: never } + | { value: string; labelKey?: never; name: string } +> = [ + { value: "Inter", name: "Inter" }, { value: "system-ui, -apple-system, sans-serif", labelKey: "classic" }, { value: "Georgia, serif", labelKey: "editor" }, { value: "Impact, Arial Black, sans-serif", labelKey: "strong" }, @@ -59,6 +63,21 @@ const FONT_FAMILIES = [ { value: "Arial, sans-serif", labelKey: "simple" }, { value: "Verdana, sans-serif", labelKey: "modern" }, { value: "Trebuchet MS, sans-serif", labelKey: "clean" }, + { value: '"Plus Jakarta Sans", sans-serif', name: "Plus Jakarta Sans" }, + { value: '"Space Grotesk", sans-serif', name: "Space Grotesk" }, + { value: '"DM Sans", sans-serif', name: "DM Sans" }, + { value: "Sora, sans-serif", name: "Sora" }, + { value: "Manrope, sans-serif", name: "Manrope" }, + { value: '"IBM Plex Sans", sans-serif', name: "IBM Plex Sans" }, + { value: '"Playfair Display", Georgia, serif', name: "Playfair Display" }, + { value: "Merriweather, Georgia, serif", name: "Merriweather" }, + { value: "Lora, Georgia, serif", name: "Lora" }, + { value: '"IBM Plex Mono", monospace', name: "IBM Plex Mono" }, + { value: '"Fira Code", monospace', name: "Fira Code" }, + { value: '"Bebas Neue", sans-serif', name: "Bebas Neue" }, + { value: "Oswald, sans-serif", name: "Oswald" }, + { value: "Caveat, cursive", name: "Caveat" }, + { value: '"Permanent Marker", cursive', name: "Permanent Marker" }, ]; const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128]; @@ -85,6 +104,8 @@ export function AnnotationSettingsPanel({ modern: t("fontStyles.modern"), clean: t("fontStyles.clean"), }; + const getFontLabel = (font: (typeof FONT_FAMILIES)[number]) => + font.labelKey ? fontStyleLabels[font.labelKey] : font.name; // Load custom fonts on mount useEffect(() => { @@ -231,7 +252,7 @@ export function AnnotationSettingsPanel({ value={font.value} style={{ fontFamily: font.value }} > - {fontStyleLabels[font.labelKey]} + {getFontLabel(font)} ))} {customFonts.length > 0 && ( diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 0515a765..912fc0ce 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -1,5 +1,6 @@ { "zoom": { + "previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom", "level": "Livello zoom", "customScale": "Zoom personalizzato", "selectRegion": "Seleziona una regione zoom da regolare", diff --git a/src/index.css b/src/index.css index d62f153c..61621c75 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,5 @@ +@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Caveat:wght@400;700&family=DM+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Fira+Code:wght@400;700&family=IBM+Plex+Mono:ital,wght@0,400;0,700;1,400;1,700&family=IBM+Plex+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Inter:ital,wght@0,400;0,700;1,400;1,700&family=Lora:ital,wght@0,400;0,700;1,400;1,700&family=Manrope:wght@400;700&family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&family=Oswald:wght@400;700&family=Permanent+Marker&family=Playfair+Display:ital,wght@0,400;0,700;1,400;1,700&family=Plus+Jakarta+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Space+Grotesk:wght@400;700&family=Sora:wght@400;700&display=swap"); + @tailwind base; @tailwind components; @tailwind utilities; From 480890bccee65853e332438271222fed9fd4bf54 Mon Sep 17 00:00:00 2001 From: Yauheni Bandziukevich Date: Thu, 28 May 2026 21:50:53 +0200 Subject: [PATCH 88/94] feat: add configurable global shortcut to open app --- electron/electron-env.d.ts | 6 ++++ electron/globalShortcut.ts | 51 +++++++++++++++++++++++++++ electron/ipc/handlers.ts | 2 +- electron/main.ts | 17 +++++++++ electron/preload.ts | 4 +++ src/contexts/ShortcutsContext.tsx | 4 ++- src/i18n/locales/ar/shortcuts.json | 1 + src/i18n/locales/en/shortcuts.json | 1 + src/i18n/locales/es/shortcuts.json | 1 + src/i18n/locales/fr/shortcuts.json | 1 + src/i18n/locales/it/shortcuts.json | 1 + src/i18n/locales/ja-JP/shortcuts.json | 1 + src/i18n/locales/ko-KR/shortcuts.json | 1 + src/i18n/locales/ru/shortcuts.json | 1 + src/i18n/locales/tr/shortcuts.json | 1 + src/i18n/locales/vi/shortcuts.json | 1 + src/i18n/locales/zh-CN/shortcuts.json | 1 + src/i18n/locales/zh-TW/shortcuts.json | 1 + src/lib/shortcuts.ts | 3 ++ 19 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 electron/globalShortcut.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1b829922..44ac54b4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -248,6 +248,12 @@ interface Window { ) => Promise<{ success: boolean; error?: string; message?: string }>; getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; + updateGlobalShortcut: (binding: { + key: string; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; + }) => Promise; hudOverlayHide: () => void; hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; diff --git a/electron/globalShortcut.ts b/electron/globalShortcut.ts new file mode 100644 index 00000000..c0093044 --- /dev/null +++ b/electron/globalShortcut.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import { globalShortcut } from "electron"; +import { type ShortcutBinding } from "../src/lib/shortcuts"; +import { SHORTCUTS_FILE } from "./ipc/handlers"; + +const DEFAULT_OPEN_APP_BINDING: ShortcutBinding = { key: "o", ctrl: true, shift: true }; + +function bindingToAccelerator(binding: ShortcutBinding): string { + const parts: string[] = []; + if (binding.ctrl) parts.push("CommandOrControl"); + if (binding.shift) parts.push("Shift"); + if (binding.alt) parts.push("Alt"); + parts.push(binding.key.toUpperCase()); + return parts.join("+"); +} + +let currentAccelerator: string | null = null; + +export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean { + if (currentAccelerator) { + globalShortcut.unregister(currentAccelerator); + } + + const accelerator = bindingToAccelerator(binding); + const success = globalShortcut.register(accelerator, onTrigger); + + if (success) { + currentAccelerator = accelerator; + console.log(`Global shortcut registered: ${accelerator}`); + } else { + console.warn(`Failed to register global shortcut: ${accelerator}`); + } + + return success; +} + +export async function loadAndRegisterGlobalShortcut(onTrigger: () => void): Promise { + try { + const data = await fs.readFile(SHORTCUTS_FILE, "utf-8"); + const shortcuts = JSON.parse(data); + const binding = shortcuts.openApp || DEFAULT_OPEN_APP_BINDING; + registerOpenAppShortcut(binding, onTrigger); + } catch { + // File doesn't exist or parse error, use default + registerOpenAppShortcut(DEFAULT_OPEN_APP_BINDING, onTrigger); + } +} + +export function unregisterAllGlobalShortcuts(): void { + globalShortcut.unregisterAll(); +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 669f1dd7..405a654b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -43,7 +43,7 @@ import type { CursorRecordingSession } from "../native-bridge/cursor/recording/s import { registerNativeBridgeHandlers } from "./nativeBridge"; const PROJECT_FILE_EXTENSION = "openscreen"; -const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); +export const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); const RECORDING_FILE_PREFIX = "recording-"; const RECORDING_SESSION_SUFFIX = ".session.json"; const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]); diff --git a/electron/main.ts b/electron/main.ts index 3e2258f8..cdddc706 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,6 +11,12 @@ import { systemPreferences, Tray, } from "electron"; +import { + loadAndRegisterGlobalShortcut, + registerOpenAppShortcut, + ShortcutBinding, + unregisterAllGlobalShortcuts, +} from "./globalShortcut"; import { mainT, setMainLocale } from "./i18n"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; import { @@ -440,6 +446,10 @@ app.on("activate", () => { } }); +app.on("will-quit", () => { + unregisterAllGlobalShortcuts(); +}); + // Register all IPC handlers when app is ready app.whenReady().then(async () => { // Force the app into "regular" activation policy so the Dock icon appears. @@ -512,6 +522,10 @@ app.whenReady().then(async () => { updateTrayMenu(); }); + ipcMain.handle("update-global-shortcut", (_, binding: ShortcutBinding) => { + registerOpenAppShortcut(binding, showMainWindow); + }); + createTray(); updateTrayMenu(); setupApplicationMenu(); @@ -545,5 +559,8 @@ app.whenReady().then(async () => { }, switchToHudWrapper, ); + + await loadAndRegisterGlobalShortcut(showMainWindow); + createWindow(); }); diff --git a/electron/preload.ts b/electron/preload.ts index 933ce9dc..c118b740 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -3,6 +3,7 @@ import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; +import type { ShortcutBinding } from "./globalShortcut"; // Asset base URL is passed from the main process via webPreferences.additionalArguments // (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we @@ -193,6 +194,9 @@ contextBridge.exposeInMainWorld("electronAPI", { saveShortcuts: (shortcuts: unknown) => { return ipcRenderer.invoke("save-shortcuts", shortcuts); }, + updateGlobalShortcut: (binding: ShortcutBinding) => { + return ipcRenderer.invoke("update-global-shortcut", binding); + }, setLocale: (locale: string) => { return ipcRenderer.invoke("set-locale", locale); }, diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx index acbe25a1..883c3f27 100644 --- a/src/contexts/ShortcutsContext.tsx +++ b/src/contexts/ShortcutsContext.tsx @@ -54,7 +54,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) { const persistShortcuts = useCallback( async (config?: ShortcutsConfig) => { - await window.electronAPI.saveShortcuts?.(config ?? shortcuts); + const configToSave = config ?? shortcuts; + await window.electronAPI.saveShortcuts?.(configToSave); + await window.electronAPI.updateGlobalShortcut?.(configToSave.openApp); }, [shortcuts], ); diff --git a/src/i18n/locales/ar/shortcuts.json b/src/i18n/locales/ar/shortcuts.json index a560c06a..94fa6923 100644 --- a/src/i18n/locales/ar/shortcuts.json +++ b/src/i18n/locales/ar/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "تم حفظ اختصارات لوحة المفاتيح", "resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق", "actions": { + "openApp": "فتح التطبيق", "addZoom": "إضافة تكبير", "addTrim": "إضافة قص", "addSpeed": "إضافة سرعة", diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json index 2c26aa48..7f3244e1 100644 --- a/src/i18n/locales/en/shortcuts.json +++ b/src/i18n/locales/en/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "Keyboard shortcuts saved", "resetToast": "Reset to default shortcuts — click Save to apply", "actions": { + "openApp": "Open App", "addZoom": "Add Zoom", "addTrim": "Add Trim", "addSpeed": "Add Speed", diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json index 888ded03..969ea746 100644 --- a/src/i18n/locales/es/shortcuts.json +++ b/src/i18n/locales/es/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "Atajos de teclado guardados", "resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar", "actions": { + "openApp": "Abrir aplicación", "addZoom": "Agregar zoom", "addTrim": "Agregar recorte", "addSpeed": "Agregar velocidad", diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json index a0ce83f7..10734d17 100644 --- a/src/i18n/locales/fr/shortcuts.json +++ b/src/i18n/locales/fr/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "Raccourcis clavier enregistrés", "resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer", "actions": { + "openApp": "Ouvrir l'application", "addZoom": "Ajouter un zoom", "addTrim": "Ajouter une coupe", "addSpeed": "Ajouter une vitesse", diff --git a/src/i18n/locales/it/shortcuts.json b/src/i18n/locales/it/shortcuts.json index 4d0c65dc..100f9a69 100644 --- a/src/i18n/locales/it/shortcuts.json +++ b/src/i18n/locales/it/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "Scorciatoie tastiera salvate", "resetToast": "Ripristino alle scorciatoie predefinite — clicca Salva per applicare", "actions": { + "openApp": "Apri applicazione", "addZoom": "Aggiungi zoom", "addTrim": "Aggiungi taglio", "addSpeed": "Aggiungi velocità", diff --git a/src/i18n/locales/ja-JP/shortcuts.json b/src/i18n/locales/ja-JP/shortcuts.json index d4b4a90e..264b3a22 100644 --- a/src/i18n/locales/ja-JP/shortcuts.json +++ b/src/i18n/locales/ja-JP/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "キーボードショートカットが保存されました", "resetToast": "デフォルトのショートカットにリセット — 保存をクリックして適用", "actions": { + "openApp": "アプリを開く", "addZoom": "ズームを追加", "addTrim": "トリムを追加", "addSpeed": "速度を追加", diff --git a/src/i18n/locales/ko-KR/shortcuts.json b/src/i18n/locales/ko-KR/shortcuts.json index 13b11a6b..e61bfdc8 100644 --- a/src/i18n/locales/ko-KR/shortcuts.json +++ b/src/i18n/locales/ko-KR/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "키보드 단축키가 저장되었습니다", "resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요", "actions": { + "openApp": "앱 열기", "addZoom": "줌 추가", "addTrim": "트림 추가", "addSpeed": "속도 추가", diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index ab6eae87..7629333e 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "Горячие клавиши сохранены", "resetToast": "Сброс к горячим клавишам по умолчанию — нажмите Сохранить для применения", "actions": { + "openApp": "Открыть приложение", "addZoom": "Добавить масштабирование", "addTrim": "Добавить обрезку", "addSpeed": "Изменить скорость", diff --git a/src/i18n/locales/tr/shortcuts.json b/src/i18n/locales/tr/shortcuts.json index 69b28eda..b6381b92 100644 --- a/src/i18n/locales/tr/shortcuts.json +++ b/src/i18n/locales/tr/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "Klavye kısayolları kaydedildi", "resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın", "actions": { + "openApp": "Uygulamayı Aç", "addZoom": "Yakınlaştırma Ekle", "addTrim": "Kırpma Ekle", "addSpeed": "Hız Ekle", diff --git a/src/i18n/locales/vi/shortcuts.json b/src/i18n/locales/vi/shortcuts.json index 918254fd..72993e37 100644 --- a/src/i18n/locales/vi/shortcuts.json +++ b/src/i18n/locales/vi/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "Đã lưu phím tắt", "resetToast": "Đã đặt lại về phím tắt mặc định — nhấp Lưu để áp dụng", "actions": { + "openApp": "Mở ứng dụng", "addZoom": "Thêm Thu phóng", "addTrim": "Thêm Cắt", "addSpeed": "Thêm Tốc độ", diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json index 33282396..f2a940f6 100644 --- a/src/i18n/locales/zh-CN/shortcuts.json +++ b/src/i18n/locales/zh-CN/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "键盘快捷键已保存", "resetToast": "已恢复默认快捷键 — 点击保存以应用", "actions": { + "openApp": "打开应用", "addZoom": "添加缩放", "addTrim": "添加剪辑", "addSpeed": "添加速度", diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json index 54c0cfca..996d885d 100644 --- a/src/i18n/locales/zh-TW/shortcuts.json +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -14,6 +14,7 @@ "savedToast": "鍵盤快捷鍵已儲存", "resetToast": "已還原預設快捷鍵 — 點擊儲存以套用", "actions": { + "openApp": "開啟應用程式", "addZoom": "新增縮放", "addTrim": "新增剪輯", "addSpeed": "新增速度", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 96592a41..485fe89d 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -1,4 +1,5 @@ export const SHORTCUT_ACTIONS = [ + "openApp", "addZoom", "addTrim", "addSpeed", @@ -105,6 +106,7 @@ export function findConflict( } export const DEFAULT_SHORTCUTS: ShortcutsConfig = { + openApp: { key: "o", ctrl: true, shift: true }, addZoom: { key: "z" }, addTrim: { key: "t" }, addSpeed: { key: "s" }, @@ -116,6 +118,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { }; export const SHORTCUT_LABELS: Record = { + openApp: "Open App", addZoom: "Add Zoom", addTrim: "Add Trim", addSpeed: "Add Speed", From f1a855204645dcea8df948d12c0d163ea23ce72f Mon Sep 17 00:00:00 2001 From: Yauheni Bandziukevich Date: Thu, 28 May 2026 21:50:55 +0200 Subject: [PATCH 89/94] refactor: update ShortcutBinding import path in main and preload scripts --- electron/main.ts | 2 +- electron/preload.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index cdddc706..94cf6bd2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,10 +11,10 @@ import { systemPreferences, Tray, } from "electron"; +import { ShortcutBinding } from "../src/lib/shortcuts"; import { loadAndRegisterGlobalShortcut, registerOpenAppShortcut, - ShortcutBinding, unregisterAllGlobalShortcuts, } from "./globalShortcut"; import { mainT, setMainLocale } from "./i18n"; diff --git a/electron/preload.ts b/electron/preload.ts index c118b740..e594c50d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -2,8 +2,8 @@ import { contextBridge, ipcRenderer } from "electron"; import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; +import type { ShortcutBinding } from "../src/lib/shortcuts"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; -import type { ShortcutBinding } from "./globalShortcut"; // Asset base URL is passed from the main process via webPreferences.additionalArguments // (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we From d86c17407a70c607d0f5ed748595cd495ebd1ccb Mon Sep 17 00:00:00 2001 From: Yauheni Bandziukevich Date: Thu, 28 May 2026 22:11:38 +0200 Subject: [PATCH 90/94] fix: convert special keys to valid Electron accelerators Maps KeyboardEvent.key values (e.g., "ArrowDown", " ") to their Electron accelerator equivalents (e.g., "Down", "Space") to prevent global shortcut registration failures. --- electron/globalShortcut.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/electron/globalShortcut.ts b/electron/globalShortcut.ts index c0093044..a05c52d8 100644 --- a/electron/globalShortcut.ts +++ b/electron/globalShortcut.ts @@ -5,12 +5,34 @@ import { SHORTCUTS_FILE } from "./ipc/handlers"; const DEFAULT_OPEN_APP_BINDING: ShortcutBinding = { key: "o", ctrl: true, shift: true }; +// Maps KeyboardEvent.key values to Electron accelerator key names +const KEY_TO_ACCELERATOR: Record = { + " ": "Space", + "+": "Plus", + "-": "numsub", + "*": "nummult", + "/": "numdiv", + arrowup: "Up", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + escape: "Escape", + enter: "Return", + backspace: "Backspace", + delete: "Delete", + tab: "Tab", +}; + function bindingToAccelerator(binding: ShortcutBinding): string { const parts: string[] = []; if (binding.ctrl) parts.push("CommandOrControl"); if (binding.shift) parts.push("Shift"); if (binding.alt) parts.push("Alt"); - parts.push(binding.key.toUpperCase()); + + const keyLower = binding.key.toLowerCase(); + const acceleratorKey = KEY_TO_ACCELERATOR[keyLower] ?? binding.key.toUpperCase(); + parts.push(acceleratorKey); + return parts.join("+"); } From 902d4e4d8a5abb33a31056623d49e8be95f631f6 Mon Sep 17 00:00:00 2001 From: Yauheni Bandziukevich Date: Thu, 28 May 2026 23:09:51 +0200 Subject: [PATCH 91/94] fix: show error feedback when global shortcut registration fails --- electron/electron-env.d.ts | 2 +- electron/globalShortcut.ts | 1 - electron/main.ts | 3 ++- src/components/video-editor/ShortcutsConfigDialog.tsx | 8 ++++++-- src/contexts/ShortcutsContext.tsx | 6 ++++-- src/i18n/locales/ar/shortcuts.json | 1 + src/i18n/locales/en/shortcuts.json | 1 + src/i18n/locales/es/shortcuts.json | 1 + src/i18n/locales/fr/shortcuts.json | 1 + src/i18n/locales/it/shortcuts.json | 1 + src/i18n/locales/ja-JP/shortcuts.json | 1 + src/i18n/locales/ko-KR/shortcuts.json | 1 + src/i18n/locales/ru/shortcuts.json | 1 + src/i18n/locales/tr/shortcuts.json | 1 + src/i18n/locales/vi/shortcuts.json | 1 + src/i18n/locales/zh-CN/shortcuts.json | 1 + src/i18n/locales/zh-TW/shortcuts.json | 1 + 17 files changed, 25 insertions(+), 7 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 44ac54b4..2c1a129c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -253,7 +253,7 @@ interface Window { ctrl?: boolean; shift?: boolean; alt?: boolean; - }) => Promise; + }) => Promise<{ success: boolean }>; hudOverlayHide: () => void; hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; diff --git a/electron/globalShortcut.ts b/electron/globalShortcut.ts index a05c52d8..8e444926 100644 --- a/electron/globalShortcut.ts +++ b/electron/globalShortcut.ts @@ -63,7 +63,6 @@ export async function loadAndRegisterGlobalShortcut(onTrigger: () => void): Prom const binding = shortcuts.openApp || DEFAULT_OPEN_APP_BINDING; registerOpenAppShortcut(binding, onTrigger); } catch { - // File doesn't exist or parse error, use default registerOpenAppShortcut(DEFAULT_OPEN_APP_BINDING, onTrigger); } } diff --git a/electron/main.ts b/electron/main.ts index 94cf6bd2..d668285f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -523,7 +523,8 @@ app.whenReady().then(async () => { }); ipcMain.handle("update-global-shortcut", (_, binding: ShortcutBinding) => { - registerOpenAppShortcut(binding, showMainWindow); + const success = registerOpenAppShortcut(binding, showMainWindow); + return { success }; }); createTray(); diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index 6b9ef783..c29bc444 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -103,8 +103,12 @@ export function ShortcutsConfigDialog() { const handleSave = useCallback(async () => { setShortcuts(draft); - await persistShortcuts(draft); - toast.success(t("savedToast")); + const success = await persistShortcuts(draft); + if (success) { + toast.success(t("savedToast")); + } else { + toast.error(t("registrationFailed")); + } closeConfig(); }, [draft, setShortcuts, persistShortcuts, closeConfig, t]); diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx index 883c3f27..aab5f45c 100644 --- a/src/contexts/ShortcutsContext.tsx +++ b/src/contexts/ShortcutsContext.tsx @@ -14,7 +14,7 @@ interface ShortcutsContextValue { shortcuts: ShortcutsConfig; isMac: boolean; setShortcuts: (config: ShortcutsConfig) => void; - persistShortcuts: (config?: ShortcutsConfig) => Promise; + persistShortcuts: (config?: ShortcutsConfig) => Promise; isConfigOpen: boolean; openConfig: () => void; closeConfig: () => void; @@ -56,7 +56,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) { async (config?: ShortcutsConfig) => { const configToSave = config ?? shortcuts; await window.electronAPI.saveShortcuts?.(configToSave); - await window.electronAPI.updateGlobalShortcut?.(configToSave.openApp); + + const result = await window.electronAPI.updateGlobalShortcut?.(configToSave.openApp); + return result ? result.success : true; }, [shortcuts], ); diff --git a/src/i18n/locales/ar/shortcuts.json b/src/i18n/locales/ar/shortcuts.json index 94fa6923..b18e3195 100644 --- a/src/i18n/locales/ar/shortcuts.json +++ b/src/i18n/locales/ar/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.", "savedToast": "تم حفظ اختصارات لوحة المفاتيح", "resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق", + "registrationFailed": "فشل في تسجيل الاختصار. قد يكون مستخدمًا من قبل تطبيق آخر. جرب مفتاحًا مختلفًا.", "actions": { "openApp": "فتح التطبيق", "addZoom": "إضافة تكبير", diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json index 7f3244e1..8994df13 100644 --- a/src/i18n/locales/en/shortcuts.json +++ b/src/i18n/locales/en/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.", "savedToast": "Keyboard shortcuts saved", "resetToast": "Reset to default shortcuts — click Save to apply", + "registrationFailed": "Failed to register shortcut. It may be in use by another app. Try a different key.", "actions": { "openApp": "Open App", "addZoom": "Add Zoom", diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json index 969ea746..49767d56 100644 --- a/src/i18n/locales/es/shortcuts.json +++ b/src/i18n/locales/es/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.", "savedToast": "Atajos de teclado guardados", "resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar", + "registrationFailed": "Error al registrar el atajo. Puede estar en uso por otra aplicación. Prueba otra tecla.", "actions": { "openApp": "Abrir aplicación", "addZoom": "Agregar zoom", diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json index 10734d17..eec8a591 100644 --- a/src/i18n/locales/fr/shortcuts.json +++ b/src/i18n/locales/fr/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.", "savedToast": "Raccourcis clavier enregistrés", "resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer", + "registrationFailed": "Échec de l'enregistrement du raccourci. Il est peut-être utilisé par une autre application. Essayez une autre touche.", "actions": { "openApp": "Ouvrir l'application", "addZoom": "Ajouter un zoom", diff --git a/src/i18n/locales/it/shortcuts.json b/src/i18n/locales/it/shortcuts.json index 100f9a69..051a8887 100644 --- a/src/i18n/locales/it/shortcuts.json +++ b/src/i18n/locales/it/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "Questa scorciatoia è riservata a \"{{label}}\" e non può essere riassegnata.", "savedToast": "Scorciatoie tastiera salvate", "resetToast": "Ripristino alle scorciatoie predefinite — clicca Salva per applicare", + "registrationFailed": "Impossibile registrare la scorciatoia. Potrebbe essere in uso da un'altra applicazione. Prova un altro tasto.", "actions": { "openApp": "Apri applicazione", "addZoom": "Aggiungi zoom", diff --git a/src/i18n/locales/ja-JP/shortcuts.json b/src/i18n/locales/ja-JP/shortcuts.json index 264b3a22..1d574198 100644 --- a/src/i18n/locales/ja-JP/shortcuts.json +++ b/src/i18n/locales/ja-JP/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "このショートカットは \"{{label}}\" に予約されており、再割り当てできません。", "savedToast": "キーボードショートカットが保存されました", "resetToast": "デフォルトのショートカットにリセット — 保存をクリックして適用", + "registrationFailed": "ショートカットの登録に失敗しました。他のアプリで使用中の可能性があります。別のキーをお試しください。", "actions": { "openApp": "アプリを開く", "addZoom": "ズームを追加", diff --git a/src/i18n/locales/ko-KR/shortcuts.json b/src/i18n/locales/ko-KR/shortcuts.json index e61bfdc8..ddac2954 100644 --- a/src/i18n/locales/ko-KR/shortcuts.json +++ b/src/i18n/locales/ko-KR/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "이 단축키는 \"{{label}}\"에 예약되어 있어 변경할 수 없습니다.", "savedToast": "키보드 단축키가 저장되었습니다", "resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요", + "registrationFailed": "단축키 등록에 실패했습니다. 다른 앱에서 사용 중일 수 있습니다. 다른 키를 시도하세요.", "actions": { "openApp": "앱 열기", "addZoom": "줌 추가", diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index 7629333e..b6e1faa5 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "Эта горячая клавиша зарезервирована для \"{{label}}\" и не может быть переназначена.", "savedToast": "Горячие клавиши сохранены", "resetToast": "Сброс к горячим клавишам по умолчанию — нажмите Сохранить для применения", + "registrationFailed": "Не удалось зарегистрировать горячую клавишу. Возможно, она используется другим приложением. Попробуйте другую клавишу.", "actions": { "openApp": "Открыть приложение", "addZoom": "Добавить масштабирование", diff --git a/src/i18n/locales/tr/shortcuts.json b/src/i18n/locales/tr/shortcuts.json index b6381b92..62cdfaf5 100644 --- a/src/i18n/locales/tr/shortcuts.json +++ b/src/i18n/locales/tr/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.", "savedToast": "Klavye kısayolları kaydedildi", "resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın", + "registrationFailed": "Kısayol kaydedilemedi. Başka bir uygulama tarafından kullanılıyor olabilir. Farklı bir tuş deneyin.", "actions": { "openApp": "Uygulamayı Aç", "addZoom": "Yakınlaştırma Ekle", diff --git a/src/i18n/locales/vi/shortcuts.json b/src/i18n/locales/vi/shortcuts.json index 72993e37..46ec9b5e 100644 --- a/src/i18n/locales/vi/shortcuts.json +++ b/src/i18n/locales/vi/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "Phím tắt này được dành riêng cho \"{{label}}\" và không thể gán lại.", "savedToast": "Đã lưu phím tắt", "resetToast": "Đã đặt lại về phím tắt mặc định — nhấp Lưu để áp dụng", + "registrationFailed": "Không thể đăng ký phím tắt. Có thể đang được sử dụng bởi ứng dụng khác. Hãy thử phím khác.", "actions": { "openApp": "Mở ứng dụng", "addZoom": "Thêm Thu phóng", diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json index f2a940f6..eb357e0e 100644 --- a/src/i18n/locales/zh-CN/shortcuts.json +++ b/src/i18n/locales/zh-CN/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "此快捷键已保留给 \"{{label}}\",无法重新分配。", "savedToast": "键盘快捷键已保存", "resetToast": "已恢复默认快捷键 — 点击保存以应用", + "registrationFailed": "快捷键注册失败。可能已被其他应用占用。请尝试其他按键。", "actions": { "openApp": "打开应用", "addZoom": "添加缩放", diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json index 996d885d..34ab7de8 100644 --- a/src/i18n/locales/zh-TW/shortcuts.json +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -13,6 +13,7 @@ "reservedShortcut": "此快捷鍵已保留給 \"{{label}}\",無法重新指定。", "savedToast": "鍵盤快捷鍵已儲存", "resetToast": "已還原預設快捷鍵 — 點擊儲存以套用", + "registrationFailed": "快捷鍵註冊失敗。可能已被其他應用程式佔用。請嘗試其他按鍵。", "actions": { "openApp": "開啟應用程式", "addZoom": "新增縮放", From 59c9a192696f4ca552fd66682f28a972a676fd75 Mon Sep 17 00:00:00 2001 From: Yauheni Bandziukevich Date: Thu, 28 May 2026 23:18:35 +0200 Subject: [PATCH 92/94] fix: preserve working shortcut when new registration fails --- electron/globalShortcut.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/electron/globalShortcut.ts b/electron/globalShortcut.ts index 8e444926..f1b046a3 100644 --- a/electron/globalShortcut.ts +++ b/electron/globalShortcut.ts @@ -39,14 +39,21 @@ function bindingToAccelerator(binding: ShortcutBinding): string { let currentAccelerator: string | null = null; export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean { - if (currentAccelerator) { - globalShortcut.unregister(currentAccelerator); + const accelerator = bindingToAccelerator(binding); + + // Same shortcut already registered, nothing to do + if (accelerator === currentAccelerator) { + return true; } - const accelerator = bindingToAccelerator(binding); + // Try to register new shortcut first (before unregistering old one) const success = globalShortcut.register(accelerator, onTrigger); if (success) { + // Only unregister old shortcut after new one succeeds + if (currentAccelerator) { + globalShortcut.unregister(currentAccelerator); + } currentAccelerator = accelerator; console.log(`Global shortcut registered: ${accelerator}`); } else { From ff40a73a1bdc025dd6dca919f4ed8051656427fc Mon Sep 17 00:00:00 2001 From: Yauheni Bandziukevich Date: Thu, 28 May 2026 23:21:41 +0200 Subject: [PATCH 93/94] fix: keep shortcuts dialog open on registration failure --- src/components/video-editor/ShortcutsConfigDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index c29bc444..08877826 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -102,14 +102,14 @@ export function ShortcutsConfigDialog() { const handleCancelConflict = useCallback(() => setConflict(null), []); const handleSave = useCallback(async () => { - setShortcuts(draft); const success = await persistShortcuts(draft); if (success) { + setShortcuts(draft); toast.success(t("savedToast")); + closeConfig(); } else { toast.error(t("registrationFailed")); } - closeConfig(); }, [draft, setShortcuts, persistShortcuts, closeConfig, t]); const handleReset = useCallback(() => { From ed5ef32280f1554b9888f2a69320098cd58ac857 Mon Sep 17 00:00:00 2001 From: Yauheni Bandziukevich Date: Thu, 28 May 2026 23:42:27 +0200 Subject: [PATCH 94/94] fix: remove conflicting hover style from shortcuts reset button --- src/components/video-editor/ShortcutsConfigDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index 6b9ef783..265c0c11 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -222,7 +222,7 @@ export function ShortcutsConfigDialog() {