Opening a recording while the session that recorded it was still live never reached ``stored_result()``, so the panel reported no result over a recording whose result was on disk. The guard now follows the cache read, results move to the app's derived-data root (swept at startup, the old in-recording root ignored), and the panel states which of the four no-result states it is in, with the pass's own reason. Verified: apps/studio 462/0/18, shared 431/0/1, autocal 358, ui_check 628/0, both gates clean, real-GL snapshots per state.
12 KiB
Replay panel contract
The replay panel plays one recording — or one video — against a single cursor. Each capability states its requirement, data source and a falsifying check. The result format and playback semantics are owned by Part A (replay-analysis.md) and Part B (replay-analysis-part-b.md).
Status: Parts A and B shipped at cf49b08, the depth channel at 9e20fd0, the result-aware mode
plan and the wiring that reaches it at 1aa4543. The cache root and the lifetime sweep are
implemented (§Cache and export policies); the display-control separation (Part C) is not.
1. Load a video
Selecting a video always gives a playable timeline, and the panel says what the file is.
load_video calls a folder holding forces.csv beside the video a recording; anything else
opens as a bare video (VideoOnlySource) and plays, with playback.video_only_opened on the
rail and playback.video_only_notice in the notes. A file that cannot be decoded is refused
with its reason.
Not available for a bare video: force rows (the charts are empty by construction), session.json
metadata, and an analysis pass — recording_directory() needs a session.json and reports
playback.analysis_no_source; the only route to numbers is rerunning the acquisition pipeline over
the video (on_recompute_toggled), forward only.
Source: record/playback.py, replay_controller.py.
Verify: test_ui_smoke.py::test_a_bare_video_plays_even_though_it_is_not_a_recording;
test_replay_controller.py::test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view.
2. Play and compute on the loaded video
Opening a recording either serves a compatible stored result or runs one pass behind a
progress-and-cancel surface. on_analysis_requested asks
AnalysisService.stored_result() first — a file read, the surface showing
analysis.reused — and otherwise starts AnalysisWorker, a plain thread whose Progress is
coalesced at 0.1 s, with AnalysisProgress showing written/expected frames and elapsed time.
The timeline stays in recording mode until the pass commits: a half-written result never
reaches the screen.
The pass is sequential and forward-only: _pass_frames walks segments and frames in order, and the
chain is stateful (Sensor.reset() clears the tracker). Cancelling sets an event; the result keeps
its INCOMPLETE marker and default readers refuse it (Part A §7). Measured: 0.48–0.51
wall-seconds per video-second (Part A §8) — ten minutes is ≈4.8 minutes.
Source: analysis/service.py, analysis_worker.py, ui/analysis_progress.py.
Verify: test_replay_analysis.py::test_opening_a_recording_with_a_stored_result_runs_no_pass,
::test_opening_a_recording_without_a_result_starts_the_pass,
::test_the_worker_reports_progress_and_a_cancelled_outcome.
3. Cursor playback
The cursor is one position for the whole panel, on the recording's own signal time: t_rel_s
from frames.csv, the axis ReplayEngine.position() and AnalysisReader.frame_at() index by.
Container time is never derived from it or converted into it. Measured: 33 container frames at
30 fps are 1.100 s of video and 1.920 s of signal at 16.664 fps (Part A §5).
Frames are addressed by the recording's own frame id, never an index: AnalysisSession.frame
refuses an id the result does not hold (AnalysisNotFoundError), and the evidence recording's
ids run 20…52, so an index-based path would answer with another frame's measurement. A seek
resolves to the stored frame at or before the position — the frame sequential playback passes —
and re-runs no pass.
Source: replay.py, replay_analysis.py.
Verify: test_replay_analysis.py::test_position_is_signal_time_and_resolves_to_a_stored_frame_id,
::test_a_frame_id_the_recording_does_not_hold_is_refused,
::test_a_seek_equals_playing_forward_to_the_same_frame.
4. Synchronised charts, 2D view and 3D view
One cursor drives all three.
| View | Data source |
|---|---|
| Charts | the recorded forces.csv (RecordingCurveSource); the result stores no force |
| 2D canvas | the cached per-frame samples and depth, drawn at display time |
| 3D pane | the packet built from the cached frame (AnalysisSession.packet → Scene3DView.submit_packet) |
A seek equals playing forward to that frame: the cursor resolves to one stored frame id and all
three views are fed from it. A derived view chosen while the timeline reads the video is drawn
from the result at the same cursor (available_result). Not synchronised: the 3D pane's depth
(AnalysisSession.packet sets depth=None, depth_valid=False); forces (the packet carries
forces_valid=False and zero, so the pane's force readout is not a measurement here); the 3D
pane with no result, which nothing feeds; and a recompute pass, where the pipeline owns the
timeline (on_cursor_moved).
Display settings are reads: the threshold re-derives activity from the stored magnitude, colour
range and idle dots live in the draw call, and a grid-moving setting is refused by name
(GRID_KEYS), the frame still drawn from the recorded grid.
Verify: test_replay_analysis.py::test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws,
::test_a_display_change_that_moves_the_grid_is_refused_not_served.
5. What the 2D view can show
The modes come from the frame's own fields and from whether a result exists (plan_image,
result_available): arrows_pure (Arrows), arrows_stitched (Arrows / Captured), depth
(Depth map), recorded (Recorded image). With a result all four are offered, even on the
recording's timeline, defaulting to recorded; with none, recorded alone plus one line naming
which absence it is (result_state): no result yet (panels.replay_raw_note), a pass running
(panels.replay_analysis_running), a pass failed or refused with its own reason
(panels.replay_analysis_failed), or no pass possible at all — a bare video
(panels.replay_analysis_unavailable).
| Mode | Drawn from |
|---|---|
| recorded | the decoded raw_*.mp4 frame (RecordingReader.video_frame_at) |
| arrows / arrows-captured | the cached samples: the grid keyframe's locations plus this frame's float32 dx/dy/magnitude and bit-packed active (4,034 B/frame before the depth channel; 4,048 B/frame now) |
| depth | a per-frame depth flag, a non-zero count and min/max; only a non-empty map adds a uint8 plane quantised over that frame's range and zlib'd at level 6 (island restriction rejected: 418 B vs 486 B on the worst frame) |
Measured: the depth map is exactly all zeros on 241 of 264 frames of the eight-times clip
(32 of 33 on the box recording) and never exceeded 336 of 84,000 pixels (0.4 %). A blank depth view
is therefore usually correct and must be explainable: panels.depth_empty says the frame's depth is
all zeros, panels.depth_unavailable says the frame has no depth channel at all — different facts,
neither drawing the processed image.
A box replay's arrow canvas is empty (pre-existing): the box style draws through the SDK's
orisys.util.draw_arrows, while a replay hands it the recovered field, finite only at the stored
candidate cells — one dense field paints 24,440 px, its recovered form 0 px, the shared painter
1,486 px.
A mode needing derived data is served from the result, never faked: with none, that view is not
offered and the state line names the action or the reason. The state above is what an operator
reaches by opening a recording that has a result: the cursor stays on the recording's own timeline
and the panel opens on a derived view, the recorded one still offered — including while the
session that recorded it is still live, because reading a result opens no chain.
Verify: test_image_backend.py (the plan), test_replay_analysis.py::test_a_derived_mode_is_served_from_the_result_even_on_the_recording_timeline,
::test_the_four_no_result_states_are_four_different_lines,
::test_the_empty_depth_note_is_not_the_missing_depth_note,
test_replay_wiring.py::test_the_operator_sees_the_cached_views_over_the_recording,
::test_a_running_session_does_not_hide_a_stored_result.
Cache and export policies
No rendered 2D images are cached. PNGs of the arrow and depth views would cost 180–540 MB per
ten minutes and bake display settings into pixels, destroying the property that a threshold,
colour or idle-dot change is a read. Drawing a frame from the cached numbers costs ≈1.4 ms, the
idle-dot pass 37 µs (owner's bench, not reproduced here). Check:
::test_a_display_change_is_a_read_and_writes_no_result, ::test_a_mode_change_writes_no_analysis.
Derived views cannot be computed from raw data during a scrub: the chain is stateful and forward-only, so a backward seek would need a full re-run at 0.48–0.51 s per video-second; the panel scrubs stored numbers and offers that run separately.
Cache location. Results live under the OS-correct derived-data root,
%LOCALAPPDATA%\OrisysStudio\analysis-cache\ (paths.analysis_cache_dir()) — never the user's
recording folder or the app's outputs/, which in the packaged build is the EXE's own directory
and holds the recordings. The key is path-independent (recording_identity), so a copied
recording finds its result; a result left under the old <recording>/analysis root is ignored —
neither read nor deleted (test_replay_analysis.py::test_a_result_under_the_old_root_is_ignored_not_migrated).
Cache lifetime. A size cap with LRU eviction (1 GiB, DEFAULT_MAX_BYTES), a startup sweep of
stale and incomplete results, a manual "clear analysis cache" action, and wipe-on-exit only as an
explicit preference that is off by default — because wiping on exit forces a full re-analysis
(≈4.8 minutes per ten minutes) on reopen and does not protect against a crash. Implemented:
evict() (incomplete first, then oldest, protecting keep) and ResultCache.sweep(), which
clears incomplete results older than an hour and then applies the cap, called once at startup
(ReplayController.sweep_cache). Not implemented: the clear action and the wipe-on-exit
preference.
Export. The deliberate way to keep a result is to export it: ≈70 MB per ten minutes, portable because the key ignores where the file sits. A rendered arrow or depth video belongs to export rather than the cache. Not implemented.
Owners and status
| Part | Owner | Status |
|---|---|---|
| Format, cache, reader, pass | orisys_app_shared.analysis |
shipped (9e20fd0, cache root and sweep now) |
| Timeline, cursor, policy, pass thread, progress surface | replay_analysis.py, replay.py, replay_controller.py, analysis_worker.py, ui/analysis_progress.py |
shipped; the no-result states are part of the panel's line |
| Mode plan, drawing, window wiring | image_backend.py, acquisition/arrows.py, app_window.py |
shipped (Part B §12 names the seams) |
| Cache root and lifetime | analysis/cache.py, paths.py, app_window.py |
root and startup sweep implemented; clear action and wipe-on-exit preference are not |
| Display-control separation: rail reset as the measurement reference, a separate display baseline, the F8 view reset | toolbar.py, display_prefs.py, app_window.py |
not started; Part B §12 records these as unchanged. The pass accepts a reference_frame but _analysis_service passes none |
Not confirmed in code
- The render cost (≈1.4 ms, idle dots 37 µs) and the PNG estimate (180–540 MB per ten minutes) are the owner's bench, not measurements here; Part B §14's single-frame snapshots (4.2–4.9 kB) suggest the estimate is high, so it should be re-measured.
- The manual clear action, the wipe-on-exit preference and result export have no code in
orisys-app; they are decisions.