Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions packages/capture/docs/WINDOW_CAPTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ In this mode:
viewport, and geometry generation. Recorder shutdown retains one final frame
after input has stopped, so a consumer can select an exact after-action frame
by ordinal instead of by nearest timestamp.
- **Dropped input is recorded, not silently missing.** Each native input
reserves a source ordinal when it arrives. Input that did not go to the
target window (a click on another window, typing in another app, the
release of a dropped press) is dropped, and its ordinal holds no event.
Capture writes each dropped ordinal and its reason code to
`source-ordinal-skips.json`, which the artifact manifest seals. The verifier
still requires every ordinal from 1 to the last one. It accepts an empty
ordinal only when that record names it with a known reason, in a
window-scoped capture. Any other hole fails verification. The record holds
ordinals and reason codes only.
- **macOS system chrome is hit-tested.** The Quartz window list can include
full-screen windows at the Dock level and above (the Dock, Notification
Center) that take clicks only where they draw. Such a window counts as the
window under the pointer only when the system's Accessibility hit test
says its process takes the click there. When the hit test can't answer,
the click is dropped (fail closed). Other apps' windows block as before.
- **Window movement and resize are supported.** The first frame fixes the
encoded video size. Later source frames scale to fit and letterbox into that
viewport. Input uses the exact current bounds and content rectangle. No frame
Expand Down
21 changes: 19 additions & 2 deletions packages/capture/openadapt_capture/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
WindowCaptureStateV2,
)
from openadapt_capture.processing import process_events
from openadapt_capture.source_skips import (
SourceOrdinalSkipError,
load_source_ordinal_skips,
)
from openadapt_capture.structural import StructuralObservation

if TYPE_CHECKING:
Expand Down Expand Up @@ -631,7 +635,14 @@ def _validate_database_contract(
ordinals_by_kind[kind] = set(typed_ordinals)
all_ordinals.extend(typed_ordinals)

calculated_last = max(all_ordinals, default=None)
# Ordinals that window-scoped recording dropped by policy hold no event.
# Only the sealed skip record can account for them.
try:
skipped_ordinals = load_source_ordinal_skips(capture.capture_dir)
except SourceOrdinalSkipError as exc:
raise InvalidCaptureEvent(str(exc)) from exc

calculated_last = max([*all_ordinals, *skipped_ordinals], default=None)
if calculated_last != last_source_ordinal:
raise InvalidCaptureEvent("sealed last source ordinal differs from the immutable database")

Expand Down Expand Up @@ -716,6 +727,10 @@ def _validate_database_contract(
and desktop_metadata.get("coordinate_space") == "virtual_desktop_pixels"
)
is_native_scoped = is_v2 or is_desktop_v1
if skipped_ordinals and not is_v2:
raise InvalidCaptureEvent(
"sealed source-ordinal skips are only valid for a window-scoped capture"
)
if is_native_scoped and not rows_by_kind["screen"]:
raise InvalidCaptureEvent("sealed native capture has no retained frames")
if is_desktop_v1:
Expand Down Expand Up @@ -846,7 +861,9 @@ def _validate_database_contract(
ordinal in allowed_pair_ordinals and kinds == {"screen", "window"}
):
raise InvalidCaptureEvent("sealed journal reuses a source ordinal across events")
if is_native_scoped and sorted(owners) != list(
if not owners.keys().isdisjoint(skipped_ordinals):
raise InvalidCaptureEvent("sealed journal has an event at a skipped source ordinal")
if is_native_scoped and sorted(set(owners) | set(skipped_ordinals)) != list(
range(1, (last_source_ordinal or 0) + 1)
):
raise InvalidCaptureEvent("sealed native source journal has a missing ordinal")
Expand Down
65 changes: 51 additions & 14 deletions packages/capture/openadapt_capture/recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@
create_input_observer,
)
from openadapt_capture.live import LiveEventTap, publish_live
from openadapt_capture.source_skips import (
INPUT_CONTINUES_DROPPED_INPUT,
SOURCE_ORDINAL_SKIP_REASONS,
write_source_ordinal_skips,
)
from openadapt_capture.structural import (
StructuralObservationRequest,
StructuralObserver,
Expand Down Expand Up @@ -290,6 +295,7 @@ class _JournalEntry:
error: BaseException | None = None
ready: bool = False
discarded: bool = False
discard_reason: str | None = None


class EventReservation:
Expand Down Expand Up @@ -335,12 +341,20 @@ def fail(self, error: BaseException) -> None:
self._finished = True
self._journal._condition.notify_all()

def discard(self) -> None:
"""Release this position without an event; the consumer skips it."""
def discard(self, reason: str) -> None:
"""Release this position without an event because policy dropped it.

The consumer skips the position and the journal records the ordinal
with ``reason`` (one of ``SOURCE_ORDINAL_SKIP_REASONS``), so the
sealed journal names every intentional hole.
"""
if self._finished:
raise RuntimeError("the event journal reservation is already complete")
if reason not in SOURCE_ORDINAL_SKIP_REASONS:
raise EventJournalOrderingError(f"unknown source-ordinal skip reason: {reason!r}")
with self._journal._condition:
self._entry.discarded = True
self._entry.discard_reason = reason
self._entry.ready = True
self._finished = True
self._journal._condition.notify_all()
Expand Down Expand Up @@ -387,9 +401,9 @@ def admit_continuation(self) -> None:
"""Accept the release or drag of a press the target already received."""
self._admitted = True

def discard(self) -> None:
def discard(self, reason: str) -> None:
"""Drop this input: its journal position completes without an event."""
self._reservation.discard()
self._reservation.discard(reason)

@property
def source_ordinal(self) -> int:
Expand Down Expand Up @@ -579,6 +593,7 @@ def __init__(self) -> None:
self._condition = threading.Condition()
self._entries: deque[_JournalEntry] = deque()
self._next_sequence = 1
self._skipped: list[tuple[int, str]] = []

def reserve(self, timestamp: float) -> EventReservation:
timestamp = float(timestamp)
Expand All @@ -594,6 +609,24 @@ def last_source_ordinal(self) -> int | None:
with self._condition:
return self._next_sequence - 1 or None

@property
def skipped_source_ordinals(self) -> tuple[tuple[int, str], ...]:
"""Return ``(ordinal, reason)`` for each dropped position consumed so far."""
with self._condition:
return tuple(self._skipped)

def _pop_discarded_head_locked(self) -> bool:
"""Consume one ready discarded head entry and record its skip."""
if not (self._entries and self._entries[0].ready and self._entries[0].discarded):
return False
entry = self._entries.popleft()
if entry.discard_reason not in SOURCE_ORDINAL_SKIP_REASONS:
raise EventJournalReservationError(
"an ordered event reservation was dropped without a policy reason"
)
self._skipped.append((entry.sequence, entry.discard_reason))
return True

def _reserve_locked(self, timestamp: float) -> _JournalEntry:
entry = _JournalEntry(timestamp, self._next_sequence)
self._next_sequence += 1
Expand Down Expand Up @@ -623,8 +656,9 @@ def reserve_window_action(
)
else:
binding = window_scope.generation_for_action()
except WindowInputNotOnTarget:
except WindowInputNotOnTarget as exc:
entry.discarded = True
entry.discard_reason = exc.reason
entry.ready = True
reservation._finished = True
self._condition.notify_all()
Expand Down Expand Up @@ -702,10 +736,10 @@ def get(self, block: bool = True, timeout: float | None = None) -> Event:
deadline = None if timeout is None else time.monotonic() + timeout
with self._condition:
while True:
if self._pop_discarded_head_locked():
continue
if self._entries and self._entries[0].ready:
entry = self._entries.popleft()
if entry.discarded:
continue
if entry.error is not None:
raise EventJournalReservationError(
"an ordered event producer failed after reservation"
Expand All @@ -727,8 +761,8 @@ def get_nowait(self) -> Event:

def empty(self) -> bool:
with self._condition:
while self._entries and self._entries[0].ready and self._entries[0].discarded:
self._entries.popleft()
while self._pop_discarded_head_locked():
pass
return not self._entries

def qsize(self) -> int:
Expand Down Expand Up @@ -2648,9 +2682,9 @@ def __init__(self, scope: WindowCaptureScope, gap_log: WindowInputGapLog) -> Non
self._held_buttons: set[str] = set()

@staticmethod
def _discard(reservation: object | None) -> None:
def _discard(reservation: object | None, reason: str) -> None:
if isinstance(reservation, WindowActionReservation):
reservation.discard()
reservation.discard(reason)

def dropped(self, event: ObservedInput, reason: str) -> None:
"""Record a marker for dropped input, never its content."""
Expand All @@ -2667,13 +2701,13 @@ def admit(self, event: ObservedInput, reservation: object | None) -> bool:
if isinstance(event, ObservedKey) and not event.pressed:
identity = _key_identity(event)
if identity not in self._held_keys:
self._discard(reservation)
self._discard(reservation, INPUT_CONTINUES_DROPPED_INPUT)
return False
self._held_keys.discard(identity)
continuation = True
elif isinstance(event, ObservedMouseButton) and not event.pressed:
if event.button not in self._held_buttons:
self._discard(reservation)
self._discard(reservation, INPUT_CONTINUES_DROPPED_INPUT)
return False
self._held_buttons.discard(event.button)
continuation = True
Expand All @@ -2696,7 +2730,7 @@ def admit(self, event: ObservedInput, reservation: object | None) -> bool:
else:
evidence = self._scope.assert_input_on_target(window, point=point)
except WindowInputNotOnTarget as exc:
self._discard(reservation)
self._discard(reservation, exc.reason)
self.dropped(event, exc.reason)
return False

Expand Down Expand Up @@ -3833,6 +3867,9 @@ def record(
_raise_for_failed_processes(task_by_name, process_errors)
if window_scope is not None:
window_scope.assert_current()
# Name every ordinal policy dropped, so the sealed journal's holes
# are explicit. Unrecorded holes still fail verification.
write_source_ordinal_skips(capture_dir, event_q.skipped_source_ordinals)
elif desktop_scope is not None:
# Close the interval between the last captured frame and operator stop.
# A topology change in that interval still invalidates the session.
Expand Down
Loading
Loading