@crvouga/atlas 1.11.0 → 1.13.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/critics/src/contract.d.ts +113 -0
  3. package/dist/critics/src/contract.js +48 -0
  4. package/dist/critics/src/credentials.d.ts +72 -0
  5. package/dist/critics/src/credentials.js +136 -0
  6. package/dist/critics/src/critique.d.ts +128 -0
  7. package/dist/critics/src/critique.js +163 -0
  8. package/dist/critics/src/index.d.ts +21 -0
  9. package/dist/critics/src/index.js +21 -0
  10. package/dist/critics/src/judging.d.ts +153 -0
  11. package/dist/critics/src/judging.js +145 -0
  12. package/dist/critics/src/publish.d.ts +23 -0
  13. package/dist/critics/src/publish.js +21 -0
  14. package/dist/critics/src/runner.d.ts +104 -0
  15. package/dist/critics/src/runner.js +202 -0
  16. package/dist/critics/src/scope.d.ts +8 -0
  17. package/dist/critics/src/scope.js +26 -0
  18. package/dist/reports/src/compose.d.ts +2 -2
  19. package/dist/reports/src/compose.js +6 -5
  20. package/dist/reports/src/evidence-view.d.ts +2 -1
  21. package/dist/reports/src/evidence-view.js +5 -2
  22. package/dist/reports/src/layer-merge.d.ts +11 -3
  23. package/dist/reports/src/layer-merge.js +14 -3
  24. package/dist/reports/src/report-status.d.ts +1 -1
  25. package/dist/reports/src/report-status.js +6 -4
  26. package/dist/schema/src/api.d.ts +1 -0
  27. package/dist/schema/src/api.js +1 -1
  28. package/dist/schema/src/critique.d.ts +144 -0
  29. package/dist/schema/src/critique.js +96 -0
  30. package/dist/schema/src/index.d.ts +2 -1
  31. package/dist/schema/src/index.js +2 -1
  32. package/dist/schema/src/server-config.d.ts +7 -1
  33. package/dist/schema/src/server-config.js +6 -3
  34. package/dist/server/src/auth.d.ts +1 -1
  35. package/dist/server/src/auth.js +2 -2
  36. package/dist/server/src/config.js +2 -2
  37. package/dist/server/src/openapi.d.ts +3 -3
  38. package/dist/server/src/openapi.js +7 -4
  39. package/dist/server/src/store-api.js +39 -1
  40. package/docs/agents.md +1 -0
  41. package/docs/critics.md +190 -0
  42. package/docs/extending.md +8 -0
  43. package/docs/reference/critics.md +840 -0
  44. package/docs/reference/http-api.md +6 -2
  45. package/docs/reference/reports.md +20 -7
  46. package/docs/reference/schema.md +119 -2
  47. package/docs/reference/server.md +3 -3
  48. package/docs/report-contract.md +4 -0
  49. package/docs/rich-evidence.md +39 -0
  50. package/docs/server.md +6 -4
  51. package/docs/ui.md +22 -0
  52. package/llms-full.txt +279 -6
  53. package/llms.txt +2 -0
  54. package/package.json +7 -2
  55. package/packages/critics/README.md +18 -0
  56. package/packages/schema/schemas/critique.schema.json +254 -0
  57. package/packages/schema/schemas/server-config.schema.json +2 -1
  58. package/packages/schema/schemas/server-info.schema.json +1 -0
  59. package/packages/server/openapi.json +7 -6
  60. package/packages/visualizer/src/app/router.tsx +3 -0
  61. package/packages/visualizer/src/evidence/artifacts.tsx +88 -42
  62. package/packages/visualizer/src/evidence/behavior-panel.tsx +44 -25
  63. package/packages/visualizer/src/evidence/media-timing.test.ts +85 -0
  64. package/packages/visualizer/src/evidence/media-timing.ts +150 -0
  65. package/packages/visualizer/src/evidence/moments.tsx +457 -0
  66. package/packages/visualizer/src/evidence/outcome-summary.tsx +27 -0
  67. package/packages/visualizer/src/evidence/outcome.ts +7 -2
  68. package/packages/visualizer/src/explorer/TestDetail.tsx +2 -2
  69. package/packages/visualizer/src/explorer/playlist.tsx +89 -21
  70. package/packages/visualizer/src/explorer/search.ts +7 -1
@@ -6,22 +6,25 @@
6
6
  * record: run, environment, source, attempts). Also the coverage summary of the whole feature. A recording is always
7
7
  * labelled as illustrative evidence with its own status, provenance and revision; it never stands for approval.
8
8
  */
9
+ import { attachmentIdentity } from '@crvouga/atlas/reports/evidence-detail';
9
10
  import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
10
11
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
11
12
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
12
13
  import { Alert, Badge, Box, Group, List, Paper, Stack, Tabs, Text, Title } from '@mantine/core';
13
14
  import { useMemo, type ReactNode } from 'react';
15
+ import type { Attachment } from '@crvouga/atlas/schema';
14
16
 
15
17
  import type { Assessment, ReleaseIdentity } from './applicability';
16
18
  import { ApplicabilitySummary } from './applicability-view';
17
- import { attachmentSource, useCheckDetail } from './artifacts';
19
+ import { attachmentSource, Timeline, useCheckDetail } from './artifacts';
18
20
  import { COVERAGE, COVERAGE_ORDER, type Behavior, type CoverageCounts, type CoverageState, type Revision } from './behavior';
19
21
  import { PlainSteps } from './check-detail';
20
22
  import { useEvidence } from './evidence-context';
21
- import { ScopeNotice } from './evidence-scope';
23
+ import { ScopeNotice, ScopeProvider } from './evidence-scope';
22
24
  import { plural, statusWord, useNow } from './format';
23
25
  import { exampleIndexFor, exampleRows, fillExample, type FeatureStep } from './gherkin';
24
26
  import { Origin, StatusMark } from './marks';
27
+ import { MomentPlayback, stepsOfRecording, useMomentVideo, useRequestedMoment } from './moments';
25
28
  import { outcomeHeadline, videosIn } from './outcome';
26
29
 
27
30
  /** The short, readable form of a spec fingerprint (`<length>:<hash>:<hash>`): its length and the start of its hash. */
@@ -80,14 +83,37 @@ function expectedSteps(steps: readonly FeatureStep[]): FeatureStep[] {
80
83
  return at < 0 ? [] : steps.slice(at);
81
84
  }
82
85
 
86
+ /** The recording itself: the one player the steps beside it seek, which marks the step playing. */
87
+ function WatchVideo({ attachment, url }: { attachment: Attachment; url: string }) {
88
+ const moments = useMomentVideo(attachment);
89
+ return (
90
+ <Box
91
+ component="video"
92
+ src={url}
93
+ controls
94
+ preload="metadata"
95
+ aria-label={`Recording: ${attachment.description ?? attachment.name}`}
96
+ maw="100%"
97
+ w="100%"
98
+ mah="70vh"
99
+ {...moments}
100
+ />
101
+ );
102
+ }
103
+
83
104
  /** The recording of one result, read only when this is on screen. */
84
105
  function Player({ result }: { result: EvidenceResult }) {
85
106
  const detail = useCheckDetail(result.layer, result.row);
86
107
  const videos = useMemo(() => videosIn(detail.data?.check), [detail.data]);
87
- const sources = videos.flatMap((attachment) => {
88
- const { url } = attachmentSource(attachment, result.layer.id);
89
- return url ? [{ attachment, url }] : [];
90
- });
108
+ const requested = useRequestedMoment();
109
+ const sources = useMemo(
110
+ () =>
111
+ videos.flatMap((attachment) => {
112
+ const { url } = attachmentSource(attachment, result.layer.id);
113
+ return url ? [{ attachment, url, list: [attachment] }] : [];
114
+ }),
115
+ [videos, result.layer.id]
116
+ );
91
117
  if (detail.isLoading) {
92
118
  return (
93
119
  <Text role="status" component="div" c="dimmed">
@@ -109,27 +135,20 @@ function Player({ result }: { result: EvidenceResult }) {
109
135
  </Text>
110
136
  );
111
137
  }
138
+ const shown = sources.find(({ attachment }) => attachmentIdentity(attachment) === requested.clip) ?? sources[0]!;
139
+ const { attachment, url } = shown;
140
+ const check = detail.data!.check;
141
+ const steps = stepsOfRecording(check, attachmentIdentity(attachment));
112
142
  return (
113
143
  <Stack gap="sm">
114
- {sources.slice(0, 1).map(({ attachment, url }) => (
115
- <Stack key={url} gap="xs">
116
- {/* What the recording does and does not show, as its producer declared it; undeclared says unknown. */}
117
- <ScopeNotice
118
- scope={effectiveScope(attachment.scope, detail.data?.check.scope, detail.data?.inheritedScope)}
119
- name={attachment.name}
120
- />
121
- <Box
122
- component="video"
123
- src={url}
124
- controls
125
- preload="metadata"
126
- aria-label={`Recording: ${attachment.description ?? attachment.name}`}
127
- maw="100%"
128
- w="100%"
129
- mah="70vh"
130
- />
131
- </Stack>
132
- ))}
144
+ {/* What the recording does and does not show, as its producer declared it; undeclared says unknown. */}
145
+ <ScopeProvider scope={effectiveScope(check.scope, detail.data?.inheritedScope)}>
146
+ <MomentPlayback steps={steps} attachments={shown.list} layer={result.layer.id}>
147
+ <ScopeNotice scope={effectiveScope(attachment.scope, check.scope, detail.data?.inheritedScope)} name={attachment.name} />
148
+ <WatchVideo attachment={attachment} url={url} />
149
+ {steps.length > 0 && <Timeline steps={steps} layer={result.layer.id} />}
150
+ </MomentPlayback>
151
+ </ScopeProvider>
133
152
  {sources.length > 1 && (
134
153
  <Text component="div" c="dimmed">
135
154
  {plural(sources.length - 1, 'more recording', 'more recordings')} in Inspect.
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { activeIndex, alignStep, clock, mediaTimingOf, pausedMs, UNALIGNED } from './media-timing';
4
+
5
+ const video = (media: unknown) => ({ extensions: { 'atlas.media': media } });
6
+
7
+ describe('what a recording declares about its clock', () => {
8
+ test('no declaration is no timing, never a timing that starts with the check', () => {
9
+ expect(mediaTimingOf({})).toBeUndefined();
10
+ expect(mediaTimingOf({ extensions: { 'com.example': {} } })).toBeUndefined();
11
+ expect(alignStep({ id: 'a', offsetMs: 0 }, undefined, 5000)).toEqual({ state: 'unavailable', reason: UNALIGNED.noTiming });
12
+ });
13
+
14
+ test('a declaration that cannot be read as a whole places nothing', () => {
15
+ for (const bad of [
16
+ 'text',
17
+ [],
18
+ { checkStartMs: '1000' },
19
+ { checkStartMs: 0, pauses: [{ atMs: 10 }] },
20
+ { checkStartMs: 0, pauses: 'long' },
21
+ { checkStartMs: 0, marks: [{ step: 'a' }] }
22
+ ]) {
23
+ const timing = mediaTimingOf(video(bad));
24
+ expect(timing?.problem).toBeDefined();
25
+ const aligned = alignStep({ id: 'a', offsetMs: 1 }, timing, undefined);
26
+ expect(aligned.state).toBe('unavailable');
27
+ }
28
+ });
29
+
30
+ test('a declaration without an origin places only the steps it marks', () => {
31
+ const timing = mediaTimingOf(video({ marks: [{ step: 'a', mediaMs: 700 }] }));
32
+ expect(alignStep({ id: 'a', offsetMs: 5 }, timing, undefined)).toEqual({ state: 'aligned', mediaMs: 700, source: 'mark', pausedMs: 0 });
33
+ expect(alignStep({ id: 'b', offsetMs: 5 }, timing, undefined)).toEqual({ state: 'unavailable', reason: UNALIGNED.noOrigin });
34
+ });
35
+ });
36
+
37
+ describe('placing a step in a recording', () => {
38
+ const timing = mediaTimingOf(video({ checkStartMs: 1200, pauses: [{ atMs: 2000, durationMs: 500 }, { atMs: 500, durationMs: 300, reason: 'title' }] }));
39
+
40
+ test('the recording starts before the check: the step is the origin plus its offset', () => {
41
+ expect(alignStep({ offsetMs: 0 }, timing, 20_000)).toMatchObject({ state: 'aligned', mediaMs: 1200, source: 'offset' });
42
+ expect(alignStep({ offsetMs: 100 }, timing, 20_000)).toMatchObject({ mediaMs: 1300 });
43
+ });
44
+
45
+ test('delays put into the recording move the steps after them and not the ones before', () => {
46
+ expect(alignStep({ offsetMs: 499 }, timing, undefined)).toMatchObject({ mediaMs: 1699, pausedMs: 0 });
47
+ expect(alignStep({ offsetMs: 500 }, timing, undefined)).toMatchObject({ mediaMs: 2000, pausedMs: 300 });
48
+ expect(alignStep({ offsetMs: 2000 }, timing, undefined)).toMatchObject({ mediaMs: 4000, pausedMs: 800 });
49
+ expect(pausedMs(timing)).toBe(800);
50
+ expect(timing!.pauses.map((pause) => pause.reason)).toEqual(['title', undefined]);
51
+ });
52
+
53
+ test('a mark states the moment outright and wins over the offset', () => {
54
+ const marked = mediaTimingOf(video({ checkStartMs: 0, marks: [{ step: 'a', mediaMs: 9 }] }));
55
+ expect(alignStep({ id: 'a', offsetMs: 5000 }, marked, undefined)).toMatchObject({ mediaMs: 9, source: 'mark' });
56
+ });
57
+
58
+ test('a step with no offset, before the start, or after the end is unavailable and is never clamped', () => {
59
+ expect(alignStep({ id: 'a' }, timing, undefined)).toEqual({ state: 'unavailable', reason: UNALIGNED.noOffset });
60
+ const early = mediaTimingOf(video({ checkStartMs: -500 }));
61
+ expect(alignStep({ offsetMs: 100 }, early, undefined)).toMatchObject({ state: 'unavailable', reason: expect.stringContaining('before the recording starts') });
62
+ expect(alignStep({ offsetMs: 100 }, early, undefined)).not.toHaveProperty('mediaMs');
63
+ expect(alignStep({ offsetMs: 9000 }, timing, 5000)).toMatchObject({ state: 'unavailable', reason: expect.stringContaining('after the recording ends') });
64
+ // A length that is not known never excludes a moment.
65
+ expect(alignStep({ offsetMs: 9000 }, timing, undefined).state).toBe('aligned');
66
+ });
67
+ });
68
+
69
+ describe('the step playing', () => {
70
+ const placed = [[0, 1000], [1, 3000], [2, 3000], [3, 6000]] as const;
71
+ test('is the latest step started, the innermost of several that started together, and none before the first', () => {
72
+ expect(activeIndex(placed, 500)).toBeNull();
73
+ expect(activeIndex(placed, 1000)).toBe(0);
74
+ expect(activeIndex(placed, 2999)).toBe(0);
75
+ expect(activeIndex(placed, 3000)).toBe(2);
76
+ expect(activeIndex(placed, 99_000)).toBe(3);
77
+ expect(activeIndex([], 10)).toBeNull();
78
+ });
79
+
80
+ test('a position reads as minutes, seconds and tenths', () => {
81
+ expect(clock(0)).toBe('0:00.0');
82
+ expect(clock(4250)).toBe('0:04.3');
83
+ expect(clock(61_000)).toBe('1:01.0');
84
+ });
85
+ });
@@ -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
+ }