Files
orisys-app/apps/studio/docs/replay-panel.md
T
ykcheungab a4313f300d 缩短两份超限文档,去掉重复内容
replay-panel.md 超出 general 上限 5、shell-lifecycle.md 超出 reference 上限 27。两者超的都是重复:前者的「手动清空/退出清空/导出无代码」在缓存一节已写过,后者重复解释了同一条症状并删掉了已被修复解决的括注。

验证:doc_count 显示 1482/1500 与 3593/3599;doc_lint changed 与 --all 均干净。
2026-10-06 14:27:53 +08:00

178 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](replay-analysis.md)) and Part B
([replay-analysis-part-b.md](replay-analysis-part-b.md)).
Status: Parts A and B shipped at `cf49b08`, the depth channel at `9e20fd0`, the result-aware mode
plan at `1aa4543`. The cache root and the lifetime sweep are implemented; 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. What a bare video lacks is force rows *measured by a device*, and
`session.json` metadata.
**A bare video can be analysed, and is asked how.** The two files the pass reads are *generated*
for the video in an app-owned workspace and every later step is the existing recording path; the
workspace, its frame index and the synthetic marker are Part A's (§1.1). One prompt asks what a
video cannot carry: the **profile** (from the catalog, each row naming what it can do — flow /
raw force / calibrated axes / depth / 3D), an **optional calibration** defaulting to the profile's
declared `default_calibration`, and a **reference frame** defaulting to the first. A profile the
SDK rejects is listed, disabled and refused with the catalog's own `start_blocked_reason()`.
Declining keeps the old behaviour (`playback.analysis_setup_declined`), and **more than one file
is refused** with its reason.
Source: `analysis/bare_video.py`, `ui/video_analysis_dialog.py`, `replay_controller.py`.
Verify: `test_bare_video_analysis.py`, `test_bare_video.py`,
`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, with `AnalysisProgress.reused` filling the bar — 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 forward-only (`_pass_frames` walks segments and frames in order; the chain is
stateful). 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).
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 (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, 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`), or — over a bare video — the result's own recomputed trace (`AnalysisCurveSource`) |
| 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`) |
The charts read one of two sources, and the header names which: `chart.mode_replay` for a
recording's own `forces.csv` (*the* measurement — a device was in the loop) and
`chart.mode_recomputed` for the trace the pass recomputed from a bare video
(`FRAME_FLAG_FORCE`, 28 B/frame; `provenance.sources["forces"]` says which). With neither —
nothing analysed yet, or a profile whose chain produced no force for this footage — the header
states why (`chart.mode_no_trace`) rather than leaving an empty plot.
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); and the 3D pane with no result. 「从视频重算」 shares
`on_analysis_requested` with opening a recording — no separate acquisition chain owns the
timeline.
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. The recovered field can only be
sampled on the pass's own grid, so the frame carries that policy (`ImageFrame.arrow_policy`).
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_stitched`, `depth`, `recorded`. 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
(`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,048 B/frame with the depth channel) |
| 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 (Part A §1) |
A blank depth view is 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. Measured: the map is exactly all zeros on 32 of the box recording's 33
frames (Part A §1 has the rest).
**A box replay's arrow canvas is painted, by the shared painter.** The box style draws through
the SDK's `orisys.util.draw_arrows`, which sweeps the dense sampling grid; a replay hands it a
*recovered* field, finite only at the stored candidate cells. Measured on one stored box frame:
a dense field through the SDK painter paints 24,440 px, that field in its recovered form 0 px,
the shared painter 1,486 px (1,486–1,964 px over the result's 25 frames with arrows). So the
painter follows **where the frame came from** — a frame read from a stored result
(`SOURCE_ANALYSIS`/`SOURCE_REPLAY`) is drawn per sample, a live or recomputed one keeps the SDK
path (`draw_arrow_display(shared_painter=True)`) — and the box style still owns the
magnification and the idle-dot tint.
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. The cursor stays on the recording's own timeline
while the derived views are read from the result — including while the session that recorded it
is still live, because reading a result opens no chain.
Verify: `test_image_backend.py` (the plan and the painter routing),
`test_bare_video_analysis.py::test_the_box_arrow_canvas_is_painted_by_the_shared_painter`,
`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: a PNG would bake display settings into pixels, destroying the
property that a threshold, colour or idle-dot change is a read. Check:
`::test_a_display_change_is_a_read_and_writes_no_result`,
`::test_a_mode_change_writes_no_analysis`. A backward seek cannot re-derive a frame either: the
chain is forward-only, so it would cost a full re-run (Part A §8).
Results live under `%LOCALAPPDATA%\OrisysStudio\analysis-cache\`
(`paths.analysis_cache_dir()`) — never the user's recording folder or the app's `outputs/` (the
EXE's own directory in a packaged build). 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. A bare video's generated
workspace lives under `…\video-workspaces\<video digest>\`. Implemented: a 1 GiB cap with LRU
eviction and `ResultCache.sweep()` at startup. Not implemented: a manual clear action, the opt-in
wipe-on-exit preference and result export (≈70 MB per ten minutes).
## 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); the painter is chosen by the frame's source |
| A bare video: workspace, setup prompt, per-frame force | `analysis/bare_video.py`, `ui/video_analysis_dialog.py`, `analysis/{model,service,reader}.py`, `force/source.py` | shipped (appended `FRAME_FLAG_FORCE`, format version unchanged) |
| Display-control separation (Part C) | `toolbar.py`, `display_prefs.py`, `app_window.py` | not started (Part B §12) |
## Not confirmed in code
* The render cost (≈1.4 ms) and the PNG estimate (180–540 MB per ten minutes) are the owner's
bench; Part B §14's snapshots (4.2–4.9 kB each) suggest the estimate is high.
* A chosen calibration can be applied to the **box** chain only (`Sensor(cal_path=…)`). The
finger runtime opens its sensor without one, so a non-default curve for a finger profile is
refused rather than silently ignored — the SDK has no entry point for it (reported, not
worked around).