Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8194aff
fix: prepare video validation before recording
thymikee Nov 3, 2026
7e440f1
fix: warm video validation without delaying capture
thymikee Nov 3, 2026
629a9fb
fix(recording): retry failed validator preparation
thymikee Nov 3, 2026
5b87b59
fix(recording): retain preparation failures for stop diagnostics
thymikee Nov 3, 2026
a5c82f5
fix(recording): distinguish overlay verification budget warnings
thymikee Nov 3, 2026
49b95be
fix(recording): recover prepared validator after failed warmup
thymikee Nov 3, 2026
d853858
fix(recording): preserve non-macOS MP4 validation
thymikee Nov 3, 2026
c2e2d27
test(android): observe settled Back transition in smoke
thymikee Nov 3, 2026
79470c8
test(recording): reject Swift use on non-macOS hosts
thymikee Nov 3, 2026
776aab8
test(android): diff confirmed field replacements
thymikee Nov 3, 2026
6829cbf
fix(recording): trust verified overlay publication
thymikee Nov 3, 2026
d8bc80e
test(recording): require overlay invocation at budget boundary
thymikee Nov 3, 2026
3e988a8
test(apple): await launch lookup spawn explicitly
thymikee Nov 3, 2026
e6b8b20
test(apple): use supported spawn signal promise
thymikee Nov 3, 2026
9bf6505
fix(recording): clarify overlay budget and fixture output
thymikee Nov 3, 2026
b92d87d
test(apple): bound launch lookup spawn assertion
thymikee Nov 3, 2026
e5cde02
docs(recording): state unsupported overlay artifacts
thymikee Nov 3, 2026
0ad30c8
fix(recording): close the validator compile on daemon shutdown
thymikee Nov 3, 2026
5a8515f
fix(recording): preserve shutdown cleanup failures
thymikee Nov 3, 2026
4394ea2
fix(shutdown): retain cleanup failure evidence
thymikee Nov 3, 2026
7ff691c
test(recording): cover finalizer deadline during preparation
thymikee Nov 3, 2026
7f15773
test: constrain fake Swift compiler invocation
thymikee Nov 3, 2026
837491f
test: cover concurrent validator cache owners
thymikee Nov 3, 2026
8566ffd
test: bound validator isolation cleanup
thymikee Nov 3, 2026
693d5fd
test: bound cache isolation failure cleanup
thymikee Nov 3, 2026
f026759
test: always remove validator isolation cache
thymikee Nov 3, 2026
e83a593
test: bound stalled validator owner close
thymikee Nov 3, 2026
b1150a8
test: bound stalled cleanup assertion
thymikee Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ test('a helper script path is resolved per host working directory, not once per
const overlay = async (cwd: string) => {
fs.writeFileSync(videoPath, 'original');
await overlayRecordingTouches({
validator: undefined,
host: { env: {}, cwd },
videoPath,
telemetryPath,
Expand Down
38 changes: 35 additions & 3 deletions packages/capture-kit/src/recording/__tests__/overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,26 @@ afterEach(() => {
});

test('overlay burns touches through a cached helper and same-directory temp output', async () => {
const validator = {
kind: 'avfoundation' as const,
executablePath: '/prepared/video-validator',
env: {},
};
const videoPath = path.join(tmpDir, 'recording.mp4');
const telemetryPath = path.join(tmpDir, 'recording.gesture-telemetry.json');
fs.writeFileSync(videoPath, 'original');
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
validator,
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
});
fs.writeFileSync(videoPath, 'second original');
await overlayRecordingTouches({
validator,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -84,6 +91,7 @@ test('overlay burns touches through a cached helper and same-directory temp outp

expect(compileCalls).toHaveLength(1);
expect(helperCalls).toHaveLength(2);
expect(vi.mocked(waitForPlayableVideo).mock.calls[0]?.[1].validator).toBe(validator);

const [helperCmd, helperArgs, helperOptions] = helperCalls[0]!;
const inputPath = helperArgs[helperArgs.indexOf('--input') + 1]!;
Expand Down Expand Up @@ -113,6 +121,7 @@ test('overlay preserves Swift helper compile hints', async () => {

await expect(
overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -134,6 +143,7 @@ test('overlay defaults to the fast medium export preset', async () => {
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -152,6 +162,7 @@ test('overlay forwards the requested high export preset', async () => {
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -171,6 +182,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -180,6 +192,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask

mockRunCmd.mockClear();
await overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -197,6 +210,7 @@ test('overlay hands the helper what is left of its budget, inside the record req
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand Down Expand Up @@ -225,7 +239,13 @@ test('overlay hands the helper only what the finalization left of the budget', a
const deadline = startRecordingOverlayBudget();
vi.setSystemTime(Date.now() + spentMs);

await overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline });
await overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
deadline,
});

const helperCall = mockRunCmd.mock.calls.find(([cmd]) => cmd !== 'xcrun');
expect(helperCall?.[2]?.timeoutMs).toBe(OVERLAY_BUDGET_MS - spentMs);
Expand All @@ -248,6 +268,7 @@ test('overlay leaves the video as recorded when compiling the helper spent its b

await expect(
overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -269,6 +290,7 @@ test('overlay names its budget when the export ran out of it', async () => {
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -282,6 +304,7 @@ test('overlay names its budget when the export ran out of it', async () => {

await expect(
overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -301,6 +324,7 @@ test('overlay keeps the plain failure when the export failed with budget to spar
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -312,6 +336,7 @@ test('overlay keeps the plain failure when the export failed with budget to spar

await expect(
overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -331,6 +356,7 @@ test('overlay keeps the video as recorded when the export never plays within its

await expect(
overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
Expand All @@ -356,13 +382,19 @@ test('overlay keeps the video as recorded when verifying the export spent its bu
});

await expect(
overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline }),
overlayRecordingTouches({
validator: undefined,
host: createTestHost(),
videoPath,
telemetryPath,
deadline,
}),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: `Failed to add touch overlays to the recording: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`,
});
expect(vi.mocked(waitForPlayableVideo)).toHaveBeenCalledWith(expect.any(String), {
host: expect.objectContaining({ cwd: process.cwd() }),
validator: undefined,
deadline,
});
expect(fs.readFileSync(videoPath, 'utf8')).toBe('original');
Expand Down
13 changes: 8 additions & 5 deletions packages/capture-kit/src/recording/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
compileSwiftSourceFile,
resolveRecordingScriptPath,
} from './swift-cache.ts';
import { waitForPlayableVideo } from './video.ts';
import { waitForPlayableVideo, type VideoValidator } from './video.ts';
import {
DEFAULT_RECORDING_EXPORT_QUALITY,
type RecordingExportQuality,
Expand All @@ -29,9 +29,9 @@ export function getRecordingOverlaySupportWarning(
* `record stop` is one daemon request, which the client abandons after `record`'s 90 s envelope:
* the caller gets "Daemon request timed out" while the daemon finishes the stop. So the overlay must
* end well inside it. The budget starts with the stop's finalization, so the playability checks
* before the overlay (on a cold host they compile their validator first) come out of it, as do
* compiling the helper, its export, its exit and verifying its output; past it the stop keeps the raw video and its
* gesture telemetry, with a warning.
* before the overlay come out of it, as do compiling the overlay helper, exporting, exiting and
* verifying its output. Past the budget, the stop keeps the raw video and gesture telemetry, with
* a warning.
*/
export const OVERLAY_BUDGET_MS = 70_000;

Expand Down Expand Up @@ -79,6 +79,7 @@ async function exportProcessedVideo(params: {
scriptArgs: string[];
commandDescription: string;
deadline: Deadline;
validator: VideoValidator | undefined;
}): Promise<void> {
const { host, videoPath, scriptPath, scriptArgs, commandDescription, deadline } = params;
const outputPath = temporarySiblingVideoPath(videoPath);
Expand Down Expand Up @@ -117,7 +118,7 @@ async function exportProcessedVideo(params: {
exportOverran = deadline.remainingMs() <= HELPER_EXIT_GRACE_MS;
throw error;
}
if (!(await waitForPlayableVideo(outputPath, { host, deadline }))) {
if (!(await waitForPlayableVideo(outputPath, { validator: params.validator, deadline }))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nit · Overlay discards a successfully exported composite when validator preparation is still pending

finalizeScreenRecording reads the preparation once (const prepared = validator.read()) and hands that same promise to waitForPlayableVideo both for the raw check and, through overlayTouches, to exportProcessedVideo. The predecessor verified the composited output with the host (waitForPlayableVideo(outputPath, { host, deadline })), which compiled the validator on demand, so the check always had an authority by the time the export finished.

Suggested change
if (!(await waitForPlayableVideo(outputPath, { validator: params.validator, deadline }))) {
if (!(await waitForPlayableVideo(outputPath, { validator: params.validator, deadline }))) {
exportOverran = deadline.isExpired();
throw new AppError(
'COMMAND_FAILED',
isVideoValidatorNotPreparedError(error) ? error.message : 'The composited video was not verified as playable',
);
}

Suggested change

Do not reuse the pending preparation promise as the authority for verifying the composited output. Either re-read validator.read() after the export (so an executable published during the export is used), or skip the composite verification with an explicit typed reason instead of deleting a composite the helper already produced.

Details

Now, after a long overlay export the deadline has little left, and isPlayableVideo races the still-pending preparation promise against deadline.remainingMs(). If preparation has not settled within what the overlay left of OVERLAY_BUDGET_MS, the race rejects with video_validator_not_prepared, exportProcessedVideo treats it as The composited video was not verified as playable, deletes the exported file in its finally, and the stop returns the raw video with an overlayWarning — even though the helper exported the composite successfully. The same happens when read() answered undefined (nothing prepared and nothing published): the raw-video check in finalizeScreenRecording throws out of complete, so showTouches stops can only ever keep the raw video.

The verified composite is thrown away for a reason unrelated to the export, and the user-visible fix (retry stop) re-runs the whole export.

persona: javascript

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

exportOverran = deadline.isExpired();
throw new AppError('COMMAND_FAILED', 'The composited video was not verified as playable');
}
Expand Down Expand Up @@ -158,6 +159,7 @@ function temporarySiblingVideoPath(videoPath: string): string {

export async function overlayRecordingTouches(params: {

@apex-sentinel-staging apex-sentinel-staging Bot Nov 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer raised on 776aab8.

🆕 🔵 Nit · Inconsistent validator parameter: explicitly undefined in 14 test call sites while TypeScript infers it

The overlayRecordingTouches signature adds validator: VideoValidator | undefined as a required parameter (not optional), which forces every call site to pass it. In overlay.test.ts, 14 call sites add validator: undefined where the value is unused. If the parameter were optional (validator?: VideoValidator), existing callers would not need a mechanical change and the tests that exercise the validator path would stand out. The required-but-always-undefined pattern makes the diff larger and hides the two call sites that actually pass a real validator.

Suggested change
export async function overlayRecordingTouches(params: {
export async function overlayRecordingTouches(params: {
host: HostEnvironment;
validator?: VideoValidator;
videoPath: string;
Details

persona: maintainability

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

host: HostEnvironment;
validator: VideoValidator | undefined;
videoPath: string;
telemetryPath: string;
exportQuality?: RecordingExportQuality;
Expand Down Expand Up @@ -186,5 +188,6 @@ export async function overlayRecordingTouches(params: {
],
commandDescription: `Failed to add touch overlays to the ${targetLabel}`,
deadline: params.deadline,
validator: params.validator,
});
}
Loading
Loading