@agent-native/core 0.175.0 → 0.175.1

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 (36) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/assets/app/root.tsx +5 -2
  3. package/corpus/templates/clips/actions/create-recording.ts +8 -1
  4. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +3 -1
  5. package/corpus/templates/clips/app/components/player/share-dialog.tsx +11 -0
  6. package/corpus/templates/clips/app/components/player/timestamped-comment-button.tsx +2 -41
  7. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +48 -4
  8. package/corpus/templates/clips/app/i18n/en-US.ts +1 -2
  9. package/corpus/templates/clips/app/lib/fmp4-seek.ts +274 -0
  10. package/corpus/templates/clips/app/lib/fmp4.ts +169 -0
  11. package/corpus/templates/clips/app/lib/mse-video-loader.ts +351 -109
  12. package/corpus/templates/clips/app/root.tsx +1 -1
  13. package/corpus/templates/clips/changelog/2026-08-25-seeking-into-a-long-recording-no-longer-sticks-on-buffering.md +6 -0
  14. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +103 -13
  15. package/corpus/templates/clips/server/lib/resumable-upload-provider.ts +2 -2
  16. package/corpus/templates/clips/server/lib/video-storage.ts +2 -2
  17. package/corpus/templates/forms/app/components/builder/FieldRenderer.tsx +162 -157
  18. package/corpus/templates/tasks/app/root.tsx +5 -2
  19. package/dist/collab/awareness.d.ts +2 -2
  20. package/dist/file-upload/builder.d.ts +2 -2
  21. package/dist/file-upload/builder.js +28 -26
  22. package/dist/file-upload/registry.js +11 -8
  23. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  24. package/dist/notifications/routes.d.ts +3 -3
  25. package/dist/observability/routes.d.ts +5 -5
  26. package/dist/secrets/routes.d.ts +9 -9
  27. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  28. package/dist/server/builder-api-auth.d.ts +31 -0
  29. package/dist/server/builder-api-auth.js +62 -0
  30. package/dist/server/builder-oauth.d.ts +5 -3
  31. package/dist/server/builder-oauth.js +40 -12
  32. package/dist/server/core-routes-plugin.js +5 -2
  33. package/dist/server/index.d.ts +2 -0
  34. package/dist/server/index.js +2 -0
  35. package/dist/server/transcribe-voice.d.ts +1 -1
  36. package/package.json +3 -3
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8615
34
+ - template files: 8617
@@ -146,9 +146,12 @@ export default function Root() {
146
146
  useCommandMenuShortcut(useCallback(() => setCmdkOpen(true), []));
147
147
  return (
148
148
  <AppToolkitProvider>
149
- <AppProviders queryClient={queryClient} i18n={{ catalog: i18nCatalog }}>
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
- "Video storage could not start a resumable upload session. Refresh and try again.",
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
- : t("clipsFinalRaw.removePersonFailed"),
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, IconAt, IconMoodSmile } from "@tabler/icons-react";
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-between">
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
- <span className="text-xs text-muted-foreground">
480
- {getRoleLabel(s.role)}
481
- </span>
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