@onmark/cli 0.2.1 → 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.
@@ -1,7 +1,7 @@
1
1
  export { ProtocolDecodeError, decodeBrowserRequest, decodeBrowserResponse, } from "./generated/codec.js";
2
2
  export type { BrowserRequest } from "./generated/browser-request.js";
3
3
  export type { BrowserResponse } from "./generated/browser-response.js";
4
- export type { BrowserPlan } from "./generated/browser-request.js";
4
+ export type { BrowserPlan, BrowserVideo } from "./generated/browser-request.js";
5
5
  export { runtimeFrameAt, type RuntimeFrame } from "./clock.js";
6
6
  export { videoFrameSelection, type RuntimeVideo, type VideoFrameSelection, } from "./media.js";
7
7
  export { DecodedVideo, materializedVideoSource, type BrowserVideoElement, type DecodedVideoOptions, } from "./video.js";
@@ -7,7 +7,16 @@ type FrameRate = RuntimePlan["frameRate"];
7
7
  export interface VideoFrameSelection {
8
8
  readonly mediaTimeSeconds: number;
9
9
  readonly seekTimeSeconds: number;
10
+ /** Largest callback error that cannot identify an adjacent source frame. */
11
+ readonly readinessToleranceSeconds: number;
10
12
  }
11
13
  /** Selects the source frame visible at one output-frame midpoint. */
12
14
  export declare function videoFrameSelection(frame: RuntimeFrame, video: RuntimeVideo, outputFrameRate: FrameRate): VideoFrameSelection | undefined;
15
+ /** Checks protocol source facts without deriving any authored timeline. */
16
+ export declare function videoSourceMappingIsValid(video: RuntimeVideo, outputFrameRate: FrameRate): boolean;
17
+ /** Checks the canonical positive-rational spelling used by Rust wire values. */
18
+ export declare function exactRatioIsCanonical(rate: {
19
+ readonly numerator: number;
20
+ readonly denominator: number;
21
+ }): boolean;
13
22
  export {};
@@ -1,28 +1,202 @@
1
1
  // Browser-media projection from exact Rust-owned frame facts.
2
2
  // It selects source frames without becoming a second timeline solver.
3
+ const MAX_DURATION_NANOSECONDS = 18446744073709551615n;
4
+ const MAX_RATIO_PART = 4_294_967_295;
3
5
  /** Selects the source frame visible at one output-frame midpoint. */
4
6
  export function videoFrameSelection(frame, video, outputFrameRate) {
5
7
  if (frame.index < video.interval.start || frame.index >= video.interval.end) {
6
8
  return undefined;
7
9
  }
8
10
  const localFrame = frame.index - video.interval.start;
9
- const sourceFrame = sourceFrameAtMidpoint(localFrame, outputFrameRate, video.sourceFrameRate);
10
- const sourceFrameDuration = video.sourceFrameRate.denominator / video.sourceFrameRate.numerator;
11
+ const sourceTime = sourceTimeAtMidpoint(localFrame, outputFrameRate, video.source);
12
+ return selectSourceFrame(sourceTime, video.sourceTiming, video.source);
13
+ }
14
+ /** Checks protocol source facts without deriving any authored timeline. */
15
+ export function videoSourceMappingIsValid(video, outputFrameRate) {
16
+ const source = video.source;
17
+ const start = BigInt(source.startNanoseconds);
18
+ const end = BigInt(source.endNanoseconds);
19
+ const naturalEnd = BigInt(source.naturalEndNanoseconds);
20
+ if (start >= end ||
21
+ end > naturalEnd ||
22
+ naturalEnd > MAX_DURATION_NANOSECONDS) {
23
+ return false;
24
+ }
25
+ if (!exactRatioIsCanonical(source.playbackRate) ||
26
+ !Number.isInteger(source.plays) ||
27
+ source.plays <= 0 ||
28
+ source.plays > 4_294_967_295 ||
29
+ !canonicalUnsignedInteger(source.holdLastNanoseconds) ||
30
+ !sourceTimingIsValid(video.sourceTiming, naturalEnd)) {
31
+ return false;
32
+ }
33
+ const sourceDuration = end - start;
34
+ const playbackNumerator = sourceDuration *
35
+ BigInt(outputFrameRate.numerator) *
36
+ BigInt(source.playbackRate.denominator) *
37
+ BigInt(source.plays);
38
+ const holdNumerator = BigInt(source.holdLastNanoseconds) *
39
+ BigInt(outputFrameRate.numerator) *
40
+ BigInt(source.playbackRate.numerator);
41
+ const numerator = playbackNumerator + holdNumerator;
42
+ const denominator = 1000000000n *
43
+ BigInt(outputFrameRate.denominator) *
44
+ BigInt(source.playbackRate.numerator);
45
+ const expectedFrames = (numerator + denominator - 1n) / denominator;
46
+ const actualFrames = BigInt(video.interval.end - video.interval.start);
47
+ return actualFrames === expectedFrames;
48
+ }
49
+ function sourceTimeAtMidpoint(localFrame, outputFrameRate, source) {
50
+ const midpoint = 2n * BigInt(localFrame) + 1n;
51
+ const outputNumerator = BigInt(outputFrameRate.numerator);
52
+ const outputDenominator = BigInt(outputFrameRate.denominator);
53
+ const speedNumerator = BigInt(source.playbackRate.numerator);
54
+ const speedDenominator = BigInt(source.playbackRate.denominator);
55
+ const startNanoseconds = BigInt(source.startNanoseconds);
56
+ const endNanoseconds = BigInt(source.endNanoseconds);
57
+ const plays = BigInt(source.plays);
58
+ const nanosecondsPerSecond = 1000000000n;
59
+ // Rust owns the source treatment. This integer projection samples one exact
60
+ // pass without reconstructing a browser timeline.
61
+ const timeDenominator = 2n * outputNumerator * speedDenominator;
62
+ const elapsedNumerator = midpoint * outputDenominator * speedNumerator * nanosecondsPerSecond;
63
+ const passNumerator = (endNanoseconds - startNanoseconds) * timeDenominator;
64
+ const playbackNumerator = passNumerator * plays;
65
+ const sourceTimeNumerator = elapsedNumerator < playbackNumerator
66
+ ? startNanoseconds * timeDenominator + (elapsedNumerator % passNumerator)
67
+ : endNanoseconds * timeDenominator - 1n;
68
+ return {
69
+ numerator: sourceTimeNumerator,
70
+ denominator: timeDenominator,
71
+ };
72
+ }
73
+ // ── CFR and VFR projection ──
74
+ function selectSourceFrame(time, timing, source) {
75
+ switch (timing.kind) {
76
+ case "constant":
77
+ return selectConstantFrame(time, timing.frameRate, source);
78
+ case "variable":
79
+ return selectVariableFrame(time, timing);
80
+ }
81
+ }
82
+ function selectConstantFrame(time, rate, source) {
83
+ const rateNumerator = BigInt(rate.numerator);
84
+ const frameDenominator = time.denominator * 1000000000n * BigInt(rate.denominator);
85
+ const selected = (time.numerator * rateNumerator) / frameDenominator;
86
+ const sourceEnd = BigInt(source.endNanoseconds) * rateNumerator;
87
+ const exclusiveEnd = divideCeil(sourceEnd, 1000000000n * BigInt(rate.denominator));
88
+ const frame = selected < exclusiveEnd ? selected : exclusiveEnd - 1n;
89
+ const rateDenominator = BigInt(rate.denominator);
90
+ return projectFrameInterval(frame * rateDenominator, (frame + 1n) * rateDenominator, rateNumerator, rateDenominator);
91
+ }
92
+ function selectVariableFrame(time, timing) {
93
+ const selected = sourceFrameIndex(time, timing.timebase, timing.boundaries);
94
+ const start = timing.boundaries[selected];
95
+ const end = timing.boundaries[selected + 1];
96
+ if (start === undefined || end === undefined) {
97
+ throw new RangeError("selected source frame lies outside its timestamp map");
98
+ }
99
+ const startTick = BigInt(start);
100
+ const endTick = BigInt(end);
101
+ const timebaseNumerator = BigInt(timing.timebase.numerator);
102
+ const previous = timing.boundaries[selected - 1];
103
+ const previousDistance = previous === undefined ? endTick - startTick : startTick - BigInt(previous);
104
+ const nearestDistance = previousDistance < endTick - startTick
105
+ ? previousDistance
106
+ : endTick - startTick;
107
+ return projectFrameInterval(startTick * timebaseNumerator, endTick * timebaseNumerator, BigInt(timing.timebase.denominator), nearestDistance * timebaseNumerator);
108
+ }
109
+ function projectFrameInterval(startNumerator, endNumerator, denominator, neighborDistanceNumerator) {
110
+ // Frame identity remains exact until this single projection into the
111
+ // browser's floating-point media API. Reject intervals for which no
112
+ // representable interior second can prove the selected frame.
113
+ const mediaTimeSeconds = rationalSeconds(startNumerator, denominator);
114
+ const seekTimeSeconds = rationalSeconds(startNumerator + endNumerator, 2n * denominator);
115
+ const endTimeSeconds = rationalSeconds(endNumerator, denominator);
116
+ const readinessToleranceSeconds = Math.min(0.000_001, rationalSeconds(neighborDistanceNumerator, denominator) / 4);
117
+ if (!Number.isFinite(mediaTimeSeconds) ||
118
+ !(mediaTimeSeconds < seekTimeSeconds) ||
119
+ !(seekTimeSeconds < endTimeSeconds) ||
120
+ !(readinessToleranceSeconds > 0)) {
121
+ throw new RangeError("selected source frame has no representable interior seek time");
122
+ }
11
123
  return Object.freeze({
12
- mediaTimeSeconds: sourceFrame * sourceFrameDuration,
13
- seekTimeSeconds: (sourceFrame + 0.5) * sourceFrameDuration,
124
+ mediaTimeSeconds,
125
+ seekTimeSeconds,
126
+ readinessToleranceSeconds,
14
127
  });
15
128
  }
16
- function sourceFrameAtMidpoint(localFrame, outputFrameRate, sourceFrameRate) {
17
- const numerator = (2n * BigInt(localFrame) + 1n) *
18
- BigInt(outputFrameRate.denominator) *
19
- BigInt(sourceFrameRate.numerator);
20
- const denominator = 2n *
21
- BigInt(outputFrameRate.numerator) *
22
- BigInt(sourceFrameRate.denominator);
23
- const sourceFrame = Number(numerator / denominator);
24
- if (!Number.isSafeInteger(sourceFrame)) {
25
- throw new RangeError("selected source frame exceeds JavaScript's exact integer range");
129
+ function rationalSeconds(numerator, denominator) {
130
+ const whole = numerator / denominator;
131
+ const remainder = numerator % denominator;
132
+ return Number(whole) + Number(remainder) / Number(denominator);
133
+ }
134
+ function sourceFrameIndex(time, timebase, boundaries) {
135
+ const targetNumerator = time.numerator * BigInt(timebase.denominator);
136
+ const targetDenominator = time.denominator * BigInt(timebase.numerator) * 1000000000n;
137
+ let low = 0;
138
+ let high = boundaries.length;
139
+ while (low < high) {
140
+ const middle = low + Math.floor((high - low) / 2);
141
+ const boundary = boundaries[middle];
142
+ if (boundary !== undefined &&
143
+ BigInt(boundary) * targetDenominator <= targetNumerator) {
144
+ low = middle + 1;
145
+ }
146
+ else {
147
+ high = middle;
148
+ }
149
+ }
150
+ return Math.min(low - 1, boundaries.length - 2);
151
+ }
152
+ // ── Wire-fact validation ──
153
+ function sourceTimingIsValid(timing, naturalEndNanoseconds) {
154
+ if (timing.kind === "constant") {
155
+ return exactRatioIsCanonical(timing.frameRate);
156
+ }
157
+ if (!exactRatioIsCanonical(timing.timebase) ||
158
+ timing.boundaries.length < 3 ||
159
+ timing.boundaries.length > 100_000) {
160
+ return false;
161
+ }
162
+ let previous = -1n;
163
+ for (const spelling of timing.boundaries) {
164
+ if (!canonicalUnsignedInteger(spelling)) {
165
+ return false;
166
+ }
167
+ const boundary = BigInt(spelling);
168
+ if (boundary <= previous) {
169
+ return false;
170
+ }
171
+ previous = boundary;
172
+ }
173
+ if (timing.boundaries[0] !== "0") {
174
+ return false;
175
+ }
176
+ const duration = divideCeil(previous * BigInt(timing.timebase.numerator) * 1000000000n, BigInt(timing.timebase.denominator));
177
+ return duration === naturalEndNanoseconds;
178
+ }
179
+ function canonicalUnsignedInteger(value) {
180
+ return (/^(0|[1-9][0-9]*)$/u.test(value) &&
181
+ BigInt(value) <= MAX_DURATION_NANOSECONDS);
182
+ }
183
+ function divideCeil(numerator, denominator) {
184
+ return (numerator + denominator - 1n) / denominator;
185
+ }
186
+ /** Checks the canonical positive-rational spelling used by Rust wire values. */
187
+ export function exactRatioIsCanonical(rate) {
188
+ if (!Number.isInteger(rate.numerator) ||
189
+ !Number.isInteger(rate.denominator) ||
190
+ rate.numerator < 1 ||
191
+ rate.denominator < 1 ||
192
+ rate.numerator > MAX_RATIO_PART ||
193
+ rate.denominator > MAX_RATIO_PART) {
194
+ return false;
195
+ }
196
+ let left = BigInt(rate.numerator);
197
+ let right = BigInt(rate.denominator);
198
+ while (right !== 0n) {
199
+ [left, right] = [right, left % right];
26
200
  }
27
- return sourceFrame;
201
+ return left === 1n;
28
202
  }
@@ -4,6 +4,7 @@
4
4
  import { decodeBrowserResponse } from "./generated/codec.js";
5
5
  import { MAX_FAILURE_MESSAGE_CHARACTERS, MAX_PENDING_RESOURCE_CHARACTERS, MAX_PENDING_RESOURCES, } from "./generated/runtime-contract.js";
6
6
  import { runtimeFrameAt } from "./clock.js";
7
+ import { exactRatioIsCanonical, videoSourceMappingIsValid } from "./media.js";
7
8
  /** Expected failure reported by a browser adapter. */
8
9
  export class RuntimeAdapterError extends Error {
9
10
  kind;
@@ -156,7 +157,7 @@ export class RuntimeSession {
156
157
  }
157
158
  }
158
159
  function response(requestId, event) {
159
- return decodeBrowserResponse({ version: 1, requestId, event });
160
+ return decodeBrowserResponse({ version: 3, requestId, event });
160
161
  }
161
162
  function invalidRequest(requestId, message) {
162
163
  return response(requestId, {
@@ -195,6 +196,9 @@ function readinessFailure(requestId, operationCode, error) {
195
196
  return operationFailure(requestId, operationCode, error);
196
197
  }
197
198
  function planViolation(plan) {
199
+ if (!exactRatioIsCanonical(plan.frameRate)) {
200
+ return "plan frame rate is not canonical";
201
+ }
198
202
  if (plan.timeline.start >= plan.timeline.end) {
199
203
  return "plan timeline interval is empty or reversed";
200
204
  }
@@ -267,6 +271,9 @@ function planViolation(plan) {
267
271
  if (!insideInterval(video.interval, shotInterval)) {
268
272
  return "plan video interval falls outside its shot";
269
273
  }
274
+ if (!videoSourceMappingIsValid(video, plan.frameRate)) {
275
+ return "plan video source mapping disagrees with its interval";
276
+ }
270
277
  }
271
278
  for (const overlay of plan.overlays) {
272
279
  const nodeViolation = claimNode(overlay.node, nodeIds, authoredIds);
@@ -399,9 +406,31 @@ function snapshotVideo(video) {
399
406
  shotId: video.shotId,
400
407
  assetId: video.assetId,
401
408
  interval: Object.freeze({ ...video.interval }),
402
- sourceFrameRate: Object.freeze({ ...video.sourceFrameRate }),
409
+ sourceTiming: snapshotSourceTiming(video.sourceTiming),
410
+ source: Object.freeze({
411
+ ...video.source,
412
+ playbackRate: Object.freeze({ ...video.source.playbackRate }),
413
+ }),
403
414
  });
404
415
  }
416
+ function snapshotSourceTiming(timing) {
417
+ switch (timing.kind) {
418
+ case "constant":
419
+ return Object.freeze({
420
+ kind: timing.kind,
421
+ frameRate: Object.freeze({ ...timing.frameRate }),
422
+ });
423
+ case "variable":
424
+ return Object.freeze({
425
+ kind: timing.kind,
426
+ timebase: Object.freeze({ ...timing.timebase }),
427
+ boundaries: snapshotFrameBoundaries(timing.boundaries),
428
+ });
429
+ }
430
+ }
431
+ function snapshotFrameBoundaries([first, second, third, ...rest]) {
432
+ return Object.freeze([first, second, third, ...rest]);
433
+ }
405
434
  function snapshotOverlay(overlay) {
406
435
  return Object.freeze({
407
436
  node: snapshotNode(overlay.node),
@@ -3,7 +3,6 @@
3
3
  import { BUNDLE_ASSET_DIRECTORY } from "./generated/bundle-layout.js";
4
4
  import { requireReadinessTimeout } from "./resource.js";
5
5
  import { RuntimeAdapterError } from "./session.js";
6
- const FRAME_TOLERANCE_SECONDS = 0.000_001;
7
6
  const LOAD_READINESS = {
8
7
  event: "loadeddata",
9
8
  failureMessage: "video data failed to load",
@@ -247,7 +246,9 @@ class StagedFrame {
247
246
  }
248
247
  matches(selection) {
249
248
  return (selection.mediaTimeSeconds === this.#selection.mediaTimeSeconds &&
250
- selection.seekTimeSeconds === this.#selection.seekTimeSeconds);
249
+ selection.seekTimeSeconds === this.#selection.seekTimeSeconds &&
250
+ selection.readinessToleranceSeconds ===
251
+ this.#selection.readinessToleranceSeconds);
251
252
  }
252
253
  async confirm(timeoutMilliseconds) {
253
254
  const observation = await observedBeforeDeadline(this.#observation, timeoutMilliseconds, this.#pendingResource);
@@ -267,7 +268,7 @@ class StagedFrame {
267
268
  #inspectFrame = (_now, metadata) => {
268
269
  this.#frameCallback = undefined;
269
270
  const exactFrame = Math.abs(metadata.mediaTime - this.#selection.mediaTimeSeconds) <=
270
- FRAME_TOLERANCE_SECONDS;
271
+ this.#selection.readinessToleranceSeconds;
271
272
  if (exactFrame) {
272
273
  this.#finish({ kind: "presented" });
273
274
  return;
@@ -337,7 +338,11 @@ function requireSelection(selection) {
337
338
  if (!Number.isFinite(selection.mediaTimeSeconds) ||
338
339
  selection.mediaTimeSeconds < 0 ||
339
340
  !Number.isFinite(selection.seekTimeSeconds) ||
340
- selection.seekTimeSeconds < selection.mediaTimeSeconds) {
341
+ selection.seekTimeSeconds <= selection.mediaTimeSeconds ||
342
+ !Number.isFinite(selection.readinessToleranceSeconds) ||
343
+ selection.readinessToleranceSeconds <= 0 ||
344
+ selection.readinessToleranceSeconds >=
345
+ selection.seekTimeSeconds - selection.mediaTimeSeconds) {
341
346
  throw new RuntimeAdapterError("operation", "video frame selection is invalid");
342
347
  }
343
348
  }