@bubo-squared/gyroview 0.1.0 → 0.3.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 (95) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +21 -5
  3. package/dist/index.d.ts +24 -2
  4. package/dist/packages/adapters/fetch/src/HttpByteStream.js +174 -0
  5. package/dist/packages/adapters/fetch/src/HttpRangeSource.js +14 -170
  6. package/dist/packages/adapters/fetch/src/HttpResource.js +192 -0
  7. package/dist/packages/adapters/fetch/src/contentRange.js +15 -0
  8. package/dist/packages/adapters/fetch/src/httpRequest.js +15 -4
  9. package/dist/packages/adapters/fetch/src/index.js +3 -1
  10. package/dist/packages/adapters/fetch/src/passingFailures.js +24 -0
  11. package/dist/packages/adapters/fetch/src/rangeFailures.js +17 -0
  12. package/dist/packages/adapters/fetch/src/recordingVersion.js +37 -0
  13. package/dist/packages/adapters/mediabunny/src/MediabunnyAudioPackager.js +129 -0
  14. package/dist/packages/adapters/mediabunny/src/MediabunnyCodecReader.js +80 -0
  15. package/dist/packages/adapters/mediabunny/src/SegmentChannel.js +58 -6
  16. package/dist/packages/adapters/mediabunny/src/decoderConfigurations.js +28 -0
  17. package/dist/packages/adapters/mediabunny/src/index.js +3 -2
  18. package/dist/packages/adapters/three/src/shaders/rawLenses.frag.js +1 -1
  19. package/dist/packages/core/src/application/download/BlockStore.js +97 -0
  20. package/dist/packages/core/src/application/download/DownloadedAudioSamples.js +50 -0
  21. package/dist/packages/core/src/application/download/DownloadedVideoTrack.js +122 -0
  22. package/dist/packages/core/src/application/download/FileDownload.js +183 -0
  23. package/dist/packages/core/src/application/download/RecordingBuffer.js +22 -0
  24. package/dist/packages/core/src/application/download/SampleCursor.js +88 -0
  25. package/dist/packages/core/src/application/download/SourceByteStream.js +46 -0
  26. package/dist/packages/core/src/application/download/Transfers.js +75 -0
  27. package/dist/packages/core/src/application/download/startFileDownload.js +37 -0
  28. package/dist/packages/core/src/application/playback/Buffering.js +53 -0
  29. package/dist/packages/core/src/application/playback/DecodePipeline.js +11 -7
  30. package/dist/packages/core/src/application/playback/DecodeRun.js +15 -0
  31. package/dist/packages/core/src/application/playback/PlaybackSession.js +18 -27
  32. package/dist/packages/core/src/application/playback/keyframeTimeAt.js +1 -1
  33. package/dist/packages/core/src/application/playback/probeDecoding.js +15 -1
  34. package/dist/packages/core/src/application/recording/readSampleTable.js +27 -0
  35. package/dist/packages/core/src/application/recording/timeRecording.js +3 -3
  36. package/dist/packages/core/src/domain/container/KeyframeRule.js +7 -0
  37. package/dist/packages/core/src/domain/container/SampleTable.js +29 -0
  38. package/dist/packages/core/src/domain/container/TrackSampleTable.js +96 -0
  39. package/dist/packages/core/src/domain/container/presentationOrder.js +10 -0
  40. package/dist/packages/core/src/domain/download/DownloadPolicy.js +41 -0
  41. package/dist/packages/core/src/domain/download/isReadyToResume.js +27 -0
  42. package/dist/packages/core/src/domain/download/planDownloads.js +175 -0
  43. package/dist/packages/core/src/domain/download/trackNeeds.js +32 -0
  44. package/dist/packages/core/src/domain/format/boxes/boxHeader.js +44 -0
  45. package/dist/packages/core/src/domain/format/boxes/scanBoxes.js +7 -34
  46. package/dist/packages/core/src/domain/format/mp4/keyframeRules.js +81 -0
  47. package/dist/packages/core/src/domain/format/mp4/movieBoxes.js +52 -0
  48. package/dist/packages/core/src/domain/format/mp4/mp4BoxTypes.js +46 -0
  49. package/dist/packages/core/src/domain/format/mp4/mp4Layouts.js +183 -0
  50. package/dist/packages/core/src/domain/format/mp4/parseMovie.js +67 -0
  51. package/dist/packages/core/src/domain/format/mp4/readTable.js +31 -0
  52. package/dist/packages/core/src/domain/format/mp4/sampleLocations.js +69 -0
  53. package/dist/packages/core/src/domain/format/mp4/sampleTiming.js +109 -0
  54. package/dist/packages/core/src/domain/format/mp4/trackHeaders.js +75 -0
  55. package/dist/packages/core/src/domain/view/lensTilesView.js +2 -2
  56. package/dist/packages/core/src/domain/view/magnification.js +1 -1
  57. package/dist/packages/core/src/domain/view/panoramaView.js +1 -1
  58. package/dist/packages/core/src/index.js +9 -1
  59. package/dist/packages/core/src/shared/async/iteration.js +24 -0
  60. package/dist/packages/core/src/shared/binary/ByteRange.js +12 -0
  61. package/dist/packages/core/src/shared/binary/ByteRangeSet.js +85 -0
  62. package/dist/packages/core/src/shared/binary/ByteReader.js +21 -3
  63. package/dist/packages/core/src/shared/binary/concatenated.js +15 -0
  64. package/dist/packages/core/src/shared/errors/GyroViewError.js +1 -0
  65. package/dist/packages/core/src/shared/math/countAtOrBelow.js +17 -0
  66. package/dist/packages/player/src/composition/browserPorts.js +38 -5
  67. package/dist/packages/player/src/composition/buildPipeline.js +15 -3
  68. package/dist/packages/player/src/composition/openInputs.js +51 -109
  69. package/dist/packages/player/src/composition/openRecording.js +13 -2
  70. package/dist/packages/player/src/composition/readInputs.js +111 -0
  71. package/dist/packages/player/src/controls/ChoiceMenu.js +16 -4
  72. package/dist/packages/player/src/controls/PictureMenus.js +22 -8
  73. package/dist/packages/player/src/controls/SeekBar.js +6 -3
  74. package/dist/packages/player/src/controls/SoundControls.js +2 -0
  75. package/dist/packages/player/src/controls/TransportButtons.js +2 -5
  76. package/dist/packages/player/src/controls/Wording.js +11 -9
  77. package/dist/packages/player/src/controls/controlParts.js +5 -3
  78. package/dist/packages/player/src/controls/controls.js +1 -1
  79. package/dist/packages/player/src/controls/controlsBar.js +4 -1
  80. package/dist/packages/player/src/controls/controlsMarkup.js +36 -21
  81. package/dist/packages/player/src/controls/icons.js +32 -8
  82. package/dist/packages/player/src/controls/messages.js +27 -4
  83. package/dist/packages/player/src/controls/sliderFill.js +16 -0
  84. package/dist/packages/player/src/element/ElementLoads.js +7 -0
  85. package/dist/packages/player/src/element/GyroViewElement.js +8 -3
  86. package/dist/packages/player/src/element/attributeNames.js +12 -1
  87. package/dist/packages/player/src/element/attributes.js +29 -7
  88. package/dist/packages/player/src/element/reflectedProperties.js +19 -1
  89. package/dist/packages/player/src/element/styles.js +1 -1
  90. package/dist/packages/player/src/inspectRecording.js +1 -1
  91. package/dist/standalone.js +385 -126
  92. package/package.json +1 -1
  93. package/dist/packages/adapters/mediabunny/src/MediabunnyAudioSegments.js +0 -133
  94. package/dist/packages/adapters/mediabunny/src/MediabunnyDemuxer.js +0 -63
  95. package/dist/packages/adapters/mediabunny/src/MediabunnyVideoTrackReader.js +0 -84
@@ -1,11 +1,15 @@
1
+ import { SourceByteStream } from "../../../core/src/application/download/SourceByteStream.js";
1
2
  import { Deferred } from "../../../core/src/shared/async/Deferred.js";
2
3
  import "../../../core/src/index.js";
3
4
  import { BlobRandomAccessSource } from "../../../adapters/blob/src/BlobRandomAccessSource.js";
4
5
  import "../../../adapters/blob/src/index.js";
6
+ import { HttpByteStream } from "../../../adapters/fetch/src/HttpByteStream.js";
5
7
  import { HttpRangeSource } from "../../../adapters/fetch/src/HttpRangeSource.js";
8
+ import { HttpResource } from "../../../adapters/fetch/src/HttpResource.js";
6
9
  import { HttpResourceLocator } from "../../../adapters/fetch/src/HttpResourceLocator.js";
7
10
  import "../../../adapters/fetch/src/index.js";
8
- import { MediabunnyDemuxer } from "../../../adapters/mediabunny/src/MediabunnyDemuxer.js";
11
+ import { MediabunnyCodecReader } from "../../../adapters/mediabunny/src/MediabunnyCodecReader.js";
12
+ import { MediabunnyAudioPackager } from "../../../adapters/mediabunny/src/MediabunnyAudioPackager.js";
9
13
  import "../../../adapters/mediabunny/src/index.js";
10
14
  import { WebCodecsVideoDecoderPort } from "../../../adapters/webcodecs/src/WebCodecsVideoDecoderPort.js";
11
15
  import "../../../adapters/webcodecs/src/index.js";
@@ -20,17 +24,46 @@ function browserPorts(options = {}) {
20
24
  const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
21
25
  return {
22
26
  sources: browserSources(http),
23
- demuxer: new MediabunnyDemuxer(),
27
+ codecReader: new MediabunnyCodecReader(),
28
+ audioPackager: new MediabunnyAudioPackager(),
24
29
  decoderPort: new WebCodecsVideoDecoderPort(),
25
- locator: new HttpResourceLocator(http),
30
+ locatorFor: (input) => new HttpResourceLocator(requestOptionsFor(http, input)),
26
31
  probeDeadline: () => deadlineIn(probeTimeoutMs)
27
32
  };
28
33
  }
29
34
  /**
30
- * URLs are read with HTTP ranges, blobs by slicing.
35
+ * URLs are read with HTTP ranges, both ways over one resource; blobs by slicing, a range whole.
31
36
  */
32
37
  function browserSources(http) {
33
- return { open: (input, signal) => isUrlInput(input) ? new HttpRangeSource(input.url, http, signal) : new BlobRandomAccessSource(input.blob) };
38
+ return { open: (input, signal) => isUrlInput(input) ? urlSource(input, http, signal) : blobSource(input) };
39
+ }
40
+ function urlSource(input, http, signal) {
41
+ const resource = new HttpResource(input.url, requestOptionsFor(http, input), signal);
42
+ return {
43
+ source: new HttpRangeSource(resource),
44
+ stream: new HttpByteStream(resource)
45
+ };
46
+ }
47
+ function blobSource(input) {
48
+ const source = new BlobRandomAccessSource(input.blob);
49
+ return {
50
+ source,
51
+ stream: new SourceByteStream(source)
52
+ };
53
+ }
54
+ /**
55
+ * The shared request settings, with the input's own credentials in place of theirs when it names
56
+ * any: how the element's `crossorigin` reaches every request for its recording (ADR 0027).
57
+ */
58
+ function requestOptionsFor(http, input) {
59
+ const { credentials } = input;
60
+ return credentials === void 0 ? http : {
61
+ ...http,
62
+ requestInit: {
63
+ ...http.requestInit,
64
+ credentials
65
+ }
66
+ };
34
67
  }
35
68
  /**
36
69
  * A deadline that passes after `ms`: the core has no timers, so the host supplies deadlines.
@@ -5,6 +5,7 @@ import { buildStitchingSetup } from "../../../core/src/domain/stitching/Stitchin
5
5
  import { StabilizingFrameSink } from "../../../core/src/application/stabilization/StabilizingFrameSink.js";
6
6
  import { GainMatchingFrameSink } from "../../../core/src/application/gainMatching/GainMatchingFrameSink.js";
7
7
  import { WallClock } from "../../../core/src/application/playback/WallClock.js";
8
+ import { isFlowing } from "../../../core/src/domain/playback/PlayerState.js";
8
9
  import { PlaybackSession } from "../../../core/src/application/playback/PlaybackSession.js";
9
10
  import "../../../core/src/index.js";
10
11
  import { MediaSourceAudioClock } from "../../../adapters/mse-audio/src/MediaSourceAudioClock.js";
@@ -37,6 +38,7 @@ async function buildPipeline(parts) {
37
38
  disposables.add(() => {
38
39
  session.dispose();
39
40
  });
41
+ readAheadOnceFlowing(session, parts.opened);
40
42
  return {
41
43
  session,
42
44
  renderer: drawing.renderer,
@@ -94,10 +96,10 @@ function pictureSettingsOf(drawing, session) {
94
96
  * otherwise a wall clock, with a warning saying why there is no sound.
95
97
  */
96
98
  async function clockFor(parts) {
97
- const { audioTrack } = parts.opened;
98
- if (!audioTrack) return wallClock(NO_AUDIO_WARNING);
99
+ const { soundSegments } = parts.opened;
100
+ if (!soundSegments) return wallClock(NO_AUDIO_WARNING);
99
101
  try {
100
- const segments = await audioTrack.openSegments();
102
+ const segments = soundSegments();
101
103
  if (!MediaSourceAudioClock.isSupported(segments)) return wallClock(AUDIO_UNSUPPORTED_WARNING);
102
104
  parts.signal.throwIfAborted();
103
105
  return {
@@ -138,6 +140,15 @@ function sinkOver(renderer, opened) {
138
140
  stabilizing
139
141
  };
140
142
  }
143
+ /**
144
+ * The downloads read only what the picture waits for until playing first starts, so a page of
145
+ * players that were never played stays quiet; from then on they read ahead, paused or not.
146
+ */
147
+ function readAheadOnceFlowing(session, opened) {
148
+ session.events.on("statechange", (state) => {
149
+ if (isFlowing(state)) opened.readAhead();
150
+ });
151
+ }
141
152
  function sessionFor(parts, clock, sink) {
142
153
  return new PlaybackSession({
143
154
  frameSources: parts.opened.frameSources,
@@ -145,6 +156,7 @@ function sessionFor(parts, clock, sink) {
145
156
  clock,
146
157
  sink,
147
158
  duration: parts.opened.duration,
159
+ buffer: parts.opened.buffer,
148
160
  pipeline: DECODE_PIPELINE_OPTIONS,
149
161
  queueCapacity: 4
150
162
  });
@@ -1,132 +1,70 @@
1
- import { GyroViewError, ensureInvariant, hasErrorCode } from "../../../core/src/shared/errors/GyroViewError.js";
1
+ import { GyroViewError, ensureInvariant } from "../../../core/src/shared/errors/GyroViewError.js";
2
2
  import { seconds } from "../../../core/src/shared/units/time.js";
3
- import { readRecording } from "../../../core/src/application/recording/readRecording.js";
4
3
  import { timeRecording } from "../../../core/src/application/recording/timeRecording.js";
5
4
  import { detectLensLayout } from "../../../core/src/domain/format/layout/detectLensLayout.js";
6
5
  import { lensFrameOrder } from "../../../core/src/domain/stitching/StitchingSetup.js";
6
+ import { startFileDownload } from "../../../core/src/application/download/startFileDownload.js";
7
+ import { downloadPolicyFor } from "../../../core/src/domain/download/DownloadPolicy.js";
8
+ import { RecordingBuffer } from "../../../core/src/application/download/RecordingBuffer.js";
7
9
  import "../../../core/src/index.js";
8
- import { inputName } from "../PlayerSource.js";
9
10
  import { Disposables } from "./Disposables.js";
10
11
  import { ensureDecodable } from "./ensureDecodable.js";
12
+ import { describedInput, readInputs } from "./readInputs.js";
11
13
  //#region ../../packages/player/src/composition/openInputs.ts
12
14
  /**
13
- * Opens the given inputs as one recording, its metadata from the input that carries the
14
- * trailer. Everything opened is released again when any step fails or the attempt is aborted.
15
+ * Opens the given inputs as one recording (ADR 0029): their sample tables and codecs read, the
16
+ * lens layout and calibration decided, then one download a file, which the decode probe reads
17
+ * through first. Everything opened is released again when any step fails or the attempt is
18
+ * aborted.
15
19
  */
16
20
  async function openInputs(inputs, attempt) {
17
21
  const disposables = new Disposables();
18
22
  try {
19
- const demuxed = await demuxInputs(inputs, attempt, disposables);
20
- const layout = detectLensLayout(demuxed.inputs, demuxed.recording.info);
21
- const frameSources = lensFrameOrder(layout).map((key) => trackAt(demuxed.inputs, key));
22
- const calibration = calibrationOf(demuxed.recording);
23
+ const read = await readInputs(inputs, attempt);
24
+ const described = read.inputs.map((input) => describedInput(input));
25
+ const layout = detectLensLayout(described, read.recording.info);
26
+ const calibration = calibrationOf(read.recording);
27
+ const files = startDownloads(read.inputs, disposables);
28
+ const frameSources = lensFrameOrder(layout).map((key) => trackAt(files, key));
23
29
  await ensureDecodable(frameSources, attempt);
24
- const timing = await timeRecording(demuxed.recording, frameSources[0]);
30
+ const timing = await timeRecording(read.recording, frameSources[0]);
25
31
  attempt.signal.throwIfAborted();
26
32
  return assemble({
27
- demuxed,
33
+ read,
34
+ files,
28
35
  layout,
29
36
  frameSources,
30
37
  calibration,
31
38
  timing
32
- }, disposables);
39
+ }, attempt.ports.audioPackager, disposables);
33
40
  } catch (error) {
34
41
  disposables.disposeAll();
35
42
  throw error;
36
43
  }
37
44
  }
38
- async function demuxInputs(inputs, attempt, disposables) {
39
- const { ports, signal } = attempt;
40
- const given = inputs.map((input) => ({
41
- input,
42
- source: ports.sources.open(input, signal)
43
- }));
44
- const recording = await readRecordingOf(given, ports);
45
- signal.throwIfAborted();
46
- const openings = [...given, ...await declaredSecond(given, recording, attempt)];
47
- const settled = await Promise.allSettled(openings.map(({ input, source }) => ports.demuxer.open(source, inputName(input))));
48
- const opened = settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
49
- for (const input of opened) disposables.add(() => {
50
- input.dispose();
51
- });
52
- const failure = settled.find((result) => result.status === "rejected");
53
- if (failure) throw failure.reason;
54
- signal.throwIfAborted();
55
- return {
56
- recording,
57
- inputs: opened
58
- };
59
- }
60
- /**
61
- * The other lens file a lone file's info record says the recording is split into, when the
62
- * server has it: found before the lone file's tracks are read, which finding it only once they
63
- * fell short would read twice. A file that does not say so still gets its sibling looked for
64
- * once its tracks fall short (see openRecording).
65
- */
66
- async function declaredSecond(given, recording, attempt) {
67
- if (!(given.length === 1 && recording.info.fileLayout === "split-files") || !attempt.findSecondFile) return [];
68
- const url = await attempt.findSecondFile();
69
- attempt.signal.throwIfAborted();
70
- if (url === void 0) return [];
71
- const input = { url };
72
- return [{
73
- input,
74
- source: attempt.ports.sources.open(input, attempt.signal)
75
- }];
76
- }
77
- /**
78
- * The layout hints of a file without an info record: the tracks alone decide.
79
- */
80
- var NO_HINTS = {
81
- fileLayout: void 0,
82
- trackOrder: void 0
83
- };
84
- async function readRecordingOf(openings, ports) {
85
- try {
86
- return await readTrailerCarrier(openings.map(({ source }) => source));
87
- } catch (error) {
88
- const [only] = openings;
89
- if (openings.length === 1 && hasErrorCode(error, "invalid-trailer") && only) await refuseAsLoneHalf(only, ports);
90
- throw error;
91
- }
92
- }
93
45
  /**
94
- * A file without a trailer may be the _10_ half of an older camera's pair, which writes the
95
- * trailer to _00_ alone: its one square track tells, and the refusal asks for the other file
96
- * instead of calling it damaged. Anything else leaves the trailer's own refusal standing.
46
+ * One download a file, each with its share of the budget.
97
47
  */
98
- async function refuseAsLoneHalf(opening, ports) {
99
- let input;
100
- try {
101
- input = await ports.demuxer.open(opening.source, inputName(opening.input));
102
- } catch {
103
- return;
104
- }
105
- try {
106
- detectLensLayout([input], NO_HINTS);
107
- } catch (error) {
108
- if (hasErrorCode(error, "missing-second-file")) throw error;
109
- } finally {
110
- input.dispose();
111
- }
112
- }
113
- /**
114
- * The recording read from the input that carries the trailer: the first, or the second when the
115
- * first has none, as the _10_ file of an older camera's pair, which writes it to _00_ alone,
116
- * when the pair is given the other way round.
117
- */
118
- async function readTrailerCarrier(sources) {
119
- const [first, second] = sources;
120
- ensureInvariant(first !== void 0, "a recording needs at least one input");
121
- try {
122
- return await readRecording(first);
123
- } catch (error) {
124
- if (second === void 0 || !hasErrorCode(error, "invalid-trailer")) throw error;
125
- return readRecording(second);
126
- }
48
+ function startDownloads(inputs, disposables) {
49
+ return inputs.map(({ opened, table, codecs, size }) => {
50
+ const policy = downloadPolicyFor({
51
+ size,
52
+ duration: table.duration
53
+ }, inputs.length);
54
+ const file = startFileDownload({
55
+ table,
56
+ stream: opened.stream,
57
+ codecs,
58
+ policy
59
+ });
60
+ disposables.add(() => {
61
+ file.dispose();
62
+ });
63
+ return file;
64
+ });
127
65
  }
128
- function trackAt(inputs, key) {
129
- const track = inputs[key.inputIndex]?.videoTracks[key.trackIndex];
66
+ function trackAt(files, key) {
67
+ const track = files[key.inputIndex]?.videoTracks[key.trackIndex];
130
68
  ensureInvariant(track !== void 0, `the layout points at track ${key.trackIndex} of input ${key.inputIndex}, which does not exist`);
131
69
  return track;
132
70
  }
@@ -136,26 +74,30 @@ function calibrationOf(recording) {
136
74
  const detail = warnings.length > 0 ? ` (${warnings.join("; ")})` : "";
137
75
  throw new GyroViewError("no-calibration", `the recording carries no usable lens calibration, so it cannot be stitched${detail}`);
138
76
  }
139
- function assemble(parts, disposables) {
140
- const { demuxed, layout, frameSources, calibration, timing } = parts;
141
- const duration = seconds(Math.min(...demuxed.inputs.map((input) => input.duration)));
142
- const [audioTrack] = demuxed.inputs.flatMap((input) => input.audioTracks);
77
+ function assemble(parts, packager, disposables) {
78
+ const { read, files, layout, frameSources, calibration, timing } = parts;
79
+ const duration = seconds(Math.min(...files.map((file) => file.duration)));
80
+ const [sound] = files.flatMap((file) => file.audioTracks);
143
81
  return {
144
- recording: demuxed.recording,
82
+ recording: read.recording,
145
83
  layout,
146
84
  frameSources,
147
85
  calibration,
148
86
  duration,
149
87
  frameTimes: timing.frameTimes,
150
88
  motion: timing.motion,
151
- audioTrack,
152
- metadata: metadataOf(parts, duration, audioTrack !== void 0),
89
+ soundSegments: sound && (() => packager.segmentsOf(sound.samples, sound.configuration)),
90
+ metadata: metadataOf(parts, duration, sound !== void 0),
153
91
  warnings: timing.warnings,
92
+ readAhead: () => {
93
+ for (const file of files) file.download.startReadingAhead();
94
+ },
95
+ buffer: new RecordingBuffer(files.map((file) => file.download)),
154
96
  dispose: disposables.toDisposer()
155
97
  };
156
98
  }
157
99
  function metadataOf(parts, duration, hasAudio) {
158
- const { info } = parts.demuxed.recording;
100
+ const { info } = parts.read.recording;
159
101
  const { motion } = parts.timing;
160
102
  return {
161
103
  model: info.model,
@@ -21,7 +21,7 @@ async function openRecording(source, ports, signal) {
21
21
  const second = await findSecondFile();
22
22
  signal.throwIfAborted();
23
23
  if (!second) throw error;
24
- return openInputs([source.main, { url: second }], attempt);
24
+ return openInputs([source.main, second], attempt);
25
25
  }
26
26
  }
27
27
  /**
@@ -32,11 +32,22 @@ function attemptFor(source, ports, signal) {
32
32
  return source.second === void 0 && isUrlInput(main) ? {
33
33
  ports,
34
34
  signal,
35
- findSecondFile: lazy(() => locateOtherLensFile(main.url, ports.locator))
35
+ findSecondFile: lazy(() => otherLensFileOf(main, ports))
36
36
  } : {
37
37
  ports,
38
38
  signal
39
39
  };
40
40
  }
41
+ /**
42
+ * The other lens file beside a lone one, when the server has it: one recording, so it is read
43
+ * as the lone file is, with the same credentials.
44
+ */
45
+ async function otherLensFileOf(main, ports) {
46
+ const url = await locateOtherLensFile(main.url, ports.locatorFor(main));
47
+ return url === void 0 ? void 0 : {
48
+ ...main,
49
+ url
50
+ };
51
+ }
41
52
  //#endregion
42
53
  export { openRecording };
@@ -0,0 +1,111 @@
1
+ import { ensureInvariant, hasErrorCode } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import { readRecording } from "../../../core/src/application/recording/readRecording.js";
3
+ import { readSampleTable } from "../../../core/src/application/recording/readSampleTable.js";
4
+ import { detectLensLayout } from "../../../core/src/domain/format/layout/detectLensLayout.js";
5
+ import "../../../core/src/index.js";
6
+ import { inputName } from "../PlayerSource.js";
7
+ //#region ../../packages/player/src/composition/readInputs.ts
8
+ /**
9
+ * Reads the given inputs, and the other lens file an info record declares, as one recording: its
10
+ * metadata from the input that carries the trailer, and every input's sample table and codecs.
11
+ */
12
+ async function readInputs(inputs, attempt) {
13
+ const { ports, signal } = attempt;
14
+ const given = inputs.map((input) => ({
15
+ input,
16
+ opened: ports.sources.open(input, signal)
17
+ }));
18
+ const recording = await readRecordingOf(given, ports);
19
+ signal.throwIfAborted();
20
+ const openings = [...given, ...await declaredSecond(given, recording, attempt)];
21
+ const read = await Promise.all(openings.map((opening) => readInput(opening, ports)));
22
+ signal.throwIfAborted();
23
+ return {
24
+ recording,
25
+ inputs: read
26
+ };
27
+ }
28
+ function describedInput(read) {
29
+ const videoTracks = read.codecs.video.map(({ description }) => ({ description }));
30
+ return {
31
+ name: inputName(read.input),
32
+ videoTracks
33
+ };
34
+ }
35
+ async function readInput(opening, ports) {
36
+ const { source } = opening.opened;
37
+ const { table, movieBytes } = await readSampleTable(source);
38
+ const codecs = await ports.codecReader.read(movieBytes);
39
+ return {
40
+ ...opening,
41
+ table,
42
+ codecs,
43
+ size: await source.size()
44
+ };
45
+ }
46
+ /**
47
+ * The other lens file a lone file's info record says the recording is split into, when the
48
+ * server has it: found before the lone file's tracks are read, which finding it only once they
49
+ * fell short would read twice. A file that does not say so still gets its sibling looked for
50
+ * once its tracks fall short (see openRecording).
51
+ */
52
+ async function declaredSecond(given, recording, attempt) {
53
+ if (!(given.length === 1 && recording.info.fileLayout === "split-files") || !attempt.findSecondFile) return [];
54
+ const input = await attempt.findSecondFile();
55
+ attempt.signal.throwIfAborted();
56
+ return input === void 0 ? [] : [{
57
+ input,
58
+ opened: attempt.ports.sources.open(input, attempt.signal)
59
+ }];
60
+ }
61
+ /**
62
+ * The layout hints of a file without an info record: the tracks alone decide.
63
+ */
64
+ var NO_HINTS = {
65
+ fileLayout: void 0,
66
+ trackOrder: void 0
67
+ };
68
+ async function readRecordingOf(openings, ports) {
69
+ try {
70
+ return await readTrailerCarrier(openings.map(({ opened }) => opened.source));
71
+ } catch (error) {
72
+ const [only] = openings;
73
+ if (openings.length === 1 && hasErrorCode(error, "invalid-trailer") && only) await refuseAsLoneHalf(only, ports);
74
+ throw error;
75
+ }
76
+ }
77
+ /**
78
+ * A file without a trailer may be the _10_ half of an older camera's pair, which writes the
79
+ * trailer to _00_ alone: its one square track tells, and the refusal asks for the other file
80
+ * instead of calling it damaged. Anything else leaves the trailer's own refusal standing.
81
+ */
82
+ async function refuseAsLoneHalf(opening, ports) {
83
+ let read;
84
+ try {
85
+ read = await readInput(opening, ports);
86
+ } catch {
87
+ return;
88
+ }
89
+ try {
90
+ detectLensLayout([describedInput(read)], NO_HINTS);
91
+ } catch (error) {
92
+ if (hasErrorCode(error, "missing-second-file")) throw error;
93
+ }
94
+ }
95
+ /**
96
+ * The recording read from the input that carries the trailer: the first, or the second when the
97
+ * first has none, as the _10_ file of an older camera's pair, which writes it to _00_ alone,
98
+ * when the pair is given the other way round.
99
+ */
100
+ async function readTrailerCarrier(sources) {
101
+ const [first, second] = sources;
102
+ ensureInvariant(first !== void 0, "a recording needs at least one input");
103
+ try {
104
+ return await readRecording(first);
105
+ } catch (error) {
106
+ if (second === void 0 || !hasErrorCode(error, "invalid-trailer")) throw error;
107
+ return readRecording(second);
108
+ }
109
+ }
110
+ //#endregion
111
+ export { describedInput, readInputs };
@@ -14,22 +14,27 @@ var FOCUS_STEPS = /* @__PURE__ */ new Map([
14
14
  var ON_THE_WAY_IN = { capture: true };
15
15
  /**
16
16
  * A button opening a popup of choices with the current one checked, as the ARIA menu pattern
17
- * has it. The button toggles it; a choice, Escape, Tab, a press outside it or the focus moving
18
- * elsewhere closes it; the arrow keys move between the choices, which are out of the tab order.
17
+ * has it; the button shows the checked one. The button toggles it; a choice, its close button,
18
+ * Escape, Tab, a press outside it or the focus moving elsewhere closes it; the arrow keys move between the choices, which are out of the tab order.
19
19
  * A press outside only closes it, so a tap on the picture does not also toggle play, and keys
20
20
  * pressed in it stay with it, so one Escape does not also leave fullscreen and the arrows do not
21
21
  * turn the view.
22
22
  */
23
23
  var ChoiceMenu = class {
24
24
  parts;
25
+ host;
25
26
  items;
26
- constructor(root, parts, choose) {
27
+ constructor(root, parts, host) {
27
28
  this.parts = parts;
29
+ this.host = host;
28
30
  this.items = [...parts.popup.querySelectorAll(":scope [role=\"menuitemradio\"]")];
29
31
  for (const item of this.items) item.addEventListener("click", () => {
30
32
  this.closeToButton();
31
33
  const { choice } = item.dataset;
32
- if (choice !== void 0) choose(choice);
34
+ if (choice !== void 0) host.choose(choice);
35
+ });
36
+ parts.close.addEventListener("click", () => {
37
+ this.closeToButton();
33
38
  });
34
39
  this.bindButton();
35
40
  this.bindDismissal(root);
@@ -38,8 +43,15 @@ var ChoiceMenu = class {
38
43
  get isOpen() {
39
44
  return !this.parts.popup.hidden;
40
45
  }
46
+ /**
47
+ * Checks `choice` and shows it on the button: its icon, and the words the button is named by.
48
+ */
41
49
  markChosen(choice) {
42
50
  for (const item of this.items) item.setAttribute("aria-checked", String(item.dataset["choice"] === choice));
51
+ const icon = this.items.find((item) => item.dataset["choice"] === choice)?.querySelector(":scope .choice-icon > svg");
52
+ this.parts.icon.replaceChildren(...icon ? [icon.cloneNode(true)] : []);
53
+ this.parts.button.dataset["choice"] = choice;
54
+ this.host.wording.write(this.parts.button);
43
55
  }
44
56
  /**
45
57
  * Shows or hides the button; a menu that goes away closes.
@@ -10,19 +10,33 @@ import { ChoiceMenu } from "./ChoiceMenu.js";
10
10
  * shown in a stitched view mode.
11
11
  */
12
12
  var PictureMenus = class {
13
- player;
14
13
  viewMode;
15
14
  stabilization;
16
- constructor(root, parts, player) {
15
+ player;
16
+ constructor(root, parts, host) {
17
+ const { player, wording } = host;
17
18
  this.player = player;
18
- this.viewMode = new ChoiceMenu(root, parts.viewMode, (choice) => {
19
- const mode = viewModeOf(choice);
20
- if (mode) player.setViewMode(mode);
19
+ this.viewMode = new ChoiceMenu(root, parts.viewMode, {
20
+ wording,
21
+ choose: (choice) => {
22
+ const mode = viewModeOf(choice);
23
+ if (mode) player.setViewMode(mode);
24
+ }
21
25
  });
22
- this.stabilization = new ChoiceMenu(root, parts.stabilization, (choice) => {
23
- const mode = stabilizationModeOf(choice);
24
- if (mode) player.setStabilization(mode);
26
+ this.stabilization = new ChoiceMenu(root, parts.stabilization, {
27
+ wording,
28
+ choose: (choice) => {
29
+ const mode = stabilizationModeOf(choice);
30
+ if (mode) player.setStabilization(mode);
31
+ }
25
32
  });
33
+ this.followPlayer();
34
+ }
35
+ /**
36
+ * Each menu checks the mode in effect, now and whenever it changes.
37
+ */
38
+ followPlayer() {
39
+ const { player } = this;
26
40
  player.events.on("viewmodechange", (mode) => {
27
41
  this.viewMode.markChosen(mode);
28
42
  this.offerStabilization();
@@ -2,6 +2,7 @@ import { messageOf } from "../../../core/src/shared/errors/GyroViewError.js";
2
2
  import "../../../core/src/index.js";
3
3
  import "./keyboard.js";
4
4
  import { formatTime } from "./formatTime.js";
5
+ import { showSliderFill } from "./sliderFill.js";
5
6
  //#region ../../packages/player/src/controls/SeekBar.ts
6
7
  /**
7
8
  * The slider's arrow keys and the direction each seeks in: the native step is the one the
@@ -110,15 +111,17 @@ var SeekBar = class {
110
111
  this.host.player.seek(Number(this.parts.seek.value));
111
112
  }
112
113
  /**
113
- * The time beside the bar, and the same time for screen readers, which would read the slider's
114
- * raw seconds.
114
+ * The time beside the bar and the bar filled up to it, and the same time for screen readers,
115
+ * which would read the slider's raw seconds.
115
116
  */
116
117
  showTime(time) {
117
118
  const [shown, total] = [formatTime(time), formatTime(this.host.player.duration)];
118
- this.parts.time.textContent = `${shown} / ${total}`;
119
+ this.parts.elapsed.textContent = shown;
120
+ this.parts.total.textContent = `/ ${total}`;
119
121
  const { position } = this.host.wording.current.labels;
120
122
  const spoken = position.replaceAll("{time}", () => shown).replaceAll("{duration}", () => total);
121
123
  this.parts.seek.setAttribute("aria-valuetext", spoken);
124
+ showSliderFill(this.parts.seek);
122
125
  }
123
126
  };
124
127
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { iconNode } from "./icons.js";
2
+ import { showSliderFill } from "./sliderFill.js";
2
3
  //#region ../../packages/player/src/controls/SoundControls.ts
3
4
  var PERCENT = 100;
4
5
  /**
@@ -35,6 +36,7 @@ var SoundControls = class {
35
36
  this.parts.mute.setAttribute("aria-pressed", String(isMuted));
36
37
  this.parts.volume.value = String(shown);
37
38
  this.parts.volume.setAttribute("aria-valuetext", `${Math.round(shown * PERCENT)}%`);
39
+ showSliderFill(this.parts.volume);
38
40
  }
39
41
  };
40
42
  //#endregion
@@ -1,8 +1,8 @@
1
1
  import { iconNode } from "./icons.js";
2
2
  //#region ../../packages/player/src/controls/TransportButtons.ts
3
3
  /**
4
- * Play and pause, on the bar and over the picture, and stop; the play buttons say what a press
5
- * does.
4
+ * Play and pause, on the bar and over the picture; the buttons say what a press does. Stopping
5
+ * is left to the S key and `stop()`.
6
6
  */
7
7
  var TransportButtons = class {
8
8
  parts;
@@ -14,9 +14,6 @@ var TransportButtons = class {
14
14
  for (const button of [parts.play, parts.bigPlay]) button.addEventListener("click", () => {
15
15
  host.togglePlay();
16
16
  });
17
- parts.stop.addEventListener("click", () => {
18
- player.stop();
19
- });
20
17
  player.events.on("statuschange", () => {
21
18
  this.reflect(player.isPaused);
22
19
  });