Merge branch 'main' into codex/editor-defaults-ssot
This commit is contained in:
@@ -61,6 +61,7 @@ import {
|
||||
DEFAULT_SOURCE_DIMENSIONS,
|
||||
DEFAULT_WEBCAM_SETTINGS,
|
||||
} from "./editorDefaults";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
@@ -79,6 +80,7 @@ import type {
|
||||
} from "./types";
|
||||
import {
|
||||
MAX_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
MAX_ZOOM_SCALE,
|
||||
MIN_ZOOM_SCALE,
|
||||
ROTATION_3D_PRESET_ORDER,
|
||||
@@ -97,37 +99,38 @@ function CustomSpeedInput({
|
||||
onError: () => void;
|
||||
}) {
|
||||
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(value));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const prevValue = useRef(value);
|
||||
if (!isFocused && prevValue.current !== value) {
|
||||
prevValue.current = value;
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const digits = e.target.value.replace(/\D/g, "");
|
||||
if (digits === "") {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
const num = Number(digits);
|
||||
if (num > MAX_PLAYBACK_SPEED) {
|
||||
const result = parseCustomPlaybackSpeedInput(e.target.value);
|
||||
if (result.status === "too-fast") {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
setDraft(digits);
|
||||
if (num >= 1) onChange(num);
|
||||
|
||||
setDraft(result.draft);
|
||||
if (result.status === "valid") {
|
||||
onChange(result.speed);
|
||||
}
|
||||
},
|
||||
[onChange, onError],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (!draft || Number(draft) < 1) {
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
const result = parseCustomPlaybackSpeedInput(draft);
|
||||
if (result.status === "valid") {
|
||||
setDraft(String(result.speed));
|
||||
} else {
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
}, [draft, isPreset, value]);
|
||||
|
||||
@@ -135,8 +138,8 @@ function CustomSpeedInput({
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*[.]?[0-9]*"
|
||||
placeholder="--"
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
|
||||
describe("parseCustomPlaybackSpeedInput", () => {
|
||||
it("accepts decimal playback speeds", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a single decimal point while typing", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.23",
|
||||
speed: 1.23,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows sub-1 custom speeds down to the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "0.1",
|
||||
speed: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds below the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({
|
||||
status: "too-slow",
|
||||
draft: "0.09",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts comma decimal input by normalizing to a dot", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds above the editor maximum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({
|
||||
status: "too-fast",
|
||||
draft: "16.1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
clampPlaybackSpeed,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type PlaybackSpeed,
|
||||
} from "./types";
|
||||
|
||||
export type CustomPlaybackSpeedInputResult =
|
||||
| { status: "empty"; draft: string }
|
||||
| { status: "too-fast"; draft: string }
|
||||
| { status: "too-slow"; draft: string }
|
||||
| { status: "valid"; draft: string; speed: PlaybackSpeed };
|
||||
|
||||
export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult {
|
||||
const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, "");
|
||||
const [whole = "", ...fractionParts] = decimalDraft.split(".");
|
||||
const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole;
|
||||
|
||||
if (draft === "" || draft === ".") {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
const speed = Number(draft);
|
||||
if (!Number.isFinite(speed)) {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
if (speed > MAX_PLAYBACK_SPEED) {
|
||||
return { status: "too-fast", draft };
|
||||
}
|
||||
|
||||
if (speed < MIN_PLAYBACK_SPEED) {
|
||||
return { status: "too-slow", draft };
|
||||
}
|
||||
|
||||
return { status: "valid", draft, speed: clampPlaybackSpeed(speed) };
|
||||
}
|
||||
+137
-49
@@ -84,6 +84,7 @@ type RecorderHandle = {
|
||||
type NativeWindowsRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
webcamRecorder: RecorderHandle | null;
|
||||
};
|
||||
|
||||
type NativeMacRecordingHandle = {
|
||||
@@ -422,58 +423,105 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
[cursorCaptureMode, teardownMedia],
|
||||
);
|
||||
|
||||
const finalizeNativeWindowsRecording = useCallback(async (discard = false) => {
|
||||
const activeNativeRecording = nativeWindowsRecording.current;
|
||||
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeNativeRecording.finalizing = true;
|
||||
|
||||
const clearNativeRecordingState = () => {
|
||||
nativeWindowsRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
|
||||
if (discard || result.discarded) {
|
||||
clearNativeRecordingState();
|
||||
return true;
|
||||
const finalizeNativeWindowsRecording = useCallback(
|
||||
async (discard = false) => {
|
||||
const activeNativeRecording = nativeWindowsRecording.current;
|
||||
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
||||
return false;
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error("Failed to stop native Windows recording:", result.error);
|
||||
toast.error(result.error ?? "Failed to stop native Windows recording");
|
||||
|
||||
activeNativeRecording.finalizing = true;
|
||||
const activeWebcamRecorder = activeNativeRecording.webcamRecorder;
|
||||
const duration = Math.max(0, getRecordingDurationMs());
|
||||
if (
|
||||
activeWebcamRecorder?.recorder.state === "recording" ||
|
||||
activeWebcamRecorder?.recorder.state === "paused"
|
||||
) {
|
||||
try {
|
||||
activeWebcamRecorder.recorder.stop();
|
||||
} catch {
|
||||
// Recorder may already be stopping.
|
||||
}
|
||||
}
|
||||
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
|
||||
webcamRecorder.current = null;
|
||||
}
|
||||
|
||||
const clearNativeRecordingState = () => {
|
||||
nativeWindowsRecording.current = null;
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
|
||||
if (discard || result.discarded) {
|
||||
clearNativeRecordingState();
|
||||
return true;
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error("Failed to stop native Windows recording:", result.error);
|
||||
toast.error(result.error ?? "Failed to stop native Windows recording");
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nativeScreenPath = result.session?.screenVideoPath ?? result.path;
|
||||
let storedSession = result.session;
|
||||
if (activeWebcamRecorder && nativeScreenPath) {
|
||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
||||
const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath);
|
||||
if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) {
|
||||
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||
const nativeScreenFileName =
|
||||
nativeScreenPath.split(/[\\/]/).pop() ??
|
||||
`${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`;
|
||||
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
||||
const stored = await window.electronAPI.storeRecordedSession({
|
||||
screen: {
|
||||
videoData: screenRead.data,
|
||||
fileName: nativeScreenFileName,
|
||||
},
|
||||
webcam: {
|
||||
videoData: await fixedWebcamBlob.arrayBuffer(),
|
||||
fileName: webcamFileName,
|
||||
},
|
||||
createdAt: activeNativeRecording.recordingId,
|
||||
cursorCaptureMode,
|
||||
});
|
||||
if (stored.success && stored.session) {
|
||||
storedSession = stored.session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearNativeRecordingState();
|
||||
if (storedSession) {
|
||||
await window.electronAPI.setCurrentRecordingSession(storedSession);
|
||||
} else if (result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving native Windows recording:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save native Windows recording",
|
||||
);
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
} finally {
|
||||
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
||||
discardRecordingId.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
clearNativeRecordingState();
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
} else if (result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
}
|
||||
|
||||
await window.electronAPI.switchToEditor();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error saving native Windows recording:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save native Windows recording",
|
||||
);
|
||||
activeNativeRecording.finalizing = false;
|
||||
return true;
|
||||
} finally {
|
||||
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
||||
discardRecordingId.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[cursorCaptureMode, getRecordingDurationMs],
|
||||
);
|
||||
|
||||
const finalizeNativeMacRecording = useCallback(
|
||||
async (discard = false) => {
|
||||
@@ -716,6 +764,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const isCountdownRunActive = (runId?: number) =>
|
||||
runId === undefined || countdownRunId.current === runId;
|
||||
|
||||
const waitForWebcamReady = async () => {
|
||||
if (webcamReady.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (webcamReady.current) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
};
|
||||
|
||||
const startNativeWindowsRecordingIfAvailable = async (
|
||||
selectedSource: ProcessedDesktopSource,
|
||||
countdownRunToken?: number,
|
||||
@@ -748,6 +815,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
|
||||
const windowHandle = parseWindowHandleFromSourceId(selectedSource.id);
|
||||
if (webcamEnabled) {
|
||||
await waitForWebcamReady();
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const browserWebcamRecorder =
|
||||
webcamEnabled && webcamStream.current
|
||||
? createRecorderHandle(webcamStream.current, {
|
||||
mimeType: selectMimeType(),
|
||||
videoBitsPerSecond: BITRATE_BASE,
|
||||
})
|
||||
: null;
|
||||
if (webcamEnabled && !browserWebcamRecorder) {
|
||||
stopWebcamPreviewStream();
|
||||
}
|
||||
const request: NativeWindowsRecordingRequest = {
|
||||
@@ -775,7 +855,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
},
|
||||
},
|
||||
webcam: {
|
||||
enabled: webcamEnabled,
|
||||
enabled: webcamEnabled && !browserWebcamRecorder,
|
||||
deviceId: webcamDeviceId,
|
||||
deviceName: webcamDeviceName,
|
||||
width: WEBCAM_TARGET_WIDTH,
|
||||
@@ -788,6 +868,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
const result = await window.electronAPI.startNativeWindowsRecording(request);
|
||||
if (!result.success || !result.recordingId) {
|
||||
if (
|
||||
browserWebcamRecorder?.recorder.state === "recording" ||
|
||||
browserWebcamRecorder?.recorder.state === "paused"
|
||||
) {
|
||||
browserWebcamRecorder.recorder.stop();
|
||||
}
|
||||
throw new Error(result.error ?? "Native Windows capture failed.");
|
||||
}
|
||||
|
||||
@@ -795,7 +881,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
nativeWindowsRecording.current = {
|
||||
recordingId: result.recordingId,
|
||||
finalizing: false,
|
||||
webcamRecorder: browserWebcamRecorder,
|
||||
};
|
||||
webcamRecorder.current = browserWebcamRecorder;
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
|
||||
@@ -104,8 +104,9 @@
|
||||
"gifButton": "Esporta GIF",
|
||||
"chooseSaveLocation": "Scegli posizione di salvataggio"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Segnala bug",
|
||||
"saveDiagnostics": "Salva dati diagnostici",
|
||||
"starOnGithub": "Metti stella su GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
@@ -11,9 +11,9 @@ import type {
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import type { CursorRecordingData } from "@/native/contracts";
|
||||
import { getPlatform } from "@/utils/platformUtils";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
import { FrameRenderer } from "./frameRenderer";
|
||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||
import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue";
|
||||
import type {
|
||||
ExportProgress,
|
||||
ExportResult,
|
||||
@@ -124,7 +124,7 @@ export class GifExporter {
|
||||
}
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||
let webcamFrameQueue: TimestampedVideoFrameQueue | null = null;
|
||||
|
||||
const warnings: string[] = [];
|
||||
const onWarning = (message: string) => warnings.push(message);
|
||||
@@ -216,7 +216,7 @@ export class GifExporter {
|
||||
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
||||
|
||||
let frameIndex = 0;
|
||||
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
|
||||
webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null;
|
||||
let stopWebcamDecode = false;
|
||||
let webcamDecodeError: Error | null = null;
|
||||
const webcamDecodePromise =
|
||||
@@ -228,7 +228,7 @@ export class GifExporter {
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
async (webcamFrame) => {
|
||||
async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => {
|
||||
while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export class GifExporter {
|
||||
webcamFrame.close();
|
||||
return;
|
||||
}
|
||||
queue.enqueue(webcamFrame);
|
||||
queue.enqueue(webcamFrame, webcamSourceTimestampMs);
|
||||
},
|
||||
onWarning,
|
||||
)
|
||||
@@ -266,7 +266,9 @@ export class GifExporter {
|
||||
return;
|
||||
}
|
||||
|
||||
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
|
||||
webcamFrame = webcamFrameQueue
|
||||
? await webcamFrameQueue.frameAt(sourceTimestampMs)
|
||||
: null;
|
||||
const renderer = this.renderer;
|
||||
if (this.cancelled || !renderer) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue";
|
||||
|
||||
class MockVideoFrame {
|
||||
timestamp: number;
|
||||
closed = false;
|
||||
|
||||
constructor(source: MockVideoFrame | number) {
|
||||
this.timestamp = typeof source === "number" ? source : source.timestamp;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreVideoFrame(originalVideoFrame: typeof globalThis.VideoFrame | undefined) {
|
||||
if (originalVideoFrame === undefined) {
|
||||
delete (globalThis as { VideoFrame?: typeof globalThis.VideoFrame }).VideoFrame;
|
||||
return;
|
||||
}
|
||||
|
||||
vi.stubGlobal("VideoFrame", originalVideoFrame);
|
||||
}
|
||||
|
||||
describe("TimestampedVideoFrameQueue", () => {
|
||||
it("samples the latest webcam frame at or before the requested source timestamp", async () => {
|
||||
const originalVideoFrame = globalThis.VideoFrame;
|
||||
vi.stubGlobal("VideoFrame", MockVideoFrame);
|
||||
try {
|
||||
const queue = new TimestampedVideoFrameQueue();
|
||||
const frame0 = new MockVideoFrame(0) as unknown as VideoFrame;
|
||||
const frame33 = new MockVideoFrame(33_000) as unknown as VideoFrame;
|
||||
const frame66 = new MockVideoFrame(66_000) as unknown as VideoFrame;
|
||||
|
||||
queue.enqueue(frame0, 0);
|
||||
queue.enqueue(frame33, 33);
|
||||
queue.enqueue(frame66, 66);
|
||||
queue.close();
|
||||
|
||||
const sampled0 = await queue.frameAt(0);
|
||||
const sampled20 = await queue.frameAt(20);
|
||||
const sampled40 = await queue.frameAt(40);
|
||||
const sampled80 = await queue.frameAt(80);
|
||||
|
||||
expect(sampled0?.timestamp).toBe(0);
|
||||
expect(sampled20?.timestamp).toBe(0);
|
||||
expect(sampled40?.timestamp).toBe(33_000);
|
||||
expect(sampled80?.timestamp).toBe(66_000);
|
||||
|
||||
sampled0?.close();
|
||||
sampled20?.close();
|
||||
sampled40?.close();
|
||||
sampled80?.close();
|
||||
queue.destroy();
|
||||
} finally {
|
||||
restoreVideoFrame(originalVideoFrame);
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for a newer frame before falling back to the held frame while open", async () => {
|
||||
const originalVideoFrame = globalThis.VideoFrame;
|
||||
vi.stubGlobal("VideoFrame", MockVideoFrame);
|
||||
try {
|
||||
const queue = new TimestampedVideoFrameQueue();
|
||||
const frame0 = new MockVideoFrame(0) as unknown as VideoFrame;
|
||||
const frame33 = new MockVideoFrame(33_000) as unknown as VideoFrame;
|
||||
|
||||
queue.enqueue(frame0, 0);
|
||||
const sampled0 = await queue.frameAt(0);
|
||||
let resolved = false;
|
||||
const pending = queue.frameAt(33).then((frame) => {
|
||||
resolved = true;
|
||||
return frame;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
queue.enqueue(frame33, 33);
|
||||
const sampled33 = await pending;
|
||||
|
||||
expect(sampled0?.timestamp).toBe(0);
|
||||
expect(sampled33?.timestamp).toBe(33_000);
|
||||
|
||||
sampled0?.close();
|
||||
sampled33?.close();
|
||||
queue.destroy();
|
||||
} finally {
|
||||
restoreVideoFrame(originalVideoFrame);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
type TimestampedVideoFrame = {
|
||||
frame: VideoFrame;
|
||||
sourceTimestampMs: number;
|
||||
};
|
||||
|
||||
type PendingConsumer = {
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
const TIMESTAMP_EPSILON_MS = 0.5;
|
||||
|
||||
export class TimestampedVideoFrameQueue {
|
||||
private frames: TimestampedVideoFrame[] = [];
|
||||
private consumers: PendingConsumer[] = [];
|
||||
private error: Error | null = null;
|
||||
private closed = false;
|
||||
private heldFrame: TimestampedVideoFrame | null = null;
|
||||
|
||||
get length() {
|
||||
return this.frames.length;
|
||||
}
|
||||
|
||||
enqueue(frame: VideoFrame, sourceTimestampMs: number) {
|
||||
if (this.closed) {
|
||||
frame.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.frames.push({ frame, sourceTimestampMs });
|
||||
const consumers = this.consumers.splice(0);
|
||||
for (const consumer of consumers) {
|
||||
consumer.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
fail(error: Error) {
|
||||
this.error = error;
|
||||
this.closed = true;
|
||||
const consumers = this.consumers.splice(0);
|
||||
for (const consumer of consumers) {
|
||||
consumer.reject(error);
|
||||
}
|
||||
this.closeOwnedFrames();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
const consumers = this.consumers.splice(0);
|
||||
for (const consumer of consumers) {
|
||||
consumer.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
async frameAt(sourceTimestampMs: number): Promise<VideoFrame | null> {
|
||||
for (;;) {
|
||||
if (this.error) {
|
||||
throw this.error;
|
||||
}
|
||||
|
||||
const next = this.frames[0] ?? null;
|
||||
if (next && next.sourceTimestampMs <= sourceTimestampMs + TIMESTAMP_EPSILON_MS) {
|
||||
this.replaceHeldFrame(this.frames.shift() ?? null);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.heldFrame &&
|
||||
(next ||
|
||||
this.closed ||
|
||||
this.heldFrame.sourceTimestampMs >= sourceTimestampMs - TIMESTAMP_EPSILON_MS)
|
||||
) {
|
||||
return new VideoFrame(this.heldFrame.frame, {
|
||||
timestamp: this.heldFrame.frame.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
if (next || this.closed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.consumers.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.close();
|
||||
this.closeOwnedFrames();
|
||||
}
|
||||
|
||||
private replaceHeldFrame(frame: TimestampedVideoFrame | null) {
|
||||
if (this.heldFrame) {
|
||||
this.heldFrame.frame.close();
|
||||
}
|
||||
this.heldFrame = frame;
|
||||
}
|
||||
|
||||
private closeOwnedFrames() {
|
||||
if (this.heldFrame) {
|
||||
this.heldFrame.frame.close();
|
||||
this.heldFrame = null;
|
||||
}
|
||||
for (const item of this.frames) {
|
||||
item.frame.close();
|
||||
}
|
||||
this.frames = [];
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import type {
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import type { CursorRecordingData } from "@/native/contracts";
|
||||
import { getPlatform } from "@/utils/platformUtils";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
import { AudioProcessor } from "./audioEncoder";
|
||||
import { FrameRenderer } from "./frameRenderer";
|
||||
import { VideoMuxer } from "./muxer";
|
||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||
import { TimestampedVideoFrameQueue } from "./timestampedVideoFrameQueue";
|
||||
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
|
||||
|
||||
const ENCODER_STALL_TIMEOUT_MS = 15_000;
|
||||
@@ -195,7 +195,7 @@ export class VideoExporter {
|
||||
private async exportWithEncoderPreference(
|
||||
encoderPreference: HardwareAcceleration,
|
||||
): Promise<ExportResult> {
|
||||
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||
let webcamFrameQueue: TimestampedVideoFrameQueue | null = null;
|
||||
let stopWebcamDecode = false;
|
||||
let webcamDecodeError: Error | null = null;
|
||||
let webcamDecodePromise: Promise<void> | null = null;
|
||||
@@ -290,7 +290,7 @@ export class VideoExporter {
|
||||
? Math.min(this.MAX_ENCODE_QUEUE, 32)
|
||||
: this.MAX_ENCODE_QUEUE;
|
||||
|
||||
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
|
||||
webcamFrameQueue = this.config.webcamVideoUrl ? new TimestampedVideoFrameQueue() : null;
|
||||
webcamDecodePromise =
|
||||
webcamDecoder && webcamFrameQueue
|
||||
? (() => {
|
||||
@@ -300,7 +300,7 @@ export class VideoExporter {
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
async (webcamFrame) => {
|
||||
async (webcamFrame, _exportTimestampUs, webcamSourceTimestampMs) => {
|
||||
while (queue.length >= 12 && !this.cancelled && !stopWebcamDecode) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
@@ -308,7 +308,7 @@ export class VideoExporter {
|
||||
webcamFrame.close();
|
||||
return;
|
||||
}
|
||||
queue.enqueue(webcamFrame);
|
||||
queue.enqueue(webcamFrame, webcamSourceTimestampMs);
|
||||
},
|
||||
onWarning,
|
||||
)
|
||||
@@ -342,7 +342,9 @@ export class VideoExporter {
|
||||
}
|
||||
|
||||
const timestamp = frameIndex * frameDuration;
|
||||
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
|
||||
webcamFrame = webcamFrameQueue
|
||||
? await webcamFrameQueue.frameAt(sourceTimestampMs)
|
||||
: null;
|
||||
if (this.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user