mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
Merge remote-tracking branch 'upstream/main' into refacto/timeline-code
This commit is contained in:
Vendored
+2
-2
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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"];
|
||||
|
||||
@@ -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.
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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": "Произошла ошибка при чтении файла."
|
||||
}
|
||||
}
|
||||
@@ -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": "Сохранить"
|
||||
}
|
||||
}
|
||||
@@ -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": "Открыть папку с записями"
|
||||
}
|
||||
@@ -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": "Не удалось включить расширение"
|
||||
}
|
||||
}
|
||||
@@ -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": "Не удалось начать запись"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"actions": {
|
||||
"addZoom": "Добавить зум",
|
||||
"addTrim": "Обрезать",
|
||||
"addSpeed": "Увеличить скорость",
|
||||
"addAnnotation": "Добавить аннотацию",
|
||||
"addKeyframe": "Добавить ключ",
|
||||
"deleteSelected": "Удалить выбранное",
|
||||
"playPause": "Воспроизведение / Пауза",
|
||||
"cycleForward": "Следующая аннотация",
|
||||
"cycleBackward": "Предыдущая аннотация",
|
||||
"deleteSelectedAlt": "Удалить выбранное (alt)",
|
||||
"panTimeline": "Прокрутка таймлайна",
|
||||
"zoomTimeline": "Масштабирование таймлайна"
|
||||
}
|
||||
}
|
||||
@@ -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": "Изменить размер справа"
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user