@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
package/corpus/README.md
CHANGED
|
@@ -146,9 +146,12 @@ export default function Root() {
|
|
|
146
146
|
useCommandMenuShortcut(useCallback(() => setCmdkOpen(true), []));
|
|
147
147
|
return (
|
|
148
148
|
<AppToolkitProvider>
|
|
149
|
-
<AppProviders
|
|
149
|
+
<AppProviders
|
|
150
|
+
queryClient={queryClient}
|
|
151
|
+
toaster={<Toaster richColors position="bottom-left" />}
|
|
152
|
+
i18n={{ catalog: i18nCatalog }}
|
|
153
|
+
>
|
|
150
154
|
<DbSyncSetup />
|
|
151
|
-
<Toaster richColors position="bottom-left" />
|
|
152
155
|
<AssetsCommandMenu open={cmdkOpen} onOpenChange={setCmdkOpen} />
|
|
153
156
|
<AppLayout>
|
|
154
157
|
<Outlet />
|
|
@@ -172,8 +172,15 @@ export default defineAction({
|
|
|
172
172
|
);
|
|
173
173
|
} catch (err) {
|
|
174
174
|
if (streamingRequired) {
|
|
175
|
+
// Keep the underlying reason. A Builder connection that needs
|
|
176
|
+
// re-authorizing is not fixed by refreshing, and replacing its
|
|
177
|
+
// message with a retry prompt is why that case looked like a
|
|
178
|
+
// random failure.
|
|
179
|
+
const reason = err instanceof Error ? err.message.trim() : "";
|
|
175
180
|
await failUploadSetup(
|
|
176
|
-
|
|
181
|
+
reason
|
|
182
|
+
? `Video storage could not start an upload: ${reason}`
|
|
183
|
+
: "Video storage could not start a resumable upload session. Refresh and try again.",
|
|
177
184
|
);
|
|
178
185
|
}
|
|
179
186
|
console.warn(
|
|
@@ -262,7 +262,9 @@ function LinkTab({
|
|
|
262
262
|
? err.message
|
|
263
263
|
: action === "invite"
|
|
264
264
|
? t("clipsFinalRaw.inviteFailed")
|
|
265
|
-
:
|
|
265
|
+
: action === "permission"
|
|
266
|
+
? t("clipsFinalRaw.permissionUpdateFailed")
|
|
267
|
+
: t("clipsFinalRaw.removePersonFailed"),
|
|
266
268
|
)
|
|
267
269
|
}
|
|
268
270
|
/>
|
|
@@ -573,6 +573,17 @@ function LinkTab({
|
|
|
573
573
|
description: t("shareUi.recordingCommenter.description"),
|
|
574
574
|
},
|
|
575
575
|
}}
|
|
576
|
+
onError={(err, action) =>
|
|
577
|
+
toast.error(
|
|
578
|
+
err instanceof Error
|
|
579
|
+
? err.message
|
|
580
|
+
: action === "invite"
|
|
581
|
+
? t("clipsFinalRaw.inviteFailed")
|
|
582
|
+
: action === "permission"
|
|
583
|
+
? t("clipsFinalRaw.permissionUpdateFailed")
|
|
584
|
+
: t("clipsFinalRaw.removePersonFailed"),
|
|
585
|
+
)
|
|
586
|
+
}
|
|
576
587
|
/>
|
|
577
588
|
) : null}
|
|
578
589
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useActionMutation } from "@agent-native/core/client/hooks";
|
|
2
2
|
import { useT } from "@agent-native/core/client/i18n";
|
|
3
|
-
import { IconMessagePlus
|
|
3
|
+
import { IconMessagePlus } from "@tabler/icons-react";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
@@ -74,23 +74,6 @@ export function TimestampedCommentBar({
|
|
|
74
74
|
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
75
75
|
}, []);
|
|
76
76
|
|
|
77
|
-
const insertAtCursor = (text: string) => {
|
|
78
|
-
const el = textareaRef.current;
|
|
79
|
-
if (!el) {
|
|
80
|
-
setDraft(draft + text);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
const start = el.selectionStart ?? draft.length;
|
|
84
|
-
const end = el.selectionEnd ?? draft.length;
|
|
85
|
-
const next = draft.slice(0, start) + text + draft.slice(end);
|
|
86
|
-
setDraft(next);
|
|
87
|
-
requestAnimationFrame(() => {
|
|
88
|
-
el.focus();
|
|
89
|
-
const pos = start + text.length;
|
|
90
|
-
el.setSelectionRange(pos, pos);
|
|
91
|
-
});
|
|
92
|
-
};
|
|
93
|
-
|
|
94
77
|
const submit = () => {
|
|
95
78
|
const content = draft.trim();
|
|
96
79
|
if (!content) return;
|
|
@@ -130,29 +113,7 @@ export function TimestampedCommentBar({
|
|
|
130
113
|
rows={2}
|
|
131
114
|
className="min-h-[3rem] resize-none border-0 bg-transparent px-1 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
132
115
|
/>
|
|
133
|
-
<div className="mt-2 flex items-center justify-
|
|
134
|
-
<div className="flex items-center gap-1">
|
|
135
|
-
<Button
|
|
136
|
-
type="button"
|
|
137
|
-
variant="ghost"
|
|
138
|
-
size="icon"
|
|
139
|
-
className="h-8 w-8 text-muted-foreground"
|
|
140
|
-
aria-label={t("commentsPanel.mentionSomeone")}
|
|
141
|
-
onClick={() => insertAtCursor("@")}
|
|
142
|
-
>
|
|
143
|
-
<IconAt className="h-4 w-4" />
|
|
144
|
-
</Button>
|
|
145
|
-
<Button
|
|
146
|
-
type="button"
|
|
147
|
-
variant="ghost"
|
|
148
|
-
size="icon"
|
|
149
|
-
className="h-8 w-8 text-muted-foreground"
|
|
150
|
-
aria-label={t("commentsPanel.addEmoji")}
|
|
151
|
-
onClick={() => insertAtCursor("🙂")}
|
|
152
|
-
>
|
|
153
|
-
<IconMoodSmile className="h-4 w-4" />
|
|
154
|
-
</Button>
|
|
155
|
-
</div>
|
|
116
|
+
<div className="mt-2 flex items-center justify-end">
|
|
156
117
|
<div className="flex items-center gap-2">
|
|
157
118
|
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
|
158
119
|
{t("common.cancel")}
|
|
@@ -341,7 +341,7 @@ export function SharePeopleTab({
|
|
|
341
341
|
sharesQuery: SharesQuery;
|
|
342
342
|
canManage: boolean;
|
|
343
343
|
roleCopy?: Partial<Record<Role, RoleCopy>>;
|
|
344
|
-
onError?: (err: unknown, action: "invite" | "remove") => void;
|
|
344
|
+
onError?: (err: unknown, action: "invite" | "permission" | "remove") => void;
|
|
345
345
|
}) {
|
|
346
346
|
const t = useT();
|
|
347
347
|
const share = useActionMutation("share-resource");
|
|
@@ -381,6 +381,24 @@ export function SharePeopleTab({
|
|
|
381
381
|
);
|
|
382
382
|
};
|
|
383
383
|
|
|
384
|
+
const handleChangeRole = (s: Share, nextRole: Role) => {
|
|
385
|
+
if (nextRole === s.role) return;
|
|
386
|
+
share.mutate(
|
|
387
|
+
{
|
|
388
|
+
resourceType,
|
|
389
|
+
resourceId,
|
|
390
|
+
principalType: s.principalType,
|
|
391
|
+
principalId: s.principalId,
|
|
392
|
+
role: nextRole,
|
|
393
|
+
notify: false,
|
|
394
|
+
} as any,
|
|
395
|
+
{
|
|
396
|
+
onSuccess: () => sharesQuery.refetch(),
|
|
397
|
+
onError: (err: unknown) => onError?.(err, "permission"),
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
};
|
|
401
|
+
|
|
384
402
|
const handleRemove = (s: Share) => {
|
|
385
403
|
unshare.mutate(
|
|
386
404
|
{
|
|
@@ -476,9 +494,35 @@ export function SharePeopleTab({
|
|
|
476
494
|
>
|
|
477
495
|
<Avatar label={s.principalId} org={s.principalType === "org"} />
|
|
478
496
|
<span className="flex-1 min-w-0 truncate">{s.principalId}</span>
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
497
|
+
{canManage ? (
|
|
498
|
+
<Select
|
|
499
|
+
value={s.role}
|
|
500
|
+
onValueChange={(value) => handleChangeRole(s, value as Role)}
|
|
501
|
+
disabled={share.isPending}
|
|
502
|
+
>
|
|
503
|
+
<SelectTrigger className="h-7 w-[110px] text-xs">
|
|
504
|
+
<SelectValue>{getRoleLabel(s.role)}</SelectValue>
|
|
505
|
+
</SelectTrigger>
|
|
506
|
+
<SelectContent>
|
|
507
|
+
{ROLE_OPTIONS.map((opt) => (
|
|
508
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
509
|
+
<div className="flex flex-col">
|
|
510
|
+
<span>{getRoleLabel(opt.value)}</span>
|
|
511
|
+
{getRoleCopy(opt.value)?.description ? (
|
|
512
|
+
<span className="text-xs text-muted-foreground">
|
|
513
|
+
{getRoleCopy(opt.value)?.description}
|
|
514
|
+
</span>
|
|
515
|
+
) : null}
|
|
516
|
+
</div>
|
|
517
|
+
</SelectItem>
|
|
518
|
+
))}
|
|
519
|
+
</SelectContent>
|
|
520
|
+
</Select>
|
|
521
|
+
) : (
|
|
522
|
+
<span className="text-xs text-muted-foreground">
|
|
523
|
+
{getRoleLabel(s.role)}
|
|
524
|
+
</span>
|
|
525
|
+
)}
|
|
482
526
|
{canManage ? (
|
|
483
527
|
<Button
|
|
484
528
|
type="button"
|
|
@@ -990,8 +990,6 @@ const messages = {
|
|
|
990
990
|
editComment: "Edit comment",
|
|
991
991
|
commentButton: "Comment",
|
|
992
992
|
composerPlaceholder: "Add a comment…",
|
|
993
|
-
mentionSomeone: "Mention someone",
|
|
994
|
-
addEmoji: "Add emoji",
|
|
995
993
|
},
|
|
996
994
|
shareMeeting: {
|
|
997
995
|
pageTitle: "Meeting notes · Clips",
|
|
@@ -1528,6 +1526,7 @@ const messages = {
|
|
|
1528
1526
|
invite: "Invite",
|
|
1529
1527
|
inviteFailed: "Couldn't invite person",
|
|
1530
1528
|
removePersonFailed: "Couldn't remove person",
|
|
1529
|
+
permissionUpdateFailed: "Couldn't update permission",
|
|
1531
1530
|
passwordProtectedDescription:
|
|
1532
1531
|
"This video is protected. Enter the password to watch.",
|
|
1533
1532
|
password: "Password",
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seek policy for the fragmented-MP4 MSE player: given a target time, decide
|
|
3
|
+
* which byte offset to start appending from.
|
|
4
|
+
*
|
|
5
|
+
* Split out from `MseVideoLoader` so the decision-making is pure and testable
|
|
6
|
+
* without a `MediaSource`. The loader owns the transport (range requests,
|
|
7
|
+
* source buffer); this owns the one rule that matters:
|
|
8
|
+
*
|
|
9
|
+
* never start appending at a fragment that begins AFTER the seek target.
|
|
10
|
+
*
|
|
11
|
+
* MSE does not jump a forward gap, so a fragment that starts even 80ms past the
|
|
12
|
+
* playhead is not an approximate seek — the element waits for media that will
|
|
13
|
+
* never be appended, and the viewer sees a spinner forever.
|
|
14
|
+
*
|
|
15
|
+
* These recordings are strongly variable-bitrate (a static screen costs almost
|
|
16
|
+
* nothing, motion costs a lot), so a byte-fraction estimate drifts from the
|
|
17
|
+
* real timeline by tens of seconds over a long clip, in whichever direction the
|
|
18
|
+
* earlier part of the clip is quieter or busier than average. The only reliable
|
|
19
|
+
* answer comes from reading fragment timestamps, which is what this does.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface FragmentLocation {
|
|
23
|
+
/** Byte offset of the fragment's `moof` box. */
|
|
24
|
+
byte: number;
|
|
25
|
+
/** Presentation time the fragment starts at, in seconds. */
|
|
26
|
+
sec: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimum time span between two anchors used to derive a bitrate. */
|
|
30
|
+
const MIN_SLOPE_SPAN_SECONDS = 0.5;
|
|
31
|
+
|
|
32
|
+
export interface EstimateBounds {
|
|
33
|
+
/**
|
|
34
|
+
* Asset length, or null when it is unknown — cross-origin responses hide
|
|
35
|
+
* `Content-Range`, so there is no end-of-file anchor to work against.
|
|
36
|
+
*/
|
|
37
|
+
totalBytes: number | null;
|
|
38
|
+
durationSec: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Observed (byte offset -> fragment start time) pairs, used to interpolate a
|
|
43
|
+
* probe position. Every probe adds one, so the map sharpens as a viewer scrubs
|
|
44
|
+
* and later seeks converge in fewer round trips.
|
|
45
|
+
*/
|
|
46
|
+
export class ByteTimeMap {
|
|
47
|
+
private readonly anchors: FragmentLocation[] = [];
|
|
48
|
+
|
|
49
|
+
constructor(private readonly initLength: number) {}
|
|
50
|
+
|
|
51
|
+
get size(): number {
|
|
52
|
+
return this.anchors.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
add(byte: number, sec: number): void {
|
|
56
|
+
if (!Number.isFinite(byte) || !Number.isFinite(sec)) return;
|
|
57
|
+
if (byte < this.initLength || sec < 0) return;
|
|
58
|
+
const existing = this.anchors.findIndex((a) => a.byte === byte);
|
|
59
|
+
if (existing >= 0) {
|
|
60
|
+
this.anchors[existing] = { byte, sec };
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.anchors.push({ byte, sec });
|
|
64
|
+
this.anchors.sort((a, b) => a.byte - b.byte);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Byte offset to probe for `targetSec`, from the local bitrate around it.
|
|
69
|
+
*
|
|
70
|
+
* The two anchors nearest the target in time define the line, whether they
|
|
71
|
+
* straddle the target or both sit on one side of it. Anchoring on the start
|
|
72
|
+
* of the file instead — the obvious choice — converges badly here: the line
|
|
73
|
+
* from t=0 to a point past the target carries the clip's *average* bitrate,
|
|
74
|
+
* which in a screen recording can be half the local one, so each correction
|
|
75
|
+
* only covers part of the remaining distance and a seek burns several round
|
|
76
|
+
* trips walking in.
|
|
77
|
+
*/
|
|
78
|
+
estimate(targetSec: number, bounds: EstimateBounds): number {
|
|
79
|
+
const points = this.referencePoints(bounds);
|
|
80
|
+
const byDistance = points
|
|
81
|
+
.slice()
|
|
82
|
+
.sort(
|
|
83
|
+
(a, b) =>
|
|
84
|
+
Math.abs(a.sec - targetSec) - Math.abs(b.sec - targetSec) ||
|
|
85
|
+
a.byte - b.byte,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const near = byDistance[0];
|
|
89
|
+
const far = near
|
|
90
|
+
? byDistance.find(
|
|
91
|
+
(p) =>
|
|
92
|
+
p.byte !== near.byte &&
|
|
93
|
+
Math.abs(p.sec - near.sec) >= MIN_SLOPE_SPAN_SECONDS,
|
|
94
|
+
)
|
|
95
|
+
: undefined;
|
|
96
|
+
if (!near || !far) return this.initLength;
|
|
97
|
+
|
|
98
|
+
const slope = (far.byte - near.byte) / (far.sec - near.sec);
|
|
99
|
+
if (!(slope > 0)) return this.initLength;
|
|
100
|
+
return this.clampByte(near.byte + (targetSec - near.sec) * slope, bounds);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Anchors plus the two synthetic endpoints, deduplicated by byte offset. */
|
|
104
|
+
private referencePoints(bounds: EstimateBounds): FragmentLocation[] {
|
|
105
|
+
const points: FragmentLocation[] = [{ byte: this.initLength, sec: 0 }];
|
|
106
|
+
for (const anchor of this.anchors) {
|
|
107
|
+
if (anchor.byte !== this.initLength) points.push(anchor);
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
bounds.totalBytes !== null &&
|
|
111
|
+
bounds.durationSec > 0 &&
|
|
112
|
+
bounds.totalBytes > this.initLength &&
|
|
113
|
+
!points.some((p) => p.byte === bounds.totalBytes)
|
|
114
|
+
) {
|
|
115
|
+
points.push({ byte: bounds.totalBytes, sec: bounds.durationSec });
|
|
116
|
+
}
|
|
117
|
+
return points;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private clampByte(byte: number, bounds: EstimateBounds): number {
|
|
121
|
+
const floored = Math.max(this.initLength, Math.floor(byte));
|
|
122
|
+
if (bounds.totalBytes === null) return floored;
|
|
123
|
+
return Math.min(floored, Math.max(this.initLength, bounds.totalBytes - 1));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ResolveSeekOptions<T extends FragmentLocation> {
|
|
128
|
+
/** Seek target in seconds. */
|
|
129
|
+
target: number;
|
|
130
|
+
/** Byte offset of the first media fragment; nothing earlier is probeable. */
|
|
131
|
+
initLength: number;
|
|
132
|
+
/** Size of one probe window in bytes. */
|
|
133
|
+
probeSize: number;
|
|
134
|
+
/** Probe budget. Bracketed interpolation converges in 2-3 in practice. */
|
|
135
|
+
maxProbes: number;
|
|
136
|
+
/**
|
|
137
|
+
* How far before the target we accept landing. The gap must be downloaded
|
|
138
|
+
* before playback resumes, so a small number keeps seeks responsive.
|
|
139
|
+
*/
|
|
140
|
+
acceptUndershootSeconds: number;
|
|
141
|
+
/** Byte offset to try next, from the anchors learned so far. */
|
|
142
|
+
estimate: () => number;
|
|
143
|
+
/** Read the first usable fragment at or after `startByte`, or null. */
|
|
144
|
+
probe: (startByte: number) => Promise<T | null>;
|
|
145
|
+
/** Abandon early — a newer seek has superseded this one. */
|
|
146
|
+
superseded?: () => boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ResolvedSeek<T> {
|
|
150
|
+
/** Fragment to append from, or null when none could be located. */
|
|
151
|
+
chosen: T | null;
|
|
152
|
+
/**
|
|
153
|
+
* True when `chosen` starts after `target`, so appending it alone leaves the
|
|
154
|
+
* playhead on an unreachable position and the caller must move the playhead.
|
|
155
|
+
*/
|
|
156
|
+
overshot: boolean;
|
|
157
|
+
probes: number;
|
|
158
|
+
superseded: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Locate the fragment to start appending from for a seek to `target`.
|
|
163
|
+
*
|
|
164
|
+
* Probes, compares the fragment's real start time against the target, and
|
|
165
|
+
* re-estimates with each new anchor until it lands at or just before the
|
|
166
|
+
* target, maintaining a byte bracket so every probe makes progress:
|
|
167
|
+
*
|
|
168
|
+
* - `probe` returns the FIRST fragment at or after the position asked for, so
|
|
169
|
+
* an overshoot proves no fragment begins in `[probed, fragment)`. The bound
|
|
170
|
+
* is therefore the position probed, not the fragment's own offset — that is
|
|
171
|
+
* what makes the bracket narrow instead of sitting still while the forward
|
|
172
|
+
* scan keeps returning the same fragment.
|
|
173
|
+
* - Inside a bracket, the probe goes to whichever is further back: the
|
|
174
|
+
* estimate, or a doubling step below the bound. Neither works alone — the
|
|
175
|
+
* estimate typically misses by less than one fragment so the bracket only
|
|
176
|
+
* creeps, while a blind stride cannot be sized (doubling past one oversized
|
|
177
|
+
* fragment walks back to the start of the file). Once a step would pass the
|
|
178
|
+
* far end it bisects, and once the bracket is about one window wide it
|
|
179
|
+
* probes the floor, the earliest fragment still reachable.
|
|
180
|
+
* - With no upper bound yet and an estimate that cannot move (a single
|
|
181
|
+
* anchor at t=0, or a duration that understates the clip), the probe
|
|
182
|
+
* gallops forward at a doubling stride rather than re-probing one spot
|
|
183
|
+
* until the budget is gone.
|
|
184
|
+
*/
|
|
185
|
+
export async function resolveSeekFragment<T extends FragmentLocation>(
|
|
186
|
+
opts: ResolveSeekOptions<T>,
|
|
187
|
+
): Promise<ResolvedSeek<T>> {
|
|
188
|
+
const { target, initLength, probeSize, maxProbes } = opts;
|
|
189
|
+
let landed: T | null = null;
|
|
190
|
+
let overshoot: T | null = null;
|
|
191
|
+
/** Highest byte known to hold a fragment at or before `target`. */
|
|
192
|
+
let lowerByte = initLength;
|
|
193
|
+
/** Lowest byte known to hold nothing usable at or before `target`. */
|
|
194
|
+
let upperByte: number | null = null;
|
|
195
|
+
let forwardStep = probeSize;
|
|
196
|
+
let backStep = probeSize;
|
|
197
|
+
let lastStart: number | null = null;
|
|
198
|
+
let probes = 0;
|
|
199
|
+
|
|
200
|
+
for (let attempt = 0; attempt < maxProbes; attempt++) {
|
|
201
|
+
if (opts.superseded?.()) {
|
|
202
|
+
return { chosen: null, overshot: false, probes, superseded: true };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Bias slightly early: `probe` scans forward to the next fragment, so
|
|
206
|
+
// starting a little before the estimated point usually lands under the
|
|
207
|
+
// target on the first probe instead of the second. The bias is a nudge, not
|
|
208
|
+
// evidence — the forward-gallop decision below reads the unbiased estimate,
|
|
209
|
+
// because a bias that dips under the floor must not be mistaken for "the
|
|
210
|
+
// estimate cannot move forward" and skip an answer sitting at the floor.
|
|
211
|
+
const estimated = opts.estimate();
|
|
212
|
+
let guess = estimated - Math.floor(probeSize / 2);
|
|
213
|
+
if (upperByte !== null) {
|
|
214
|
+
// Below a known bound, take whichever is further back: the estimate, or a
|
|
215
|
+
// doubling step. The estimate carries a large correction in one hop, but
|
|
216
|
+
// on its own it stalls — it typically misses by a fraction of a second,
|
|
217
|
+
// which is less than one fragment, so the forward scan keeps returning
|
|
218
|
+
// the same fragment and the bracket creeps. The doubling step guarantees
|
|
219
|
+
// each probe clears at least one fragment.
|
|
220
|
+
const ceiling = upperByte - backStep;
|
|
221
|
+
backStep *= 2;
|
|
222
|
+
guess = Math.min(guess, ceiling);
|
|
223
|
+
if (guess <= lowerByte) {
|
|
224
|
+
// Once the bracket is down to about one window, its floor is the most
|
|
225
|
+
// informative position left: a forward scan from there returns the
|
|
226
|
+
// earliest fragment in the bracket. Above that, bisect.
|
|
227
|
+
guess =
|
|
228
|
+
upperByte - lowerByte <= probeSize
|
|
229
|
+
? lowerByte
|
|
230
|
+
: Math.floor((lowerByte + upperByte) / 2);
|
|
231
|
+
}
|
|
232
|
+
} else if (estimated <= lowerByte) {
|
|
233
|
+
guess = lowerByte + forwardStep;
|
|
234
|
+
forwardStep *= 2;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const start = Math.max(Math.floor(guess), initLength);
|
|
238
|
+
if (upperByte !== null && start >= upperByte) break;
|
|
239
|
+
// No new information is available from a position already probed.
|
|
240
|
+
if (start === lastStart) break;
|
|
241
|
+
lastStart = start;
|
|
242
|
+
|
|
243
|
+
probes++;
|
|
244
|
+
const candidate = await opts.probe(start);
|
|
245
|
+
|
|
246
|
+
if (!candidate) {
|
|
247
|
+
// Nothing usable from here to the end of the asset — past EOF, or an
|
|
248
|
+
// unparseable tail. Bound and keep looking earlier rather than
|
|
249
|
+
// abandoning the seek, which would strand a viewer whose target is
|
|
250
|
+
// perfectly playable.
|
|
251
|
+
if (upperByte === null || start < upperByte) upperByte = start;
|
|
252
|
+
if (upperByte <= initLength) break;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (candidate.sec <= target) {
|
|
257
|
+
if (!landed || candidate.sec > landed.sec) landed = candidate;
|
|
258
|
+
if (candidate.byte > lowerByte) lowerByte = candidate.byte;
|
|
259
|
+
if (target - candidate.sec <= opts.acceptUndershootSeconds) break;
|
|
260
|
+
continue; // undershot too far; the fresh anchor sharpens the next guess
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!overshoot || candidate.byte < overshoot.byte) overshoot = candidate;
|
|
264
|
+
if (upperByte === null || start < upperByte) upperByte = start;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const chosen = landed ?? overshoot;
|
|
268
|
+
return {
|
|
269
|
+
chosen,
|
|
270
|
+
overshot: chosen !== null && landed === null,
|
|
271
|
+
probes,
|
|
272
|
+
superseded: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -108,6 +108,174 @@ export interface ParsedInitSegment {
|
|
|
108
108
|
codecs: string;
|
|
109
109
|
hasVideo: boolean;
|
|
110
110
|
hasAudio: boolean;
|
|
111
|
+
/** Declared tracks, needed to turn a fragment's `tfdt` into seconds. */
|
|
112
|
+
tracks: Mp4Track[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Boxes directly contained in `[start, end)`, with offsets relative to `bytes`. */
|
|
116
|
+
function boxesIn(bytes: Uint8Array, start: number, end: number): TopLevelBox[] {
|
|
117
|
+
const clampedEnd = Math.min(end, bytes.byteLength);
|
|
118
|
+
if (start >= clampedEnd) return [];
|
|
119
|
+
return readTopLevelBoxes(bytes.subarray(start, clampedEnd)).map((box) => ({
|
|
120
|
+
...box,
|
|
121
|
+
start: box.start + start,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface Mp4Track {
|
|
126
|
+
trackId: number;
|
|
127
|
+
/** Ticks per second for this track's `tfdt` / `mdhd` timestamps. */
|
|
128
|
+
timescale: number;
|
|
129
|
+
/** `hdlr` handler type: "vide", "soun", or "" when absent. */
|
|
130
|
+
kind: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read each track's id and timescale from a `moov` box (passed including its
|
|
135
|
+
* own header). A fragment's `tfdt` is in its own track's timescale — Clips
|
|
136
|
+
* recordings carry video at 600 and audio at 48000 — so converting a fragment
|
|
137
|
+
* timestamp to seconds without matching its track id is off by 80x.
|
|
138
|
+
*/
|
|
139
|
+
export function parseTracks(moovRegion: Uint8Array): Mp4Track[] {
|
|
140
|
+
const moovEnd = moovRegion.byteLength;
|
|
141
|
+
if (moovEnd < 8) return [];
|
|
142
|
+
|
|
143
|
+
const tracks: Mp4Track[] = [];
|
|
144
|
+
for (const trak of boxesIn(moovRegion, 8, moovEnd)) {
|
|
145
|
+
if (trak.type !== "trak") continue;
|
|
146
|
+
const trakEnd = trak.size ? trak.start + trak.size : moovEnd;
|
|
147
|
+
const trakChildren = boxesIn(
|
|
148
|
+
moovRegion,
|
|
149
|
+
trak.start + trak.headerSize,
|
|
150
|
+
trakEnd,
|
|
151
|
+
);
|
|
152
|
+
const tkhd = trakChildren.find((b) => b.type === "tkhd");
|
|
153
|
+
const mdia = trakChildren.find((b) => b.type === "mdia");
|
|
154
|
+
if (!tkhd || !mdia) continue;
|
|
155
|
+
|
|
156
|
+
// FullBox payloads start with a version byte; version 1 widens the two
|
|
157
|
+
// timestamps that precede the field we want from 4 to 8 bytes each.
|
|
158
|
+
const trackId = readFullBoxU32(moovRegion, tkhd, 8);
|
|
159
|
+
if (trackId === null) continue;
|
|
160
|
+
|
|
161
|
+
const mdiaEnd = mdia.size ? mdia.start + mdia.size : moovEnd;
|
|
162
|
+
const mdiaChildren = boxesIn(
|
|
163
|
+
moovRegion,
|
|
164
|
+
mdia.start + mdia.headerSize,
|
|
165
|
+
mdiaEnd,
|
|
166
|
+
);
|
|
167
|
+
const mdhd = mdiaChildren.find((b) => b.type === "mdhd");
|
|
168
|
+
if (!mdhd) continue;
|
|
169
|
+
const timescale = readFullBoxU32(moovRegion, mdhd, 8);
|
|
170
|
+
if (timescale === null || timescale <= 0) continue;
|
|
171
|
+
|
|
172
|
+
const hdlr = mdiaChildren.find((b) => b.type === "hdlr");
|
|
173
|
+
let kind = "";
|
|
174
|
+
if (hdlr) {
|
|
175
|
+
const handlerAt = hdlr.start + hdlr.headerSize + 8;
|
|
176
|
+
if (handlerAt + 4 <= moovRegion.byteLength) {
|
|
177
|
+
kind = readType(moovRegion, handlerAt);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
tracks.push({ trackId, timescale, kind });
|
|
182
|
+
}
|
|
183
|
+
return tracks;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Read a uint32 from a FullBox payload at `afterTimestamps` bytes past the
|
|
188
|
+
* version/flags word, accounting for the version-1 widening of the two
|
|
189
|
+
* timestamp fields that precede it (`tkhd` track_ID, `mdhd` timescale).
|
|
190
|
+
*/
|
|
191
|
+
function readFullBoxU32(
|
|
192
|
+
bytes: Uint8Array,
|
|
193
|
+
box: TopLevelBox,
|
|
194
|
+
afterTimestamps: number,
|
|
195
|
+
): number | null {
|
|
196
|
+
const payload = box.start + box.headerSize;
|
|
197
|
+
if (payload >= bytes.byteLength) return null;
|
|
198
|
+
const version = bytes[payload];
|
|
199
|
+
const widened = version === 1 ? afterTimestamps * 2 : afterTimestamps;
|
|
200
|
+
const at = payload + 4 + widened;
|
|
201
|
+
if (at + 4 > bytes.byteLength) return null;
|
|
202
|
+
return readU32(bytes, at);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface FragmentDecodeTime {
|
|
206
|
+
/** Track the timestamp belongs to; resolve its timescale via `parseTracks`. */
|
|
207
|
+
trackId: number;
|
|
208
|
+
baseMediaDecodeTime: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read the first track fragment's decode time from the `moof` box at
|
|
213
|
+
* `moofStart`. This is what makes a byte offset locatable on the timeline: it
|
|
214
|
+
* answers "what presentation time does the fragment at this byte position
|
|
215
|
+
* start at", which a byte-fraction estimate can only guess at.
|
|
216
|
+
*
|
|
217
|
+
* Returns null when the `moof` is truncated or carries no `tfdt`.
|
|
218
|
+
*/
|
|
219
|
+
export function readFragmentDecodeTime(
|
|
220
|
+
bytes: Uint8Array,
|
|
221
|
+
moofStart: number,
|
|
222
|
+
): FragmentDecodeTime | null {
|
|
223
|
+
if (moofStart < 0 || moofStart + 8 > bytes.byteLength) return null;
|
|
224
|
+
const size = readU32(bytes, moofStart);
|
|
225
|
+
if (size < 16) return null;
|
|
226
|
+
const moofEnd = Math.min(moofStart + size, bytes.byteLength);
|
|
227
|
+
|
|
228
|
+
for (const traf of boxesIn(bytes, moofStart + 8, moofEnd)) {
|
|
229
|
+
if (traf.type !== "traf") continue;
|
|
230
|
+
const trafEnd = traf.size ? traf.start + traf.size : moofEnd;
|
|
231
|
+
let trackId: number | null = null;
|
|
232
|
+
let baseMediaDecodeTime: number | null = null;
|
|
233
|
+
|
|
234
|
+
for (const child of boxesIn(bytes, traf.start + traf.headerSize, trafEnd)) {
|
|
235
|
+
const payload = child.start + child.headerSize;
|
|
236
|
+
if (child.type === "tfhd") {
|
|
237
|
+
if (payload + 8 <= bytes.byteLength) {
|
|
238
|
+
trackId = readU32(bytes, payload + 4);
|
|
239
|
+
}
|
|
240
|
+
} else if (child.type === "tfdt") {
|
|
241
|
+
if (payload >= bytes.byteLength) continue;
|
|
242
|
+
if (bytes[payload] === 1) {
|
|
243
|
+
if (payload + 12 > bytes.byteLength) continue;
|
|
244
|
+
// 64-bit: JS numbers hold this exactly for any real recording length.
|
|
245
|
+
baseMediaDecodeTime =
|
|
246
|
+
readU32(bytes, payload + 4) * 4294967296 +
|
|
247
|
+
readU32(bytes, payload + 8);
|
|
248
|
+
} else {
|
|
249
|
+
if (payload + 8 > bytes.byteLength) continue;
|
|
250
|
+
baseMediaDecodeTime = readU32(bytes, payload + 4);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (trackId !== null && baseMediaDecodeTime !== null) {
|
|
256
|
+
return { trackId, baseMediaDecodeTime };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Presentation time in seconds of the fragment whose `moof` starts at
|
|
264
|
+
* `moofStart`, or null when it cannot be read (truncated box, or a track id
|
|
265
|
+
* absent from `tracks`). Never falls back to a different track's timescale — a
|
|
266
|
+
* wrong timescale is worse than no answer, because callers use this to decide
|
|
267
|
+
* where to start appending.
|
|
268
|
+
*/
|
|
269
|
+
export function fragmentPtsSeconds(
|
|
270
|
+
bytes: Uint8Array,
|
|
271
|
+
moofStart: number,
|
|
272
|
+
tracks: readonly Mp4Track[],
|
|
273
|
+
): number | null {
|
|
274
|
+
const decodeTime = readFragmentDecodeTime(bytes, moofStart);
|
|
275
|
+
if (!decodeTime) return null;
|
|
276
|
+
const track = tracks.find((t) => t.trackId === decodeTime.trackId);
|
|
277
|
+
if (!track || track.timescale <= 0) return null;
|
|
278
|
+
return decodeTime.baseMediaDecodeTime / track.timescale;
|
|
111
279
|
}
|
|
112
280
|
|
|
113
281
|
/**
|
|
@@ -140,6 +308,7 @@ export function parseInitSegment(bytes: Uint8Array): ParsedInitSegment | null {
|
|
|
140
308
|
codecs: codecParts.join(","),
|
|
141
309
|
hasVideo,
|
|
142
310
|
hasAudio,
|
|
311
|
+
tracks: parseTracks(moovRegion),
|
|
143
312
|
};
|
|
144
313
|
}
|
|
145
314
|
|