@crvouga/atlas 1.9.0 → 1.11.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 +15 -1
- package/dist/reports/src/review-collection-store.d.ts +47 -0
- package/dist/reports/src/review-collection-store.js +214 -0
- package/dist/reports/src/review-collection.d.ts +88 -0
- package/dist/reports/src/review-collection.js +144 -0
- package/dist/reports/src/test-catalog.d.ts +2 -0
- package/dist/reports/src/test-catalog.js +1 -1
- package/dist/schema/src/api.d.ts +574 -0
- package/dist/schema/src/api.js +152 -1
- package/dist/server/src/app.js +4 -1
- package/dist/server/src/openapi.js +46 -1
- package/dist/server/src/review-collections.d.ts +39 -0
- package/dist/server/src/review-collections.js +179 -0
- package/docs/extending.md +1 -1
- package/docs/reference/http-api.md +122 -0
- package/docs/reference/reports.md +294 -0
- package/docs/reference/schema.md +131 -0
- package/docs/reference/server.md +44 -0
- package/docs/rich-evidence.md +38 -0
- package/docs/server.md +41 -0
- package/docs/ui.md +67 -1
- package/llms-full.txt +269 -2
- package/package.json +1 -1
- package/packages/schema/schemas/review-collection.schema.json +398 -0
- package/packages/schema/schemas/review-manifest.schema.json +475 -0
- package/packages/server/openapi.json +6729 -4084
- package/packages/visualizer/src/app/router.tsx +6 -1
- package/packages/visualizer/src/evidence/applicability-view.tsx +233 -0
- package/packages/visualizer/src/evidence/applicability.test.ts +131 -0
- package/packages/visualizer/src/evidence/applicability.ts +241 -0
- package/packages/visualizer/src/evidence/artifact-tags.tsx +3 -2
- package/packages/visualizer/src/evidence/behavior-panel.tsx +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +26 -2
- package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
- package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +11 -1
- package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
- package/packages/visualizer/src/explorer/collection-model.ts +45 -0
- package/packages/visualizer/src/explorer/collections.tsx +513 -0
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +51 -0
- package/packages/visualizer/src/explorer/playlist-entries.ts +163 -0
- package/packages/visualizer/src/explorer/playlist.tsx +500 -0
- package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
- package/packages/visualizer/src/explorer/recording-text.ts +490 -0
- package/packages/visualizer/src/explorer/search.ts +10 -2
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
|
|
2
|
+
import { indexCorpus, matchText } from '../find/rank';
|
|
3
|
+
import { parseSearch } from '../find/query';
|
|
4
|
+
import type { Attachment } from '@crvouga/atlas/schema';
|
|
5
|
+
import { expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import { clock, coverageCounts, coverageOf, indexTrack, parseTimestamp, rangeWord, segmentDoc, segmentsOf, tracksOf, trackReadable, uncertaintyWord } from './recording-text';
|
|
8
|
+
import type { PlaylistEntry } from './playlist-entries';
|
|
9
|
+
|
|
10
|
+
const video = (name: string, extra: Partial<Attachment> = {}): Attachment => ({ name, contentType: 'video/webm', bundleKey: `attachments/${name}`, sha256: name, ...extra });
|
|
11
|
+
const track = (name: string, extra: Partial<Attachment> = {}): Attachment => ({ name, contentType: 'text/vtt', bundleKey: `attachments/${name}`, ...extra });
|
|
12
|
+
const detail = (check: Record<string, unknown>): CheckDetail => ({ layer: 'l', kind: 'gherkin', check: { id: 'pay', title: 'Pay', status: 'passed', ...check } as never, run: {} });
|
|
13
|
+
const entry: PlaylistEntry = { id: 'e', title: 'Pay', key: 'k', specPath: 'a.feature', status: 'failed', environment: 'web', producer: 'p', at: 1, layer: 'l', backend: 'local', check: 'pay', kind: null, tags: [], videos: [] };
|
|
14
|
+
|
|
15
|
+
const VTT = `WEBVTT
|
|
16
|
+
|
|
17
|
+
NOTE a comment
|
|
18
|
+
|
|
19
|
+
1
|
|
20
|
+
00:00:01.000 --> 00:00:03.500 align:start
|
|
21
|
+
Please confirm the <b>delivery</b> address
|
|
22
|
+
|
|
23
|
+
00:01:02.5 --> 00:01:04.000
|
|
24
|
+
Thank you
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
test('WebVTT and SubRip cues become timed segments with their markup removed', () => {
|
|
28
|
+
const result = indexTrack(tracksOf(detail({ attachments: [video('a.webm'), track('t.vtt')] }))[0]!, VTT);
|
|
29
|
+
expect(result.state).toBe('indexed');
|
|
30
|
+
expect(result.timing).toBe('valid');
|
|
31
|
+
expect(result.segments).toEqual([
|
|
32
|
+
{ text: 'Please confirm the delivery address', startMs: 1000, endMs: 3500 },
|
|
33
|
+
{ text: 'Thank you', startMs: 62_500, endMs: 64_000 }
|
|
34
|
+
]);
|
|
35
|
+
const srt = indexTrack(tracksOf(detail({ attachments: [video('a.webm'), track('t.srt', { contentType: 'application/x-subrip' })] }))[0]!, '1\n00:00:01,200 --> 00:00:02,000\nHello there\n');
|
|
36
|
+
expect(srt.segments).toEqual([{ text: 'Hello there', startMs: 1200, endMs: 2000 }]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('JSON segments take seconds or milliseconds and keep only a confidence between 0 and 1', () => {
|
|
40
|
+
const t = tracksOf(detail({ attachments: [video('a.webm'), track('o.json', { contentType: 'application/json', role: 'ocr' })] }))[0]!;
|
|
41
|
+
const result = indexTrack(t, JSON.stringify({ segments: [{ start: 1, end: 2.5, text: 'Order total', confidence: 0.62 }, { startMs: 4000, endMs: 3000, text: 'Backwards' }, { text: 'No time' }, { text: 'Odd', confidence: 7 }] }));
|
|
42
|
+
expect(result.segments.map((s) => [s.text, s.startMs, s.endMs, s.confidence])).toEqual([
|
|
43
|
+
['Order total', 1000, 2500, 0.62],
|
|
44
|
+
['Backwards', null, null, undefined],
|
|
45
|
+
['No time', null, null, undefined],
|
|
46
|
+
['Odd', null, null, undefined]
|
|
47
|
+
]);
|
|
48
|
+
expect(result.timing).toBe('partial');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('timing that is absent or invalid keeps the words searchable and says it cannot seek', () => {
|
|
52
|
+
const plain = indexTrack(tracksOf(detail({ attachments: [video('a.webm'), track('t.txt', { contentType: 'text/plain', role: 'transcript' })] }))[0]!, 'delivery address\n\nthanks');
|
|
53
|
+
expect(plain.timing).toBe('absent');
|
|
54
|
+
expect(plain.segments.map((s) => s.startMs)).toEqual([null, null]);
|
|
55
|
+
const bad = indexTrack(tracksOf(detail({ attachments: [video('a.webm'), track('t.vtt')] }))[0]!, 'WEBVTT\n\nlater --> sooner\nThe words');
|
|
56
|
+
expect(bad.state).toBe('indexed');
|
|
57
|
+
expect(bad.timing).toBe('invalid');
|
|
58
|
+
expect(rangeWord({ segment: bad.segments[0]!, timing: bad.timing })).toBe('Time unusable');
|
|
59
|
+
expect(rangeWord({ segment: plain.segments[0]!, timing: plain.timing })).toBe('No time given');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a form that is not read is unsupported, text that does not parse failed, and an empty file failed', () => {
|
|
63
|
+
const t = tracksOf(detail({ attachments: [video('a.webm'), track('t.vtt')] }))[0]!;
|
|
64
|
+
expect(indexTrack({ ...t, attachment: track('x.bin', { contentType: 'application/pdf', role: 'transcript' }) }, '').state).toBe('unsupported');
|
|
65
|
+
expect(indexTrack({ ...t, attachment: track('x.json', { contentType: 'application/json', role: 'transcript' }) }, '{nope').state).toBe('failed');
|
|
66
|
+
expect(indexTrack({ ...t, attachment: track('x.json', { contentType: 'application/json', role: 'transcript' }) }, '{"nothing":1}').state).toBe('failed');
|
|
67
|
+
expect(indexTrack(t, 'WEBVTT\n').state).toBe('failed');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a track belongs to the video that lists it, else the only video at its level, else to none', () => {
|
|
71
|
+
const linked = tracksOf(detail({ attachments: [video('a.webm', { id: 'v1', captions: ['t1'] }), video('b.webm', { id: 'v2' }), track('t1.vtt', { id: 't1' })] }));
|
|
72
|
+
expect(linked[0]!.recording).toBe('sha256:a.webm');
|
|
73
|
+
const named = tracksOf(detail({ attachments: [video('a.webm', { id: 'v1' }), video('b.webm', { id: 'v2' }), track('t2.vtt', { extensions: { 'atlas.text': { recording: 'v2' } } })] }));
|
|
74
|
+
expect(named[0]!.recording).toBe('sha256:b.webm');
|
|
75
|
+
expect(tracksOf(detail({ attachments: [video('a.webm'), track('t.vtt')] }))[0]!.recording).toBe('sha256:a.webm');
|
|
76
|
+
expect(tracksOf(detail({ attachments: [video('a.webm'), video('b.webm'), track('t.vtt')] }))[0]!.recording).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('each retry attempt keeps its own video and words, with the attempt named', () => {
|
|
80
|
+
const d = detail({
|
|
81
|
+
status: 'passed',
|
|
82
|
+
attempts: [
|
|
83
|
+
{ index: 0, status: 'failed', attachments: [video('first.webm'), track('first.vtt')] },
|
|
84
|
+
{ index: 1, status: 'passed', attachments: [video('second.webm'), track('second.vtt')] }
|
|
85
|
+
]
|
|
86
|
+
});
|
|
87
|
+
const tracks = tracksOf(d);
|
|
88
|
+
expect(tracks.map((t) => [t.attempt, t.recording])).toEqual([[0, 'sha256:first.webm'], [1, 'sha256:second.webm']]);
|
|
89
|
+
const results = [
|
|
90
|
+
indexTrack(tracks[0]!, 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\ncard declined banner'),
|
|
91
|
+
indexTrack(tracks[1]!, 'WEBVTT\n\n00:00:05.000 --> 00:00:06.000\npayment accepted banner')
|
|
92
|
+
];
|
|
93
|
+
const videos = coverageOf(d, results);
|
|
94
|
+
const segments = segmentsOf(entry, d, results, videos);
|
|
95
|
+
expect(segments.map((s) => [s.segment.text, s.attempt, s.attemptLabel, s.recording])).toEqual([
|
|
96
|
+
['card declined banner', 0, 'Attempt 1 of 2 (failed)', 'sha256:first.webm'],
|
|
97
|
+
['payment accepted banner', 1, 'Attempt 2 of 2 (passed)', 'sha256:second.webm']
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('no text, a silent recording, an unreadable file and a searched recording are four different states', () => {
|
|
102
|
+
const d = detail({
|
|
103
|
+
attachments: [
|
|
104
|
+
video('none.webm'),
|
|
105
|
+
video('silent.webm', { scope: { audioCaptured: false } }),
|
|
106
|
+
video('broken.webm', { id: 'b', captions: ['bt'] }),
|
|
107
|
+
track('bt.vtt', { id: 'bt', extensions: { 'atlas.text': { state: 'failed', reason: 'The recognizer crashed.' } } }),
|
|
108
|
+
video('good.webm', { id: 'g', captions: ['gt'] }),
|
|
109
|
+
track('gt.vtt', { id: 'gt' })
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
const tracks = tracksOf(d);
|
|
113
|
+
const results = tracks.map((t) => (trackReadable(t).readable ? indexTrack(t, 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nhello') : { track: t, state: 'failed' as const, reason: (trackReadable(t) as { reason: string }).reason, segments: [], timing: 'absent' as const, truncated: false }));
|
|
114
|
+
const videos = coverageOf(d, results);
|
|
115
|
+
expect(Object.fromEntries(videos.map((v) => [v.name, v.state]))).toEqual({ 'none.webm': 'not-indexed', 'silent.webm': 'unsupported', 'broken.webm': 'failed', 'good.webm': 'searched' });
|
|
116
|
+
expect(videos.find((v) => v.name === 'broken.webm')!.reason).toBe('The recognizer crashed.');
|
|
117
|
+
expect(coverageCounts(videos)).toEqual({ searched: 1, 'not-indexed': 1, unsupported: 1, failed: 1 });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a file the producer left out, or kept at an address, is never read', () => {
|
|
121
|
+
const [left] = tracksOf(detail({ attachments: [video('a.webm'), track('t.vtt', { omitted: 'not-allowed' })] }));
|
|
122
|
+
expect(trackReadable(left!)).toMatchObject({ readable: false, state: 'failed' });
|
|
123
|
+
const [away] = tracksOf(detail({ attachments: [video('a.webm'), { name: 't.vtt', contentType: 'text/vtt', uri: 'https://example.test/t.vtt' }] }));
|
|
124
|
+
expect(trackReadable(away!)).toMatchObject({ readable: false, state: 'unsupported' });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the shared matcher finds a phrase, a misspelling and screen text in the indexed words only', () => {
|
|
128
|
+
const t = tracksOf(detail({ attachments: [video('a.webm'), track('t.vtt', { role: 'ocr', extensions: { 'atlas.text': { generated: true, generator: 'ocr-tool' } } })] }))[0]!;
|
|
129
|
+
expect(t.kind).toBe('screen');
|
|
130
|
+
const result = indexTrack(t, VTT);
|
|
131
|
+
const d = detail({ attachments: [video('a.webm'), track('t.vtt')] });
|
|
132
|
+
const items = segmentsOf(entry, d, [result], coverageOf(d, [result]));
|
|
133
|
+
const index = indexCorpus(items.map(segmentDoc));
|
|
134
|
+
const found = (query: string) => matchText(index, parseSearch(query), { snippets: false })?.map((hit) => hit.doc.title) ?? null;
|
|
135
|
+
expect(found('delivery address')).toEqual(['Please confirm the delivery address']);
|
|
136
|
+
expect(found('delivry adress')).toEqual(['Please confirm the delivery address']);
|
|
137
|
+
expect(found('refund')).toEqual([]);
|
|
138
|
+
expect(items[0]!.kind).toBe('screen');
|
|
139
|
+
expect(items[0]!.generated).toBe(true);
|
|
140
|
+
expect(uncertaintyWord({ ...items[0]!, segment: { ...items[0]!.segment, confidence: 0.62 } })).toBe('Generated by ocr-tool, confidence 62%. It may contain errors.');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('clocks and timestamps', () => {
|
|
144
|
+
expect(clock(3_725_000)).toBe('1:02:05');
|
|
145
|
+
expect(clock(62_500)).toBe('1:02');
|
|
146
|
+
expect(parseTimestamp('1:02.5')).toBe(62_500);
|
|
147
|
+
expect(parseTimestamp('00:61:00.000')).toBeNull();
|
|
148
|
+
expect(parseTimestamp('soon')).toBeNull();
|
|
149
|
+
});
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Words in recordings
|
|
3
|
+
*
|
|
4
|
+
* What a person said or what was shown on screen in a recording, found as text a producer supplied with it. Atlas never
|
|
5
|
+
* transcribes or reads a screen: it indexes attachments the producer attached on purpose (a WebVTT or SRT file, a JSON
|
|
6
|
+
* list of timed segments, a plain transcript), and says how the text came to be (spoken, on screen, a caption; written
|
|
7
|
+
* by a person or generated, and with what confidence). A match is a way to find a moment, never a statement that the
|
|
8
|
+
* behavior passed: the outcome beside it is the result's own.
|
|
9
|
+
*
|
|
10
|
+
* Which attachment is text for which video: a text track is an attachment whose `extensions["atlas.text"]` says so, whose
|
|
11
|
+
* `role` is `transcript`, `ocr`, `screen-text`, `captions` or `subtitles`, or whose type is WebVTT or SubRip. It belongs to
|
|
12
|
+
* the video that lists its `id` in `captions`, or that its `extensions["atlas.text"].recording` names by `id`, else to the
|
|
13
|
+
* only video held at the same level (the same retry attempt, or the check). Several videos and no link: the words are
|
|
14
|
+
* found but no recording is named. Everything here is pure; reading the files is `RecordingTextSearch.tsx`'s job and
|
|
15
|
+
* goes through the same report API, and so the same access rules, as playing the recording.
|
|
16
|
+
*/
|
|
17
|
+
import { attachmentIdentity, isVideoAttachment, type CheckDetail } from '@crvouga/atlas/reports/evidence-detail';
|
|
18
|
+
import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
|
|
19
|
+
import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
|
|
20
|
+
import type { Attachment, EvidenceScope } from '@crvouga/atlas/schema';
|
|
21
|
+
|
|
22
|
+
import type { FindDoc } from '../find/rank';
|
|
23
|
+
import type { PlaylistEntry } from './playlist-entries';
|
|
24
|
+
|
|
25
|
+
/** The key of the extension a producer uses to describe a text track. */
|
|
26
|
+
export const TEXT_EXTENSION = 'atlas.text';
|
|
27
|
+
|
|
28
|
+
/** Where the words of a segment came from: said aloud, shown on screen, or captions (which a person or a tool wrote for the sound). */
|
|
29
|
+
export type TextKind = 'speech' | 'screen' | 'caption';
|
|
30
|
+
|
|
31
|
+
/** What each kind is called, in a result. */
|
|
32
|
+
export const TEXT_KIND_WORD: Record<TextKind, string> = { speech: 'Spoken (transcript)', screen: 'On screen (OCR)', caption: 'Caption' };
|
|
33
|
+
|
|
34
|
+
/** The longest text read from one track, in characters; more is left unread and said to be. */
|
|
35
|
+
export const MAX_TRACK_CHARS = 2_000_000;
|
|
36
|
+
/** The most segments indexed from one track. */
|
|
37
|
+
export const MAX_SEGMENTS = 5000;
|
|
38
|
+
|
|
39
|
+
/** One indexed piece of text. `startMs` and `endMs` are null when the track gave no usable time for it. */
|
|
40
|
+
export type TextSegment = {
|
|
41
|
+
readonly text: string;
|
|
42
|
+
readonly startMs: number | null;
|
|
43
|
+
readonly endMs: number | null;
|
|
44
|
+
/** The producer's or tool's own confidence, 0 to 1, when it gave one. */
|
|
45
|
+
readonly confidence?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** How well the times of a track's segments can be trusted: all `valid`, some `partial`, none given (`absent`) or none usable (`invalid`). */
|
|
49
|
+
export type TimingState = 'valid' | 'partial' | 'absent' | 'invalid';
|
|
50
|
+
|
|
51
|
+
/** A text track found in a result's evidence, before its file is read. */
|
|
52
|
+
export type TrackRef = {
|
|
53
|
+
readonly attachment: Attachment;
|
|
54
|
+
readonly kind: TextKind;
|
|
55
|
+
readonly generated: boolean;
|
|
56
|
+
readonly generator?: string;
|
|
57
|
+
/** The retry attempt that holds it; null when the check does. */
|
|
58
|
+
readonly attempt: number | null;
|
|
59
|
+
/** The identity of the video it belongs to (`attachmentIdentity`), or null when none is named. */
|
|
60
|
+
readonly recording: string | null;
|
|
61
|
+
/** What the producer declared about its indexing, when it declared a problem. */
|
|
62
|
+
readonly declared?: { readonly state: 'failed' | 'unsupported'; readonly reason: string };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** What reading one track gave: its segments, or why it gave none. */
|
|
66
|
+
export type TrackResult = {
|
|
67
|
+
readonly track: TrackRef;
|
|
68
|
+
readonly state: 'indexed' | 'failed' | 'unsupported';
|
|
69
|
+
/** In words, when it is not indexed. */
|
|
70
|
+
readonly reason?: string;
|
|
71
|
+
readonly segments: readonly TextSegment[];
|
|
72
|
+
readonly timing: TimingState;
|
|
73
|
+
/** True when the track was longer than what is indexed. */
|
|
74
|
+
readonly truncated: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether a recording's words were searched, and if not, why: `searched` (some text was read, so "no match" means no
|
|
79
|
+
* match), `not-indexed` (no text was supplied), `unsupported` (this recording cannot have the text: it is silent, or the
|
|
80
|
+
* text is in a form Atlas does not read) or `failed` (text was supplied and could not be read).
|
|
81
|
+
*/
|
|
82
|
+
export type CoverageState = 'searched' | 'not-indexed' | 'unsupported' | 'failed';
|
|
83
|
+
|
|
84
|
+
/** One video of a result and what is known about searching inside it. */
|
|
85
|
+
export type VideoCoverage = {
|
|
86
|
+
readonly name: string;
|
|
87
|
+
/** `attachmentIdentity` of the video; null when it names no bytes. */
|
|
88
|
+
readonly recording: string | null;
|
|
89
|
+
readonly attempt: number | null;
|
|
90
|
+
readonly state: CoverageState;
|
|
91
|
+
readonly reason: string;
|
|
92
|
+
readonly tracks: readonly TrackResult[];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const ROLES: Readonly<Record<string, TextKind>> = {
|
|
96
|
+
transcript: 'speech',
|
|
97
|
+
transcription: 'speech',
|
|
98
|
+
speech: 'speech',
|
|
99
|
+
ocr: 'screen',
|
|
100
|
+
'screen-text': 'screen',
|
|
101
|
+
captions: 'caption',
|
|
102
|
+
caption: 'caption',
|
|
103
|
+
subtitles: 'caption'
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const type = (attachment: Attachment) => (attachment.contentType ?? contentTypeOf(attachment.name)).split(';')[0]!.trim().toLowerCase();
|
|
107
|
+
|
|
108
|
+
function declaration(attachment: Attachment): Record<string, unknown> | null {
|
|
109
|
+
const value = attachment.extensions?.[TEXT_EXTENSION];
|
|
110
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The kind of text an attachment is, or null when it is not a text track. A video is never one. */
|
|
114
|
+
export function textKindOf(attachment: Attachment): TextKind | null {
|
|
115
|
+
if (isVideoAttachment(attachment)) return null;
|
|
116
|
+
const declared = declaration(attachment);
|
|
117
|
+
const said = typeof declared?.kind === 'string' ? declared.kind.toLowerCase() : '';
|
|
118
|
+
if (said === 'speech' || said === 'screen' || said === 'caption') return said;
|
|
119
|
+
const role = attachment.role?.toLowerCase();
|
|
120
|
+
if (role && ROLES[role]) return ROLES[role]!;
|
|
121
|
+
if (declared) return 'speech';
|
|
122
|
+
const media = type(attachment);
|
|
123
|
+
return media === 'text/vtt' || media === 'application/x-subrip' ? 'caption' : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type Group = { readonly attempt: number | null; readonly attachments: readonly Attachment[]; readonly scope: EvidenceScope | undefined };
|
|
127
|
+
|
|
128
|
+
/** The attachments of the check, then of each retry attempt, with their steps' attachments, each group with the scope its attempt declares. */
|
|
129
|
+
function groupsOf(detail: CheckDetail): Group[] {
|
|
130
|
+
const own = [...(detail.check.attachments ?? []), ...(detail.check.steps ?? []).flatMap((step) => step.attachments ?? [])];
|
|
131
|
+
const groups: Group[] = [{ attempt: null, attachments: own, scope: undefined }];
|
|
132
|
+
for (const attempt of detail.check.attempts ?? []) {
|
|
133
|
+
groups.push({
|
|
134
|
+
attempt: attempt.index,
|
|
135
|
+
attachments: [...(attempt.attachments ?? []), ...(attempt.steps ?? []).flatMap((step) => step.attachments ?? [])],
|
|
136
|
+
scope: attempt.scope
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return groups;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function referenceTo(track: Attachment, videos: readonly Attachment[], pool: readonly Attachment[]): Attachment | null {
|
|
143
|
+
const declared = declaration(track);
|
|
144
|
+
const named = typeof declared?.recording === 'string' ? declared.recording : undefined;
|
|
145
|
+
const claimed = pool.filter((video) => (track.id !== undefined && video.captions?.includes(track.id)) || (named !== undefined && video.id === named));
|
|
146
|
+
if (claimed.length > 0) return claimed[0]!;
|
|
147
|
+
return videos.length === 1 ? videos[0]! : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The text tracks of a result, each tied to its video when that can be told, with the retry attempt that holds it. */
|
|
151
|
+
export function tracksOf(detail: CheckDetail): TrackRef[] {
|
|
152
|
+
const groups = groupsOf(detail);
|
|
153
|
+
const allVideos = groups.flatMap((group) => group.attachments.filter(isVideoAttachment));
|
|
154
|
+
const out: TrackRef[] = [];
|
|
155
|
+
for (const group of groups) {
|
|
156
|
+
const videos = group.attachments.filter(isVideoAttachment);
|
|
157
|
+
for (const attachment of group.attachments) {
|
|
158
|
+
const kind = textKindOf(attachment);
|
|
159
|
+
if (!kind) continue;
|
|
160
|
+
const declared = declaration(attachment);
|
|
161
|
+
const state = typeof declared?.state === 'string' ? declared.state.toLowerCase() : typeof declared?.indexing === 'string' ? declared.indexing.toLowerCase() : '';
|
|
162
|
+
const reason = typeof declared?.reason === 'string' ? declared.reason : '';
|
|
163
|
+
// Unlinked at its own level, a track of the check may still name a video of an attempt, and the reverse.
|
|
164
|
+
const video = referenceTo(attachment, videos, allVideos) ?? (videos.length === 0 && group.attempt !== null ? referenceTo(attachment, groups[0]!.attachments.filter(isVideoAttachment), allVideos) : null);
|
|
165
|
+
out.push({
|
|
166
|
+
attachment,
|
|
167
|
+
kind,
|
|
168
|
+
generated: declared?.generated === true,
|
|
169
|
+
...(typeof declared?.generator === 'string' ? { generator: declared.generator } : {}),
|
|
170
|
+
attempt: group.attempt,
|
|
171
|
+
recording: video ? (attachmentIdentity(video) ?? null) : null,
|
|
172
|
+
...(state === 'failed' || state === 'unsupported'
|
|
173
|
+
? { declared: { state: state as 'failed' | 'unsupported', reason: reason || (state === 'failed' ? 'The producer reported that indexing this text failed.' : 'The producer reported that this text is not supported.') } }
|
|
174
|
+
: {})
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The videos of a result: where each is held, and the scope that says whether it has sound. */
|
|
182
|
+
export function videosOf(detail: CheckDetail): { attachment: Attachment; attempt: number | null; scope: EvidenceScope | undefined }[] {
|
|
183
|
+
return groupsOf(detail).flatMap((group) =>
|
|
184
|
+
group.attachments.filter(isVideoAttachment).map((attachment) => ({ attachment, attempt: group.attempt, scope: effectiveScope(attachment.scope, group.scope, detail.check.scope, detail.inheritedScope) }))
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What a track is called in a note: its description, else its file name. */
|
|
189
|
+
export function trackLabel(track: TrackRef): string {
|
|
190
|
+
return track.attachment.description ?? track.attachment.name;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Whether a stored or inline track can be read here: its bytes are in the layer or inline, and its producer did not leave them out. */
|
|
194
|
+
export function trackReadable(track: TrackRef): { readable: true } | { readable: false; state: 'failed' | 'unsupported'; reason: string } {
|
|
195
|
+
const attachment = track.attachment;
|
|
196
|
+
if (track.declared) return { readable: false, ...track.declared };
|
|
197
|
+
if (attachment.omitted) return { readable: false, state: 'failed', reason: `The producer left this file out (${attachment.omitted}), so its words are not searched.` };
|
|
198
|
+
if (attachment.text === undefined && !attachment.bundleKey)
|
|
199
|
+
return { readable: false, state: 'unsupported', reason: attachment.uri ? 'The text is kept at an address outside the report, which Atlas does not fetch for search.' : 'The attachment names no text.' };
|
|
200
|
+
return { readable: true };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type Format = 'vtt' | 'srt' | 'json' | 'plain';
|
|
204
|
+
|
|
205
|
+
function formatOf(attachment: Attachment): Format | null {
|
|
206
|
+
const media = type(attachment);
|
|
207
|
+
if (media === 'text/vtt') return 'vtt';
|
|
208
|
+
if (media === 'application/x-subrip' || media === 'text/srt') return 'srt';
|
|
209
|
+
if (media.includes('json')) return 'json';
|
|
210
|
+
if (media === 'text/plain') return 'plain';
|
|
211
|
+
if (media === 'application/octet-stream' || media === '') {
|
|
212
|
+
const extension = /\.([a-z0-9]+)$/i.exec(attachment.name)?.[1]?.toLowerCase();
|
|
213
|
+
if (extension === 'vtt') return 'vtt';
|
|
214
|
+
if (extension === 'srt') return 'srt';
|
|
215
|
+
if (extension === 'json') return 'json';
|
|
216
|
+
if (extension === 'txt') return 'plain';
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `00:01:02.500`, `01:02.500` or `00:00:01,5`, in milliseconds; null for anything else. */
|
|
222
|
+
export function parseTimestamp(value: string): number | null {
|
|
223
|
+
const match = /^(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?$/.exec(value.trim());
|
|
224
|
+
if (!match) return null;
|
|
225
|
+
const [hours, minutes, seconds] = [Number(match[1] ?? 0), Number(match[2]), Number(match[3])];
|
|
226
|
+
if (minutes > 59 || seconds > 59) return null;
|
|
227
|
+
const millis = match[4] ? Number(match[4].padEnd(3, '0')) : 0;
|
|
228
|
+
return ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function range(start: number | null, end: number | null): { startMs: number | null; endMs: number | null } {
|
|
232
|
+
if (start === null || end === null || !Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) return { startMs: null, endMs: null };
|
|
233
|
+
return { startMs: Math.round(start), endMs: Math.round(end) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const TAGS = /<[^>]*>/g;
|
|
237
|
+
|
|
238
|
+
function cues(raw: string): { segments: TextSegment[]; timed: boolean } {
|
|
239
|
+
const segments: TextSegment[] = [];
|
|
240
|
+
let timed = false;
|
|
241
|
+
for (const block of raw.replace(/\r\n?/g, '\n').split(/\n{2,}/)) {
|
|
242
|
+
const lines = block.split('\n');
|
|
243
|
+
const at = lines.findIndex((line) => line.includes('-->'));
|
|
244
|
+
if (at < 0) continue;
|
|
245
|
+
if (/^(NOTE|STYLE|REGION)\b/.test(lines[0]!.trim())) continue;
|
|
246
|
+
timed = true;
|
|
247
|
+
const [from = '', to = ''] = lines[at]!.split('-->');
|
|
248
|
+
const text = lines
|
|
249
|
+
.slice(at + 1)
|
|
250
|
+
.join(' ')
|
|
251
|
+
.replace(TAGS, '')
|
|
252
|
+
.replace(/\s+/g, ' ')
|
|
253
|
+
.trim();
|
|
254
|
+
if (!text) continue;
|
|
255
|
+
// Cue settings follow the end time (`align:start`).
|
|
256
|
+
segments.push({ text, ...range(parseTimestamp(from), parseTimestamp(to.trim().split(/\s+/)[0] ?? '')) });
|
|
257
|
+
}
|
|
258
|
+
return { segments, timed };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function seconds(value: unknown): number | null {
|
|
262
|
+
return typeof value === 'number' && Number.isFinite(value) ? value * 1000 : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function milliseconds(value: unknown): number | null {
|
|
266
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function jsonSegments(raw: string): { segments: TextSegment[]; timed: boolean } {
|
|
270
|
+
const parsed: unknown = JSON.parse(raw);
|
|
271
|
+
const record = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
|
272
|
+
const list = Array.isArray(parsed) ? parsed : (record.segments ?? record.cues ?? record.results ?? record.lines);
|
|
273
|
+
if (!Array.isArray(list)) throw new Error('The JSON holds no list of segments (`segments`, `cues`, `results` or `lines`).');
|
|
274
|
+
const segments: TextSegment[] = [];
|
|
275
|
+
let timed = false;
|
|
276
|
+
for (const item of list) {
|
|
277
|
+
if (item === null || typeof item !== 'object') continue;
|
|
278
|
+
const entry = item as Record<string, unknown>;
|
|
279
|
+
const text = typeof entry.text === 'string' ? entry.text.replace(/\s+/g, ' ').trim() : '';
|
|
280
|
+
if (!text) continue;
|
|
281
|
+
const hasTime = ['startMs', 'endMs', 'start', 'end'].some((key) => entry[key] !== undefined);
|
|
282
|
+
if (hasTime) timed = true;
|
|
283
|
+
const start = entry.startMs !== undefined ? milliseconds(entry.startMs) : seconds(entry.start);
|
|
284
|
+
const end = entry.endMs !== undefined ? milliseconds(entry.endMs) : seconds(entry.end);
|
|
285
|
+
const confidence = typeof entry.confidence === 'number' && entry.confidence >= 0 && entry.confidence <= 1 ? entry.confidence : undefined;
|
|
286
|
+
segments.push({ text, ...range(start, end), ...(confidence === undefined ? {} : { confidence }) });
|
|
287
|
+
}
|
|
288
|
+
return { segments, timed };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Indexes the text of a track. A format Atlas does not read is `unsupported`; text that does not parse is `failed`;
|
|
293
|
+
* otherwise its segments are indexed, with `timing` saying whether their times can be used to seek. Words are
|
|
294
|
+
* kept even when the times are not, so they can still be found; they just cannot be jumped to.
|
|
295
|
+
*/
|
|
296
|
+
export function indexTrack(track: TrackRef, raw: string): TrackResult {
|
|
297
|
+
const format = formatOf(track.attachment);
|
|
298
|
+
if (!format) return { track, state: 'unsupported', reason: `The text is in a form Atlas does not read (${type(track.attachment)}). WebVTT, SubRip, JSON segments and plain text are read.`, segments: [], timing: 'absent', truncated: false };
|
|
299
|
+
const truncated = raw.length > MAX_TRACK_CHARS;
|
|
300
|
+
const text = truncated ? raw.slice(0, MAX_TRACK_CHARS) : raw;
|
|
301
|
+
let found: { segments: TextSegment[]; timed: boolean };
|
|
302
|
+
try {
|
|
303
|
+
if (format === 'json') found = jsonSegments(text);
|
|
304
|
+
else if (format === 'plain') found = { segments: text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => ({ text: line, startMs: null, endMs: null })), timed: false };
|
|
305
|
+
else found = cues(text);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return { track, state: 'failed', reason: `The text could not be read: ${error instanceof Error ? error.message : 'it is not valid'}.`, segments: [], timing: 'absent', truncated };
|
|
308
|
+
}
|
|
309
|
+
const segments = found.segments.slice(0, MAX_SEGMENTS);
|
|
310
|
+
if (segments.length === 0) return { track, state: 'failed', reason: 'The file holds no text segments.', segments: [], timing: 'absent', truncated };
|
|
311
|
+
const usable = segments.filter((segment) => segment.startMs !== null).length;
|
|
312
|
+
const timing: TimingState = !found.timed ? 'absent' : usable === segments.length ? 'valid' : usable === 0 ? 'invalid' : 'partial';
|
|
313
|
+
return { track, state: 'indexed', segments, timing, truncated: truncated || found.segments.length > MAX_SEGMENTS };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The result of a track that could not be read at all (the report refused it, or it was not found). */
|
|
317
|
+
export function unreadTrack(track: TrackRef, reason: string): TrackResult {
|
|
318
|
+
return { track, state: 'failed', reason, segments: [], timing: 'absent', truncated: false };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The sentence that says why a video has no searched text, per state. */
|
|
322
|
+
const COVERAGE_REASON: Record<Exclude<CoverageState, 'searched'>, string> = {
|
|
323
|
+
'not-indexed': 'No transcript, captions or screen text were supplied for this recording, so nothing in it can be searched.',
|
|
324
|
+
unsupported: 'This recording cannot be searched by words.',
|
|
325
|
+
failed: 'Text was supplied for this recording and could not be read.'
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* What searching inside each video of a result amounts to. A video with at least one readable track is `searched`: if no
|
|
330
|
+
* words match, that is a real "no match". Otherwise it says which of three different things is true, never one blank
|
|
331
|
+
* "nothing found": no text was supplied (`not-indexed`); the recording is silent and no screen text was supplied, or the
|
|
332
|
+
* text is in a form that is not read (`unsupported`); or the text could not be read (`failed`).
|
|
333
|
+
*/
|
|
334
|
+
export function coverageOf(detail: CheckDetail, results: readonly TrackResult[]): VideoCoverage[] {
|
|
335
|
+
return videosOf(detail).map(({ attachment, attempt, scope }) => {
|
|
336
|
+
const recording = attachmentIdentity(attachment) ?? null;
|
|
337
|
+
const mine = results.filter((result) => result.track.recording === recording && recording !== null && (result.track.attempt === attempt || result.track.attempt === null || attempt === null));
|
|
338
|
+
const base = { name: attachment.description ?? attachment.name, recording, attempt, tracks: mine };
|
|
339
|
+
if (mine.some((result) => result.state === 'indexed')) return { ...base, state: 'searched', reason: '' };
|
|
340
|
+
const failed = mine.find((result) => result.state === 'failed');
|
|
341
|
+
if (failed) return { ...base, state: 'failed', reason: failed.reason ?? COVERAGE_REASON.failed };
|
|
342
|
+
const unsupported = mine.find((result) => result.state === 'unsupported');
|
|
343
|
+
if (unsupported) return { ...base, state: 'unsupported', reason: unsupported.reason ?? COVERAGE_REASON.unsupported };
|
|
344
|
+
if (scope?.audioCaptured === false) return { ...base, state: 'unsupported', reason: 'This recording is silent (no audio was captured) and no screen text was supplied, so there are no spoken words to search.' };
|
|
345
|
+
return { ...base, state: 'not-indexed', reason: COVERAGE_REASON['not-indexed'] };
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** One indexed segment with whom it belongs to, ready to be matched and opened. */
|
|
350
|
+
export type IndexedSegment = {
|
|
351
|
+
/** The id of its search document. */
|
|
352
|
+
readonly id: string;
|
|
353
|
+
readonly entry: PlaylistEntry;
|
|
354
|
+
readonly segment: TextSegment;
|
|
355
|
+
readonly kind: TextKind;
|
|
356
|
+
readonly generated: boolean;
|
|
357
|
+
readonly generator?: string;
|
|
358
|
+
readonly trackName: string;
|
|
359
|
+
readonly timing: TimingState;
|
|
360
|
+
readonly recording: string | null;
|
|
361
|
+
/** The retry attempt whose evidence holds it; null when the check does. */
|
|
362
|
+
readonly attempt: number | null;
|
|
363
|
+
/** In words: "Attempt 2 of 3 (failed)", or an empty string when the result was not retried. */
|
|
364
|
+
readonly attemptLabel: string;
|
|
365
|
+
readonly videoName: string | null;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** The moment of a recording a search result opens at: the playlist selects this exact attempt's video and seeks only when the time is usable. */
|
|
369
|
+
export type RecordingFocus = {
|
|
370
|
+
/** The playlist entry (result) it belongs to. */
|
|
371
|
+
readonly entryId: string;
|
|
372
|
+
/** The identity of the video to select; null when the track names none, so the result's own recording is shown. */
|
|
373
|
+
readonly recording: string | null;
|
|
374
|
+
readonly startMs: number | null;
|
|
375
|
+
readonly endMs: number | null;
|
|
376
|
+
readonly timing: TimingState;
|
|
377
|
+
readonly kind: TextKind;
|
|
378
|
+
/** The indexed words, shown beside the video exactly as indexed. */
|
|
379
|
+
readonly text: string;
|
|
380
|
+
readonly generated: boolean;
|
|
381
|
+
readonly attemptLabel: string;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** The focus a result opens at. */
|
|
385
|
+
export function focusOf(item: IndexedSegment): RecordingFocus {
|
|
386
|
+
return {
|
|
387
|
+
entryId: item.entry.id,
|
|
388
|
+
recording: item.recording,
|
|
389
|
+
startMs: item.segment.startMs,
|
|
390
|
+
endMs: item.segment.endMs,
|
|
391
|
+
timing: item.timing,
|
|
392
|
+
kind: item.kind,
|
|
393
|
+
text: item.segment.text,
|
|
394
|
+
generated: item.generated,
|
|
395
|
+
attemptLabel: item.attemptLabel
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The words for a retry attempt: which one of how many, and how it ended. */
|
|
400
|
+
export function attemptLabel(detail: CheckDetail, attempt: number | null): string {
|
|
401
|
+
const attempts = detail.check.attempts ?? [];
|
|
402
|
+
if (attempt === null || attempts.length === 0) return '';
|
|
403
|
+
const found = attempts.find((item) => item.index === attempt);
|
|
404
|
+
return `Attempt ${attempt + 1} of ${attempts.length}${found ? ` (${found.status})` : ''}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Every segment of a result's indexed tracks. */
|
|
408
|
+
export function segmentsOf(entry: PlaylistEntry, detail: CheckDetail, results: readonly TrackResult[], videos: readonly VideoCoverage[]): IndexedSegment[] {
|
|
409
|
+
const out: IndexedSegment[] = [];
|
|
410
|
+
results.forEach((result, trackIndex) => {
|
|
411
|
+
if (result.state !== 'indexed') return;
|
|
412
|
+
const video = videos.find((item) => item.recording !== null && item.recording === result.track.recording && (item.attempt === result.track.attempt || result.track.attempt === null || item.attempt === null));
|
|
413
|
+
result.segments.forEach((segment, index) => {
|
|
414
|
+
out.push({
|
|
415
|
+
id: `words:${entry.id}:${trackIndex}:${index}`,
|
|
416
|
+
entry,
|
|
417
|
+
segment,
|
|
418
|
+
kind: result.track.kind,
|
|
419
|
+
generated: result.track.generated,
|
|
420
|
+
...(result.track.generator ? { generator: result.track.generator } : {}),
|
|
421
|
+
trackName: trackLabel(result.track),
|
|
422
|
+
timing: segment.startMs === null ? (result.timing === 'absent' ? 'absent' : 'invalid') : result.timing,
|
|
423
|
+
recording: result.track.recording,
|
|
424
|
+
attempt: result.track.attempt ?? video?.attempt ?? null,
|
|
425
|
+
attemptLabel: attemptLabel(detail, result.track.attempt ?? video?.attempt ?? null),
|
|
426
|
+
videoName: video?.name ?? null
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The searchable document of a segment, for the shared matcher. Only the indexed words are matched, so a result can
|
|
435
|
+
* never show more than the track said; the filters (`status:failed`, `env:`, `producer:`, `tag:`, `spec:`) read the
|
|
436
|
+
* result the words belong to.
|
|
437
|
+
*/
|
|
438
|
+
export function segmentDoc(item: IndexedSegment): FindDoc {
|
|
439
|
+
const { entry } = item;
|
|
440
|
+
return {
|
|
441
|
+
id: item.id,
|
|
442
|
+
group: 'Runs',
|
|
443
|
+
kind: 'run',
|
|
444
|
+
title: item.segment.text,
|
|
445
|
+
subtitle: entry.title,
|
|
446
|
+
fields: [{ name: 'title', text: item.segment.text }],
|
|
447
|
+
status: entry.status === 'passed' ? 'passed' : entry.status === 'failed' ? 'failed' : 'none',
|
|
448
|
+
related: [],
|
|
449
|
+
target: { to: '/', search: { test: entry.key } },
|
|
450
|
+
recording: null,
|
|
451
|
+
facets: {
|
|
452
|
+
status: [entry.status],
|
|
453
|
+
env: entry.environment ? [entry.environment] : ['(none)'],
|
|
454
|
+
producer: [entry.producer],
|
|
455
|
+
domain: [],
|
|
456
|
+
spec: entry.specPath ? [entry.specPath] : [],
|
|
457
|
+
tag: [...entry.tags],
|
|
458
|
+
branch: [],
|
|
459
|
+
video: true
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** `m:ss`, or `h:mm:ss` from an hour on. */
|
|
465
|
+
export function clock(ms: number): string {
|
|
466
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
467
|
+
const [h, m, s] = [Math.floor(total / 3600), Math.floor((total % 3600) / 60), total % 60];
|
|
468
|
+
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The time range of a segment in words, or why it has none. */
|
|
472
|
+
export function rangeWord(item: Pick<IndexedSegment, 'segment' | 'timing'>): string {
|
|
473
|
+
const { startMs, endMs } = item.segment;
|
|
474
|
+
if (startMs === null || endMs === null) return item.timing === 'absent' ? 'No time given' : 'Time unusable';
|
|
475
|
+
return startMs === endMs ? clock(startMs) : `${clock(startMs)}–${clock(endMs)}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** What says that generated text may be wrong, and how sure its producer was. */
|
|
479
|
+
export function uncertaintyWord(item: Pick<IndexedSegment, 'generated' | 'generator' | 'segment'>): string {
|
|
480
|
+
const confidence = item.segment.confidence;
|
|
481
|
+
const by = item.generator ? `Generated by ${item.generator}` : 'Generated text';
|
|
482
|
+
return `${by}${confidence === undefined ? '' : `, confidence ${Math.round(confidence * 100)}%`}. It may contain errors.`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Counts of videos by coverage state. */
|
|
486
|
+
export function coverageCounts(videos: readonly VideoCoverage[]): Record<CoverageState, number> {
|
|
487
|
+
const counts: Record<CoverageState, number> = { searched: 0, 'not-indexed': 0, unsupported: 0, failed: 0 };
|
|
488
|
+
for (const video of videos) counts[video.state] += 1;
|
|
489
|
+
return counts;
|
|
490
|
+
}
|