@agent-native/core 0.175.0 → 0.175.1-nightly-20260826122129
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/corpus/README.md +1 -1
- package/corpus/templates/assets/app/root.tsx +5 -2
- package/corpus/templates/clips/actions/create-recording.ts +8 -1
- package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +3 -1
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +11 -0
- package/corpus/templates/clips/app/components/player/timestamped-comment-button.tsx +2 -41
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +48 -4
- package/corpus/templates/clips/app/i18n/en-US.ts +1 -2
- package/corpus/templates/clips/app/lib/fmp4-seek.ts +274 -0
- package/corpus/templates/clips/app/lib/fmp4.ts +169 -0
- package/corpus/templates/clips/app/lib/mse-video-loader.ts +351 -109
- package/corpus/templates/clips/app/root.tsx +1 -1
- package/corpus/templates/clips/changelog/2026-08-25-seeking-into-a-long-recording-no-longer-sticks-on-buffering.md +6 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +103 -13
- package/corpus/templates/clips/server/lib/resumable-upload-provider.ts +2 -2
- package/corpus/templates/clips/server/lib/video-storage.ts +2 -2
- package/corpus/templates/forms/app/components/builder/FieldRenderer.tsx +162 -157
- package/corpus/templates/tasks/app/root.tsx +5 -2
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/file-upload/builder.d.ts +2 -2
- package/dist/file-upload/builder.js +28 -26
- package/dist/file-upload/registry.js +11 -8
- package/dist/observability/routes.d.ts +3 -3
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/builder-api-auth.d.ts +31 -0
- package/dist/server/builder-api-auth.js +62 -0
- package/dist/server/builder-oauth.d.ts +5 -3
- package/dist/server/builder-oauth.js +40 -12
- package/dist/server/core-routes-plugin.js +5 -2
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2 -0
- package/dist/server/realtime-token.d.ts +1 -1
- package/package.json +1 -1
|
@@ -15,18 +15,42 @@
|
|
|
15
15
|
* ranges under memory pressure. Any unrecoverable failure calls `onFatal` so
|
|
16
16
|
* the caller can drop back to the plain `<video src>` path.
|
|
17
17
|
*
|
|
18
|
+
* Seeking reads each probed fragment's real `tfdt` timestamp rather than
|
|
19
|
+
* trusting a byte-fraction estimate. These recordings are variable-bitrate, so
|
|
20
|
+
* byte position and presentation time drift apart by tens of seconds over a
|
|
21
|
+
* long clip; appending a fragment that starts *after* `currentTime` leaves the
|
|
22
|
+
* element permanently unplayable, because MSE never jumps a forward gap on its
|
|
23
|
+
* own. See `seekToTime`.
|
|
24
|
+
*
|
|
18
25
|
* The player component only ever sees a normal `HTMLVideoElement`; this class
|
|
19
26
|
* drives it entirely through `video.src = objectUrl` + range fetches.
|
|
20
27
|
*/
|
|
21
28
|
|
|
22
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
type Mp4Track,
|
|
31
|
+
findMoofOffset,
|
|
32
|
+
fragmentPtsSeconds,
|
|
33
|
+
parseInitSegment,
|
|
34
|
+
} from "./fmp4";
|
|
35
|
+
import { ByteTimeMap, resolveSeekFragment } from "./fmp4-seek";
|
|
23
36
|
|
|
24
37
|
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB sequential range reads
|
|
25
38
|
const INIT_PROBE_SIZE = 512 * 1024; // enough to always contain ftyp+moov
|
|
26
|
-
const SEEK_PROBE_SIZE =
|
|
39
|
+
const SEEK_PROBE_SIZE = 1024 * 1024; // window scanned for a moof + its tfdt
|
|
27
40
|
const BUFFER_AHEAD_SECONDS = 30; // download target ahead of currentTime
|
|
28
41
|
const BUFFER_BEHIND_SECONDS = 10; // played media kept before evicting on quota
|
|
29
|
-
|
|
42
|
+
/** Probe budget per seek. Bracketed interpolation converges in 2-3 in practice. */
|
|
43
|
+
const MAX_SEEK_PROBES = 6;
|
|
44
|
+
/**
|
|
45
|
+
* How far before the seek target we accept landing. The gap is downloaded and
|
|
46
|
+
* decoded before playback can resume, so a small number keeps seeks responsive;
|
|
47
|
+
* landing even slightly *past* the target is never acceptable.
|
|
48
|
+
*/
|
|
49
|
+
const SEEK_ACCEPT_UNDERSHOOT_SECONDS = 4;
|
|
50
|
+
/** Realign retries before nudging `currentTime` into the buffer we do have. */
|
|
51
|
+
const MAX_REALIGN_ATTEMPTS = 3;
|
|
52
|
+
/** Overlap between probe steps, so a `moof` on a window boundary is not skipped. */
|
|
53
|
+
const PROBE_STEP_OVERLAP_BYTES = 4096;
|
|
30
54
|
|
|
31
55
|
export interface MseVideoLoaderOptions {
|
|
32
56
|
/** Asset URL. Range requests go straight here (external/proxied media). */
|
|
@@ -59,6 +83,18 @@ function isAbortError(err: unknown): boolean {
|
|
|
59
83
|
return err instanceof DOMException && err.name === "AbortError";
|
|
60
84
|
}
|
|
61
85
|
|
|
86
|
+
/**
|
|
87
|
+
* True when a ranged request came back as a whole-file `200` for a nonzero
|
|
88
|
+
* start, meaning the origin ignored `Range`. A `200` to a request starting at
|
|
89
|
+
* byte 0 is just the whole asset, which lines up fine.
|
|
90
|
+
*/
|
|
91
|
+
export function rangeWasIgnored(
|
|
92
|
+
status: number,
|
|
93
|
+
requestedStart: number,
|
|
94
|
+
): boolean {
|
|
95
|
+
return status === 200 && requestedStart > 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
62
98
|
export class MseVideoLoader {
|
|
63
99
|
readonly objectUrl: string;
|
|
64
100
|
|
|
@@ -79,17 +115,26 @@ export class MseVideoLoader {
|
|
|
79
115
|
private initLength = 0;
|
|
80
116
|
private nextOffset = 0;
|
|
81
117
|
private initAppended = false;
|
|
82
|
-
private needsRealign = false;
|
|
83
118
|
/** Duration (seconds) waiting to be written once the source buffer is idle. */
|
|
84
119
|
private pendingDurationSec: number | null = null;
|
|
120
|
+
/** Track ids and timescales from the init segment, for reading `tfdt`. */
|
|
121
|
+
private tracks: Mp4Track[] = [];
|
|
122
|
+
/** Observed byte<->time anchors, built as the asset is read. */
|
|
123
|
+
private anchors: ByteTimeMap | null = null;
|
|
124
|
+
/** Seek target waiting to be resolved by the pump, in seconds. */
|
|
125
|
+
private pendingSeek: number | null = null;
|
|
85
126
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
127
|
+
* Bumped on every `seeking` event, including one whose target is already
|
|
128
|
+
* buffered. A resolve in flight for an older target must not be allowed to
|
|
129
|
+
* finish and repoint the download cursor: the playhead has moved on, so the
|
|
130
|
+
* cursor would end up filling a region the playhead never reaches.
|
|
89
131
|
*/
|
|
90
|
-
private
|
|
91
|
-
/**
|
|
92
|
-
private
|
|
132
|
+
private seekGeneration = 0;
|
|
133
|
+
/** Target of the realigns counted by `realignAttempts`. */
|
|
134
|
+
private realignTarget: number | null = null;
|
|
135
|
+
private realignAttempts = 0;
|
|
136
|
+
/** Start time of the most recently appended fragment, for stall detection. */
|
|
137
|
+
private lastAppendSec: number | null = null;
|
|
93
138
|
|
|
94
139
|
private destroyed = false;
|
|
95
140
|
private pumping = false;
|
|
@@ -184,8 +229,8 @@ export class MseVideoLoader {
|
|
|
184
229
|
// Reopened after `endOfStream()`: a seek into an evicted/unbuffered range
|
|
185
230
|
// transitions the "ended" source back to "open". The pipeline is already
|
|
186
231
|
// built, so just clear the EOF latch and resume fetching for the seek
|
|
187
|
-
// (`onSeeking` has set `
|
|
188
|
-
//
|
|
232
|
+
// (`onSeeking` has set `pendingSeek`) instead of re-running init (which
|
|
233
|
+
// would try to add a second source buffer).
|
|
189
234
|
if (this.initAppended) {
|
|
190
235
|
this.eofReached = false;
|
|
191
236
|
this.schedulePump();
|
|
@@ -205,6 +250,14 @@ export class MseVideoLoader {
|
|
|
205
250
|
const parsed = parseInitSegment(first.bytes);
|
|
206
251
|
if (!parsed) throw new Error("Could not parse fMP4 init segment");
|
|
207
252
|
this.initLength = parsed.initLength;
|
|
253
|
+
this.tracks = parsed.tracks;
|
|
254
|
+
this.anchors = new ByteTimeMap(parsed.initLength);
|
|
255
|
+
if (this.tracks.length === 0) {
|
|
256
|
+
// Without timescales a fragment's tfdt cannot be converted to seconds,
|
|
257
|
+
// so seeking would be back to blind byte-fraction guessing. Fall back to
|
|
258
|
+
// the native pipeline rather than ship a player that cannot seek.
|
|
259
|
+
throw new Error("fMP4 init segment declares no readable tracks");
|
|
260
|
+
}
|
|
208
261
|
|
|
209
262
|
const mime = `video/mp4; codecs="${parsed.codecs}"`;
|
|
210
263
|
if (!window.MediaSource.isTypeSupported(mime)) {
|
|
@@ -229,8 +282,9 @@ export class MseVideoLoader {
|
|
|
229
282
|
const fetchedEnd = first.bytes.byteLength;
|
|
230
283
|
if (fetchedEnd > this.initLength) {
|
|
231
284
|
const media = first.bytes.subarray(this.initLength);
|
|
285
|
+
const firstSec = this.recordAnchorAt(first.bytes, 0, this.initLength);
|
|
232
286
|
await this.appendWithQuota(media);
|
|
233
|
-
this.
|
|
287
|
+
if (firstSec !== null) this.lastAppendSec = firstSec;
|
|
234
288
|
}
|
|
235
289
|
this.nextOffset = fetchedEnd;
|
|
236
290
|
if (first.eof) this.eofReached = true;
|
|
@@ -250,39 +304,54 @@ export class MseVideoLoader {
|
|
|
250
304
|
private onSeeking = (): void => {
|
|
251
305
|
if (this.destroyed || !this.initAppended) return;
|
|
252
306
|
const target = this.video.currentTime;
|
|
253
|
-
|
|
307
|
+
// Invalidate first, and for every seek: a target that is already buffered
|
|
308
|
+
// needs no resolve of its own, but it still has to cancel one in flight.
|
|
309
|
+
this.seekGeneration++;
|
|
310
|
+
if (this.isBuffered(target)) {
|
|
311
|
+
this.pendingSeek = null;
|
|
312
|
+
// Let the pump re-check whether its download cursor still feeds this
|
|
313
|
+
// playhead; the buffered range it landed in may not be the one growing.
|
|
314
|
+
this.schedulePump();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
254
317
|
|
|
255
318
|
// Abort any in-flight sequential fetch so we can jump.
|
|
256
319
|
this.currentFetch?.abort();
|
|
257
320
|
this.eofReached = false;
|
|
258
321
|
|
|
259
|
-
|
|
260
|
-
this.
|
|
261
|
-
this.
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
|
|
322
|
+
if (
|
|
323
|
+
this.realignTarget === null ||
|
|
324
|
+
Math.abs(this.realignTarget - target) > 1
|
|
325
|
+
) {
|
|
326
|
+
this.realignTarget = target;
|
|
327
|
+
this.realignAttempts = 0;
|
|
328
|
+
}
|
|
329
|
+
this.pendingSeek = target;
|
|
265
330
|
this.schedulePump();
|
|
266
331
|
};
|
|
267
332
|
|
|
268
333
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
334
|
+
* Record a (byte -> fragment start time) anchor for the first `moof` at or
|
|
335
|
+
* after `searchFrom` within `bytes`, which was fetched from absolute offset
|
|
336
|
+
* `fetchStart`. Both offsets are required: defaulting either one silently
|
|
337
|
+
* files the anchor under the wrong byte position, which corrupts every later
|
|
338
|
+
* seek estimate rather than failing visibly.
|
|
339
|
+
*
|
|
340
|
+
* Returns that fragment's start time, or null when the chunk carries no
|
|
341
|
+
* readable fragment header.
|
|
276
342
|
*/
|
|
277
|
-
private
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
343
|
+
private recordAnchorAt(
|
|
344
|
+
bytes: Uint8Array,
|
|
345
|
+
fetchStart: number,
|
|
346
|
+
searchFrom: number,
|
|
347
|
+
): number | null {
|
|
348
|
+
const moof = findMoofOffset(bytes.subarray(searchFrom));
|
|
349
|
+
if (moof < 0) return null;
|
|
350
|
+
const at = searchFrom + moof;
|
|
351
|
+
const sec = fragmentPtsSeconds(bytes, at, this.tracks);
|
|
352
|
+
if (sec === null) return null;
|
|
353
|
+
this.anchors?.add(fetchStart + at, sec);
|
|
354
|
+
return sec;
|
|
286
355
|
}
|
|
287
356
|
|
|
288
357
|
private schedulePump(): void {
|
|
@@ -302,33 +371,38 @@ export class MseVideoLoader {
|
|
|
302
371
|
this.sourceBuffer &&
|
|
303
372
|
this.mediaSource.readyState === "open"
|
|
304
373
|
) {
|
|
374
|
+
if (this.pendingSeek !== null) {
|
|
375
|
+
const target = this.pendingSeek;
|
|
376
|
+
this.pendingSeek = null;
|
|
377
|
+
const resolved = await this.seekToTime(target);
|
|
378
|
+
if (this.destroyed) break;
|
|
379
|
+
if (!resolved) break;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
305
383
|
if (
|
|
306
384
|
this.eofReached ||
|
|
307
385
|
(this.totalKnown && this.nextOffset >= this.totalBytes)
|
|
308
386
|
) {
|
|
309
|
-
// A realign that reached EOF without finding its boundary overshot
|
|
310
|
-
// into the final fragment — back up and keep looking before ending.
|
|
311
|
-
if (this.needsRealign && this.backUpRealignProbe()) {
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
this.needsRealign = false;
|
|
315
387
|
this.tryEndOfStream();
|
|
316
388
|
break;
|
|
317
389
|
}
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
390
|
+
|
|
391
|
+
// We are filling media the playhead cannot reach. Downloading further
|
|
392
|
+
// only widens the gap, so realign to the playhead instead of quietly
|
|
393
|
+
// streaming to EOF behind a spinner.
|
|
394
|
+
if (this.downloadIsDisjoint()) {
|
|
395
|
+
if (this.requestRealign(this.video.currentTime)) continue;
|
|
324
396
|
break;
|
|
325
397
|
}
|
|
326
398
|
|
|
399
|
+
// Stop downloading once we're comfortably ahead.
|
|
400
|
+
if (this.bufferedAhead() >= BUFFER_AHEAD_SECONDS) break;
|
|
401
|
+
|
|
327
402
|
const chunkStart = this.nextOffset;
|
|
328
|
-
const windowSize = this.needsRealign ? SEEK_PROBE_SIZE : CHUNK_SIZE;
|
|
329
403
|
const chunkEnd = this.totalKnown
|
|
330
|
-
? Math.min(chunkStart +
|
|
331
|
-
: chunkStart +
|
|
404
|
+
? Math.min(chunkStart + CHUNK_SIZE, this.totalBytes) - 1
|
|
405
|
+
: chunkStart + CHUNK_SIZE - 1;
|
|
332
406
|
|
|
333
407
|
let res: { bytes: Uint8Array; eof: boolean };
|
|
334
408
|
try {
|
|
@@ -339,46 +413,14 @@ export class MseVideoLoader {
|
|
|
339
413
|
}
|
|
340
414
|
if (this.destroyed) break;
|
|
341
415
|
if (res.bytes.byteLength === 0) {
|
|
342
|
-
if (this.needsRealign && this.backUpRealignProbe()) {
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
416
|
this.eofReached = true;
|
|
346
417
|
this.tryEndOfStream();
|
|
347
418
|
break;
|
|
348
419
|
}
|
|
349
420
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (moof < 0) {
|
|
354
|
-
if (res.eof) {
|
|
355
|
-
// Reached EOF with no boundary — the estimate overshot into the
|
|
356
|
-
// final fragment's mdat. Back up and re-probe rather than
|
|
357
|
-
// stalling the seek; give up only once nothing earlier remains.
|
|
358
|
-
if (this.backUpRealignProbe()) {
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
this.needsRealign = false;
|
|
362
|
-
this.eofReached = true;
|
|
363
|
-
this.tryEndOfStream();
|
|
364
|
-
break;
|
|
365
|
-
}
|
|
366
|
-
// No fragment boundary in this window — advance and keep scanning.
|
|
367
|
-
this.nextOffset = chunkStart + bytes.byteLength;
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
bytes = bytes.subarray(moof);
|
|
371
|
-
this.nextOffset = chunkStart + moof;
|
|
372
|
-
this.needsRealign = false;
|
|
373
|
-
// Reset the segment parser so it drops any partial fragment left over
|
|
374
|
-
// from the aborted sequential append and treats these bytes as a
|
|
375
|
-
// fresh media segment. Without this, appending a fragment from a new
|
|
376
|
-
// byte position fails with CHUNK_DEMUXER_ERROR_APPEND_FAILED.
|
|
377
|
-
this.abortParser();
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
await this.appendWithQuota(bytes);
|
|
381
|
-
this.mediaBytesAppended += bytes.byteLength;
|
|
421
|
+
const chunkSec = this.recordAnchorAt(res.bytes, chunkStart, 0);
|
|
422
|
+
await this.appendWithQuota(res.bytes);
|
|
423
|
+
if (chunkSec !== null) this.lastAppendSec = chunkSec;
|
|
382
424
|
this.nextOffset = chunkStart + res.bytes.byteLength;
|
|
383
425
|
if (res.eof) this.eofReached = true;
|
|
384
426
|
}
|
|
@@ -426,34 +468,225 @@ export class MseVideoLoader {
|
|
|
426
468
|
return 0;
|
|
427
469
|
}
|
|
428
470
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
471
|
+
private estimateByteOffset(targetSec: number): number {
|
|
472
|
+
if (!this.anchors) return this.initLength;
|
|
473
|
+
return this.anchors.estimate(targetSec, {
|
|
474
|
+
totalBytes: this.totalKnown ? this.totalBytes : null,
|
|
475
|
+
durationSec: this.opts.durationMs / 1000,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private clampProbeStart(byte: number): number {
|
|
480
|
+
const floor = Math.max(this.initLength, Math.floor(byte));
|
|
481
|
+
if (!this.totalKnown) return floor;
|
|
482
|
+
return Math.min(
|
|
483
|
+
floor,
|
|
484
|
+
Math.max(this.initLength, this.totalBytes - SEEK_PROBE_SIZE),
|
|
485
|
+
);
|
|
434
486
|
}
|
|
435
487
|
|
|
436
488
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* target fragment; the caller realigns forward to the next `moof`.
|
|
489
|
+
* Fetch a window at `startByte` and return the first fragment in it whose
|
|
490
|
+
* timestamp is readable, walking forward a couple of windows when a fragment
|
|
491
|
+
* is larger than one probe. Records the (byte -> time) pair as an anchor.
|
|
441
492
|
*/
|
|
442
|
-
private
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
493
|
+
private async probeFragmentAt(startByte: number): Promise<{
|
|
494
|
+
fetchStart: number;
|
|
495
|
+
bytes: Uint8Array;
|
|
496
|
+
moof: number;
|
|
497
|
+
byte: number;
|
|
498
|
+
sec: number;
|
|
499
|
+
} | null> {
|
|
500
|
+
let start = this.clampProbeStart(startByte);
|
|
501
|
+
for (let step = 0; step < 3; step++) {
|
|
502
|
+
const end = this.totalKnown
|
|
503
|
+
? Math.min(start + SEEK_PROBE_SIZE, this.totalBytes) - 1
|
|
504
|
+
: start + SEEK_PROBE_SIZE - 1;
|
|
505
|
+
const res = await this.fetchRange(start, end);
|
|
506
|
+
if (this.destroyed || res.bytes.byteLength === 0) return null;
|
|
507
|
+
|
|
508
|
+
const moof = findMoofOffset(res.bytes);
|
|
509
|
+
if (moof >= 0) {
|
|
510
|
+
const sec = fragmentPtsSeconds(res.bytes, moof, this.tracks);
|
|
511
|
+
if (sec !== null) {
|
|
512
|
+
this.anchors?.add(start + moof, sec);
|
|
513
|
+
return {
|
|
514
|
+
fetchStart: start,
|
|
515
|
+
bytes: res.bytes,
|
|
516
|
+
moof,
|
|
517
|
+
byte: start + moof,
|
|
518
|
+
sec,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (res.eof) return null;
|
|
523
|
+
// Overlap the step: a `moof` header straddling the window boundary is
|
|
524
|
+
// unreadable in this window, and stepping the full width would skip past
|
|
525
|
+
// that fragment entirely.
|
|
526
|
+
start += Math.max(1, res.bytes.byteLength - PROBE_STEP_OVERLAP_BYTES);
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Resolve a seek by reading real fragment timestamps (see `fmp4-seek`),
|
|
533
|
+
* append from the fragment it picks, and leave the sequential pump to fill
|
|
534
|
+
* forward from there.
|
|
535
|
+
*
|
|
536
|
+
* Returns false when the pump should stop (destroyed, or reported fatal).
|
|
537
|
+
*/
|
|
538
|
+
private async seekToTime(target: number): Promise<boolean> {
|
|
539
|
+
const generation = this.seekGeneration;
|
|
540
|
+
let resolution;
|
|
541
|
+
try {
|
|
542
|
+
resolution = await resolveSeekFragment({
|
|
543
|
+
target,
|
|
544
|
+
initLength: this.initLength,
|
|
545
|
+
probeSize: SEEK_PROBE_SIZE,
|
|
546
|
+
maxProbes: MAX_SEEK_PROBES,
|
|
547
|
+
acceptUndershootSeconds: SEEK_ACCEPT_UNDERSHOOT_SECONDS,
|
|
548
|
+
estimate: () => this.estimateByteOffset(target),
|
|
549
|
+
probe: (startByte) => this.probeFragmentAt(startByte),
|
|
550
|
+
superseded: () =>
|
|
551
|
+
this.destroyed ||
|
|
552
|
+
this.pendingSeek !== null ||
|
|
553
|
+
this.seekGeneration !== generation,
|
|
554
|
+
});
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (isAbortError(err)) return !this.destroyed; // superseded by a new seek
|
|
557
|
+
throw err;
|
|
447
558
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
559
|
+
if (this.destroyed) return false;
|
|
560
|
+
// A newer seek arrived; the pump loop picks it up on the next iteration.
|
|
561
|
+
// Checked again here because the resolve may have completed in the same
|
|
562
|
+
// tick the new seek arrived — appending now would aim the download cursor
|
|
563
|
+
// at a target the viewer has already left.
|
|
564
|
+
if (resolution.superseded || this.seekGeneration !== generation)
|
|
565
|
+
return true;
|
|
566
|
+
|
|
567
|
+
const chosen = resolution.chosen;
|
|
568
|
+
if (!chosen) {
|
|
569
|
+
this.fail(
|
|
570
|
+
new Error(`No fMP4 fragment found for a seek to ${target.toFixed(2)}s`),
|
|
454
571
|
);
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
this.eofReached = false;
|
|
576
|
+
// Reset the segment parser so it drops any partial fragment left over from
|
|
577
|
+
// the aborted sequential append and treats these bytes as a fresh media
|
|
578
|
+
// segment. Without this, appending a fragment from a new byte position
|
|
579
|
+
// fails with CHUNK_DEMUXER_ERROR_APPEND_FAILED.
|
|
580
|
+
this.abortParser();
|
|
581
|
+
await this.appendWithQuota(chosen.bytes.subarray(chosen.moof));
|
|
582
|
+
this.lastAppendSec = chosen.sec;
|
|
583
|
+
this.nextOffset = chosen.fetchStart + chosen.bytes.byteLength;
|
|
584
|
+
|
|
585
|
+
if (resolution.overshot) {
|
|
586
|
+
// Every probe landed past the target — a target beyond the last fragment,
|
|
587
|
+
// or a tail we cannot parse. Move the playhead onto media we actually
|
|
588
|
+
// hold instead of leaving it on a position that will never arrive.
|
|
589
|
+
this.nudgeCurrentTimeTo(chosen.sec);
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// A resolved seek clears the realign budget: the next stall, if any, is a
|
|
594
|
+
// new problem and deserves its own retries.
|
|
595
|
+
this.realignTarget = null;
|
|
596
|
+
this.realignAttempts = 0;
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* True when the download cursor is filling media the playhead cannot reach:
|
|
602
|
+
* either the playhead has nothing buffered at all, or it sits in a range that
|
|
603
|
+
* we are not extending, so that range will run out with nothing behind it.
|
|
604
|
+
*
|
|
605
|
+
* Checking only "playhead unbuffered" is not enough. After a seek back into
|
|
606
|
+
* an already-buffered stretch, the playhead is buffered while the cursor is
|
|
607
|
+
* still filling somewhere else entirely — playback then runs to the end of
|
|
608
|
+
* its own range and stops, which looks exactly like the stall this loader
|
|
609
|
+
* exists to prevent.
|
|
610
|
+
*/
|
|
611
|
+
private downloadIsDisjoint(): boolean {
|
|
612
|
+
if (this.lastAppendSec === null) return false;
|
|
613
|
+
const t = this.video.currentTime;
|
|
614
|
+
const end = this.bufferedEndAt(t);
|
|
615
|
+
if (end === null) return true; // nothing buffered at the playhead
|
|
616
|
+
return this.lastAppendSec > end + 1;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** End of the buffered range containing `time`, or null when unbuffered. */
|
|
620
|
+
private bufferedEndAt(time: number): number | null {
|
|
621
|
+
const buffered = this.sourceBuffer?.buffered;
|
|
622
|
+
if (!buffered) return null;
|
|
623
|
+
for (let i = 0; i < buffered.length; i++) {
|
|
624
|
+
if (time >= buffered.start(i) - 0.25 && time <= buffered.end(i)) {
|
|
625
|
+
return buffered.end(i);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Queue another seek resolution for `target`. Returns false once the retry
|
|
633
|
+
* budget is spent, having either moved the playhead onto media we hold or
|
|
634
|
+
* reported a fatal — never leaving the pump stopped with the playhead parked
|
|
635
|
+
* on an unreachable position and no pending work to fix it.
|
|
636
|
+
*/
|
|
637
|
+
private requestRealign(target: number): boolean {
|
|
638
|
+
if (
|
|
639
|
+
this.realignTarget === null ||
|
|
640
|
+
Math.abs(this.realignTarget - target) > 1
|
|
641
|
+
) {
|
|
642
|
+
this.realignTarget = target;
|
|
643
|
+
this.realignAttempts = 0;
|
|
644
|
+
}
|
|
645
|
+
if (this.realignAttempts >= MAX_REALIGN_ATTEMPTS) {
|
|
646
|
+
const start = this.firstBufferedStartAfter(target);
|
|
647
|
+
if (start === null) {
|
|
648
|
+
this.fail(
|
|
649
|
+
new Error(
|
|
650
|
+
`Playback stalled at ${target.toFixed(2)}s with no buffered media to resume from`,
|
|
651
|
+
),
|
|
652
|
+
);
|
|
653
|
+
} else {
|
|
654
|
+
this.nudgeCurrentTimeTo(start);
|
|
655
|
+
}
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
this.realignAttempts++;
|
|
659
|
+
this.pendingSeek = target;
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private firstBufferedStartAfter(time: number): number | null {
|
|
664
|
+
const buffered = this.sourceBuffer?.buffered;
|
|
665
|
+
if (!buffered) return null;
|
|
666
|
+
for (let i = 0; i < buffered.length; i++) {
|
|
667
|
+
if (buffered.start(i) > time) return buffered.start(i);
|
|
668
|
+
}
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Move the playhead onto buffered media. The resulting `seeking` event finds
|
|
674
|
+
* the position already buffered and returns early, so this cannot re-enter
|
|
675
|
+
* the seek resolver.
|
|
676
|
+
*/
|
|
677
|
+
private nudgeCurrentTimeTo(sec: number): void {
|
|
678
|
+
if (!Number.isFinite(sec) || sec < 0) {
|
|
679
|
+
this.fail(new Error(`Cannot nudge the playhead to ${sec}`));
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
this.video.currentTime = sec + 0.05;
|
|
684
|
+
} catch (err) {
|
|
685
|
+
// Terminal safety net: without the nudge the element stays parked on an
|
|
686
|
+
// unreachable time, which is the stall this path exists to prevent.
|
|
687
|
+
// Report so the caller drops back to the native `<video src>` pipeline.
|
|
688
|
+
this.fail(err);
|
|
455
689
|
}
|
|
456
|
-
return this.initLength;
|
|
457
690
|
}
|
|
458
691
|
|
|
459
692
|
private isBuffered(time: number): boolean {
|
|
@@ -569,6 +802,15 @@ export class MseVideoLoader {
|
|
|
569
802
|
if (!res.ok) {
|
|
570
803
|
throw new Error(`Range request failed: ${res.status}`);
|
|
571
804
|
}
|
|
805
|
+
if (rangeWasIgnored(res.status, start)) {
|
|
806
|
+
// A 200 to a ranged request is the whole file, so its bytes start at 0 —
|
|
807
|
+
// not at `start`. Reading it as though they lined up files every anchor
|
|
808
|
+
// and the resume offset under the wrong byte position, poisoning later
|
|
809
|
+
// seeks. An origin that ignores Range also defeats the point of this
|
|
810
|
+
// loader (each 2MB window would pull the entire asset), so hand back to
|
|
811
|
+
// the native pipeline instead of trying to compensate.
|
|
812
|
+
throw new Error("Origin ignored the Range header; cannot stream windows");
|
|
813
|
+
}
|
|
572
814
|
// If the backing file shrank mid-response (ERR_CONTENT_LENGTH_MISMATCH),
|
|
573
815
|
// arrayBuffer() throws here, which also routes through fail() -> onFatal.
|
|
574
816
|
const buffer = await res.arrayBuffer();
|
|
@@ -584,8 +826,8 @@ export class MseVideoLoader {
|
|
|
584
826
|
}
|
|
585
827
|
|
|
586
828
|
const requested = end - start + 1;
|
|
587
|
-
// A 200 (
|
|
588
|
-
// the
|
|
829
|
+
// A 200 (only reachable from a start of 0, per the guard above, so it is
|
|
830
|
+
// the whole asset) or a short 206 both mean this response ran to the end.
|
|
589
831
|
const eof =
|
|
590
832
|
res.status === 200 ||
|
|
591
833
|
bytes.byteLength < requested ||
|
|
@@ -388,7 +388,6 @@ function AppContent() {
|
|
|
388
388
|
)}
|
|
389
389
|
{standalonePublic ? null : <DevOverlay />}
|
|
390
390
|
<Outlet />
|
|
391
|
-
<Toaster richColors position="bottom-left" />
|
|
392
391
|
</>
|
|
393
392
|
);
|
|
394
393
|
}
|
|
@@ -408,6 +407,7 @@ export default function Root() {
|
|
|
408
407
|
<AppProviders
|
|
409
408
|
queryClient={queryClient}
|
|
410
409
|
isPublicPath={isStandalonePublicPath(location.pathname)}
|
|
410
|
+
toaster={<Toaster richColors position="bottom-left" />}
|
|
411
411
|
i18n={{
|
|
412
412
|
catalog: i18nCatalog,
|
|
413
413
|
initialLocale: loaderData.locale,
|