Merge remote-tracking branch 'upstream/main' into refacto/timeline-code

This commit is contained in:
Alan Trebugeais
2026-05-09 11:30:37 +02:00
44 changed files with 2460 additions and 353 deletions
+2 -2
View File
@@ -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;
+18 -1
View File
@@ -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" },
+23 -10
View File
@@ -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";
+37
View File
@@ -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));
+14 -6
View File
@@ -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 };
});
+2 -2
View File
@@ -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"];
+70
View File
@@ -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 {" },
]);
});
});
@@ -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",
Binary file not shown.
+20 -1
View File
@@ -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,
+61 -1
View File
@@ -2,6 +2,7 @@
#include <mfapi.h>
#include <mferror.h>
#include <codecapi.h>
#include <algorithm>
#include <iostream>
#include <cstring>
@@ -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<UINT>(width_) &&
sourceDesc.Height == static_cast<UINT>(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<UINT>(width_));
sourceBox.bottom = (std::min)(sourceDesc.Height, static_cast<UINT>(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();
@@ -30,6 +30,8 @@ private:
ID3D11Device* device_ = nullptr;
ID3D11DeviceContext* context_ = nullptr;
ComPtr<ID3D11Texture2D> stagingTexture_;
ComPtr<ID3D11Texture2D> resizeCompositeTexture_;
ComPtr<ID3D11RenderTargetView> resizeCompositeView_;
std::vector<uint8_t> nv12Buffer_;
std::vector<uint8_t> lastFrameBuffer_;
DWORD streamIndex_ = 0;
@@ -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<std::chrono::duration<int64_t, std::ratio<1, 10000000>>>(timestamp).count();
@@ -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<bool> capturing_{false};
std::atomic<bool> 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);
+4 -4
View File
@@ -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);
+7 -7
View File
@@ -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,
},
+83
View File
@@ -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);
+143 -134
View File
@@ -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" }) => (
<Cursor weight="fill" className={props.className} />
@@ -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<string>(
@@ -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<SpeedRegion[]>(() => {
@@ -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,
),
);
}}
>
<SkipForward className="w-3.5 h-3.5" weight="fill" />
</Button>
<span className="text-[10px] font-medium text-muted-foreground/70 tabular-nums ml-1">
{formatTime(duration)}
{formatTime(timelineDuration)}
</span>
</div>
</div>
@@ -6585,10 +6594,10 @@ export default function VideoEditor() {
<TimelineEditor
ref={timelineRef}
hideToolbar
videoDuration={duration}
videoDuration={timelineDuration}
currentTime={currentTime}
playheadTime={timelinePlayheadTime}
onSeek={handleSeek}
onSeek={handleTimelineSeek}
videoPath={videoPath}
cursorTelemetry={normalizedCursorTelemetry}
autoSuggestZoomsTrigger={autoSuggestZoomsTrigger}
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
describe("calculateMp4SourceDimensions", () => {
it("keeps native exports at the source dimensions", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "native")).toEqual({
width: 1920,
height: 1080,
});
});
it("uses the rotated source bounds for 9:16 original exports", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "9:16")).toEqual({
width: 1080,
height: 1920,
});
});
it("uses the rotated source bounds for portrait social ratios", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "4:5")).toEqual({
width: 1080,
height: 1350,
});
});
it("keeps landscape aspect-ratio exports inside the source bounds", () => {
expect(calculateMp4SourceDimensions(1920, 1080, "4:3")).toEqual({
width: 1440,
height: 1080,
});
});
});
describe("calculateMp4ExportDimensions", () => {
it("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,
});
});
});
@@ -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),
};
}
@@ -107,6 +107,7 @@ export default function Item({
style={safeItemStyle}
{...listeners}
{...attributes}
data-timeline-item="true"
onPointerDownCapture={handleSelect}
className="group h-full"
>
+22 -1
View File
@@ -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);
});
});
+12
View File
@@ -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);
}
@@ -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";
@@ -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<number | null>(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({
+68
View File
@@ -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(() => {
+9 -5
View File
@@ -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);
}
+26
View File
@@ -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": "Произошла ошибка при чтении файла."
}
}
+62
View File
@@ -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": "Уже используется в <strong>{{action}}</strong>",
"swap": "Заменить",
"reserved": "Это сочетание клавиш зарезервировано для \"{{label}}\" – его нельзя изменить.",
"saved": "Сочетания клавиш сохранены",
"resetNotice": "Сброс к настройкам по умолчанию — нажмите «Сохранить»",
"instructions": "Выберите сочетание и нажмите новую комбинацию клавиш. Нажмите Esc для отмены.",
"resetToDefaults": "Сбросить по умолчанию",
"cancel": "Отмена",
"save": "Сохранить"
}
}
+142
View File
@@ -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": "Открыть папку с записями"
}
+60
View File
@@ -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": "Не удалось включить расширение"
}
}
+80
View File
@@ -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": "Не удалось начать запись"
}
}
+205
View File
@@ -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"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"actions": {
"addZoom": "Добавить зум",
"addTrim": "Обрезать",
"addSpeed": "Увеличить скорость",
"addAnnotation": "Добавить аннотацию",
"addKeyframe": "Добавить ключ",
"deleteSelected": "Удалить выбранное",
"playPause": "Воспроизведение / Пауза",
"cycleForward": "Следующая аннотация",
"cycleBackward": "Предыдущая аннотация",
"deleteSelectedAlt": "Удалить выбранное (alt)",
"panTimeline": "Прокрутка таймлайна",
"zoomTimeline": "Масштабирование таймлайна"
}
}
+41
View File
@@ -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": "Изменить размер справа"
}
+54
View File
@@ -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");
});
});
+46
View File
@@ -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.`;
}
+56 -1
View File
@@ -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<void>;
syncBackgroundFrame: (timeSeconds: number) => Promise<void>;
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();
});
});
+150 -44
View File
@@ -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<void>((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<boolean> {
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<boolean> {
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<ReturnType<typeof resolveMediaElementSource>>;
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<boolean>((resolve) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | 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<void> {
const video = this.backgroundVideoElement;
if (!video) return;
+238 -1
View File
@@ -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();
}
});
});
+286 -122
View File
@@ -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<void>((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<boolean> {
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<boolean> {
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<ReturnType<typeof resolveMediaElementSource>>;
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<boolean>((resolve) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | 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<boolean> {
this.clearWebcamMediaElement();
let webcamSource: Awaited<ReturnType<typeof resolveMediaElementSource>>;
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<boolean>((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<void> {
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<void>((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;
@@ -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<unknown>;
loadNativeStaticLayoutVideoInfo: () => Promise<unknown>;
tryExportNativeStaticLayout: () => Promise<unknown>;
tryStartNativeVideoExport: () => Promise<boolean>;
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);
});
+16 -3
View File
@@ -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();
}
}
+34 -2
View File
@@ -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: {