diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 5836b3e4..cdd90881 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -254,12 +254,12 @@ interface Window { message?: string; error?: string; }>; - pauseCursorCapture: () => Promise<{ + pauseCursorCapture: (pausedAtMs?: number) => Promise<{ success: boolean; message?: string; error?: string; }>; - resumeCursorCapture: () => Promise<{ + resumeCursorCapture: (resumedAtMs?: number) => Promise<{ success: boolean; message?: string; error?: string; diff --git a/electron/ipc/cursor/telemetry.test.ts b/electron/ipc/cursor/telemetry.test.ts index 8c9b06b8..03613859 100644 --- a/electron/ipc/cursor/telemetry.test.ts +++ b/electron/ipc/cursor/telemetry.test.ts @@ -29,21 +29,24 @@ vi.mock("../utils", () => ({ })), })); +import { activeCursorSamples, setActiveCursorSamples, setCursorCaptureStartTimeMs } from "../state"; import { getCursorCaptureElapsedMs, normalizeCursorTelemetrySamples, pauseCursorCapture, + pauseCursorCaptureAtBoundary, + pushCursorSample, resetCursorCaptureClock, resumeCursorCapture, writeCursorTelemetry, } from "./telemetry"; -import { setCursorCaptureStartTimeMs } from "../state"; describe("cursor telemetry pause clock", () => { beforeEach(() => { writeFile.mockReset(); rm.mockReset(); setCursorCaptureStartTimeMs(1_000); + setActiveCursorSamples([]); resetCursorCaptureClock(); }); @@ -66,6 +69,20 @@ describe("cursor telemetry pause clock", () => { expect(getCursorCaptureElapsedMs(1_900)).toBe(550); }); + it("drops cursor samples captured after the renderer pause boundary", () => { + pushCursorSample(0.1, 0.1, 120, "move"); + pushCursorSample(0.2, 0.2, 205, "move"); + pushCursorSample(0.3, 0.3, 260, "move"); + + pauseCursorCaptureAtBoundary(1_200); + + expect(getCursorCaptureElapsedMs(1_500)).toBe(200); + expect(activeCursorSamples.map((sample) => sample.timeMs)).toEqual([120]); + + resumeCursorCapture(1_700); + expect(getCursorCaptureElapsedMs(1_900)).toBe(400); + }); + it("normalizes cursor telemetry samples before persisting them", async () => { const samples = normalizeCursorTelemetrySamples([ { timeMs: 30, cx: 2, cy: -1, interactionType: "click", cursorType: "pointer" }, diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 2c14705a..ebedfe72 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -1,28 +1,29 @@ import fs from "node:fs/promises"; -import { getTelemetryPathForVideo, getScreen } from "../utils"; import { + CURSOR_SAMPLE_INTERVAL_MS, CURSOR_TELEMETRY_VERSION, MAX_CURSOR_SAMPLES, - CURSOR_SAMPLE_INTERVAL_MS, } from "../constants"; -import type { CursorVisualType, CursorInteractionType, CursorTelemetryPoint } from "../types"; import { - cursorCaptureInterval, - setCursorCaptureInterval, + activeCursorSamples, + currentCursorVisualType, cursorCaptureAccumulatedPausedMs, + cursorCaptureInterval, cursorCapturePauseStartedAtMs, cursorCaptureStartTimeMs, - activeCursorSamples, - pendingCursorSamples, - setPendingCursorSamples, isCursorCaptureActive, - currentCursorVisualType, linuxCursorScreenPoint, + pendingCursorSamples, selectedSource, selectedWindowBounds, + setActiveCursorSamples, setCursorCaptureAccumulatedPausedMs, + setCursorCaptureInterval, setCursorCapturePauseStartedAtMs, + setPendingCursorSamples, } from "../state"; +import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; +import { getScreen, getTelemetryPathForVideo } from "../utils"; export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -125,6 +126,18 @@ export function pauseCursorCapture(pausedAtMs: number) { setCursorCapturePauseStartedAtMs(pausedAtMs); } +export function pauseCursorCaptureAtBoundary(pausedAtMs: number) { + if (cursorCapturePauseStartedAtMs !== null) { + return; + } + + const pausedElapsedMs = getCursorCaptureElapsedMs(pausedAtMs); + setActiveCursorSamples( + activeCursorSamples.filter((sample) => sample.timeMs <= pausedElapsedMs), + ); + setCursorCapturePauseStartedAtMs(pausedAtMs); +} + export function resumeCursorCapture(resumedAtMs: number) { if (cursorCapturePauseStartedAtMs === null) { return; @@ -310,6 +323,6 @@ export function startCursorSampling() { setCursorCaptureInterval(setTimeout(tick, CURSOR_SAMPLE_INTERVAL_MS)); } +export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; // Re-export for consumers that use it from this module export { getTelemetryPathForVideo } from "../utils"; -export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index c095cf39..ede2272f 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -51,6 +51,16 @@ function getPartialExportDestinationPath(destinationPath: string) { return path.join(parsed.dir, `.recordly-partial-${parsed.name}-${suffix}${parsed.ext}`); } +const MAX_IN_MEMORY_EXPORT_BYTES = 0x7fffffff; + +function getInMemoryExportTooLargeMessage(byteLength: number) { + if (byteLength <= MAX_IN_MEMORY_EXPORT_BYTES) { + return null; + } + + return "Export is too large for the legacy in-memory save path. Please retry with temp-file streaming enabled."; +} + export async function moveExportedTempFile(tempPath: string, destinationPath: string) { await fs.mkdir(path.dirname(destinationPath), { recursive: true }); try { @@ -700,6 +710,14 @@ export function registerExportHandlers() { "mux-exported-video-audio", async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + error: sizeError, + }; + } + const result = await muxExportedVideoAudioBuffer(videoData, options ?? {}); // Register the muxed output so finalize-exported-video / discard- // exported-temp accept it. Returning a temp path (instead of the @@ -797,6 +815,15 @@ export function registerExportHandlers() { "save-exported-video", async (event, videoData: ArrayBuffer, fileName: string) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + message: sizeError, + error: sizeError, + }; + } + // Determine file type from extension const isGif = fileName.toLowerCase().endsWith(".gif"); const filters = isGif @@ -845,6 +872,16 @@ export function registerExportHandlers() { "write-exported-video-to-path", async (_event, videoData: ArrayBuffer, outputPath: string) => { try { + const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength); + if (sizeError) { + return { + success: false, + message: sizeError, + canceled: false, + error: sizeError, + }; + } + const resolvedPath = path.resolve(outputPath); await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, Buffer.from(videoData)); diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 69ed08cb..f2cb6660 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -19,7 +19,7 @@ import { startInteractionCapture, stopInteractionCapture } from "../cursor/inter import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor"; import { normalizeCursorTelemetrySamples, - pauseCursorCapture, + pauseCursorCaptureAtBoundary, resetCursorCaptureClock, resumeCursorCapture, sampleCursorPoint, @@ -192,6 +192,15 @@ function pickPrimitiveRecord(value: unknown) { return entries.length > 0 ? Object.fromEntries(entries) : null; } +function normalizeRendererTimestampMs(value: unknown) { + const nowMs = Date.now(); + if (typeof value !== "number" || !Number.isFinite(value)) { + return nowMs; + } + + return Math.min(Math.max(0, Math.round(value)), nowMs); +} + function pickMicrophoneChunkEvents(value: unknown): MicrophoneChunkTimingEvent[] | null { if (!Array.isArray(value)) { return null; @@ -1715,14 +1724,13 @@ export function registerRecordingHandlers( } }); - ipcMain.handle("pause-cursor-capture", () => { - sampleCursorPoint(); - pauseCursorCapture(Date.now()); + ipcMain.handle("pause-cursor-capture", (_, pausedAtMs?: unknown) => { + pauseCursorCaptureAtBoundary(normalizeRendererTimestampMs(pausedAtMs)); return { success: true }; }); - ipcMain.handle("resume-cursor-capture", () => { - resumeCursorCapture(Date.now()); + ipcMain.handle("resume-cursor-capture", (_, resumedAtMs?: unknown) => { + resumeCursorCapture(normalizeRendererTimestampMs(resumedAtMs)); sampleCursorPoint(); return { success: true }; }); diff --git a/electron/main.ts b/electron/main.ts index 3aea001b..a4836f74 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -52,7 +52,7 @@ import { showUpdateToastWindow, } from "./windows"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronMainDir = path.dirname(fileURLToPath(import.meta.url)); const IS_SMOKE_EXPORT = process.env.RECORDLY_SMOKE_EXPORT === "1"; function ignoreBrokenConsolePipe(stream: NodeJS.WritableStream | undefined) { @@ -118,7 +118,7 @@ async function ensureRecordingsDir() { // │ │ ├── main.js // │ │ └── preload.mjs // │ -process.env.APP_ROOT = path.join(__dirname, ".."); +process.env.APP_ROOT = path.join(electronMainDir, ".."); // Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; diff --git a/electron/mainCjsNormalize.test.mjs b/electron/mainCjsNormalize.test.mjs new file mode 100644 index 00000000..9ccf91da --- /dev/null +++ b/electron/mainCjsNormalize.test.mjs @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + findElectronMainCjsEsmSyntax, + normalizeElectronMainCjsSource, +} from "../scripts/normalize-electron-main-cjs.mjs"; + +describe("Electron main CJS normalizer", () => { + it("converts Rollup named export blocks to CommonJS assignments", () => { + const source = [ + 'const MAIN_DIST = "dist-electron";', + 'const RENDERER_DIST = "dist";', + "const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;", + "export {", + " MAIN_DIST,", + " RENDERER_DIST,", + " VITE_DEV_SERVER_URL", + "};", + ].join("\n"); + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toContain("exports.MAIN_DIST = MAIN_DIST;"); + expect(result.source).toContain("exports.RENDERER_DIST = RENDERER_DIST;"); + expect(result.source).toContain("exports.VITE_DEV_SERVER_URL = VITE_DEV_SERVER_URL;"); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([]); + }); + + it("converts single-line aliased named exports to CommonJS assignments", () => { + const source = "export { VITE_DEV_SERVER_URL as devServerUrl };"; + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toBe("exports.devServerUrl = VITE_DEV_SERVER_URL;"); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([]); + }); + + it("reports unsupported ESM export syntax that cannot be normalized safely", () => { + const source = "export default MAIN_DIST;"; + + expect(findElectronMainCjsEsmSyntax(source)).toEqual([ + { line: 1, text: "export default MAIN_DIST;" }, + ]); + }); + + it("preserves an unsupported export block exactly while normalizing other syntax", () => { + const source = [ + 'import fs from "node:fs";', + "export {", + ' MAIN_DIST as "main-dist",', + "};", + ].join("\n"); + + const result = normalizeElectronMainCjsSource(source); + + expect(result.changed).toBe(true); + expect(result.source).toBe( + [ + 'const fs = require("node:fs");', + "export {", + ' MAIN_DIST as "main-dist",', + "};", + ].join("\n"), + ); + expect(findElectronMainCjsEsmSyntax(result.source)).toEqual([ + { line: 2, text: "export {" }, + ]); + }); +}); diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index be08a2b3..bbcef557 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -5,10 +5,10 @@ "helpers": { "wgc-capture": { "binaryName": "wgc-capture.exe", - "binarySha256": "88226bd499c820081a27264b40f37875d0949d9f921920738e05b7eaeb1a9e7d", + "binarySha256": "4f8873abbff58add37672114184c154dee838939ee8f2b7df45d658d078af28c", "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "7190e148ede1fb1c573cc1866405d6345b0c35378a7e32499580bffacf1cc6d2", - "updatedAt": "2026-05-07T15:21:44.354Z" + "sourceFingerprint": "6f725fad6eb515d81b2d553ec45ddbf7c681d2725bba48f0c0722ce00166d967", + "updatedAt": "2026-05-09T00:49:28.306Z" }, "cursor-monitor": { "binaryName": "cursor-monitor.exe", diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe index e55df506..37de4c9c 100644 Binary files a/electron/native/bin/win32-x64/wgc-capture.exe and b/electron/native/bin/win32-x64/wgc-capture.exe differ diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 2c433b3b..0f007826 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -397,7 +397,7 @@ int main(int argc, char* argv[]) { } // Wait for stop signal while pausing/resuming audio tracks in lockstep. - while (!g_stopRequested) { + while (!g_stopRequested && !session.hasFatalError()) { if (g_pauseRequested) { if (audioActive) loopback.pause(); if (micActive) micCapture.pause(); @@ -416,6 +416,25 @@ int main(int argc, char* argv[]) { if (audioActive) loopback.stop(); if (micActive) micCapture.stop(); + if (session.hasFatalError()) { + std::cerr << "ERROR: WGC capture session failed during recording" << std::endl; + encoder.finalize(); + DeleteFileW(outputPathW.c_str()); + if (!config.audioOutputPath.empty()) { + const std::wstring audioPathW = utf8ToWide(config.audioOutputPath); + const std::wstring audioMetadataPathW = utf8ToWide(config.audioOutputPath + ".json"); + DeleteFileW(audioPathW.c_str()); + DeleteFileW(audioMetadataPathW.c_str()); + } + if (!config.micOutputPath.empty()) { + const std::wstring micPathW = utf8ToWide(config.micOutputPath); + const std::wstring micMetadataPathW = utf8ToWide(config.micOutputPath + ".json"); + DeleteFileW(micPathW.c_str()); + DeleteFileW(micMetadataPathW.c_str()); + } + return 1; + } + if (audioActive) { writeCompanionAudioTimingMetadata( config.audioOutputPath, diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 30969e39..1a89b21e 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -116,6 +117,31 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height return false; } + // WGC window captures can change frame size while recording. Keep the muxer + // output dimensions stable by compositing resized frames into this fixed + // BGRA surface before CPU readback. + D3D11_TEXTURE2D_DESC compositeDesc = {}; + compositeDesc.Width = width_; + compositeDesc.Height = height_; + compositeDesc.MipLevels = 1; + compositeDesc.ArraySize = 1; + compositeDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + compositeDesc.SampleDesc.Count = 1; + compositeDesc.Usage = D3D11_USAGE_DEFAULT; + compositeDesc.BindFlags = D3D11_BIND_RENDER_TARGET; + + hr = device_->CreateTexture2D(&compositeDesc, nullptr, &resizeCompositeTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create resize composite texture: 0x" << std::hex << hr << std::endl; + return false; + } + + hr = device_->CreateRenderTargetView(resizeCompositeTexture_.Get(), nullptr, &resizeCompositeView_); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create resize composite view: 0x" << std::hex << hr << std::endl; + return false; + } + // Pre-allocate NV12 buffer const int ySize = width_ * height_; const int uvSize = (width_ / 2) * (height_ / 2) * 2; @@ -132,7 +158,39 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { if (!initialized_ || !sinkWriter_) return false; - context_->CopyResource(stagingTexture_.Get(), texture); + D3D11_TEXTURE2D_DESC sourceDesc = {}; + texture->GetDesc(&sourceDesc); + + if (sourceDesc.Width == static_cast(width_) && + sourceDesc.Height == static_cast(height_)) { + context_->CopyResource(stagingTexture_.Get(), texture); + } else { + if (!resizeCompositeTexture_ || !resizeCompositeView_) return false; + + const FLOAT clearColor[4] = {0.0f, 0.0f, 0.0f, 1.0f}; + context_->ClearRenderTargetView(resizeCompositeView_.Get(), clearColor); + + D3D11_BOX sourceBox = {}; + sourceBox.left = 0; + sourceBox.top = 0; + sourceBox.front = 0; + sourceBox.right = (std::min)(sourceDesc.Width, static_cast(width_)); + sourceBox.bottom = (std::min)(sourceDesc.Height, static_cast(height_)); + sourceBox.back = 1; + + if (sourceBox.right == 0 || sourceBox.bottom == 0) return false; + + context_->CopySubresourceRegion( + resizeCompositeTexture_.Get(), + 0, + 0, + 0, + 0, + texture, + 0, + &sourceBox); + context_->CopyResource(stagingTexture_.Get(), resizeCompositeTexture_.Get()); + } D3D11_MAPPED_SUBRESOURCE mapped; HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped); @@ -242,6 +300,8 @@ bool MFEncoder::finalize() { initialized_ = false; sinkWriter_.Reset(); stagingTexture_.Reset(); + resizeCompositeView_.Reset(); + resizeCompositeTexture_.Reset(); nv12Buffer_.clear(); lastFrameBuffer_.clear(); nv12Buffer_.shrink_to_fit(); diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 21f49032..48fdeaad 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -30,6 +30,8 @@ private: ID3D11Device* device_ = nullptr; ID3D11DeviceContext* context_ = nullptr; ComPtr stagingTexture_; + ComPtr resizeCompositeTexture_; + ComPtr resizeCompositeView_; std::vector nv12Buffer_; std::vector lastFrameBuffer_; DWORD streamIndex_ = 0; diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index 987c649b..898a9fdc 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -24,6 +24,12 @@ extern "C" { IInspectable** graphicsDevice); } +static int normalizeFramePoolExtent(int value) { + int normalized = value < 2 ? 2 : value; + if ((normalized % 2) != 0) ++normalized; + return normalized; +} + WgcSession::WgcSession() {} WgcSession::~WgcSession() { @@ -114,6 +120,8 @@ bool WgcSession::initializeWithItem(int fps) { auto size = captureItem_.Size(); captureWidth_ = size.Width; captureHeight_ = size.Height; + framePoolWidth_ = size.Width; + framePoolHeight_ = size.Height; framePool_ = winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::CreateFreeThreaded( winrtDevice_, @@ -128,7 +136,42 @@ bool WgcSession::initializeWithItem(int fps) { // IsBorderRequired is only available on Windows 11+ (build 22000). propagating an hresult_error results in Native Windows capture failure try { session_.IsBorderRequired(false); + } catch (winrt::hresult_error const&) { + } + + return true; +} + +bool WgcSession::recreateFramePoolIfNeeded( + winrt::Windows::Graphics::SizeInt32 const& contentSize) { + if (!framePool_) return false; + + const int normalizedWidth = normalizeFramePoolExtent(contentSize.Width); + const int normalizedHeight = normalizeFramePoolExtent(contentSize.Height); + if (normalizedWidth == framePoolWidth_ && normalizedHeight == framePoolHeight_) { + return false; + } + + winrt::Windows::Graphics::SizeInt32 normalizedSize{ + normalizedWidth, + normalizedHeight, + }; + + try { + framePool_.Recreate( + winrtDevice_, + winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized, + 2, + normalizedSize); + framePoolWidth_ = normalizedWidth; + framePoolHeight_ = normalizedHeight; + std::cerr << "INFO: Recreated WGC frame pool for resized content " + << framePoolWidth_ << "x" << framePoolHeight_ << std::endl; } catch (winrt::hresult_error const& e) { + fatalError_ = true; + capturing_ = false; + std::cerr << "ERROR: Failed to recreate WGC frame pool after resize: 0x" + << std::hex << e.code() << std::dec << std::endl; } return true; @@ -174,6 +217,7 @@ bool WgcSession::startCapture() { if (!session_ || !framePool_) return false; capturing_ = true; + fatalError_ = false; lastFrameTimeHns_ = 0; frameArrivedRevoker_ = framePool_.FrameArrived( @@ -205,10 +249,15 @@ void WgcSession::onFrameArrived( winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, winrt::Windows::Foundation::IInspectable const&) { - if (!capturing_) return; + if (!capturing_ || fatalError_) return; auto frame = sender.TryGetNextFrame(); if (!frame) return; + auto contentSize = frame.ContentSize(); + if (recreateFramePoolIfNeeded(contentSize)) { + frame.Close(); + return; + } auto timestamp = frame.SystemRelativeTime(); int64_t frameTimeHns = std::chrono::duration_cast>>(timestamp).count(); diff --git a/electron/native/wgc-capture/src/wgc_session.h b/electron/native/wgc-capture/src/wgc_session.h index 5ca6be34..1755f4b1 100644 --- a/electron/native/wgc-capture/src/wgc_session.h +++ b/electron/native/wgc-capture/src/wgc_session.h @@ -28,6 +28,7 @@ public: void setFrameCallback(FrameCallback callback); bool startCapture(); void stopCapture(); + bool hasFatalError() const { return fatalError_.load(); } int captureWidth() const { return captureWidth_; } int captureHeight() const { return captureHeight_; } @@ -45,9 +46,12 @@ private: FrameCallback frameCallback_; std::atomic capturing_{false}; + std::atomic fatalError_{false}; int fps_ = 60; int captureWidth_ = 0; int captureHeight_ = 0; + int framePoolWidth_ = 0; + int framePoolHeight_ = 0; int64_t frameIntervalHns_ = 0; int64_t lastFrameTimeHns_ = 0; @@ -56,6 +60,8 @@ private: winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForMonitor(HMONITOR monitor); winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForWindow(HWND hwnd); bool initializeWithItem(int fps); + bool recreateFramePoolIfNeeded( + winrt::Windows::Graphics::SizeInt32 const& contentSize); void onFrameArrived( winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, winrt::Windows::Foundation::IInspectable const& args); diff --git a/electron/preload.ts b/electron/preload.ts index 478cb018..cfb8015f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -493,11 +493,11 @@ contextBridge.exposeInMainWorld("electronAPI", { resumeNativeScreenRecording: () => { return ipcRenderer.invoke("resume-native-screen-recording"); }, - pauseCursorCapture: () => { - return ipcRenderer.invoke("pause-cursor-capture"); + pauseCursorCapture: (pausedAtMs?: number) => { + return ipcRenderer.invoke("pause-cursor-capture", pausedAtMs); }, - resumeCursorCapture: () => { - return ipcRenderer.invoke("resume-cursor-capture"); + resumeCursorCapture: (resumedAtMs?: number) => { + return ipcRenderer.invoke("resume-cursor-capture", resumedAtMs); }, startFfmpegRecording: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("start-ffmpeg-recording", source); diff --git a/electron/windows.ts b/electron/windows.ts index cd8bf052..f1b448b6 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -7,10 +7,10 @@ import { app, BrowserWindow, ipcMain } from "electron"; import { USER_DATA_PATH } from "./appPaths"; import { getPackagedRendererBaseUrl } from "./rendererServer"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url)); const nodeRequire = createRequire(import.meta.url); -const APP_ROOT = path.join(__dirname, ".."); +const APP_ROOT = path.join(electronWindowsDir, ".."); const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; const RENDERER_DIST = path.join(APP_ROOT, "dist"); const WINDOW_ICON_PATH = path.join( @@ -374,7 +374,7 @@ export function createHudOverlayWindow(): BrowserWindow { hasShadow: false, show: false, webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, webSecurity: false, @@ -537,7 +537,7 @@ export function createUpdateToastWindow(): BrowserWindow { ...(parentWindow ? { parent: parentWindow } : {}), backgroundColor: useTransparentToastWindow ? "#00000000" : "#101418", webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, backgroundThrottling: false, @@ -726,7 +726,7 @@ export function createEditorWindow(): BrowserWindow { show: false, backgroundColor: "#000000", webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, webSecurity: false, @@ -799,7 +799,7 @@ export function createSourceSelectorWindow(): BrowserWindow { }), backgroundColor: "#00000000", webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, }, @@ -846,7 +846,7 @@ export function createCountdownWindow(): BrowserWindow { focusable: true, show: false, webPreferences: { - preload: path.join(__dirname, "preload.mjs"), + preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, contextIsolation: true, }, diff --git a/scripts/normalize-electron-main-cjs.mjs b/scripts/normalize-electron-main-cjs.mjs index 3d3b186c..fcbca31f 100644 --- a/scripts/normalize-electron-main-cjs.mjs +++ b/scripts/normalize-electron-main-cjs.mjs @@ -49,6 +49,49 @@ function convertImportLine(line) { return null; } +function convertNamedExports(namedSpec, indent = "") { + const statements = []; + for (const rawSpecifier of namedSpec.split(",")) { + const specifier = rawSpecifier.trim(); + if (!specifier) { + continue; + } + + const aliasMatch = specifier.match( + /^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$/, + ); + const localName = aliasMatch ? aliasMatch[1] : specifier; + const exportName = aliasMatch ? aliasMatch[2] : specifier; + if ( + !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName) || + !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(exportName) + ) { + return null; + } + + statements.push(`${indent}exports.${exportName} = ${localName};`); + } + + return statements; +} + +function convertExportLine(line) { + const singleLineMatch = line.match( + /^([ \t]*)export\s*\{\s*([^}]*)\s*\}\s*;?[ \t]*$/, + ); + if (singleLineMatch) { + const [, indent, namedSpec] = singleLineMatch; + return convertNamedExports(namedSpec, indent); + } + + const blockStartMatch = line.match(/^([ \t]*)export\s*\{\s*$/); + if (blockStartMatch) { + return { indent: blockStartMatch[1], specifiers: [], rawLines: [line] }; + } + + return null; +} + function updateLexicalState(line, state) { let mode = state.mode; let escaped = false; @@ -296,8 +339,30 @@ export function normalizeElectronMainCjsSource(source) { const lines = source.split(/\r?\n/); const normalizedLines = []; let state = { mode: null }; + let exportBlock = null; for (const line of lines) { + if (exportBlock) { + if (/^[ \t]*\}\s*;?[ \t]*$/.test(line)) { + const statements = convertNamedExports( + exportBlock.specifiers.join(","), + exportBlock.indent, + ); + if (statements === null) { + normalizedLines.push(...exportBlock.rawLines, line); + } else { + normalizedLines.push(...statements); + changed = true; + } + exportBlock = null; + continue; + } + + exportBlock.specifiers.push(line.trim().replace(/,$/, "")); + exportBlock.rawLines.push(line); + continue; + } + if (state.mode === null) { const converted = convertImportLine(line); if (converted !== null) { @@ -305,6 +370,17 @@ export function normalizeElectronMainCjsSource(source) { changed = true; continue; } + + const convertedExport = convertExportLine(line); + if (convertedExport !== null) { + if (Array.isArray(convertedExport)) { + normalizedLines.push(...convertedExport); + changed = true; + } else { + exportBlock = convertedExport; + } + continue; + } } const normalized = replaceImportMetaUrlInCode(line, state); @@ -348,6 +424,13 @@ export function findElectronMainCjsEsmSyntax(source) { }); continue; } + if (/^[ \t]*export\b/.test(line)) { + matches.push({ + line: index + 1, + text: line.trim(), + }); + continue; + } } state = updateLexicalState(line, state); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9c402f02..fa5fc5ac 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -76,6 +76,10 @@ import { VideoExporter, } from "@/lib/exporter"; import { getMp4ExportBitrate, getSourceQualityBitrate } from "@/lib/exporter/exportBitrate"; +import { + canUseInMemoryExportSaveFallback, + describeBlockedInMemoryExportSave, +} from "@/lib/exporter/exportSavePolicy"; import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; import { resolveSourceAudioFallbackPaths } from "@/lib/exporter/sourceAudioFallback"; import { @@ -93,6 +97,10 @@ import { getAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { ExtensionIcon } from "./ExtensionIcon"; +import { + calculateMp4ExportDimensions, + calculateMp4SourceDimensions, +} from "./exportDimensions"; const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => ( @@ -182,6 +190,7 @@ import { extendAutoFullTrackClip, type FigureData, getClipSourceEndMs, + getTimelineDurationMs, type Padding, mapSourceTimeToTimelineTime as resolveSourceTimeToTimelineTime, mapTimelineTimeToSourceTime as resolveTimelineTimeToSourceTime, @@ -339,6 +348,11 @@ async function writeSmokeExportReport( const SMOKE_EXPORT_READY_TIMEOUT_MS = 30_000; const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30; const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error"; +const SOURCE_AUDIO_PREVIEW_PLAYING_SEEK_DRIFT_SECONDS = 0.18; +const SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS = 0.01; +const SOURCE_AUDIO_PREVIEW_RATE_TOLERANCE_SECONDS = 0.08; +const SOURCE_AUDIO_PREVIEW_RATE_CORRECTION_WINDOW_SECONDS = 8; +const SOURCE_AUDIO_PREVIEW_MAX_RATE_ADJUSTMENT = 0.015; const PROJECT_AUTOSAVE_DELAY_MS = 1000; const EXPORT_ERROR_TOAST_DURATION_MS = 20000; @@ -439,7 +453,9 @@ function getSmokeExportConfig(search: string): SmokeExportConfig { : enabled && params.get("smokeBackendPreference") === "breeze" ? "breeze" : undefined, - renderBackend: enabled ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) : undefined, + renderBackend: enabled + ? parseSmokeRenderBackend(params.get("smokeRenderBackend")) + : undefined, maxEncodeQueue: enabled ? parseSmokeExportNumber(params.get("smokeMaxEncodeQueue")) : undefined, @@ -505,76 +521,6 @@ function areDeepEqual(left: unknown, right: unknown): boolean { return true; } -function calculateMp4SourceDimensions( - sourceWidth: number, - sourceHeight: number, - aspectRatio: AspectRatio, -): { width: number; height: number } { - const safeSourceWidth = Math.max(2, Math.floor(sourceWidth / 2) * 2); - const safeSourceHeight = Math.max(2, Math.floor(sourceHeight / 2) * 2); - const sourceAspectRatio = safeSourceHeight > 0 ? safeSourceWidth / safeSourceHeight : 16 / 9; - const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio); - - if (aspectRatio === "native") { - return { width: safeSourceWidth, height: safeSourceHeight }; - } - - if (aspectRatioValue === 1) { - const baseDimension = Math.max( - 2, - Math.floor(Math.min(safeSourceWidth, safeSourceHeight) / 2) * 2, - ); - return { width: baseDimension, height: baseDimension }; - } - - if (aspectRatioValue > 1) { - const baseWidth = safeSourceWidth; - for (let width = baseWidth; width >= 100; width -= 2) { - const height = Math.round(width / aspectRatioValue); - if (height % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { - return { width, height }; - } - } - - return { - width: baseWidth, - height: Math.max(2, Math.floor(baseWidth / aspectRatioValue / 2) * 2), - }; - } - - const baseHeight = safeSourceHeight; - for (let height = baseHeight; height >= 100; height -= 2) { - const width = Math.round(height * aspectRatioValue); - if (width % 2 === 0 && Math.abs(width / height - aspectRatioValue) < 0.0001) { - return { width, height }; - } - } - - return { - height: baseHeight, - width: Math.max(2, Math.floor((baseHeight * aspectRatioValue) / 2) * 2), - }; -} - -function calculateMp4ExportDimensions( - baseWidth: number, - baseHeight: number, - quality: ExportQuality, -): { width: number; height: number } { - if (quality === "source") { - return { - width: Math.max(2, Math.floor(baseWidth / 2) * 2), - height: Math.max(2, Math.floor(baseHeight / 2) * 2), - }; - } - - const qualityScale = quality === "medium" ? 0.6 : quality === "good" ? 0.75 : 0.9; - return { - width: Math.max(2, Math.floor((baseWidth * qualityScale) / 2) * 2), - height: Math.max(2, Math.floor((baseHeight * qualityScale) / 2) * 2), - }; -} - function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -595,9 +541,7 @@ export default function VideoEditor() { ); const devOpenRecordingConfig = useMemo( () => - getDevOpenRecordingConfig( - typeof window === "undefined" ? "" : window.location.search, - ), + getDevOpenRecordingConfig(typeof window === "undefined" ? "" : window.location.search), [], ); const [appPlatform, setAppPlatform] = useState( @@ -1480,6 +1424,12 @@ export default function VideoEditor() { const saveBlobExport = useCallback( async (blob: Blob, fileName: string, outputPath: string | null = null) => { const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; + const hasExportStreamApi = + typeof window !== "undefined" && + typeof window.electronAPI?.openExportStream === "function" && + typeof window.electronAPI?.writeExportStreamChunk === "function" && + typeof window.electronAPI?.closeExportStream === "function"; + let streamError: unknown = null; try { const tempFilePath = await streamExportBlobToTempFile(blob, extension); @@ -1497,9 +1447,37 @@ export default function VideoEditor() { }; } } catch (error) { - console.warn("[export] Falling back to in-memory blob save", error); + streamError = error; + console.warn("[export] Temp-file blob save failed", error); } + if ( + !canUseInMemoryExportSaveFallback({ + blobSize: blob.size, + extension, + hasExportStreamApi, + }) + ) { + const message = describeBlockedInMemoryExportSave({ + blobSize: blob.size, + extension, + }); + console.error("[export] Refusing in-memory blob save fallback", { + fileName, + blobSize: blob.size, + extension, + hasExportStreamApi, + streamError, + }); + throw new Error(message); + } + + console.warn("[export] Falling back to in-memory blob save", { + fileName, + blobSize: blob.size, + extension, + hasExportStreamApi, + }); const arrayBuffer = await blob.arrayBuffer(); return { saveResult: outputPath @@ -1730,16 +1708,16 @@ export default function VideoEditor() { }, [activeEffectSection]); const buildPersistedEditorState = useCallback( - ( - editor: Partial<{ - wallpaper: string; - shadowIntensity: number; - backgroundBlur: number; - zoomMotionBlur: number; - zoomMotionBlurTuning: ZoomMotionBlurTuning; - zoomTemporalMotionBlur: number; - zoomMotionBlurSampleCount: number | null; - zoomMotionBlurShutterFraction: number | null; + ( + editor: Partial<{ + wallpaper: string; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur: number; + zoomMotionBlurTuning: ZoomMotionBlurTuning; + zoomTemporalMotionBlur: number; + zoomMotionBlurSampleCount: number | null; + zoomMotionBlurShutterFraction: number | null; connectZooms: boolean; zoomInDurationMs: number; zoomInOverlapMs: number; @@ -2135,15 +2113,16 @@ export default function VideoEditor() { preserveProjectPath: Boolean(path), }, ); - } else { - await window.electronAPI.setCurrentVideoPath(sourcePath, { - preserveProjectPath: Boolean(path), - }); - } const sessionResult = await window.electronAPI.getCurrentRecordingSession?.(); applySessionPresentation(sessionResult?.success ? sessionResult.session : null); + } else { + await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: Boolean(path), + }); + applySessionPresentation(null); + } - setWallpaper(normalizedEditor.wallpaper); + setWallpaper(normalizedEditor.wallpaper); setShadowIntensity(normalizedEditor.shadowIntensity); setBackgroundBlur(normalizedEditor.backgroundBlur); setZoomMotionBlur(normalizedEditor.zoomMotionBlur); @@ -2157,10 +2136,10 @@ export default function VideoEditor() { setZoomOutDurationMs(normalizedEditor.zoomOutDurationMs); setConnectedZoomGapMs(normalizedEditor.connectedZoomGapMs); setConnectedZoomDurationMs(normalizedEditor.connectedZoomDurationMs); - setZoomInEasing(normalizedEditor.zoomInEasing); - setZoomOutEasing(normalizedEditor.zoomOutEasing); - setConnectedZoomEasing(normalizedEditor.connectedZoomEasing); - setShowCursor(normalizedEditor.showCursor); + setZoomInEasing(normalizedEditor.zoomInEasing); + setZoomOutEasing(normalizedEditor.zoomOutEasing); + setConnectedZoomEasing(normalizedEditor.connectedZoomEasing); + setShowCursor(normalizedEditor.showCursor); setLoopCursor(normalizedEditor.loopCursor); setCursorStyle(normalizedEditor.cursorStyle); setCursorSize(normalizedEditor.cursorSize); @@ -2249,7 +2228,12 @@ export default function VideoEditor() { await refreshProjectLibrary(); return true; }, - [buildPersistedEditorState, refreshProjectLibrary, syncHistoryButtons], + [ + applySessionPresentation, + buildPersistedEditorState, + refreshProjectLibrary, + syncHistoryButtons, + ], ); const currentProjectSnapshot = useMemo(() => { @@ -3369,6 +3353,10 @@ export default function VideoEditor() { () => mapSourceTimeToTimelineTime(currentTime * 1000) / 1000, [currentTime, mapSourceTimeToTimelineTime], ); + const timelineDuration = useMemo( + () => getTimelineDurationMs(clipRegions, duration * 1000) / 1000, + [clipRegions, duration], + ); // Merge clip speeds into speed regions so playback + export respect per-clip speed const effectiveSpeedRegions = useMemo(() => { @@ -3409,11 +3397,21 @@ export default function VideoEditor() { setAutoSuggestZoomsTrigger(0); }, []); - function handleSeek(time: number) { - const video = videoPlaybackRef.current?.video; + const handleSeek = useCallback((time: number, options: { pause?: boolean } = {}) => { + const playback = videoPlaybackRef.current; + const video = playback?.video; if (!video) return; + + if (options.pause && !video.paused) { + playback?.pause(); + } + video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; - } + }, [mapTimelineTimeToSourceTime]); + + const handleTimelineSeek = useCallback((time: number) => { + handleSeek(time, { pause: true }); + }, [handleSeek]); const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); @@ -3907,7 +3905,7 @@ export default function VideoEditor() { ), ); }, - [applySessionPresentation], + [], ); const handleAnnotationDelete = useCallback( @@ -4326,7 +4324,9 @@ export default function VideoEditor() { const previousTimelineTime = lastSourceAudioSyncTimeRef.current; const timelineJumped = previousTimelineTime === null || Math.abs(currentTime - previousTimelineTime) > 0.25; - const driftThreshold = isPlaying ? 0.35 : 0.01; + const driftThreshold = isPlaying + ? SOURCE_AUDIO_PREVIEW_PLAYING_SEEK_DRIFT_SECONDS + : SOURCE_AUDIO_PREVIEW_PAUSED_SEEK_DRIFT_SECONDS; for (const audio of sourceAudioElementsRef.current.values()) { enablePitchPreservingPlayback(audio); @@ -4354,6 +4354,9 @@ export default function VideoEditor() { basePlaybackRate: targetPlaybackRate, currentTime: audio.currentTime, targetTime, + toleranceSeconds: SOURCE_AUDIO_PREVIEW_RATE_TOLERANCE_SECONDS, + correctionWindowSeconds: SOURCE_AUDIO_PREVIEW_RATE_CORRECTION_WINDOW_SECONDS, + maxAdjustment: SOURCE_AUDIO_PREVIEW_MAX_RATE_ADJUSTMENT, }); if (Math.abs(audio.playbackRate - syncedPlaybackRate) > 0.001) { audio.playbackRate = syncedPlaybackRate; @@ -4606,8 +4609,7 @@ export default function VideoEditor() { ? (smokeExportConfig.fps ?? settings.mp4FrameRate ?? mp4FrameRate) : (settings.mp4FrameRate ?? mp4FrameRate); const pipelineModel = smokeExportConfig.enabled - ? (smokeExportConfig.pipelineModel ?? - "modern") + ? (smokeExportConfig.pipelineModel ?? "modern") : (settings.pipelineModel ?? exportPipelineModel); const useExperimentalNativeExport = pipelineModel === "modern" && @@ -4615,12 +4617,12 @@ export default function VideoEditor() { const backendPreference = pipelineModel === "legacy" ? "webcodecs" - : useExperimentalNativeExport - ? "auto" : smokeExportConfig.enabled ? (smokeExportConfig.backendPreference ?? (smokeExportConfig.useNativeExport ? "breeze" : "webcodecs")) - : (settings.backendPreference ?? exportBackendPreference); + : useExperimentalNativeExport + ? "auto" + : (settings.backendPreference ?? exportBackendPreference); const supportedSourceDimensions = await ensureSupportedMp4SourceDimensions(selectedMp4FrameRate); const { width: exportWidth, height: exportHeight } = @@ -5368,31 +5370,35 @@ export default function VideoEditor() { ? isExportPreparing ? t("editor.exportStatus.preparing", "Preparing export...") : isExportSaving - ? t("editor.exportStatus.saving", "Opening save dialog...") - : isRenderingAudio - ? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", { - percent: Math.round((exportProgress.audioProgress ?? 0) * 100), - }) - : isExportFinalizing - ? exportFormat === "mp4" && exportPipelineModel === "modern" - ? isExportFinalSaveIndeterminate - ? t( - "editor.exportStatus.muxingAndSaving", - "Muxing audio and saving file...", - ) - : t( - "editor.exportStatus.muxingAndSavingPercent", - "Muxing and saving {{percent}}%", - { - percent: exportFinalizingPercent ?? 100, - }, - ) - : t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", { - percent: exportFinalizingPercent ?? 100, - }) - : t("editor.exportStatus.completePercent", "{{percent}}% complete", { - percent: Math.round(exportProgress.percentage), + ? t("editor.exportStatus.saving", "Opening save dialog...") + : isRenderingAudio + ? t("editor.exportStatus.renderingAudio", "Rendering audio {{percent}}%", { + percent: Math.round((exportProgress.audioProgress ?? 0) * 100), }) + : isExportFinalizing + ? exportFormat === "mp4" && exportPipelineModel === "modern" + ? isExportFinalSaveIndeterminate + ? t( + "editor.exportStatus.muxingAndSaving", + "Muxing audio and saving file...", + ) + : t( + "editor.exportStatus.muxingAndSavingPercent", + "Muxing and saving {{percent}}%", + { + percent: exportFinalizingPercent ?? 100, + }, + ) + : t( + "editor.exportStatus.finalizingPercent", + "Finalizing {{percent}}%", + { + percent: exportFinalizingPercent ?? 100, + }, + ) + : t("editor.exportStatus.completePercent", "{{percent}}% complete", { + percent: Math.round(exportProgress.percentage), + }) : t("editor.exportStatus.preparing", "Preparing export..."); const projectBrowser = ( @@ -6492,14 +6498,17 @@ export default function VideoEditor() { handleSeek( next ? next.time / 1000 - : Math.min(duration, timelinePlayheadTime + 5), + : Math.min( + timelineDuration, + timelinePlayheadTime + 5, + ), ); }} > - {formatTime(duration)} + {formatTime(timelineDuration)} @@ -6585,10 +6594,10 @@ export default function VideoEditor() { { + it("keeps native exports at the source dimensions", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "native")).toEqual({ + width: 1920, + height: 1080, + }); + }); + + it("uses the rotated source bounds for 9:16 original exports", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "9:16")).toEqual({ + width: 1080, + height: 1920, + }); + }); + + it("uses the rotated source bounds for portrait social ratios", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "4:5")).toEqual({ + width: 1080, + height: 1350, + }); + }); + + it("keeps landscape aspect-ratio exports inside the source bounds", () => { + expect(calculateMp4SourceDimensions(1920, 1080, "4:3")).toEqual({ + width: 1440, + height: 1080, + }); + }); +}); + +describe("calculateMp4ExportDimensions", () => { + it("normalizes odd source dimensions to even export dimensions", () => { + const sourceDimensions = calculateMp4SourceDimensions(1919, 1079, "native"); + + expect(sourceDimensions).toEqual({ + width: 1918, + height: 1078, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "source"), + ).toEqual({ + width: 1918, + height: 1078, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "high"), + ).toEqual({ + width: 1726, + height: 970, + }); + }); + + it("scales portrait output dimensions from the aspect target", () => { + const sourceDimensions = calculateMp4SourceDimensions(1920, 1080, "9:16"); + + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "source"), + ).toEqual({ + width: 1080, + height: 1920, + }); + expect( + calculateMp4ExportDimensions(sourceDimensions.width, sourceDimensions.height, "high"), + ).toEqual({ + width: 972, + height: 1728, + }); + }); +}); diff --git a/src/components/video-editor/exportDimensions.ts b/src/components/video-editor/exportDimensions.ts new file mode 100644 index 00000000..8c36d895 --- /dev/null +++ b/src/components/video-editor/exportDimensions.ts @@ -0,0 +1,68 @@ +import type { ExportQuality } from "@/lib/exporter"; +import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; + +function normalizeEvenDimension(value: number): number { + return Math.max(2, Math.floor(value / 2) * 2); +} + +function fitAspectRatioWithinBounds( + maxWidth: number, + maxHeight: number, + aspectRatioValue: number, +): { width: number; height: number } { + const safeMaxWidth = normalizeEvenDimension(maxWidth); + const safeMaxHeight = normalizeEvenDimension(maxHeight); + const safeAspectRatio = + Number.isFinite(aspectRatioValue) && aspectRatioValue > 0 ? aspectRatioValue : 16 / 9; + + if (safeMaxWidth / safeMaxHeight > safeAspectRatio) { + const height = safeMaxHeight; + const width = normalizeEvenDimension(height * safeAspectRatio); + return { width: Math.min(width, safeMaxWidth), height }; + } + + const width = safeMaxWidth; + const height = normalizeEvenDimension(width / safeAspectRatio); + return { width, height: Math.min(height, safeMaxHeight) }; +} + +export function calculateMp4SourceDimensions( + sourceWidth: number, + sourceHeight: number, + aspectRatio: AspectRatio, +): { width: number; height: number } { + const safeSourceWidth = normalizeEvenDimension(sourceWidth); + const safeSourceHeight = normalizeEvenDimension(sourceHeight); + const sourceAspectRatio = safeSourceHeight > 0 ? safeSourceWidth / safeSourceHeight : 16 / 9; + const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio); + + if (aspectRatio === "native") { + return { width: safeSourceWidth, height: safeSourceHeight }; + } + + const longSide = Math.max(safeSourceWidth, safeSourceHeight); + const shortSide = Math.min(safeSourceWidth, safeSourceHeight); + const maxWidth = aspectRatioValue >= 1 ? longSide : shortSide; + const maxHeight = aspectRatioValue >= 1 ? shortSide : longSide; + + return fitAspectRatioWithinBounds(maxWidth, maxHeight, aspectRatioValue); +} + +export function calculateMp4ExportDimensions( + baseWidth: number, + baseHeight: number, + quality: ExportQuality, +): { width: number; height: number } { + if (quality === "source") { + return { + width: normalizeEvenDimension(baseWidth), + height: normalizeEvenDimension(baseHeight), + }; + } + + const qualityScale = quality === "medium" ? 0.6 : quality === "good" ? 0.75 : 0.9; + return { + width: normalizeEvenDimension(baseWidth * qualityScale), + height: normalizeEvenDimension(baseHeight * qualityScale), + }; +} diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 3d600273..333b8855 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -107,6 +107,7 @@ export default function Item({ style={safeItemStyle} {...listeners} {...attributes} + data-timeline-item="true" onPointerDownCapture={handleSelect} className="group h-full" > diff --git a/src/components/video-editor/types.test.ts b/src/components/video-editor/types.test.ts index 4333ce5e..6da7a8da 100644 --- a/src/components/video-editor/types.test.ts +++ b/src/components/video-editor/types.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; +import { deriveNextId } from "./projectPersistence"; import { extendAutoFullTrackClip, findClipAtTimelineTime, + getTimelineDurationMs, mapSourceTimeToTimelineTime, mapTimelineTimeToSourceTime, trimsToClips, } from "./types"; -import { deriveNextId } from "./projectPersistence"; describe("extendAutoFullTrackClip", () => { it("extends the default full-track clip when metadata duration grows", () => { @@ -157,3 +158,23 @@ describe("clip timeline mapping", () => { expect(deriveNextId("clip", clipsFromTrims.map((clip) => clip.id))).toBe(4); }); }); + +describe("getTimelineDurationMs", () => { + it("extends the timeline when a slow clip becomes longer than the source duration", () => { + expect( + getTimelineDurationMs( + [{ id: "clip-1", startMs: 0, endMs: 20_000, speed: 0.5 }], + 10_000, + ), + ).toBe(20_000); + }); + + it("keeps the source duration when speed edits make clips shorter", () => { + expect( + getTimelineDurationMs( + [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 2 }], + 10_000, + ), + ).toBe(10_000); + }); +}); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 18ef9a1c..92f84fd6 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -177,6 +177,18 @@ export function getClipSourceEndMs(clip: ClipRegion): number { return Math.round(clip.startMs + displayDurationMs * speed); } +export function getTimelineDurationMs(clips: ClipRegion[], sourceDurationMs: number): number { + const baseDurationMs = Math.max(0, Math.round(sourceDurationMs)); + if (clips.length === 0) { + return baseDurationMs; + } + + return clips.reduce( + (durationMs, clip) => Math.max(durationMs, Math.max(0, Math.round(clip.endMs))), + baseDurationMs, + ); +} + export function sortClipRegions(clips: ClipRegion[]): ClipRegion[] { return [...clips].sort((left, right) => left.startMs - right.startMs); } diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 0402c412..644cf899 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -1,8 +1,8 @@ import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { getRenderableAssetUrl } from "@/lib/assetPath"; import { extensionHost } from "@/lib/extensions"; -import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { type CursorStyle, type CursorTelemetryPoint, @@ -12,11 +12,11 @@ import { import { computeCursorSwayRotation } from "./cursorSway"; import { type CursorViewportRect, projectCursorPositionToViewport } from "./cursorViewport"; import { + type CursorSpringTuning, createSpringState, getCursorSpringConfig, resetSpringState, stepSpringValue, - type CursorSpringTuning, } from "./motionSmoothing"; import { cursorSetAssets, getCursorStyleSizeMultiplier } from "./uploadedCursorAssets"; diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts index 44414c53..fd5d1322 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts @@ -123,6 +123,35 @@ describe("createVideoEventHandlers", () => { expect(onTimeUpdate).toHaveBeenCalledWith(0.75); }); + it("skips removed footage when playback reaches a cut region", () => { + let animationFrameCallback: FrameRequestCallback | null = null; + requestAnimationFrameMock.mockImplementation((callback: FrameRequestCallback) => { + animationFrameCallback = callback; + return 29; + }); + const video = createMockVideo({ currentTime: 1.25, duration: 10 }); + const onTimeUpdate = vi.fn(); + const handlers = createVideoEventHandlers({ + video, + isSeekingRef: createMutableRef(false), + isPlayingRef: createMutableRef(false), + allowPlaybackRef: createMutableRef(true), + currentTimeRef: createMutableRef(0), + timeUpdateAnimationRef: createMutableRef(null), + onPlayStateChange: vi.fn(), + onTimeUpdate, + trimRegionsRef: createMutableRef([{ id: "trim-1", startMs: 1000, endMs: 2000 }]), + speedRegionsRef: createMutableRef([]), + }); + + handlers.handlePlay(); + animationFrameCallback?.(0); + + expect(video.currentTime).toBe(2); + expect(video.pause).not.toHaveBeenCalled(); + expect(onTimeUpdate).toHaveBeenLastCalledWith(2); + }); + it("cancels a pending requestVideoFrameCallback on pause and dispose", () => { const cancelVideoFrameCallback = vi.fn(); const video = createMockVideo({ diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index e98554ad..7cf480cf 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -132,6 +132,11 @@ function stopRecording( if (webcamRecorder && webcamRecorder.state !== "inactive") { webcamRecorder.stop(); } + try { + recorder.requestData(); + } catch { + // Stopping should continue even if the browser refuses an explicit flush. + } recorder.stop(); return { stopped: true, wasNative: false }; } @@ -328,6 +333,69 @@ describe("useScreenRecorder state machine", () => { expect(callOrder).toEqual(["resume", "stop"]); }); + it("flushes the current recorder data before stopping", () => { + const callOrder: string[] = []; + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["requestData", "stop"]); + }); + + it("resumes, flushes, then stops from paused state", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["resume", "requestData", "stop"]); + }); + + it("still stops when the explicit data flush fails", () => { + recorder.requestData.mockImplementation(() => { + throw new Error("flush failed"); + }); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.stop).toHaveBeenCalled(); + }); + + it("still stops from paused state when the explicit data flush fails", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.requestData.mockImplementation(() => { + callOrder.push("requestData"); + throw new Error("flush failed"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(callOrder).toEqual(["resume", "requestData", "stop"]); + }); + it("still stops when resume throws from paused state", () => { recorder.pause(); recorder.resume.mockImplementation(() => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 3d7933ee..40628d47 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1092,7 +1092,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } pendingWebcamPathPromise.current = stopWebcamRecorder(); - cleanupCapturedMedia(); + try { + recorder.requestData(); + } catch (error) { + console.warn("Failed to flush recorder before stopping:", error); + } recorder.stop(); setRecording(false); setFinalizing(true); @@ -1777,7 +1781,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { markRecordingPaused(boundaryMs); setPaused(true); try { - await window.electronAPI.pauseCursorCapture(); + await window.electronAPI.pauseCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to pause cursor capture:", error); } @@ -1794,7 +1798,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { markRecordingPaused(boundaryMs); setPaused(true); try { - await window.electronAPI.pauseCursorCapture(); + await window.electronAPI.pauseCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to pause cursor capture:", error); } @@ -1823,7 +1827,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { markRecordingResumed(boundaryMs); setPaused(false); try { - await window.electronAPI.resumeCursorCapture(); + await window.electronAPI.resumeCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to resume cursor capture:", error); } @@ -1840,7 +1844,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { markRecordingResumed(boundaryMs); setPaused(false); try { - await window.electronAPI.resumeCursorCapture(); + await window.electronAPI.resumeCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to resume cursor capture:", error); } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json new file mode 100644 index 00000000..3946d72b --- /dev/null +++ b/src/i18n/locales/ru/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly Editor", + "subtitle": "Запись экрана и редактирование видео", + "language": "Язык", + "manageRecordings": "Открыть папку с записями" + }, + "actions": { + "cancel": "Отмена", + "close": "Закрыть", + "export": "Экспорт", + "load": "Загрузить", + "redo": "Повторить", + "reset": "Сброс", + "save": "Сохранить", + "undo": "Отменить", + "delete": "Удалить", + "done": "Готово" + }, + "errors": { + "invalidFileType": "Неверный формат файла", + "failedToUploadImage": "Не удалось загрузить изображение", + "fileReadError": "Произошла ошибка при чтении файла." + } +} \ No newline at end of file diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json new file mode 100644 index 00000000..da6ed8ee --- /dev/null +++ b/src/i18n/locales/ru/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Пожалуйста, попробуйте еще раз", + "compilingGifProgress": "Создание GIF... {{progress}}%", + "compilingGifWait": "Создание GIF... Это займет некоторое время", + "takeMoment": "Это займет некоторое время...", + "exportFailed": "Экспорт не удался", + "compilingGifTitle": "Создание GIF", + "exportingFormat": "Экспорт в {{format}}", + "exportComplete": "Экспорт завершён", + "formatReady": "Ваш {{format}} готов", + "showInFolder": "Показать в папке", + "compiling": "Создание", + "renderingFrames": "Обработка кадров", + "processing": "Обработка...", + "status": "Статус", + "format": "Формат", + "compilingStatus": "Создание...", + "frames": "Кадров", + "cancelExport": "Отменить экспорт", + "reopenSaveDialog": "Снова открыть окно сохранения", + "savedSuccess": "{{format}} успешно сохранён!" + }, + "addFont": { + "title": "Google Fonts", + "heading": "Добавить Google Font", + "description": "Добавьте шрифт из Google Fonts для использования в аннотациях.", + "urlLabel": "Ссылка на импорт из Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Выберите шрифт на Google Fonts → нажмите \"Get font\" → скопируйте ссылку из блока @import", + "nameLabel": "Название шрифта", + "namePlaceholder": "Мой шрифт", + "nameHelp": "Так он будет отображаться в списке", + "adding": "Добавление...", + "addFont": "Добавить шрифт", + "enterUrl": "Введите ссылку для импорта из Google Fonts", + "invalidUrl": "Введите корректную ссылку на Google Fonts", + "enterName": "Введите название шрифта", + "extractFailed": "Не удалось определить семейство шрифтов по ссылке", + "addSuccess": "Шрифт \"{{name}}\" успешно добавлен", + "addFailed": "Не удалось добавить шрифт", + "loadTimeout": "Шрифт загружается слишком долго. Проверьте ссылку и попробуйте снова.", + "loadFailed": "Не удалось загрузить шрифт. Проверьте ссылку на Google Fonts." + }, + "shortcutsConfig": { + "title": "Сочетания клавиш", + "configurable": "Настраиваемые", + "fixed": "Фиксированные", + "pressEscToCancel": "Нажмите Esc для отмены", + "clickToChange": "Нажмите, чтобы изменить", + "pressAKey": "Нажмите клавишу…", + "alreadyUsedBy": "Уже используется в {{action}}", + "swap": "Заменить", + "reserved": "Это сочетание клавиш зарезервировано для \"{{label}}\" – его нельзя изменить.", + "saved": "Сочетания клавиш сохранены", + "resetNotice": "Сброс к настройкам по умолчанию — нажмите «Сохранить»", + "instructions": "Выберите сочетание и нажмите новую комбинацию клавиш. Нажмите Esc для отмены.", + "resetToDefaults": "Сбросить по умолчанию", + "cancel": "Отмена", + "save": "Сохранить" + } +} \ No newline at end of file diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json new file mode 100644 index 00000000..673f27ee --- /dev/null +++ b/src/i18n/locales/ru/editor.json @@ -0,0 +1,142 @@ +{ + "playback": { + "play": "Воспроизвести", + "pause": "Пауза", + "skipBack": "Перемотать назад", + "skipForward": "Перемотать вперёд", + "muteUnmute": "Вкл./выкл. звук" + }, + "annotations": { + "settings": "Настройки аннотации", + "active": "Активно", + "text": "Текст", + "image": "Изображение", + "arrow": "Стрелки", + "blur": "Размытие", + "textContent": "Текст", + "textPlaceholder": "Введите текст...", + "fontStyle": "Стиль шрифта", + "selectStyle": "Выберите стиль", + "size": "Размер", + "toggleBold": "Жирный", + "toggleItalic": "Курсив", + "toggleUnderline": "Подчёркивание", + "alignLeft": "По левому краю", + "alignCenter": "По центру", + "alignRight": "По правому краю", + "textColor": "Цвет текста", + "background": "Фон", + "none": "Нет", + "clearBackground": "Убрать фон", + "uploadImage": "Загрузить изображение", + "supportedFormats": "Форматы: JPG, PNG, GIF, WebP", + "arrowDirection": "Направление стрелки", + "strokeWidth": "Толщина: {{width}}px", + "arrowColor": "Цвет стрелки", + "deleteAnnotation": "Удалить аннотацию", + "shortcutsAndTips": "Горячие клавиши и советы", + "tipSelectAnnotation": "Переместите ползунок в область пересечения аннотаций и выберите нужную.", + "tipCycleForward": "Нажмите Tab для переключения между элементами.", + "tipCycleBackward": "Shift+Tab — для переключения в обратном направлении.", + "imageUploadSuccess": "Изображение загружено.", + "imageUploadError": "Загрузите файл JPG, PNG, GIF, или WebP.", + "blurStrength": "Сила размытия: {{strength}}", + "solidColor": "Сплошной цвет (цензура)", + "borderRadius": "Скругление углов" + }, + + "fontStyles": { + "classic": "Классический", + "editor": "Базовый", + "strong": "Акцентный", + "typewriter": "Моноширинный", + "deco": "Декоративный", + "simple": "Простой", + "modern": "Современный", + "clean": "Минималистичный" + }, + "format": { + "mp4Video": "Видео MP4", + "mp4Description": "Высококачественный видеофайл", + "gifAnimation": "GIF-анимация", + "gifDescription": "Анимированное изображение для соцсетей и мессенджеров" + }, + "gifOptions": { + "frameRate": "Частота кадров", + "outputSize": "Размер", + "outputDimensions": "Разрешение: {{width}} × {{height}}px", + "loopAnimation": "Зациклить анимацию", + "loopDescription": "GIF будет воспроизводиться непрерывно" + }, + "tutorial": { + "howTrimmingWorks": "Как работает обрезка", + "title": "Как работает обрезка", + "understanding": "Как вырезать ненужные фрагменты из видео.", + "descriptionP1": "Инструмент обрезки позволяет задать участки, которые нужно", + "descriptionRemove": "удалить", + "descriptionP2": "из видео.", + "descriptionP3": "Все красные части таймлайна будут вырезаны при экспорте.", + "visualExample": "Пример", + "removed": "УДАЛЕНО", + "kept": "Оставлено", + "finalVideo": "Итоговое видео", + "part": "Часть {{number}}", + "addTrimStep": "1. Добавьте обрезку", + "addTrimDesc": "Нажмите T или иконку ножниц, чтобы отметить участок для удаления.", + "adjustStep": "2. Настройте", + "adjustDesc": "Перетащите края красной области, чтобы точно выделить нужный фрагмент." + }, + "feedback": { + "trigger": "Обратная связь", + "title": "Обратная связь и контакты", + "description": "Свяжитесь с нами или сообщите о проблеме, если что-то не работает или отсутствует.", + "emailLabel": "Email", + "xLabel": "X", + "reportIssue": "Сообщить о проблеме / отправить отзыв", + "openFailed": "Не удалось открыть ссылку." + }, + "keyboardShortcuts": { + "trigger": "Горячие клавиши", + "title": "Горячие клавиши", + "description": "Памятка для работы с таймлайном.", + "customizeTooltip": "Настроить клавиши", + "customize": "Настроить", + "panTimeline": "Перемещение по таймлайну", + "zoomTimeline": "Увеличение таймлайна", + "cycleAnnotations": "Переключение аннотаций", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Сохранить ещё раз", + "showInFolder": "Показать в папке" + }, + "project": { + "untitled": "Без названия" + }, + "nativeCaptureUnavailable": { + "title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.", + "description": "Устройство не поддерживает нативный захват изображения. Запись продолжится, но без сглаживания курсора.", + "confirm": "Хорошо" + }, + "exportStatus": { + "exporting": "Экспорт", + "renderingFile": "Обработка файла.", + "preparing": "Подготовка к экспорту...", + "completePercent": "Готово: {{percent}}%", + "issue": "Ошибка экспорта", + "complete": "Экспорт завершён", + "savedSuccessfully": "Файл сохранён" + }, + "export": { + "processingAudioEdits": "Обработка звука (скорость, наложения)" + }, + "toolbar": { + "addLayer": "Добавить слой", + "splitClip": "Разрезать клип (C)" + }, + "timeline": { + "expand": "Развернуть таймлайн", + "collapse": "Свернуть таймлайн" + }, + "openRecordingsFolder": "Открыть папку с записями" +} \ No newline at end of file diff --git a/src/i18n/locales/ru/extensions.json b/src/i18n/locales/ru/extensions.json new file mode 100644 index 00000000..239fa9a3 --- /dev/null +++ b/src/i18n/locales/ru/extensions.json @@ -0,0 +1,60 @@ +{ + "title": "Расширения", + "tabs": { + "browse": "Обзор", + "installed": "Установленные" + }, + "actions": { + "submit": "Отправить расширение", + "docs": "Документация", + "refresh": "Обновить", + "openFolder": "Открыть папку с расширениями", + "uninstall": "Удалить", + "install": "Установить", + "installing": "Установка", + "add": "Добавить", + "retry": "Повторить", + "close": "Закрыть", + "folder": "Папка с расширениями" + }, + "status": { + "enabled": "Включить", + "disabled": "Выключить", + "installed": "Установлено" + }, + "detail": { + "by": "Автор: {{author}}", + "unknownAuthor": "Неизвестный автор", + "noDescription": "Описание отсутствует", + "downloads": "Загрузок: {{count}}", + "preview": "Предпросмотр", + "screenshotAlt": "Скриншот {{number}}", + "description": "Описание", + "tags": "Теги", + "permissions": "Разрешения", + "location": "Расположение", + "error": "Ошибка: {{message}}" + }, + "empty": { + "title": "У вас пока нет расширений", + "description": "Устанавливайте расширения: рамки, курсоры и дополнительные инструменты для редактирования." + }, + "search": { + "placeholder": "Поиск расширений...", + "noResults": "Ничего не найдено", + "noMarketplace": "В магазине пока нет доступных расширений", + "count": "Найдено расширений: {{count}}", + "countPlural": "Найдено расширений: {{count}}" + }, + "toast": { + "installedAndEnabled": "Расширение установлено и включено", + "uninstalled": "Удалено: {{name}}", + "uninstallFailed": "Не удалось удалить {{name}}", + "searchFailed": "Ошибка при поиске в магазине расширений", + "refreshed": "Список расширений обновлен", + "refreshFailed": "Не удалось обновить расширения", + "marketplaceInstalled": "Установлено и включено: {{name}}", + "marketplaceInstallFailed": "Не удалось установить {{name}}", + "enableFailed": "Не удалось включить расширение" + } +} \ No newline at end of file diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json new file mode 100644 index 00000000..65bd57b8 --- /dev/null +++ b/src/i18n/locales/ru/launch.json @@ -0,0 +1,80 @@ +{ + "recording": { + "disableSystemAudio": "Отключить системные звуки", + "enableSystemAudio": "Включить системные звуки", + "disableMicrophone": "Отключить микрофон", + "enableMicrophone": "Включить микрофон", + "micToggleDisabledTip": "Нельзя переключать микрофон во время записи", + "disableWebcam": "Отключить наложение веб-камеры", + "enableWebcam": "Включить наложение веб-камеры", + "countdownDelay": "Обратный отсчёт", + "noDelay": "Без задержки", + "record": "Запись", + "recordingFolder": "Место сохранения: {{path}}", + "chooseRecordingsFolder": "Изменить место сохранения", + "folderPath": "Папка: /{{name}}/", + "openVideoFile": "Открыть записанное видео", + "openProject": "Открыть проект", + "hideHudFromVideo": "Скрыть интерфейс во время записи", + "showHudInVideo": "Показать интерфейс во время записи", + "hideHud": "Скрыть интерфейс", + "closeApp": "Закрыть приложение", + "screens": "Экраны", + "windows": "Окна", + "screen": "Экран", + "window": "Окно", + "noSourcesFound": "Источники изображения не найдены", + "microphone": "Микрофон", + "turnOffMicrophone": "Выключить микрофон", + "selectMicToEnable": "Выберите микрофон для записи звука", + "noMicrophonesFound": "Нет доступных микрофонов", + "webcam": "Веб-камера", + "turnOffWebcam": "Выключить веб-камеру", + "hideFloatingWebcamPreview": "Скрыть предпросмотр", + "showFloatingWebcamPreview": "Включить предпросмотр", + "selectWebcamToEnable": "Выберите веб-камеру", + "noWebcamsFound": "Нет доступных веб-камер", + "recordingsFolder": "Место сохранения", + "language": "Язык", + "paused": "ПАУЗА", + "rec": "ЗАПИСЬ", + "resume": "Возобновить", + "pause": "Пауза", + "stop": "Стоп", + "cancel": "Отмена", + "more": "Ещё", + "update": { + "update": "Обновить", + "updated": "Обновлено", + "idleTitle": "Проверить обновления.", + "checkingTitle": "Поиск обновлений...", + "downloadingTitle": "Загрузка обновления...", + "errorTitle": "Ошибка при проверке обновления. Нажмите, чтобы повторить", + "upToDateTitle": "У вас актуальная версия Recordly {{version}}.", + "availableTitle": "Доступна новая версия Recordly {{version}}.", + "availableGenericTitle": "Доступно обновление." + } + }, + "sourceSelector": { + "loadingSources": "Загрузка источников...", + "screens": "Экраны", + "windows": "Окна", + "noScreensAvailable": "Нет экранов для захвата", + "noWindowsAvailable": "Нет окон для захвата", + "windowsNote": "Записать можно только видимые (не свёрнутые) окна.", + "windowPlaceholder": "Окно", + "cancel": "Отменить", + "share": "Поделиться" + }, + "permissions": { + "screenRecordingNeeded": "Recordly нужно разрешение на запись экрана. Мы открыли настройки – включите доступ и перезапустите приложение.", + "screenRecordingMissing": "Доступа к записи экрана всё ещё нет. Мы снова открыли настройки. Разрешите доступ и перезапустите Recordly.", + "accessibilityNeeded": "Разрешите Recordly использовать универсальный доступ, чтобы отслеживать курсор. Мы открыли настройки – включите его и перезапустите приложение.", + "accessibilityMissing": "Разрешения для универсального доступа всё ещё нет. Включите его в настройках и перезапустите Recordly.", + "selectSource": "Выберите источник записи", + "systemAudioUnavailable": "Системные звуки недоступны для этого источника. Запись продолжится без их захвата.", + "microphoneDenied": "Нет доступа к микрофону. Запись продолжится без вашего голоса.", + "failedToStart": "Не удалось начать запись: {{error}}", + "failedToStartGeneric": "Не удалось начать запись" + } +} \ No newline at end of file diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json new file mode 100644 index 00000000..88e48ec5 --- /dev/null +++ b/src/i18n/locales/ru/settings.json @@ -0,0 +1,205 @@ +{ + "zoom": { + "level": "Масштабирование", + "selectRegion": "Выберите область для масштабирования", + "deleteZoom": "Удалить", + "modeAuto": "Автоматически", + "modeManual": "Вручную", + "modeManualDescription": "Установить фиксированную точку фокусировки", + "modeAutoDescription": "Камера выравнивается по центру, когда курсор приближается к краю увеличенного изображения" + }, + "trim": { + "deleteRegion": "Удалить" + }, + "speed": { + "playbackSpeed": "Скорость воспроизведения", + "selectRegion": "Диапазон скорости", + "deleteRegion": "Удалить", + "label": "Скорость" + }, + "clip": { + "title": "Клип", + "muteAudio": "Выключить звук", + "delete": "Удалить" + }, + "effects": { + "title": "Эффекты", + "show": "Показать", + "showCursor": "Показать курсор", + "loopCursor": "Зациклить курсор", + "cursorStyle": "Стиль курсора", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe (инверсия)", + "dot": "Точка", + "figma": "Минималистичный", + "lavender": "Лаванда", + "parched": "Опаленный", + "chooper": "Чоппер", + "amongus": "Among Us", + "turtle": "Черепаха" + }, + "backgroundBlur": "Размытие фона", + "zoomMotionBlur": "Размытие при зуме", + "temporalZoomMotionBlur": "Временное размытие зума", + "temporalZoomMotionBlurDescription": "Настройка плавности зума по времени и кадрам.", + "zoomMotionBlurSamples": "Образцы размытия", + "zoomMotionBlurShutter": "Выдержка", + "auto": "Авто", + "connectZooms": "Связать зумы", + "connectZoomsDescription": "Плавный переход между зумами.", + "autoApplyFreshRecordingZooms": "Автозум для новых записей", + "autoApplyFreshRecordingZoomsDescription": "Автоматически предлагает зум при открытии записи.", + "zoomGeneralTitle": "Общее", + "zoomGeneralDescription": "Настройки движения зума.", + "zoomInTitle": "Приближение", + "zoomInDescription": "Настройка входа камеры в область зума.", + "zoomOutTitle": "Отдаление", + "zoomOutDescription": "Настройка выхода камеры из области зума.", + "connectedZoomTitle": "Между зумами", + "connectedZoomDescription": "Настройка плавного перехода между зумами.", + "motionPresetsTitle": "Пресеты движения", + "motionPresetsZoomHint": "Предварительные параметры доступны в настройках.", + "animationPresets": "Пресеты анимации", + "cursorMotionPresets": "Предустановки движения курсора", + "motionPresets": { + "focused": { + "label": "Фокус", + "description": "Быстрая анимация для демо-роликов, пошаговых инструкций и повседневных записей." + }, + "smooth": { + "label": "Спокойный", + "description": "Мягкая анимация для видеороликов в стиле Keynote и презентаций." + } + }, + "zoomInDuration": "Длительность приближения", + "zoomInOverlap": "Перекрытие приближения", + "zoomOutDuration": "Длительность отдаления", + "zoomInEasing": "Кривая приближения", + "zoomOutEasing": "Кривая отдаления", + "connectedZoomGap": "Интервал между зумами", + "connectedZoomDuration": "Длительность интервала", + "connectedZoomEasing": "Кривая панорамирования", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Плавно", + "smooth": "Мягко", + "snappy": "Резко", + "linear": "Линейно" + }, + "cursorSize": "Размер курсора", + "cursorSmoothing": "Сглаживание курсора", + "cursorSpringStiffness": "Жёсткость пружины", + "cursorSpringDamping": "Затухание", + "cursorSpringMass": "Масса (инерция)", + "off": "Выкл.", + "cursorMotionBlur": "Размытие в движении", + "cursorClickBounce": "Отскок при клике", + "cursorClickBounceDuration": "Скорость отскока", + "cursorSway": "Покачивание курсора", + "webcam": "Наложение веб-камеры", + "webcamFootage": "Запись с веб-камеры", + "webcamFootageDescription": "Видео не добавлено", + "uploadWebcamFootage": "Загрузить видео", + "replaceWebcamFootage": "Заменить видео", + "removeWebcamFootage": "Удалить видео", + "webcamFootageAdded": "Видео добавлено", + "webcamFootageRemoved": "Видео удалено", + "webcamSize": "Размер веб-камеры", + "webcamCrop": "Обрезка веб-камеры", + "webcamReactToZoom": "Веб-камера реагирует на зум", + "webcamMirror": "Отразить веб-камеру", + "webcamRoundness": "Скругление веб-камеры", + "webcamShadow": "Тень веб-камеры", + "shadow": "Тень", + "radius": "Радиус", + "roundness": "Скругление", + "padding": "Отступы", + "paddingLinked": "Связанные (одинаковые)", + "paddingUnlinked": "Раздельные", + "paddingTop": "Сверху", + "paddingBottom": "Снизу", + "paddingLeft": "Слева", + "paddingRight": "Справа", + "removeBackground": "Удалить фон" + }, + "sections": { + "scene": "Сцена", + "captions": "Субтитры", + "zoom": "Зум", + "cursor": "Курсор", + "webcam": "Веб-камера", + "frame": "Рамка", + "crop": "Обрезать видео" + }, + "captions": { + "enabled": "Показать", + "language": "Язык", + "downloading": "Загрузка...", + "deleteModel": "Удалить модель", + "clearModel": "Очистить модель", + "downloadModel": "Загрузить модель", + "generating": "Создание...", + "generateFull": "Создать субтитры", + "regenerateFull": "Пересоздать субтитры", + "clearFull": "Удалить субтитры", + "fontSettings": "Настройка шрифта", + "defaultFont": "По умолчанию", + "fontFamily": "Шрифт", + "fontSize": "Размер", + "rowCount": "Строки", + "animation": "Анимация", + "animationOff": "Выкл", + "animationFade": "Появление", + "animationRise": "Подъем", + "animationPop": "Всплытие", + "bottomOffset": "Отступ снизу", + "maxWidth": "Максимальная ширина", + "boxRadius": "Скругление", + "backgroundOpacity": "Прозрачность фона", + "textColor": "Цвет текста" + }, + "crop": { + "title": "Обрезка видео", + "instruction": "Перетащите края для обрезки", + "top": "Верх", + "bottom": "Низ", + "left": "Левый край", + "right": "Правый край", + "openEditor": "Открыть редактор" + }, + "background": { + "title": "Фон", + "image": "Изображение", + "color": "Цвет", + "gradient": "Градиент", + "wallpaperPreview": "Предпросмотр", + "uploadCustom": "Загрузить", + "uploadSuccess": "Изображение загружено.", + "uploadError": "Загрузите JPG или JPEG.", + "uploadErrorDescription": "Поддерживаются только JPG и JPEG." + }, + "export": { + "title": "Экспорт", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Низкое", + "medium": "Среднее", + "high": "Высокое", + "original": "Исходное" + }, + "fpsTitle": "FPS", + "loop": "Зациклить", + "outputDimensions": "Размер: {{dimensions}}px", + "sizePresetOriginalShort": "Ориг", + "sizePresetMediumShort": "Сред", + "sizePresetLargeShort": "Бол", + "loadProject": "Загрузить проект", + "saveProject": "Сохранить проект", + "exportVideo": "Экспортировать {{format}}", + "reportBug": "Сообщить об ошибке", + "starOnGithub": "Оценить на GitHub" + } +} \ No newline at end of file diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json new file mode 100644 index 00000000..9898e48c --- /dev/null +++ b/src/i18n/locales/ru/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Добавить зум", + "addTrim": "Обрезать", + "addSpeed": "Увеличить скорость", + "addAnnotation": "Добавить аннотацию", + "addKeyframe": "Добавить ключ", + "deleteSelected": "Удалить выбранное", + "playPause": "Воспроизведение / Пауза", + "cycleForward": "Следующая аннотация", + "cycleBackward": "Предыдущая аннотация", + "deleteSelectedAlt": "Удалить выбранное (alt)", + "panTimeline": "Прокрутка таймлайна", + "zoomTimeline": "Масштабирование таймлайна" + } +} \ No newline at end of file diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json new file mode 100644 index 00000000..8c1d2b77 --- /dev/null +++ b/src/i18n/locales/ru/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Нельзя добавить зум здесь", + "existsOrNoSpace": "Зум уже есть или недостаточно места для его добавления.", + "suggestHandlerUnavailable": "Подсказки недоступны", + "noTelemetry": "Нет данных о курсоре", + "recordFirst": "Запишите видео для генерации подсказок на основе курсора.", + "noUsableTelemetry": "Недостаточно данных о курсоре", + "notEnoughMovement": "В видео мало движения курсора.", + "noInteractionMoments": "Нет точек взаимодействия", + "tryRecording": "Попробуйте записать видео с паузами или щелчками мыши во время важных действий.", + "noAutoZoomSlots": "Нет слотов с авто-зумом.", + "dwellPointsOverlap": "Конфликт с существующими зумами.", + "addedSuggestions": "Добавлено зумов: {{count}}", + "label": "Зум {{index}}", + "addZoom": "Добавить зум (Z)", + "suggestZooms": "Предложить зумы на основе курсора" + }, + "trim": { + "cannotPlace": "Нельзя обрезать здесь", + "existsOrNoSpace": "Уже обрезано или недостаточно места.", + "label": "Обрезка {{index}}", + "addTrim": "Обрезать (T)" + }, + "speed": { + "cannotPlace": "Нельзя изменить скорость здесь", + "existsOrNoSpace": "Скорость уже задана или недостаточно места.", + "label": "Скорость" + }, + "annotation": { + "label": "Аннотация", + "image": "Изображение", + "addAnnotation": "Добавить аннотацию (A)" + }, + "audio": { + "label": "Аудио" + }, + "addSpeed": "Скорость (S)", + "resizeLeft": "Изменить размер слева", + "resizeRight": "Изменить размер справа" +} \ No newline at end of file diff --git a/src/lib/exporter/exportSavePolicy.test.ts b/src/lib/exporter/exportSavePolicy.test.ts new file mode 100644 index 00000000..fb60c285 --- /dev/null +++ b/src/lib/exporter/exportSavePolicy.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + canUseInMemoryExportSaveFallback, + describeBlockedInMemoryExportSave, + isExportTooLargeForInMemorySave, + MAX_IN_MEMORY_EXPORT_BYTES, + normalizeExportExtension, +} from "./exportSavePolicy"; + +describe("exportSavePolicy", () => { + it("normalizes export extensions before policy checks", () => { + expect(normalizeExportExtension(" MP4 ")).toBe("mp4"); + }); + + it("blocks the legacy in-memory save path above Node's Buffer limit", () => { + expect(isExportTooLargeForInMemorySave(MAX_IN_MEMORY_EXPORT_BYTES + 1)).toBe(true); + expect( + canUseInMemoryExportSaveFallback({ + blobSize: MAX_IN_MEMORY_EXPORT_BYTES + 1, + extension: "gif", + hasExportStreamApi: false, + }), + ).toBe(false); + }); + + it("keeps Electron MP4 exports on the temp-file save path", () => { + expect( + canUseInMemoryExportSaveFallback({ + blobSize: 1024, + extension: "mp4", + hasExportStreamApi: true, + }), + ).toBe(false); + }); + + it("allows small non-MP4 exports to use the legacy save fallback", () => { + expect( + canUseInMemoryExportSaveFallback({ + blobSize: 1024, + extension: "gif", + hasExportStreamApi: false, + }), + ).toBe(true); + }); + + it("explains blocked large saves without mentioning implementation stack traces", () => { + expect( + describeBlockedInMemoryExportSave({ + blobSize: MAX_IN_MEMORY_EXPORT_BYTES + 1, + extension: "mp4", + }), + ).toContain("too large"); + }); +}); diff --git a/src/lib/exporter/exportSavePolicy.ts b/src/lib/exporter/exportSavePolicy.ts new file mode 100644 index 00000000..c48516ed --- /dev/null +++ b/src/lib/exporter/exportSavePolicy.ts @@ -0,0 +1,46 @@ +export const MAX_IN_MEMORY_EXPORT_BYTES = 0x7fffffff; + +export function normalizeExportExtension(extension: string): string { + return extension.trim().toLowerCase(); +} + +export function isExportTooLargeForInMemorySave(byteLength: number): boolean { + return byteLength > MAX_IN_MEMORY_EXPORT_BYTES; +} + +export function canUseInMemoryExportSaveFallback({ + blobSize, + extension, + hasExportStreamApi, +}: { + blobSize: number; + extension: string; + hasExportStreamApi: boolean; +}): boolean { + if (isExportTooLargeForInMemorySave(blobSize)) { + return false; + } + + // In Electron, MP4 exports should stay on the temp-file path. If that path + // failed, silently falling back to ArrayBuffer reintroduces the >2 GiB crash. + if (hasExportStreamApi && normalizeExportExtension(extension) === "mp4") { + return false; + } + + return true; +} + +export function describeBlockedInMemoryExportSave({ + blobSize, + extension, +}: { + blobSize: number; + extension: string; +}): string { + const normalizedExtension = normalizeExportExtension(extension) || "export"; + if (isExportTooLargeForInMemorySave(blobSize)) { + return `The ${normalizedExtension.toUpperCase()} export is too large to save through the legacy in-memory path. Please retry the export so Recordly can save it through the temp-file streaming path.`; + } + + return `The ${normalizedExtension.toUpperCase()} export could not be saved through the temp-file streaming path, and Recordly will not fall back to the legacy in-memory path for MP4 exports. Please retry the export.`; +} diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 671c1c4b..651bb08f 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -1,7 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; -const { initializeForwardFrameSourceMock, resolveMediaElementSourceMock } = vi.hoisted(() => ({ +const { + cancelForwardFrameSourceMock, + destroyForwardFrameSourceMock, + getForwardFrameAtTimeMock, + initializeForwardFrameSourceMock, + resolveMediaElementSourceMock, +} = vi.hoisted(() => ({ + cancelForwardFrameSourceMock: vi.fn(), + destroyForwardFrameSourceMock: vi.fn(async () => undefined), + getForwardFrameAtTimeMock: vi.fn(async () => null), initializeForwardFrameSourceMock: vi.fn(async () => undefined), resolveMediaElementSourceMock: vi.fn(async () => ({ src: "blob:background", @@ -68,6 +77,9 @@ vi.mock("@/components/video-editor/videoPlayback/cursorRenderer", () => ({ vi.mock("./forwardFrameSource", () => ({ ForwardFrameSource: class { + cancel = cancelForwardFrameSourceMock; + destroy = destroyForwardFrameSourceMock; + getFrameAtTime = getForwardFrameAtTimeMock; initialize = initializeForwardFrameSourceMock; }, })); @@ -449,6 +461,7 @@ describe("FrameRenderer webcam export path", () => { }); it("prefers decoder-backed sync for video wallpapers during export", async () => { + vi.clearAllMocks(); const renderer = new FrameRenderer({ width: 1920, height: 1080, @@ -479,4 +492,46 @@ describe("FrameRenderer webcam export path", () => { expect(renderer.backgroundVideoElement).toBeNull(); expect(renderer.backgroundSprite).toBeTruthy(); }); + + it("falls back to media-element sync when video wallpaper packet streaming fails", async () => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:background-video", + revoke: vi.fn(), + }); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as unknown as { + setupBackground: () => Promise; + syncBackgroundFrame: (timeSeconds: number) => Promise; + backgroundForwardFrameSource: unknown; + backgroundVideoElement: FakeVideoElement | null; + }; + + await renderer.setupBackground(); + await expect(renderer.syncBackgroundFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(renderer.backgroundForwardFrameSource).toBeNull(); + expect(renderer.backgroundVideoElement).toBeTruthy(); + }); }); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 8e723d68..3a01403e 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -153,10 +153,14 @@ type PixiRendererAttempt = { }; const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; function isCanvasRenderer(renderer: Application): boolean { const rendererName = renderer?.renderer?.constructor?.name?.toLowerCase(); - return Boolean(rendererName && (rendererName.includes("canvasrenderer") || rendererName.includes("canvas"))); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); } function toErrorMessage(error: unknown): string { @@ -357,7 +361,8 @@ export class FrameRenderer { backend, ); const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); if (isCanvasRenderer(app)) { throw new Error( @@ -367,9 +372,13 @@ export class FrameRenderer { return { app, backend }; } catch (error) { const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); - failures.push({ backend, message: `${toErrorMessage(error)} (after ${elapsed}ms)` }); + failures.push({ + backend, + message: `${toErrorMessage(error)} (after ${elapsed}ms)`, + }); console.warn( `[FrameRenderer] ${backend} renderer unavailable after ${elapsed}ms; trying next backend.`, error, @@ -574,44 +583,9 @@ export class FrameRenderer { ); } - const backgroundSource = await resolveMediaElementSource(videoSrc); - this.cleanupBackgroundSource = backgroundSource.revoke; - - const video = document.createElement("video"); - video.muted = true; - video.loop = true; - video.playsInline = true; - video.preload = "auto"; - video.src = backgroundSource.src; - video.load(); - - await new Promise((resolve, reject) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error(`Failed to load video wallpaper: ${wallpaper}`)); - }; - const cleanup = () => { - video.removeEventListener("loadeddata", onReady); - video.removeEventListener("canplay", onReady); - video.removeEventListener("error", onError); - }; - - video.addEventListener("loadeddata", onReady); - video.addEventListener("canplay", onReady); - video.addEventListener("error", onError); - onReady(); - }); - - this.backgroundVideoElement = video; - this.lastSyncedBackgroundLoopTimeSec = null; - this.drawVideoFrameToBackground(); + if (!(await this.loadBackgroundMediaElementSource(videoSrc, wallpaper))) { + throw new Error(`Failed to load video wallpaper: ${wallpaper}`); + } this.backgroundSprite = bgCanvas; return; } @@ -828,8 +802,20 @@ export class FrameRenderer { } } - const decodedFrame = - await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + let decodedFrame: VideoFrame | null = null; + try { + decodedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper failed during export; falling back to media element sync:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } const resolvedDecodedDuration = this.backgroundForwardFrameSource.getResolvedDurationSec(); if ( @@ -865,6 +851,10 @@ export class FrameRenderer { "[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:", error, ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; } } this.closeBackgroundDecodedFrame(); @@ -883,6 +873,122 @@ export class FrameRenderer { await this.syncBackgroundVideo(timeSeconds); } + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.lastSyncedBackgroundLoopTimeSec = null; + + return sourceUrl ? this.loadBackgroundMediaElementSource(sourceUrl, sourceUrl) : false; + } + + private async loadBackgroundMediaElementSource( + videoSrc: string, + errorLabel: string, + ): Promise { + if (this.backgroundVideoElement) { + try { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + + let backgroundSource: Awaited>; + try { + backgroundSource = await resolveMediaElementSource(videoSrc); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to resolve video wallpaper fallback source:", + error, + ); + return false; + } + this.cleanupBackgroundSource = backgroundSource.revoke; + + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.src = backgroundSource.src; + video.load(); + + const ready = await new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + + function cleanup() { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("error", onError); + } + + function settle(value: boolean) { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + } + function onReady() { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + settle(true); + } + function onError() { + settle(false); + } + + video.addEventListener("loadeddata", onReady); + video.addEventListener("canplay", onReady); + video.addEventListener("error", onError); + timeoutId = setTimeout(() => { + console.warn( + `[FrameRenderer] Video wallpaper media element fallback did not become ready within ${BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + settle(false); + }, BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS); + onReady(); + }); + + if (!ready) { + console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors on failed fallback. + } + backgroundSource.revoke(); + if (this.cleanupBackgroundSource === backgroundSource.revoke) { + this.cleanupBackgroundSource = null; + } + return false; + } + + this.backgroundVideoElement = video; + this.lastSyncedBackgroundLoopTimeSec = null; + this.drawVideoFrameToBackground(); + return true; + } + private async syncBackgroundVideo(timeSeconds: number): Promise { const video = this.backgroundVideoElement; if (!video) return; diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index f8b95fd5..7df341f9 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -1,7 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; -const { initializeForwardFrameSourceMock, resolveMediaElementSourceMock } = vi.hoisted(() => ({ +const { + cancelForwardFrameSourceMock, + destroyForwardFrameSourceMock, + getForwardFrameAtTimeMock, + initializeForwardFrameSourceMock, + resolveMediaElementSourceMock, +} = vi.hoisted(() => ({ + cancelForwardFrameSourceMock: vi.fn(), + destroyForwardFrameSourceMock: vi.fn(async () => undefined), + getForwardFrameAtTimeMock: vi.fn(async () => null), initializeForwardFrameSourceMock: vi.fn(async () => undefined), resolveMediaElementSourceMock: vi.fn(async () => ({ src: "blob:background", @@ -82,6 +91,9 @@ vi.mock("@/components/video-editor/videoPlayback/cursorRenderer", () => ({ vi.mock("./forwardFrameSource", () => ({ ForwardFrameSource: class { + cancel = cancelForwardFrameSourceMock; + destroy = destroyForwardFrameSourceMock; + getFrameAtTime = getForwardFrameAtTimeMock; initialize = initializeForwardFrameSourceMock; }, })); @@ -171,6 +183,11 @@ describe("ModernFrameRenderer blur export path", () => { beforeEach(() => { Object.assign(globalThis, { window: globalThis, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + cancelAnimationFrame: vi.fn(), HTMLMediaElement: { HAVE_CURRENT_DATA: 2, }, @@ -220,6 +237,7 @@ describe("ModernFrameRenderer blur export path", () => { }); it("prefers decoder-backed sync for video wallpapers during export", async () => { + vi.clearAllMocks(); const renderer = new FrameRenderer({ width: 1920, height: 1080, @@ -245,9 +263,84 @@ describe("ModernFrameRenderer blur export path", () => { expect(renderer.backgroundForwardFrameSource).toBeTruthy(); expect(renderer.backgroundVideoElement).toBeNull(); }); + + it("falls back to media-element sync when video wallpaper packet streaming fails", async () => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:background-video", + revoke: vi.fn(), + }); + const renderer = new FrameRenderer({ + width: 1920, + height: 1080, + nativeReadbackMode: "pixels", + wallpaper: "/wallpapers/wispysky.mp4", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + backgroundBlur: 0, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + webcam: { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: false, + }, + videoWidth: 1920, + videoHeight: 1080, + }) as any; + + await renderer.setupBackground(); + await expect(renderer.syncBackgroundFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("wallpapers/wispysky.mp4"); + expect(renderer.backgroundForwardFrameSource).toBeNull(); + expect(renderer.backgroundVideoElement).toBeTruthy(); + }); }); describe("ModernFrameRenderer webcam frame cache", () => { + it("uses staging canvas instead of recursing when WebGPU frame retention fails", () => { + const renderer = createRenderer() as any; + const originalVideoFrame = (globalThis as any).VideoFrame; + + (globalThis as any).VideoFrame = class { + constructor() { + throw new Error("retain failed"); + } + }; + + try { + renderer.rendererBackend = "webgpu"; + const frame = { + displayWidth: 320, + displayHeight: 180, + timestamp: 0, + } as VideoFrame; + + const result = renderer.stageVideoFrameForTexture(frame, "webcam", 640, 360); + + expect(result).toBe(renderer.webcamVideoFrameStagingCanvas); + expect(renderer.webcamVideoFrameStagingCtx.drawImage).toHaveBeenCalledWith( + frame, + 0, + 0, + 320, + 180, + ); + } finally { + if (originalVideoFrame === undefined) { + delete (globalThis as any).VideoFrame; + } else { + (globalThis as any).VideoFrame = originalVideoFrame; + } + } + }); + it("keeps the refresh throttle for default crop regions", () => { const renderer = createRenderer() as any; @@ -270,3 +363,147 @@ describe("ModernFrameRenderer webcam frame cache", () => { expect(renderer.shouldRefreshWebcamFrameCache(1280, 720)).toBe(true); }); }); + +describe("ModernFrameRenderer webcam export fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + initializeForwardFrameSourceMock.mockResolvedValue(undefined); + getForwardFrameAtTimeMock.mockResolvedValue(null); + resolveMediaElementSourceMock.mockResolvedValue({ + src: "blob:webcam", + revoke: vi.fn(), + }); + + Object.assign(globalThis, { + window: { + clearTimeout, + setTimeout, + }, + HTMLMediaElement: { + HAVE_CURRENT_DATA: 2, + }, + cancelAnimationFrame: vi.fn(), + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + document: { + createElement: vi.fn((tag: string) => { + if (tag === "video") { + return { + duration: 5, + readyState: 2, + videoWidth: 640, + videoHeight: 360, + muted: true, + loop: true, + playsInline: true, + preload: "auto", + src: "", + currentTime: 0, + seeking: false, + load: vi.fn(), + pause: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + } + if (tag !== "canvas") { + throw new Error(`Unexpected element requested in test: ${tag}`); + } + + return createMockCanvas(); + }), + }, + }); + }); + + it("falls back to media-element webcam sync when packet streaming fails after initialize", async () => { + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + }; + renderer.config.webcamUrl = "file:///tmp/webcam.webm"; + + await renderer.setupWebcamSource(); + await expect(renderer.syncWebcamFrame(1)).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(resolveMediaElementSourceMock).toHaveBeenCalledWith("file:///tmp/webcam.webm"); + expect(renderer.webcamForwardFrameSource).toBeNull(); + expect(renderer.webcamVideoElement).toBeTruthy(); + }); + + it("tears down the media-element fallback when readiness times out", async () => { + vi.useFakeTimers(); + const originalCreateElement = (globalThis as any).document.createElement; + const revoke = vi.fn(); + getForwardFrameAtTimeMock.mockRejectedValueOnce( + new Error("readAVPacket pipeline failed: Failed after 3 attempts"), + ); + resolveMediaElementSourceMock.mockResolvedValueOnce({ + src: "blob:webcam-timeout", + revoke, + }); + Object.assign((globalThis as any).window, { + clearTimeout, + setTimeout, + }); + + (globalThis as any).document.createElement = vi.fn((tag: string) => { + if (tag === "video") { + return { + duration: Number.NaN, + readyState: 0, + videoWidth: 0, + videoHeight: 0, + muted: true, + loop: true, + playsInline: true, + preload: "auto", + src: "", + currentTime: 0, + seeking: false, + load: vi.fn(), + pause: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + } + if (tag !== "canvas") { + throw new Error(`Unexpected element requested in test: ${tag}`); + } + + return createMockCanvas(); + }); + + try { + const renderer = createRenderer() as any; + renderer.config.webcam = { + ...DEFAULT_WEBCAM_OVERLAY, + enabled: true, + }; + renderer.config.webcamUrl = "file:///tmp/webcam.webm"; + + await renderer.setupWebcamSource(); + const syncPromise = renderer.syncWebcamFrame(1); + + await vi.advanceTimersByTimeAsync(5_001); + await expect(syncPromise).resolves.toBeUndefined(); + + expect(cancelForwardFrameSourceMock).toHaveBeenCalled(); + expect(destroyForwardFrameSourceMock).toHaveBeenCalled(); + expect(revoke).toHaveBeenCalled(); + expect(renderer.webcamForwardFrameSource).toBeNull(); + expect(renderer.webcamVideoElement).toBeNull(); + } finally { + (globalThis as any).document.createElement = originalCreateElement; + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index c6830315..cf52faea 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -246,10 +246,15 @@ type PixiRendererAttempt = { const CANVAS_RENDERER_NOT_IMPLEMENTED_HINT = "CanvasRenderer is not yet implemented"; const NO_RENDERER_HINT = "no available renderer"; const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; +const BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; +const WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS = 5_000; function isCanvasRenderer(application: Application): boolean { const rendererName = application?.renderer?.constructor?.name?.toLowerCase(); - return Boolean(rendererName && (rendererName.includes("canvasrenderer") || rendererName.includes("canvas"))); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); } function toErrorMessage(error: unknown): string { @@ -681,7 +686,8 @@ export class FrameRenderer { backend, ); const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); if (isCanvasRenderer(app)) { throw new Error( @@ -691,7 +697,8 @@ export class FrameRenderer { return { app, backend }; } catch (error) { const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); failures.push({ backend, @@ -894,7 +901,9 @@ export class FrameRenderer { } const cachedTimestamp = - kind === "scene" ? this.retainedSceneBitmapTimestamp : this.retainedBackgroundBitmapTimestamp; + kind === "scene" + ? this.retainedSceneBitmapTimestamp + : this.retainedBackgroundBitmapTimestamp; const cachedBitmap = kind === "scene" ? this.retainedSceneBitmap : this.retainedBackgroundBitmap; if (cachedTimestamp === frame.timestamp && cachedBitmap) { @@ -924,8 +933,8 @@ export class FrameRenderer { private resolveRetainedVideoFrameSource( frame: VideoFrame, kind: "scene" | "background" | "webcam", - _fallbackWidth: number, - _fallbackHeight: number, + fallbackWidth: number, + fallbackHeight: number, ): CanvasImageSource | VideoFrame { if (this.rendererBackend !== "webgpu") { return frame; @@ -948,12 +957,7 @@ export class FrameRenderer { `[ModernFrameRenderer] Failed to retain ${kind} VideoFrame, falling back to staging canvas:`, error, ); - return this.stageVideoFrameForTexture( - frame, - "scene", - this.config.videoWidth, - this.config.videoHeight, - ); + return this.stageVideoFrameOnCanvas(frame, kind, fallbackWidth, fallbackHeight); } } @@ -1010,21 +1014,12 @@ export class FrameRenderer { return { canvas, context }; } - private stageVideoFrameForTexture( + private stageVideoFrameOnCanvas( frame: VideoFrame, kind: "scene" | "background" | "webcam", fallbackWidth: number, fallbackHeight: number, ): CanvasImageSource | VideoFrame { - if (this.rendererBackend === "webgpu") { - return this.resolveRetainedVideoFrameSource( - frame, - kind, - fallbackWidth, - fallbackHeight, - ); - } - const width = Math.max(1, frame.displayWidth || fallbackWidth); const height = Math.max(1, frame.displayHeight || fallbackHeight); const staging = this.ensureVideoFrameStagingCanvas(kind, width, height); @@ -1037,6 +1032,19 @@ export class FrameRenderer { return staging.canvas; } + private stageVideoFrameForTexture( + frame: VideoFrame, + kind: "scene" | "background" | "webcam", + fallbackWidth: number, + fallbackHeight: number, + ): CanvasImageSource | VideoFrame { + if (this.rendererBackend === "webgpu") { + return this.resolveRetainedVideoFrameSource(frame, kind, fallbackWidth, fallbackHeight); + } + + return this.stageVideoFrameOnCanvas(frame, kind, fallbackWidth, fallbackHeight); + } + private replaceSpriteTexture( sprite: Sprite, source: CanvasImageSource | VideoFrame, @@ -1137,44 +1145,9 @@ export class FrameRenderer { ); } - const backgroundSource = await resolveMediaElementSource(videoSrc); - this.cleanupBackgroundSource = backgroundSource.revoke; - - const video = document.createElement("video"); - video.muted = true; - video.loop = true; - video.playsInline = true; - video.preload = "auto"; - video.src = backgroundSource.src; - video.load(); - - await new Promise((resolve, reject) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error(`Failed to load video wallpaper: ${wallpaper}`)); - }; - const cleanup = () => { - video.removeEventListener("loadeddata", onReady); - video.removeEventListener("canplay", onReady); - video.removeEventListener("error", onError); - }; - - video.addEventListener("loadeddata", onReady); - video.addEventListener("canplay", onReady); - video.addEventListener("error", onError); - onReady(); - }); - - this.backgroundVideoElement = video; - this.lastSyncedBackgroundLoopTimeSec = null; - this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight); + if (!(await this.loadBackgroundMediaElementSource(videoSrc, wallpaper))) { + throw new Error(`Failed to load video wallpaper: ${wallpaper}`); + } return; } @@ -1846,8 +1819,20 @@ export class FrameRenderer { } } - const decodedFrame = - await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + let decodedFrame: VideoFrame | null = null; + try { + decodedFrame = + await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime); + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed video wallpaper failed during export; falling back to media element sync:", + error, + ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; + } const resolvedDecodedDuration = this.backgroundForwardFrameSource.getResolvedDurationSec(); if ( @@ -1890,6 +1875,10 @@ export class FrameRenderer { "[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:", error, ); + if (await this.fallbackBackgroundForwardFrameSourceToMediaElement()) { + await this.syncBackgroundFrame(timeSeconds); + } + return; } } this.closeBackgroundDecodedFrame(); @@ -2085,6 +2074,215 @@ export class FrameRenderer { return getRenderableAssetUrl(wallpaperAsset); } + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise { + const sourceUrl = this.backgroundForwardFrameSourceUrl; + this.backgroundForwardFrameSource?.cancel(); + void this.backgroundForwardFrameSource?.destroy(); + this.backgroundForwardFrameSource = null; + this.backgroundForwardFrameSourceUrl = null; + this.backgroundForwardFrameDurationSec = null; + this.closeBackgroundDecodedFrame(); + this.lastSyncedBackgroundLoopTimeSec = null; + + return sourceUrl ? this.loadBackgroundMediaElementSource(sourceUrl, sourceUrl) : false; + } + + private async loadBackgroundMediaElementSource( + videoSrc: string, + errorLabel: string, + ): Promise { + if (this.backgroundVideoElement) { + try { + this.backgroundVideoElement.pause(); + this.backgroundVideoElement.src = ""; + this.backgroundVideoElement.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + this.backgroundVideoElement = null; + } + this.backgroundSeekPromise = null; + this.cleanupBackgroundSource?.(); + this.cleanupBackgroundSource = null; + + let backgroundSource: Awaited>; + try { + backgroundSource = await resolveMediaElementSource(videoSrc); + } catch (error) { + console.warn( + "[FrameRenderer] Unable to resolve video wallpaper fallback source:", + error, + ); + return false; + } + this.cleanupBackgroundSource = backgroundSource.revoke; + + const video = document.createElement("video"); + video.muted = true; + video.loop = true; + video.playsInline = true; + video.preload = "auto"; + video.src = backgroundSource.src; + video.load(); + + const ready = await new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + + function cleanup() { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("error", onError); + } + + function settle(value: boolean) { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(value); + } + function onReady() { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + settle(true); + } + function onError() { + settle(false); + } + + video.addEventListener("loadeddata", onReady); + video.addEventListener("canplay", onReady); + video.addEventListener("error", onError); + timeoutId = setTimeout(() => { + console.warn( + `[FrameRenderer] Video wallpaper media element fallback did not become ready within ${BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + settle(false); + }, BACKGROUND_MEDIA_ELEMENT_READY_TIMEOUT_MS); + onReady(); + }); + + if (!ready) { + console.warn(`[FrameRenderer] Failed to load video wallpaper: ${errorLabel}`); + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors on failed fallback. + } + backgroundSource.revoke(); + if (this.cleanupBackgroundSource === backgroundSource.revoke) { + this.cleanupBackgroundSource = null; + } + return false; + } + + this.backgroundVideoElement = video; + this.lastSyncedBackgroundLoopTimeSec = null; + await this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight); + return true; + } + + private disposeWebcamMediaElement(video: HTMLVideoElement): void { + try { + video.pause(); + video.src = ""; + video.load(); + } catch { + // Ignore media element teardown errors during export fallback. + } + } + + private clearWebcamMediaElement(): void { + if (this.webcamVideoElement) { + this.disposeWebcamMediaElement(this.webcamVideoElement); + } + + this.webcamVideoElement = null; + this.webcamSeekPromise = null; + this.cleanupWebcamSource?.(); + this.cleanupWebcamSource = null; + } + + private async loadWebcamMediaElementSource(webcamUrl: string): Promise { + this.clearWebcamMediaElement(); + + let webcamSource: Awaited>; + try { + webcamSource = await resolveMediaElementSource(webcamUrl); + } catch (error) { + console.warn("[FrameRenderer] Unable to resolve webcam media element source:", error); + return false; + } + this.cleanupWebcamSource = webcamSource.revoke; + + const video = document.createElement("video"); + video.src = webcamSource.src; + video.muted = true; + video.preload = "auto"; + video.playsInline = true; + video.load(); + + const ready = await new Promise((resolve) => { + let readyTimeout: number | null = null; + const onReady = () => { + if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return; + } + cleanup(); + resolve(true); + }; + const onError = () => { + cleanup(); + resolve(false); + }; + const cleanup = () => { + video.removeEventListener("loadeddata", onReady); + video.removeEventListener("canplay", onReady); + video.removeEventListener("canplaythrough", onReady); + video.removeEventListener("error", onError); + if (readyTimeout !== null) { + window.clearTimeout(readyTimeout); + readyTimeout = null; + } + }; + + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + resolve(true); + return; + } + + video.addEventListener("loadeddata", onReady, { once: true }); + video.addEventListener("canplay", onReady, { once: true }); + video.addEventListener("canplaythrough", onReady, { once: true }); + video.addEventListener("error", onError, { once: true }); + readyTimeout = window.setTimeout(() => { + console.warn( + `[FrameRenderer] Webcam media element fallback did not become ready within ${WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS}ms`, + ); + onError(); + }, WEBCAM_MEDIA_ELEMENT_READY_TIMEOUT_MS); + }); + + if (ready && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + this.webcamVideoElement = video; + this.lastSyncedWebcamTime = null; + return true; + } + + console.warn("[FrameRenderer] Webcam overlay unavailable during export"); + this.disposeWebcamMediaElement(video); + this.clearWebcamMediaElement(); + return false; + } + private async setupWebcamSource(): Promise { const webcamUrl = this.config.webcamUrl; if (!this.config.webcam?.enabled || !webcamUrl) { @@ -2092,9 +2290,7 @@ export class FrameRenderer { void this.webcamForwardFrameSource?.destroy(); this.webcamForwardFrameSource = null; this.closeWebcamDecodedFrame(); - this.cleanupWebcamSource?.(); - this.cleanupWebcamSource = null; - this.webcamVideoElement = null; + this.clearWebcamMediaElement(); this.webcamFrameCacheCanvas = null; this.webcamFrameCacheCtx = null; this.lastSyncedWebcamTime = null; @@ -2108,8 +2304,7 @@ export class FrameRenderer { void this.webcamForwardFrameSource?.destroy(); this.webcamForwardFrameSource = null; this.closeWebcamDecodedFrame(); - this.cleanupWebcamSource?.(); - this.cleanupWebcamSource = null; + this.clearWebcamMediaElement(); this.webcamFrameCacheCanvas = null; this.webcamFrameCacheCtx = null; this.lastWebcamCacheRefreshTime = null; @@ -2132,55 +2327,7 @@ export class FrameRenderer { ); } - const webcamSource = await resolveMediaElementSource(webcamUrl); - this.cleanupWebcamSource = webcamSource.revoke; - - const video = document.createElement("video"); - video.src = webcamSource.src; - video.muted = true; - video.preload = "auto"; - video.playsInline = true; - video.load(); - - await new Promise((resolve, reject) => { - const onReady = () => { - if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - cleanup(); - resolve(); - }; - const onError = () => { - cleanup(); - reject(new Error("Failed to load webcam source for export")); - }; - const cleanup = () => { - video.removeEventListener("loadeddata", onReady); - video.removeEventListener("canplay", onReady); - video.removeEventListener("canplaythrough", onReady); - video.removeEventListener("error", onError); - }; - - if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { - resolve(); - return; - } - - video.addEventListener("loadeddata", onReady, { once: true }); - video.addEventListener("canplay", onReady, { once: true }); - video.addEventListener("canplaythrough", onReady, { once: true }); - video.addEventListener("error", onError, { once: true }); - }).catch((error) => { - console.warn("[FrameRenderer] Webcam overlay unavailable during export:", error); - this.webcamVideoElement = null; - }); - - if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { - this.webcamVideoElement = video; - return; - } - - this.webcamVideoElement = null; + await this.loadWebcamMediaElementSource(webcamUrl); this.lastSyncedWebcamTime = null; } @@ -2457,13 +2604,30 @@ export class FrameRenderer { if (this.webcamForwardFrameSource) { const clampedTime = clampMediaTimeToDuration(webcamTargetTime, null); - const decodedFrame = await this.webcamForwardFrameSource.getFrameAtTime(clampedTime); - this.closeWebcamDecodedFrame(); - this.webcamDecodedFrame = decodedFrame; - if (decodedFrame) { - this.lastSyncedWebcamTime = clampedTime; + try { + const decodedFrame = + await this.webcamForwardFrameSource.getFrameAtTime(clampedTime); + this.closeWebcamDecodedFrame(); + this.webcamDecodedFrame = decodedFrame; + if (decodedFrame) { + this.lastSyncedWebcamTime = clampedTime; + } + return; + } catch (error) { + console.warn( + "[FrameRenderer] Decoder-backed webcam source failed during export; falling back to media element sync:", + error, + ); + this.webcamForwardFrameSource.cancel(); + void this.webcamForwardFrameSource.destroy(); + this.webcamForwardFrameSource = null; + this.closeWebcamDecodedFrame(); + this.lastSyncedWebcamTime = null; + const webcamUrl = this.config.webcamUrl; + if (!webcamUrl || !(await this.loadWebcamMediaElementSource(webcamUrl))) { + return; + } } - return; } const webcamVideo = this.webcamVideoElement; diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts new file mode 100644 index 00000000..80125266 --- /dev/null +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const videoInfo = { + width: 1920, + height: 1080, + duration: 1, + streamDuration: 1, + frameRate: 30, + codec: "h264", + hasAudio: false, + audioCodec: null, + audioSampleRate: null, + }; + + return { + videoInfo, + streamingDecoderDestroy: vi.fn(), + streamingDecoderCancel: vi.fn(), + streamingDecoderDecodeAll: vi.fn(async () => {}), + streamingDecoderGetDemuxer: vi.fn(() => null), + streamingDecoderGetEffectiveDuration: vi.fn(() => 0), + streamingDecoderLoadMetadata: vi.fn(async () => videoInfo), + frameRendererDestroy: vi.fn(), + frameRendererGetBackend: vi.fn(() => "webgl"), + frameRendererInitialize: vi.fn(async () => {}), + muxerDestroy: vi.fn(), + muxerFinalize: vi.fn(async () => ({ + mode: "buffer" as const, + blob: new Blob([], { type: "video/mp4" }), + })), + muxerInitialize: vi.fn(async () => {}), + }; +}); + +vi.mock("./streamingDecoder", () => ({ + StreamingVideoDecoder: vi.fn().mockImplementation(function () { + return { + cancel: mocks.streamingDecoderCancel, + decodeAll: mocks.streamingDecoderDecodeAll, + destroy: mocks.streamingDecoderDestroy, + getDemuxer: mocks.streamingDecoderGetDemuxer, + getEffectiveDuration: mocks.streamingDecoderGetEffectiveDuration, + loadMetadata: mocks.streamingDecoderLoadMetadata, + }; + }), +})); + +vi.mock("./modernFrameRenderer", () => ({ + FrameRenderer: vi.fn().mockImplementation(function () { + return { + destroy: mocks.frameRendererDestroy, + getRendererBackend: mocks.frameRendererGetBackend, + initialize: mocks.frameRendererInitialize, + }; + }), +})); + +vi.mock("./muxer", () => ({ + VideoMuxer: vi.fn().mockImplementation(function () { + return { + destroy: mocks.muxerDestroy, + finalize: mocks.muxerFinalize, + initialize: mocks.muxerInitialize, + }; + }), +})); + +describe("ModernVideoExporter native fallback routing", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("falls back to WebCodecs instead of surfacing a native error when Breeze is unavailable", async () => { + const { ModernVideoExporter } = await import("./modernVideoExporter"); + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + experimentalNativeExport: true, + backendPreference: "breeze", + } as never) as unknown as { + export: () => Promise<{ success: boolean; blob?: Blob; error?: string }>; + initializeEncoder: () => Promise; + loadNativeStaticLayoutVideoInfo: () => Promise; + tryExportNativeStaticLayout: () => Promise; + tryStartNativeVideoExport: () => Promise; + lastNativeExportError: string | null; + }; + + vi.spyOn(exporter, "loadNativeStaticLayoutVideoInfo").mockResolvedValue(mocks.videoInfo); + vi.spyOn(exporter, "tryExportNativeStaticLayout").mockResolvedValue(null); + vi.spyOn(exporter, "tryStartNativeVideoExport").mockImplementation(async () => { + exporter.lastNativeExportError = "Breeze native encoder unavailable"; + return false; + }); + const initializeEncoder = vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640034", + hardwareAcceleration: "prefer-hardware", + }); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.blob).toBeInstanceOf(Blob); + expect(initializeEncoder).toHaveBeenCalledTimes(1); + expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1); + }, 15_000); +}); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 501dd2b9..ce8b3eae 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -346,7 +346,7 @@ export class ModernVideoExporter { const runtimePlatform = this.getRuntimePlatform(); let useNativeEncoder = false; let triedNativeStaticLayoutWithProbe = false; - const shouldDeferNativeEncoderStart = backendPreference === "breeze"; + let shouldDeferNativeEncoderStart = backendPreference === "breeze"; this.lastNativeExportError = null; let stageStartedAt = this.getNowMs(); @@ -512,10 +512,23 @@ export class ModernVideoExporter { useNativeEncoder = await this.tryStartNativeVideoExport(); this.nativeSessionStartTimeMs = this.getNowMs() - stageStartedAt; if (!useNativeEncoder) { - throw new Error( + const nativeFailure = this.lastNativeExportError ?? - `${NATIVE_EXPORT_ENGINE_NAME} export is unavailable for this output profile on this system.`, + `${NATIVE_EXPORT_ENGINE_NAME} export is unavailable for this output profile on this system.`; + console.warn( + `[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} native export unavailable after static-layout fallback; falling back to WebCodecs.`, + nativeFailure, ); + shouldDeferNativeEncoderStart = false; + this.backpressureProfile = getExportBackpressureProfile({ + encodeBackend: "webcodecs", + width: this.config.width, + height: this.config.height, + frameRate: this.config.frameRate, + encodingMode: this.config.encodingMode, + }); + this.maxNativeWriteInFlight = 1; + await this.initializeEncoder(); } } diff --git a/vite.config.ts b/vite.config.ts index 64614cdb..3dd36633 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,36 @@ import react from "@vitejs/plugin-react"; import { defineConfig, type Plugin } from "vite"; import electron from "vite-plugin-electron/simple"; +function electronMainCjsOutputPlugin(): Plugin { + return { + name: "recordly-electron-main-cjs-output", + enforce: "post", + config(config) { + // Vite mergeConfig concatenates lib.formats with the plugin's ESM default. + config.build ??= {}; + const build = config.build; + const lib = build.lib; + if (lib && typeof lib === "object") { + lib.formats = ["cjs"]; + lib.fileName = (_format, entryName) => `${entryName}.cjs`; + } + + build.rollupOptions ??= {}; + const rollupOptions = build.rollupOptions; + const cjsOutput = { + format: "cjs" as const, + inlineDynamicImports: true, + entryFileNames: "[name].cjs", + chunkFileNames: "[name]-[hash].cjs", + }; + + rollupOptions.output = Array.isArray(rollupOptions.output) + ? rollupOptions.output.map((output) => ({ ...output, ...cjsOutput })) + : { ...(rollupOptions.output ?? {}), ...cjsOutput }; + }, + }; +} + function electronMainCjsGuardPlugin(): Plugin { return { name: "recordly-electron-main-cjs-guard", @@ -37,17 +67,19 @@ export default defineConfig({ lib: { entry: "electron/main.ts", formats: ["cjs"], + fileName: (_format, entryName) => `${entryName}.cjs`, }, rollupOptions: { external: ["ffmpeg-static", "uiohook-napi"], output: { format: "cjs", + inlineDynamicImports: true, entryFileNames: "[name].cjs", - chunkFileNames: "[name].cjs", + chunkFileNames: "[name]-[hash].cjs", }, }, }, - plugins: [electronMainCjsGuardPlugin()], + plugins: [electronMainCjsOutputPlugin(), electronMainCjsGuardPlugin()], }, }, preload: {