@crvouga/atlas 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -1
- package/docs/extending.md +7 -0
- package/docs/rich-evidence.md +39 -0
- package/docs/ui.md +22 -0
- package/llms-full.txt +68 -0
- package/package.json +1 -1
- package/packages/visualizer/src/app/router.tsx +3 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +88 -42
- package/packages/visualizer/src/evidence/behavior-panel.tsx +44 -25
- package/packages/visualizer/src/evidence/media-timing.test.ts +85 -0
- package/packages/visualizer/src/evidence/media-timing.ts +150 -0
- package/packages/visualizer/src/evidence/moments.tsx +457 -0
- package/packages/visualizer/src/evidence/outcome-summary.tsx +27 -0
- package/packages/visualizer/src/evidence/outcome.ts +7 -2
- package/packages/visualizer/src/explorer/TestDetail.tsx +2 -2
- package/packages/visualizer/src/explorer/playlist.tsx +89 -21
- package/packages/visualizer/src/explorer/search.ts +7 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recording timing
|
|
3
|
+
*
|
|
4
|
+
* Where a check's steps are in its recording. A step says when it started on the check's own clock (`offsetMs`, after
|
|
5
|
+
* the check started); a recording has its own clock, which starts when the recorder did. The two are never assumed to
|
|
6
|
+
* agree. A producer that wants a step to be placed in a recording says how the clocks relate under the `atlas.media`
|
|
7
|
+
* extension of the video attachment:
|
|
8
|
+
*
|
|
9
|
+
* ```json
|
|
10
|
+
* { "name": "run.webm", "role": "video", "durationMs": 9000,
|
|
11
|
+
* "extensions": { "atlas.media": {
|
|
12
|
+
* "checkStartMs": 1200, // the check started 1.2 s into the recording
|
|
13
|
+
* "pauses": [{ "atMs": 900, "durationMs": 2000, "reason": "title card" }], // delay put into the recording, not into the check
|
|
14
|
+
* "marks": [{ "step": "pay", "mediaMs": 4100 }] // a step's own moment, when the producer knows it exactly
|
|
15
|
+
* } } }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Every field is optional and nothing here decides a status, a slot or a winner. Without a mark or a `checkStartMs` a
|
|
19
|
+
* step is not placed: it is never guessed from the step's name, from the recording starting with the check, or from
|
|
20
|
+
* another recording's timing. A marker outside the recording, or timing that cannot be read, is reported as
|
|
21
|
+
* unavailable with the reason. Pure functions; the player is `moments.tsx`.
|
|
22
|
+
*/
|
|
23
|
+
import type { Attachment, Step } from '@crvouga/atlas/schema';
|
|
24
|
+
|
|
25
|
+
/** The extension key under which a producer may relate a recording's clock to its check's. */
|
|
26
|
+
export const MEDIA_EXTENSION = 'atlas.media';
|
|
27
|
+
|
|
28
|
+
/** Time added to the recording and not to the check, such as a title card or a deliberate pause for a viewer. */
|
|
29
|
+
export type MediaPause = {
|
|
30
|
+
/** Where on the check's clock the delay was inserted: steps that started at or after it are later in the recording by `durationMs`. */
|
|
31
|
+
readonly atMs: number;
|
|
32
|
+
readonly durationMs: number;
|
|
33
|
+
readonly reason: string | undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** What a recording says about its own clock. */
|
|
37
|
+
export type MediaTiming = {
|
|
38
|
+
/** Where on the recording's clock the check started (its `offsetMs` 0). Undefined: not said, so steps are not placed by offset. */
|
|
39
|
+
readonly checkStartMs: number | undefined;
|
|
40
|
+
/** Delays inserted into the recording, in the order they occur. */
|
|
41
|
+
readonly pauses: readonly MediaPause[];
|
|
42
|
+
/** Steps whose moment in the recording is stated outright, by step `id`. */
|
|
43
|
+
readonly marks: ReadonlyMap<string, number>;
|
|
44
|
+
/** Set when the declaration could not be read as a whole; no step is then placed from it. */
|
|
45
|
+
readonly problem: string | undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The timing a video attachment declares under {@link MEDIA_EXTENSION}. Undefined when it declares none. A declaration
|
|
52
|
+
* with a field that cannot be read is kept with a `problem`, so it is reported instead of being half-applied.
|
|
53
|
+
*/
|
|
54
|
+
export function mediaTimingOf(attachment: Pick<Attachment, 'extensions'>): MediaTiming | undefined {
|
|
55
|
+
const raw = attachment.extensions?.[MEDIA_EXTENSION];
|
|
56
|
+
if (raw === undefined) return undefined;
|
|
57
|
+
const empty = { checkStartMs: undefined, pauses: [], marks: new Map<string, number>() };
|
|
58
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ...empty, problem: `The ${MEDIA_EXTENSION} declaration is not an object.` };
|
|
59
|
+
const { checkStartMs, pauses, marks } = raw as Record<string, unknown>;
|
|
60
|
+
let problem: string | undefined;
|
|
61
|
+
if (checkStartMs !== undefined && !finite(checkStartMs)) problem = '`checkStartMs` is not a number.';
|
|
62
|
+
const pauseList: MediaPause[] = [];
|
|
63
|
+
if (pauses !== undefined) {
|
|
64
|
+
if (!Array.isArray(pauses)) problem ??= '`pauses` is not a list.';
|
|
65
|
+
else
|
|
66
|
+
for (const pause of pauses as unknown[]) {
|
|
67
|
+
const { atMs, durationMs, reason } = (pause !== null && typeof pause === 'object' ? pause : {}) as Record<string, unknown>;
|
|
68
|
+
if (finite(atMs) && atMs >= 0 && finite(durationMs) && durationMs >= 0)
|
|
69
|
+
pauseList.push({ atMs, durationMs, reason: typeof reason === 'string' ? reason : undefined });
|
|
70
|
+
else problem ??= 'A pause needs a non-negative `atMs` and `durationMs`.';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const markMap = new Map<string, number>();
|
|
74
|
+
if (marks !== undefined) {
|
|
75
|
+
if (!Array.isArray(marks)) problem ??= '`marks` is not a list.';
|
|
76
|
+
else
|
|
77
|
+
for (const mark of marks as unknown[]) {
|
|
78
|
+
const { step, mediaMs } = (mark !== null && typeof mark === 'object' ? mark : {}) as Record<string, unknown>;
|
|
79
|
+
if (typeof step === 'string' && step !== '' && finite(mediaMs)) markMap.set(step, mediaMs);
|
|
80
|
+
else problem ??= 'A mark needs a step `id` and a `mediaMs`.';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
pauseList.sort((a, b) => a.atMs - b.atMs);
|
|
84
|
+
return { checkStartMs: finite(checkStartMs) ? checkStartMs : undefined, pauses: pauseList, marks: markMap, problem };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The total length of the delays put into a recording. */
|
|
88
|
+
export function pausedMs(timing: MediaTiming | undefined): number {
|
|
89
|
+
return (timing?.pauses ?? []).reduce((sum, pause) => sum + pause.durationMs, 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Where a step is in a recording, or why it is not placed. */
|
|
93
|
+
export type Alignment =
|
|
94
|
+
| { readonly state: 'aligned'; readonly mediaMs: number; readonly source: 'mark' | 'offset'; readonly pausedMs: number }
|
|
95
|
+
| { readonly state: 'unavailable'; readonly reason: string };
|
|
96
|
+
|
|
97
|
+
/** The reasons a step cannot be placed, in words. */
|
|
98
|
+
export const UNALIGNED = {
|
|
99
|
+
noTiming:
|
|
100
|
+
'This recording carries no timing metadata, so its steps cannot be placed in it. Atlas does not assume the check started when the recording did, and does not guess from step names.',
|
|
101
|
+
noOrigin: 'This recording does not say where the check starts in it, so a step is not placed by its offset.',
|
|
102
|
+
noOffset: 'This step recorded no start offset and no recording mark, so it cannot be placed.'
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Where `step` is in a recording that declared `timing`, with `durationMs` the recording's length when known. A mark for
|
|
107
|
+
* the step wins; otherwise the recording's clock at the check's start, plus the step's offset, plus the delays inserted
|
|
108
|
+
* before it. A moment before the recording starts or after it ends is unavailable, never clamped.
|
|
109
|
+
*/
|
|
110
|
+
export function alignStep(step: Pick<Step, 'id' | 'offsetMs'>, timing: MediaTiming | undefined, durationMs: number | undefined): Alignment {
|
|
111
|
+
if (!timing) return { state: 'unavailable', reason: UNALIGNED.noTiming };
|
|
112
|
+
if (timing.problem) return { state: 'unavailable', reason: `The timing metadata of this recording cannot be used: ${timing.problem}` };
|
|
113
|
+
const marked = step.id === undefined ? undefined : timing.marks.get(step.id);
|
|
114
|
+
let mediaMs: number;
|
|
115
|
+
let source: 'mark' | 'offset';
|
|
116
|
+
let paused = 0;
|
|
117
|
+
if (marked !== undefined) {
|
|
118
|
+
mediaMs = marked;
|
|
119
|
+
source = 'mark';
|
|
120
|
+
} else {
|
|
121
|
+
if (timing.checkStartMs === undefined) return { state: 'unavailable', reason: UNALIGNED.noOrigin };
|
|
122
|
+
if (step.offsetMs === undefined) return { state: 'unavailable', reason: UNALIGNED.noOffset };
|
|
123
|
+
paused = timing.pauses.filter((pause) => pause.atMs <= step.offsetMs!).reduce((sum, pause) => sum + pause.durationMs, 0);
|
|
124
|
+
mediaMs = timing.checkStartMs + step.offsetMs + paused;
|
|
125
|
+
source = 'offset';
|
|
126
|
+
}
|
|
127
|
+
if (mediaMs < 0)
|
|
128
|
+
return { state: 'unavailable', reason: `This step is placed ${clock(-mediaMs)} before the recording starts, so it is not in the recording.` };
|
|
129
|
+
if (durationMs !== undefined && durationMs > 0 && mediaMs > durationMs)
|
|
130
|
+
return { state: 'unavailable', reason: `This step is placed at ${clock(mediaMs)}, after the recording ends at ${clock(durationMs)}.` };
|
|
131
|
+
return { state: 'aligned', mediaMs, source, pausedMs: paused };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The index of the step playing at `timeMs`: the latest placed step that started at or before it (the last of them when
|
|
136
|
+
* several started together, which is the innermost). Null before the first placed step. `placed` are `[index, mediaMs]`.
|
|
137
|
+
*/
|
|
138
|
+
export function activeIndex(placed: readonly (readonly [number, number])[], timeMs: number): number | null {
|
|
139
|
+
let found: readonly [number, number] | null = null;
|
|
140
|
+
for (const entry of placed) if (entry[1] <= timeMs && (found === null || entry[1] >= found[1])) found = entry;
|
|
141
|
+
return found === null ? null : found[0];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A position in a recording as `m:ss.s`: `0:04.2`. */
|
|
145
|
+
export function clock(ms: number): string {
|
|
146
|
+
const tenths = Math.round(Math.max(0, ms) / 100);
|
|
147
|
+
const minutes = Math.floor(tenths / 600);
|
|
148
|
+
const seconds = (tenths % 600) / 10;
|
|
149
|
+
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds.toFixed(1)}`;
|
|
150
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recording moments
|
|
3
|
+
*
|
|
4
|
+
* Connects a check's steps to its recordings. Inside a {@link MomentPlayback}, a step offers to show itself in the
|
|
5
|
+
* selected recording (its place comes from the recording's own timing, `media-timing.ts`, never from an assumption that
|
|
6
|
+
* the check and the recording started together), playback marks the step that is playing without moving focus, the
|
|
7
|
+
* failing step can be jumped to, and the current moment can be copied as a link that names the run, the recording and
|
|
8
|
+
* the position. A step that cannot be placed says why and nothing is guessed. One provider serves one holder (a check
|
|
9
|
+
* or one of its retries): a retry's steps are placed in that retry's own recording with that recording's own timing.
|
|
10
|
+
*/
|
|
11
|
+
import { attachmentIdentity, isVideoAttachment, type EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
|
|
12
|
+
import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
|
|
13
|
+
import type { Attachment, Step } from '@crvouga/atlas/schema';
|
|
14
|
+
import { Alert, Button, Group, NativeSelect, Stack, Text, TextInput } from '@mantine/core';
|
|
15
|
+
import { IconAlertTriangle, IconLink, IconPlayerPlay } from '@tabler/icons-react';
|
|
16
|
+
import { useRouter } from '@tanstack/react-router';
|
|
17
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, type SyntheticEvent } from 'react';
|
|
18
|
+
|
|
19
|
+
import { useScope } from './evidence-scope';
|
|
20
|
+
import { duration } from './format';
|
|
21
|
+
import { activeIndex, alignStep, clock, mediaTimingOf, pausedMs, type Alignment, type MediaTiming } from './media-timing';
|
|
22
|
+
import { failedStepOf } from './outcome';
|
|
23
|
+
|
|
24
|
+
/** One recording whose clock the steps can be placed on. */
|
|
25
|
+
export type MomentVideo = { readonly id: string; readonly attachment: Attachment; readonly timing: MediaTiming | undefined };
|
|
26
|
+
|
|
27
|
+
/** One step and where it is in the selected recording. */
|
|
28
|
+
export type Moment = { readonly index: number; readonly step: Step; readonly alignment: Alignment };
|
|
29
|
+
|
|
30
|
+
/** What asking for a moment came to. */
|
|
31
|
+
export type MomentOutcome = { readonly seeked: boolean; readonly text: string };
|
|
32
|
+
|
|
33
|
+
type Notice = { readonly text: string };
|
|
34
|
+
|
|
35
|
+
type Playback = {
|
|
36
|
+
readonly videos: readonly MomentVideo[];
|
|
37
|
+
readonly selected: MomentVideo | null;
|
|
38
|
+
readonly select: (id: string) => void;
|
|
39
|
+
readonly momentOf: (step: Step) => Moment | undefined;
|
|
40
|
+
/** The index (in the holder's steps) of the step that is playing. */
|
|
41
|
+
readonly active: number | null;
|
|
42
|
+
readonly notice: Notice | null;
|
|
43
|
+
readonly seek: (index: number) => MomentOutcome;
|
|
44
|
+
readonly seekFailure: () => MomentOutcome;
|
|
45
|
+
readonly failed: Step | undefined;
|
|
46
|
+
/** How many steps the holder recorded. */
|
|
47
|
+
readonly stepCount: number;
|
|
48
|
+
readonly currentMs: () => number | undefined;
|
|
49
|
+
readonly linkTo: (atMs: number) => string | null;
|
|
50
|
+
register: (id: string, element: HTMLVideoElement | null) => void;
|
|
51
|
+
loaded: (id: string, element: HTMLVideoElement) => void;
|
|
52
|
+
progress: (id: string, element: HTMLVideoElement) => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const Around = createContext<Playback | null>(null);
|
|
56
|
+
|
|
57
|
+
/** The playback of the holder around this point, null outside one. */
|
|
58
|
+
export function usePlayback(): Playback | null {
|
|
59
|
+
return useContext(Around);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The window event by which a part of the page asks the check's player for a moment (see {@link requestFailureMoment}). */
|
|
63
|
+
const REQUEST = 'atlas:moment-request';
|
|
64
|
+
type Request = { kind: 'failure'; owner: string; handled?: MomentOutcome };
|
|
65
|
+
|
|
66
|
+
/** Names the one result of a check, so a request reaches the player of that result and not another open beside it. */
|
|
67
|
+
export function momentOwner(layer: string, kind: string, check: string): string {
|
|
68
|
+
return `${layer}|${kind}|${check}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Asks the player of the result named by `owner` (see {@link momentOwner}) to jump to the step where it failed. Returns
|
|
73
|
+
* what came of it, or null when no player of that result is on screen (nothing is read or played on its behalf).
|
|
74
|
+
*/
|
|
75
|
+
export function requestFailureMoment(owner: string): MomentOutcome | null {
|
|
76
|
+
const detail: Request = { kind: 'failure', owner };
|
|
77
|
+
window.dispatchEvent(new CustomEvent(REQUEST, { detail }));
|
|
78
|
+
return detail.handled ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The moment an address names: the recording (an `attachmentIdentity`), a position in it, and the run's layer. */
|
|
82
|
+
export type RequestedMoment = { readonly clip: string | undefined; readonly atMs: number | undefined; readonly layer: string | undefined };
|
|
83
|
+
|
|
84
|
+
type RouterLike = {
|
|
85
|
+
state: { location: { pathname: string; search: unknown } };
|
|
86
|
+
buildLocation: (options: { to: string; search: unknown }) => { href: string };
|
|
87
|
+
history: { createHref: (href: string) => string };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function text(value: unknown): string | undefined {
|
|
91
|
+
return typeof value === 'string' && value !== '' ? value : typeof value === 'number' ? String(value) : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The moment the address names, read when it is called; all undefined when the address names none or there is no router. */
|
|
95
|
+
export function useRequestedMoment(): RequestedMoment {
|
|
96
|
+
const router = useRouter({ warn: false }) as unknown as RouterLike | undefined;
|
|
97
|
+
const search = (router?.state.location.search ?? {}) as Record<string, unknown>;
|
|
98
|
+
const at = Number(search.clipAt);
|
|
99
|
+
return { clip: text(search.clip), atMs: search.clipAt !== undefined && Number.isFinite(at) && at >= 0 ? at : undefined, layer: text(search.layer) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The recordings among `attachments` that name bytes, each once, with the timing they declare. */
|
|
103
|
+
function momentVideos(attachments: readonly Attachment[]): MomentVideo[] {
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
return attachments.flatMap((attachment) => {
|
|
106
|
+
const id = isVideoAttachment(attachment) ? attachmentIdentity(attachment) : undefined;
|
|
107
|
+
if (id === undefined || seen.has(id)) return [];
|
|
108
|
+
seen.add(id);
|
|
109
|
+
return [{ id, attachment, timing: mediaTimingOf(attachment) }];
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const NONE: readonly Step[] = [];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The steps that belong to the recording with this identity in a check's evidence: those of the check when it (or one of
|
|
117
|
+
* its steps) carries the recording, else those of the retry that does. A retry's recording is never read with another
|
|
118
|
+
* try's steps.
|
|
119
|
+
*/
|
|
120
|
+
export function stepsOfRecording(check: EvidenceHolder, identity: string | undefined): readonly Step[] {
|
|
121
|
+
const carries = (attachments: readonly Attachment[] | undefined, steps: readonly Step[] | undefined) =>
|
|
122
|
+
identity !== undefined &&
|
|
123
|
+
[...(attachments ?? []), ...(steps ?? []).flatMap((step) => step.attachments ?? [])].some((item) => attachmentIdentity(item) === identity);
|
|
124
|
+
if (carries(check.attachments, check.steps)) return check.steps ?? NONE;
|
|
125
|
+
const attempt = (check.attempts ?? []).find((each) => carries(each.attachments, each.steps));
|
|
126
|
+
return (attempt ? attempt.steps : check.steps) ?? NONE;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Places the steps of one holder in its recordings. `attachments` are the holder's own files (the videos among them are
|
|
131
|
+
* the recordings); `steps` are its timeline. `primary` marks the check itself, which answers
|
|
132
|
+
* {@link requestFailureMoment}. `linkSearch` is merged into the current address to make a link to a moment (by default
|
|
133
|
+
* the exact `layer`). When the address names one of the recordings (`clip`, `clipAt`), that recording is selected and the
|
|
134
|
+
* player goes to the position once it can.
|
|
135
|
+
*/
|
|
136
|
+
export function MomentPlayback({
|
|
137
|
+
steps = NONE,
|
|
138
|
+
attachments,
|
|
139
|
+
layer,
|
|
140
|
+
primary = false,
|
|
141
|
+
owner,
|
|
142
|
+
linkSearch,
|
|
143
|
+
children
|
|
144
|
+
}: {
|
|
145
|
+
steps?: readonly Step[];
|
|
146
|
+
attachments: readonly Attachment[];
|
|
147
|
+
layer: string;
|
|
148
|
+
primary?: boolean;
|
|
149
|
+
owner?: string;
|
|
150
|
+
linkSearch?: Record<string, unknown>;
|
|
151
|
+
children: ReactNode;
|
|
152
|
+
}) {
|
|
153
|
+
const router = useRouter({ warn: false }) as unknown as RouterLike | undefined;
|
|
154
|
+
const requested = useRequestedMoment();
|
|
155
|
+
const videos = useMemo(() => momentVideos(attachments), [attachments]);
|
|
156
|
+
const [chosen, setChosen] = useState<string | null>(() =>
|
|
157
|
+
requested.clip !== undefined && videos.some((video) => video.id === requested.clip)
|
|
158
|
+
? requested.clip
|
|
159
|
+
: ((videos.find((video) => video.timing && !video.timing.problem) ?? videos[0])?.id ?? null)
|
|
160
|
+
);
|
|
161
|
+
const selected = videos.find((video) => video.id === chosen) ?? videos[0] ?? null;
|
|
162
|
+
const elements = useRef(new Map<string, HTMLVideoElement>());
|
|
163
|
+
const pending = useRef<{ id: string; atMs: number } | null>(
|
|
164
|
+
requested.clip !== undefined && requested.atMs !== undefined && videos.some((video) => video.id === requested.clip)
|
|
165
|
+
? { id: requested.clip, atMs: requested.atMs }
|
|
166
|
+
: null
|
|
167
|
+
);
|
|
168
|
+
const [lengths, setLengths] = useState<Readonly<Record<string, number>>>({});
|
|
169
|
+
const [active, setActive] = useState<number | null>(null);
|
|
170
|
+
const [notice, setNotice] = useState<Notice | null>(null);
|
|
171
|
+
|
|
172
|
+
const lengthMs = selected ? (lengths[selected.id] ?? selected.attachment.durationMs) : undefined;
|
|
173
|
+
const moments = useMemo<Moment[]>(
|
|
174
|
+
() => steps.map((step, index) => ({ index, step, alignment: alignStep(step, selected?.timing, lengthMs) })),
|
|
175
|
+
[steps, selected, lengthMs]
|
|
176
|
+
);
|
|
177
|
+
const byStep = useMemo(() => new Map(moments.map((moment) => [moment.step, moment])), [moments]);
|
|
178
|
+
const placed = useMemo(
|
|
179
|
+
() => moments.flatMap((moment) => (moment.alignment.state === 'aligned' ? [[moment.index, moment.alignment.mediaMs] as const] : [])),
|
|
180
|
+
[moments]
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const goTo = useCallback(
|
|
184
|
+
(element: HTMLVideoElement | undefined, atMs: number): MomentOutcome => {
|
|
185
|
+
if (!element) return { seeked: false, text: 'The recording is not loaded, so it cannot be moved.' };
|
|
186
|
+
const length = Number.isFinite(element.duration) && element.duration > 0 ? element.duration * 1000 : undefined;
|
|
187
|
+
if (length !== undefined && atMs > length + 1) {
|
|
188
|
+
const text = `${clock(atMs)} is after the end of the recording, which is ${clock(length)} long. Nothing was moved.`;
|
|
189
|
+
setNotice({ text });
|
|
190
|
+
return { seeked: false, text };
|
|
191
|
+
}
|
|
192
|
+
element.currentTime = atMs / 1000;
|
|
193
|
+
setActive(activeIndex(placed, atMs));
|
|
194
|
+
setNotice(null);
|
|
195
|
+
// The page scrolls to the player; focus stays where it is.
|
|
196
|
+
element.scrollIntoView?.({ block: 'nearest' });
|
|
197
|
+
return { seeked: true, text: `Moved the recording to ${clock(atMs)}.` };
|
|
198
|
+
},
|
|
199
|
+
[placed]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const seek = useCallback(
|
|
203
|
+
(index: number): MomentOutcome => {
|
|
204
|
+
const moment = moments[index];
|
|
205
|
+
if (!moment || !selected) return { seeked: false, text: 'This result has no recording to place the step in.' };
|
|
206
|
+
if (moment.alignment.state === 'unavailable') {
|
|
207
|
+
const text = `“${moment.step.name}” cannot be shown in the recording. ${moment.alignment.reason}`;
|
|
208
|
+
setNotice({ text });
|
|
209
|
+
return { seeked: false, text };
|
|
210
|
+
}
|
|
211
|
+
return goTo(elements.current.get(selected.id), moment.alignment.mediaMs);
|
|
212
|
+
},
|
|
213
|
+
[moments, selected, goTo]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const failed = useMemo(() => failedStepOf(steps), [steps]);
|
|
217
|
+
const seekFailure = useCallback((): MomentOutcome => {
|
|
218
|
+
if (!failed) return { seeked: false, text: 'No failed step was recorded, so there is no failing moment to show.' };
|
|
219
|
+
return seek(steps.indexOf(failed));
|
|
220
|
+
}, [failed, steps, seek]);
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!primary || owner === undefined) return;
|
|
224
|
+
const answer = (event: Event) => {
|
|
225
|
+
const request = (event as CustomEvent<Request>).detail;
|
|
226
|
+
if (request?.kind === 'failure' && request.owner === owner && request.handled === undefined) request.handled = seekFailure();
|
|
227
|
+
};
|
|
228
|
+
window.addEventListener(REQUEST, answer);
|
|
229
|
+
return () => window.removeEventListener(REQUEST, answer);
|
|
230
|
+
}, [primary, owner, seekFailure]);
|
|
231
|
+
|
|
232
|
+
const progress = useCallback(
|
|
233
|
+
(id: string, element: HTMLVideoElement) => {
|
|
234
|
+
if (id === selected?.id) setActive(activeIndex(placed, element.currentTime * 1000));
|
|
235
|
+
},
|
|
236
|
+
[selected, placed]
|
|
237
|
+
);
|
|
238
|
+
const register = useCallback((id: string, element: HTMLVideoElement | null) => {
|
|
239
|
+
if (element) elements.current.set(id, element);
|
|
240
|
+
else elements.current.delete(id);
|
|
241
|
+
}, []);
|
|
242
|
+
const loaded = useCallback(
|
|
243
|
+
(id: string, element: HTMLVideoElement) => {
|
|
244
|
+
if (Number.isFinite(element.duration) && element.duration > 0) setLengths((held) => ({ ...held, [id]: element.duration * 1000 }));
|
|
245
|
+
const wanted = pending.current;
|
|
246
|
+
if (wanted && wanted.id === id) {
|
|
247
|
+
pending.current = null;
|
|
248
|
+
goTo(element, wanted.atMs);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
[goTo]
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const linkTo = useCallback(
|
|
255
|
+
(atMs: number): string | null => {
|
|
256
|
+
if (!router || !selected) return null;
|
|
257
|
+
try {
|
|
258
|
+
const built = router.buildLocation({
|
|
259
|
+
to: router.state.location.pathname,
|
|
260
|
+
search: (prev: Record<string, unknown>) => ({ ...prev, ...(linkSearch ?? { layer }), clip: selected.id, clipAt: Math.round(atMs) })
|
|
261
|
+
});
|
|
262
|
+
return new URL(router.history.createHref(built.href), window.location.href).href;
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
[router, selected, linkSearch, layer]
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const value = useMemo<Playback>(
|
|
271
|
+
() => ({
|
|
272
|
+
videos,
|
|
273
|
+
selected,
|
|
274
|
+
select: (id) => {
|
|
275
|
+
setChosen(id);
|
|
276
|
+
setActive(null);
|
|
277
|
+
setNotice(null);
|
|
278
|
+
},
|
|
279
|
+
momentOf: (step) => byStep.get(step),
|
|
280
|
+
active,
|
|
281
|
+
notice,
|
|
282
|
+
seek,
|
|
283
|
+
seekFailure,
|
|
284
|
+
failed,
|
|
285
|
+
stepCount: steps.length,
|
|
286
|
+
currentMs: () => {
|
|
287
|
+
const element = selected ? elements.current.get(selected.id) : undefined;
|
|
288
|
+
return element ? element.currentTime * 1000 : undefined;
|
|
289
|
+
},
|
|
290
|
+
linkTo,
|
|
291
|
+
register,
|
|
292
|
+
loaded,
|
|
293
|
+
progress
|
|
294
|
+
}),
|
|
295
|
+
[videos, selected, byStep, active, notice, seek, seekFailure, failed, steps, linkTo, register, loaded, progress]
|
|
296
|
+
);
|
|
297
|
+
return <Around.Provider value={value}>{children}</Around.Provider>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The props that join a `<video>` of `attachment` to the playback around it; undefined when it is not one of its recordings. */
|
|
301
|
+
export function useMomentVideo(attachment: Attachment):
|
|
302
|
+
| {
|
|
303
|
+
ref: (element: HTMLVideoElement | null) => void;
|
|
304
|
+
onLoadedMetadata: (event: SyntheticEvent<HTMLVideoElement>) => void;
|
|
305
|
+
onTimeUpdate: (event: SyntheticEvent<HTMLVideoElement>) => void;
|
|
306
|
+
onSeeked: (event: SyntheticEvent<HTMLVideoElement>) => void;
|
|
307
|
+
}
|
|
308
|
+
| undefined {
|
|
309
|
+
const playback = usePlayback();
|
|
310
|
+
const id = isVideoAttachment(attachment) ? attachmentIdentity(attachment) : undefined;
|
|
311
|
+
const member = playback !== null && id !== undefined && playback.videos.some((video) => video.id === id);
|
|
312
|
+
const register = playback?.register;
|
|
313
|
+
const ref = useCallback(
|
|
314
|
+
(element: HTMLVideoElement | null) => {
|
|
315
|
+
if (id !== undefined) register?.(id, element);
|
|
316
|
+
},
|
|
317
|
+
[register, id]
|
|
318
|
+
);
|
|
319
|
+
if (!playback || !member || id === undefined) return undefined;
|
|
320
|
+
return {
|
|
321
|
+
ref,
|
|
322
|
+
onLoadedMetadata: (event) => playback.loaded(id, event.currentTarget),
|
|
323
|
+
onTimeUpdate: (event) => playback.progress(id, event.currentTarget),
|
|
324
|
+
onSeeked: (event) => playback.progress(id, event.currentTarget)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const label = (attachment: Attachment) => attachment.description ?? attachment.name;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A step's control in a timeline: shows the step in the selected recording at its recorded moment, or says that it
|
|
332
|
+
* cannot be placed. Renders nothing outside a playback with a recording.
|
|
333
|
+
*/
|
|
334
|
+
export function SeekStep({ step }: { step: Step }) {
|
|
335
|
+
const playback = usePlayback();
|
|
336
|
+
const moment = playback?.momentOf(step);
|
|
337
|
+
if (!playback || !moment || !playback.selected) return null;
|
|
338
|
+
const aligned = moment.alignment.state === 'aligned';
|
|
339
|
+
const at = moment.alignment.state === 'aligned' ? clock(moment.alignment.mediaMs) : '';
|
|
340
|
+
return (
|
|
341
|
+
<Button
|
|
342
|
+
type="button"
|
|
343
|
+
size="compact-xs"
|
|
344
|
+
variant="subtle"
|
|
345
|
+
data-seek={aligned ? 'aligned' : 'unavailable'}
|
|
346
|
+
leftSection={<IconPlayerPlay size={14} aria-hidden="true" />}
|
|
347
|
+
aria-label={aligned ? `Show “${step.name}” in the recording at ${at}` : `Show “${step.name}” in the recording (alignment unavailable)`}
|
|
348
|
+
onClick={() => playback.seek(moment.index)}
|
|
349
|
+
>
|
|
350
|
+
{aligned ? `Recording ${at}` : 'Not aligned'}
|
|
351
|
+
</Button>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Whether the step is the one playing, so its row can say so. */
|
|
356
|
+
export function useIsPlaying(step: Step): boolean {
|
|
357
|
+
const playback = usePlayback();
|
|
358
|
+
const moment = playback?.momentOf(step);
|
|
359
|
+
return moment !== undefined && playback?.active === moment.index;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* What the playback says above a timeline: which recording the steps are placed in and how, the pacing of a paced
|
|
364
|
+
* recording (never application latency), the jump to the failing moment, the link to the current moment, and why a
|
|
365
|
+
* step could not be shown. Renders nothing outside a playback with a recording.
|
|
366
|
+
*/
|
|
367
|
+
export function MomentBar() {
|
|
368
|
+
const playback = usePlayback();
|
|
369
|
+
const scope = useScope();
|
|
370
|
+
const [copied, setCopied] = useState<'copied' | string | null>(null);
|
|
371
|
+
const [jump, setJump] = useState<string | null>(null);
|
|
372
|
+
if (!playback || !playback.selected) return null;
|
|
373
|
+
const { selected, videos, notice, failed } = playback;
|
|
374
|
+
const scoped = effectiveScope(selected.attachment.scope, scope);
|
|
375
|
+
const timing = selected.timing;
|
|
376
|
+
const paused = pausedMs(timing);
|
|
377
|
+
const reasons = [...new Set((timing?.pauses ?? []).flatMap((pause) => (pause.reason ? [pause.reason] : [])))];
|
|
378
|
+
const paced = scoped?.pacing === 'paced' || (timing?.pauses.length ?? 0) > 0;
|
|
379
|
+
const copy = async () => {
|
|
380
|
+
const at = playback.currentMs() ?? 0;
|
|
381
|
+
const link = playback.linkTo(at);
|
|
382
|
+
if (!link) {
|
|
383
|
+
setCopied('A link to this moment could not be made from here.');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
await navigator.clipboard.writeText(link);
|
|
388
|
+
setCopied('copied');
|
|
389
|
+
} catch {
|
|
390
|
+
setCopied(link);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
return (
|
|
394
|
+
<Stack gap="xs" component="section" aria-label="Recording moments" data-moments>
|
|
395
|
+
{videos.length > 1 && (
|
|
396
|
+
<NativeSelect
|
|
397
|
+
label="Recording the steps are shown in"
|
|
398
|
+
value={selected.id}
|
|
399
|
+
onChange={(event) => playback.select(event.currentTarget.value)}
|
|
400
|
+
data={videos.map((video) => ({ value: video.id, label: label(video.attachment) }))}
|
|
401
|
+
/>
|
|
402
|
+
)}
|
|
403
|
+
{playback.stepCount > 0 && (
|
|
404
|
+
<Text component="div" c="dimmed" data-alignment={timing && !timing.problem ? 'recorded' : 'unavailable'}>
|
|
405
|
+
{timing && !timing.problem
|
|
406
|
+
? timing.checkStartMs !== undefined
|
|
407
|
+
? `Steps are placed in ${label(selected.attachment)} from its recorded timing: the check starts ${clock(timing.checkStartMs)} into the recording.`
|
|
408
|
+
: `Steps are placed in ${label(selected.attachment)} only where its producer recorded the moment.`
|
|
409
|
+
: `Steps cannot be placed in ${label(selected.attachment)}: ${timing?.problem ?? 'it carries no timing metadata.'} Atlas does not guess where a step is.`}
|
|
410
|
+
</Text>
|
|
411
|
+
)}
|
|
412
|
+
{paced && playback.stepCount > 0 && (
|
|
413
|
+
<Text component="div" c="dimmed" data-pacing>
|
|
414
|
+
Paced recording
|
|
415
|
+
{timing && timing.pauses.length > 0
|
|
416
|
+
? `: ${timing.pauses.length === 1 ? 'a pause' : `${timing.pauses.length} pauses`} totalling ${duration(paused)} ${timing.pauses.length === 1 ? 'was' : 'were'} put into the recording${reasons.length ? ` (${reasons.join(', ')})` : ''}. `
|
|
417
|
+
: ': it was slowed for a viewer. '}
|
|
418
|
+
The time between steps includes deliberate pauses; it is pacing, not application latency.
|
|
419
|
+
</Text>
|
|
420
|
+
)}
|
|
421
|
+
<Group gap="xs" wrap="wrap">
|
|
422
|
+
{failed && (
|
|
423
|
+
<Button
|
|
424
|
+
type="button"
|
|
425
|
+
variant="light"
|
|
426
|
+
color="red"
|
|
427
|
+
leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
|
|
428
|
+
onClick={() => setJump(playback.seekFailure().text)}
|
|
429
|
+
>
|
|
430
|
+
Jump to the failing moment
|
|
431
|
+
</Button>
|
|
432
|
+
)}
|
|
433
|
+
<Button type="button" variant="default" leftSection={<IconLink size={16} aria-hidden="true" />} onClick={() => void copy()}>
|
|
434
|
+
Copy link to this moment
|
|
435
|
+
</Button>
|
|
436
|
+
</Group>
|
|
437
|
+
{copied === 'copied' && (
|
|
438
|
+
<Text component="div" role="status" c="dimmed">
|
|
439
|
+
Link copied. It names this run, this recording and this position, and opens that run even when a newer one exists.
|
|
440
|
+
</Text>
|
|
441
|
+
)}
|
|
442
|
+
{copied !== null && copied !== 'copied' && (
|
|
443
|
+
<TextInput label="Link to this moment" description="Copy it from here." readOnly value={copied} onFocus={(event) => event.currentTarget.select()} />
|
|
444
|
+
)}
|
|
445
|
+
{jump && !notice && (
|
|
446
|
+
<Text component="div" role="status" c="dimmed">
|
|
447
|
+
{jump}
|
|
448
|
+
</Text>
|
|
449
|
+
)}
|
|
450
|
+
{notice && (
|
|
451
|
+
<Alert color="orange" role="status" data-moment-notice>
|
|
452
|
+
{notice.text}
|
|
453
|
+
</Alert>
|
|
454
|
+
)}
|
|
455
|
+
</Stack>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
@@ -11,9 +11,12 @@ import type { CheckSlot, HistoryEntry } from '@crvouga/atlas/reports/evidence-vi
|
|
|
11
11
|
import type { CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
|
|
12
12
|
import { effectiveScope, scopeBadges } from '@crvouga/atlas/reports/evidence-scope';
|
|
13
13
|
import { Badge, Box, Button, Flex, List, Paper, Stack, Text, Title } from '@mantine/core';
|
|
14
|
+
import { IconAlertTriangle } from '@tabler/icons-react';
|
|
15
|
+
import { useState } from 'react';
|
|
14
16
|
|
|
15
17
|
import { ago, duration, shortSha, statusWord } from './format';
|
|
16
18
|
import { StatusMark } from './marks';
|
|
19
|
+
import { momentOwner, requestFailureMoment } from './moments';
|
|
17
20
|
import {
|
|
18
21
|
chronology,
|
|
19
22
|
failureNote,
|
|
@@ -67,6 +70,7 @@ export function OutcomeSummary({
|
|
|
67
70
|
const video = point?.videos[0] ?? videosIn(detail?.check)[0];
|
|
68
71
|
const scope = video ? effectiveScope(video.scope, detail?.check.scope, detail?.inheritedScope) : undefined;
|
|
69
72
|
const timeline = chronology(recordOf(selected), history);
|
|
73
|
+
const [jumped, setJumped] = useState<string | null>(null);
|
|
70
74
|
return (
|
|
71
75
|
<Paper component="section" aria-label="Outcome summary" data-outcome={selected.row.status} withBorder p="md">
|
|
72
76
|
<Stack gap="sm" miw={0}>
|
|
@@ -164,6 +168,29 @@ export function OutcomeSummary({
|
|
|
164
168
|
</Text>
|
|
165
169
|
</Text>
|
|
166
170
|
)}
|
|
171
|
+
{video && point && (
|
|
172
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
173
|
+
<Button
|
|
174
|
+
type="button"
|
|
175
|
+
variant="light"
|
|
176
|
+
color="red"
|
|
177
|
+
leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
|
|
178
|
+
onClick={() =>
|
|
179
|
+
setJumped(
|
|
180
|
+
requestFailureMoment(momentOwner(selected.layer.id, selected.row.kind, selected.row.id))?.text ??
|
|
181
|
+
'The recording of this result is not on screen yet. It is in the evidence below.'
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
>
|
|
185
|
+
Jump to the failing moment in the recording
|
|
186
|
+
</Button>
|
|
187
|
+
{jumped && (
|
|
188
|
+
<Text span inherit role="status" c="dimmed" data-jump-result>
|
|
189
|
+
{jumped}
|
|
190
|
+
</Text>
|
|
191
|
+
)}
|
|
192
|
+
</Flex>
|
|
193
|
+
)}
|
|
167
194
|
</Stack>
|
|
168
195
|
)}
|
|
169
196
|
{failed && (
|
|
@@ -92,12 +92,17 @@ export function failureNote(extensions: Readonly<Record<string, unknown>> | unde
|
|
|
92
92
|
* the action). Steps are ordered by their start offset, else by their position. Undefined when no step failed or none
|
|
93
93
|
* was recorded.
|
|
94
94
|
*/
|
|
95
|
-
export function
|
|
95
|
+
export function failedStepOf(steps: readonly Step[] | undefined): Step | undefined {
|
|
96
96
|
if (!steps?.length) return undefined;
|
|
97
97
|
const failedParents = new Set(steps.filter((step) => step.status === 'failed' && step.parentId).map((step) => step.parentId));
|
|
98
98
|
const order = steps.map((step, index) => ({ step, index }));
|
|
99
99
|
order.sort((a, b) => (a.step.offsetMs ?? 0) - (b.step.offsetMs ?? 0) || a.index - b.index);
|
|
100
|
-
|
|
100
|
+
return order.find(({ step }) => step.status === 'failed' && !(step.id !== undefined && failedParents.has(step.id)))?.step;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** {@link failedStepOf} described as the point a failure happened at: its kind and phase, message, offset and own videos. */
|
|
104
|
+
export function failedStep(steps: readonly Step[] | undefined): FailurePoint | undefined {
|
|
105
|
+
const found = failedStepOf(steps);
|
|
101
106
|
if (!found) return undefined;
|
|
102
107
|
return {
|
|
103
108
|
name: found.name,
|