@onmark/cli 0.5.1 → 0.6.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,6 +1,6 @@
1
1
  export { ProtocolDecodeError, decodeBrowserRequest, decodeBrowserResponse, } from "./generated/codec.js";
2
2
  export type { BrowserRequest } from "./generated/browser-request.js";
3
- export type { BrowserMediaLayout, BrowserMediaPlacement, BrowserResponse, } from "./generated/browser-response.js";
3
+ export type { BrowserMediaLayout, BrowserMediaPlacement, BrowserResponse, BrowserVisualFinding, BrowserVisualFindings, BrowserVisualIssue, } from "./generated/browser-response.js";
4
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";
@@ -8,6 +8,6 @@ export { DecodedVideo, materializedVideoSource, type BrowserVideoElement, type D
8
8
  export { MAX_PRESENTATION_EFFECTS, PresentationRuntimeAdapter, type ContainerPresentation, type FrameEffect, type OverlayPresentation, type PresentationBindings, type PresentationExtensions, type RuntimeOverlay, type RuntimeNode, type RuntimeScene, type RuntimeShot, type RuntimeTransition, type TransitionPresentation, type VideoPresentation, } from "./presentation.js";
9
9
  export { MAX_PRESENTATION_RESOURCES, PRESENTATION_RESOURCE_KINDS, type PresentationResource, type PresentationResourceKind, } from "./resource.js";
10
10
  export { BUNDLE_ASSET_DIRECTORY, PRESENTATION_TEMPORAL_CAPABILITIES, type PresentationTemporalCapability, } from "./generated/bundle-layout.js";
11
- export { BROWSER_PROTOCOL_VERSION, BROWSER_OBJECT_POSITION_SCALE, MAX_BROWSER_MEDIA_LAYOUTS, MAX_BROWSER_OVERLAYS, MAX_BROWSER_OVERLAY_TEXT_CHARACTERS, MAX_BROWSER_VIDEOS, MAX_BROWSER_TRANSITIONS, MAX_FAILURE_MESSAGE_CHARACTERS, MAX_PENDING_RESOURCE_CHARACTERS, MAX_PENDING_RESOURCES, } from "./generated/runtime-contract.js";
11
+ export { BROWSER_PROTOCOL_VERSION, BROWSER_OBJECT_POSITION_SCALE, MAX_BROWSER_MEDIA_LAYOUTS, MAX_BROWSER_OVERLAYS, MAX_BROWSER_VISUAL_FINDINGS, MAX_BROWSER_OVERLAY_TEXT_CHARACTERS, MAX_BROWSER_VIDEOS, MAX_BROWSER_TRANSITIONS, MAX_FAILURE_MESSAGE_CHARACTERS, MAX_PENDING_RESOURCE_CHARACTERS, MAX_PENDING_RESOURCES, } from "./generated/runtime-contract.js";
12
12
  export { RUNTIME_HOST_NAME, installRuntimeHost, type RuntimeHost, } from "./host.js";
13
13
  export { RuntimeAdapterError, RuntimeSession, type RuntimeAdapter, type RuntimeAdapterFailureKind, type RuntimeMediaMode, type RuntimePlan, } from "./session.js";
@@ -7,6 +7,6 @@ export { DecodedVideo, materializedVideoSource, } from "./video.js";
7
7
  export { MAX_PRESENTATION_EFFECTS, PresentationRuntimeAdapter, } from "./presentation.js";
8
8
  export { MAX_PRESENTATION_RESOURCES, PRESENTATION_RESOURCE_KINDS, } from "./resource.js";
9
9
  export { BUNDLE_ASSET_DIRECTORY, PRESENTATION_TEMPORAL_CAPABILITIES, } from "./generated/bundle-layout.js";
10
- export { BROWSER_PROTOCOL_VERSION, BROWSER_OBJECT_POSITION_SCALE, MAX_BROWSER_MEDIA_LAYOUTS, MAX_BROWSER_OVERLAYS, MAX_BROWSER_OVERLAY_TEXT_CHARACTERS, MAX_BROWSER_VIDEOS, MAX_BROWSER_TRANSITIONS, MAX_FAILURE_MESSAGE_CHARACTERS, MAX_PENDING_RESOURCE_CHARACTERS, MAX_PENDING_RESOURCES, } from "./generated/runtime-contract.js";
10
+ export { BROWSER_PROTOCOL_VERSION, BROWSER_OBJECT_POSITION_SCALE, MAX_BROWSER_MEDIA_LAYOUTS, MAX_BROWSER_OVERLAYS, MAX_BROWSER_VISUAL_FINDINGS, MAX_BROWSER_OVERLAY_TEXT_CHARACTERS, MAX_BROWSER_VIDEOS, MAX_BROWSER_TRANSITIONS, MAX_FAILURE_MESSAGE_CHARACTERS, MAX_PENDING_RESOURCE_CHARACTERS, MAX_PENDING_RESOURCES, } from "./generated/runtime-contract.js";
11
11
  export { RUNTIME_HOST_NAME, installRuntimeHost, } from "./host.js";
12
12
  export { RuntimeAdapterError, RuntimeSession, } from "./session.js";
@@ -1,5 +1,5 @@
1
1
  import type { RuntimeFrame } from "./clock.js";
2
- import type { BrowserMediaLayout, BrowserMediaPlacement } from "./generated/browser-response.js";
2
+ import type { BrowserMediaLayout, BrowserMediaPlacement, BrowserVisualFindings } from "./generated/browser-response.js";
3
3
  import { type RuntimeVideo } from "./media.js";
4
4
  import { type RuntimeAdapter, type RuntimeMediaMode, type RuntimePlan } from "./session.js";
5
5
  import { type BrowserVideoElement } from "./video.js";
@@ -76,6 +76,6 @@ export declare class PresentationRuntimeAdapter implements RuntimeAdapter {
76
76
  load(plan: RuntimePlan, mediaMode?: RuntimeMediaMode): Promise<void>;
77
77
  prepare(_frame: RuntimeFrame): Promise<BrowserMediaLayout>;
78
78
  seek(frame: RuntimeFrame): Promise<void>;
79
- confirm(frame: RuntimeFrame): Promise<void>;
79
+ confirm(frame: RuntimeFrame): Promise<BrowserVisualFindings>;
80
80
  dispose(): Promise<void>;
81
81
  }
@@ -2,6 +2,7 @@
2
2
  // Rust owns every interval; presentation callbacks own DOM and layout effects.
3
3
  import { videoFrameSelection, } from "./media.js";
4
4
  import { RuntimeAdapterError, } from "./session.js";
5
+ import { MAX_BROWSER_VISUAL_FINDINGS } from "./generated/runtime-contract.js";
5
6
  import { DecodedVideo } from "./video.js";
6
7
  import { ownPresentationResources, preparePresentationResources, releasePresentationResources, requireReadinessTimeout, validatePresentationResources, } from "./resource.js";
7
8
  // ── Presentation boundary ──
@@ -33,6 +34,7 @@ export class PresentationRuntimeAdapter {
33
34
  shots: [],
34
35
  };
35
36
  let boundStructure;
37
+ let visualSubjects;
36
38
  const videos = [];
37
39
  const layoutVideos = [];
38
40
  const overlays = [];
@@ -42,6 +44,7 @@ export class PresentationRuntimeAdapter {
42
44
  this.#bindTransitions(plan, transitions);
43
45
  this.#bindVideos(plan, mediaMode, videos, layoutVideos);
44
46
  this.#bindOverlays(plan, overlays);
47
+ visualSubjects = bindVisualSubjects(boundStructure.shots, overlays);
45
48
  const extensions = await this.#bindings.bindExtensions(plan);
46
49
  // Take both returned collections before either ownership projection or
47
50
  // validation can fail; cleanup must retain every transferred resource.
@@ -70,6 +73,7 @@ export class PresentationRuntimeAdapter {
70
73
  videos,
71
74
  layoutVideos,
72
75
  overlays,
76
+ visualSubjects,
73
77
  };
74
78
  }
75
79
  async prepare(_frame) {
@@ -104,14 +108,16 @@ export class PresentationRuntimeAdapter {
104
108
  }
105
109
  }
106
110
  async confirm(frame) {
107
- this.#loadedState("confirm");
111
+ const state = this.#loadedState("confirm");
108
112
  const staged = this.#staged;
109
113
  if (staged === undefined || staged.frame.index !== frame.index) {
110
114
  throw new RuntimeAdapterError("operation", "presentation confirmation requires the staged frame");
111
115
  }
112
116
  try {
113
117
  await confirmVideos(staged.videos);
118
+ const findings = inspectPresentation(frame, state.visualSubjects);
114
119
  this.#staged = undefined;
120
+ return findings;
115
121
  }
116
122
  catch (error) {
117
123
  this.#staged = undefined;
@@ -193,6 +199,78 @@ export class PresentationRuntimeAdapter {
193
199
  return this.#state;
194
200
  }
195
201
  }
202
+ function inspectPresentation(frame, subjects) {
203
+ const findings = [];
204
+ for (const subject of subjects) {
205
+ const { interval } = subject;
206
+ if (frame.index < interval.start || frame.index >= interval.end) {
207
+ continue;
208
+ }
209
+ const issues = inspectVisualSubject(subject);
210
+ for (const issue of issues) {
211
+ findings.push({
212
+ nodeId: subject.nodeId,
213
+ issue,
214
+ });
215
+ if (findings.length === MAX_BROWSER_VISUAL_FINDINGS) {
216
+ return findings;
217
+ }
218
+ }
219
+ }
220
+ return findings;
221
+ }
222
+ function bindVisualSubjects(shots, overlays) {
223
+ const subjects = [];
224
+ for (const shot of shots) {
225
+ subjects.push({
226
+ element: shot.presentation.element,
227
+ interval: shot.placement.interval,
228
+ kind: "shot",
229
+ nodeId: shot.placement.node.nodeId,
230
+ });
231
+ }
232
+ for (const overlay of overlays) {
233
+ subjects.push({
234
+ element: overlay.presentation.element,
235
+ interval: overlay.placement.interval,
236
+ kind: "overlay",
237
+ nodeId: overlay.placement.node.nodeId,
238
+ });
239
+ }
240
+ subjects.sort((left, right) => left.nodeId - right.nodeId);
241
+ return subjects;
242
+ }
243
+ function inspectVisualSubject(subject) {
244
+ if (hasEmptyBox(subject.element)) {
245
+ return ["emptyBox"];
246
+ }
247
+ return subject.kind === "overlay" ? inspectOverlay(subject.element) : [];
248
+ }
249
+ function inspectOverlay(element) {
250
+ if (hasEmptyBox(element)) {
251
+ return ["emptyBox"];
252
+ }
253
+ const view = element.ownerDocument.defaultView;
254
+ if (view === null) {
255
+ throw new RuntimeAdapterError("operation", "overlay layout requires an active browser document");
256
+ }
257
+ const style = view.getComputedStyle(element);
258
+ const issues = [];
259
+ if (clips(style.overflowX) && element.scrollWidth > element.clientWidth) {
260
+ issues.push("clippedHorizontally");
261
+ }
262
+ if (clips(style.overflowY) && element.scrollHeight > element.clientHeight) {
263
+ issues.push("clippedVertically");
264
+ }
265
+ return issues;
266
+ }
267
+ function hasEmptyBox(element) {
268
+ const rectangle = element.getBoundingClientRect();
269
+ return rectangle.width <= 0 || rectangle.height <= 0;
270
+ }
271
+ function clips(overflow) {
272
+ return overflow === "hidden" || overflow === "clip";
273
+ }
196
274
  // ── Frame application ──
197
275
  function ownFrameEffects(effects) {
198
276
  return Object.freeze(effects.map(ownFrameEffect));
@@ -1,5 +1,5 @@
1
1
  import type { BrowserPlan, BrowserRequest } from "./generated/browser-request.js";
2
- import type { BrowserMediaLayout, BrowserResponse } from "./generated/browser-response.js";
2
+ import type { BrowserMediaLayout, BrowserResponse, BrowserVisualFindings } from "./generated/browser-response.js";
3
3
  import { type RuntimeFrame } from "./clock.js";
4
4
  type Immutable<T> = T extends object ? {
5
5
  readonly [Key in keyof T]: Immutable<T[Key]>;
@@ -19,8 +19,8 @@ export interface RuntimeAdapter {
19
19
  prepare(frame: RuntimeFrame): Promise<BrowserMediaLayout>;
20
20
  /** Stages one exact frame and registers media presentation observers. */
21
21
  seek(frame: RuntimeFrame): Promise<void>;
22
- /** Verifies media presentation after native compositor capture. */
23
- confirm(frame: RuntimeFrame): Promise<void>;
22
+ /** Verifies captured media and returns bounded facts about the staged layout. */
23
+ confirm(frame: RuntimeFrame): Promise<BrowserVisualFindings>;
24
24
  /** Releases all resources owned by this adapter. */
25
25
  dispose(): Promise<void>;
26
26
  }
@@ -2,9 +2,10 @@
2
2
  // Owns command ordering and state while a narrow adapter owns browser effects;
3
3
  // this split keeps protocol behavior deterministic and directly testable.
4
4
  import { decodeBrowserResponse } from "./generated/codec.js";
5
- import { BROWSER_PROTOCOL_VERSION, MAX_BROWSER_MEDIA_LAYOUTS, MAX_FAILURE_MESSAGE_CHARACTERS, MAX_PENDING_RESOURCE_CHARACTERS, MAX_PENDING_RESOURCES, } from "./generated/runtime-contract.js";
5
+ import { BROWSER_PROTOCOL_VERSION, MAX_BROWSER_TEXT_BYTES, MAX_BROWSER_MEDIA_LAYOUTS, MAX_BROWSER_VISUAL_FINDINGS, MAX_FAILURE_MESSAGE_CHARACTERS, MAX_PENDING_RESOURCE_CHARACTERS, MAX_PENDING_RESOURCES, MAX_VARIANT_TEXT_BYTES, } from "./generated/runtime-contract.js";
6
6
  import { runtimeFrameAt } from "./clock.js";
7
7
  import { exactRatioIsCanonical, videoSourceMappingIsValid } from "./media.js";
8
+ const UTF8 = new TextEncoder();
8
9
  /** Expected failure reported by a browser adapter. */
9
10
  export class RuntimeAdapterError extends Error {
10
11
  kind;
@@ -133,7 +134,14 @@ export class RuntimeSession {
133
134
  return invalidRequest(requestId, "confirm must use the staged frame");
134
135
  }
135
136
  try {
136
- await this.#adapter.confirm(runtimeFrameAt(frame, this.#state.frameRate));
137
+ const findings = await this.#adapter.confirm(runtimeFrameAt(frame, this.#state.frameRate));
138
+ const visualFindings = snapshotVisualFindings(findings);
139
+ this.#state = { ...this.#state, kind: "ready" };
140
+ return response(requestId, {
141
+ type: "frameReady",
142
+ frame,
143
+ visualFindings,
144
+ });
137
145
  }
138
146
  catch (error) {
139
147
  // Confirmation consumes staged media observers and may succeed for only
@@ -141,8 +149,6 @@ export class RuntimeSession {
141
149
  this.#state = { kind: "failed" };
142
150
  return readinessFailure(requestId, "confirmFailed", error);
143
151
  }
144
- this.#state = { ...this.#state, kind: "ready" };
145
- return response(requestId, { type: "frameReady", frame });
146
152
  }
147
153
  async #dispose(requestId) {
148
154
  if (this.#state.kind === "disposed") {
@@ -167,6 +173,47 @@ function response(requestId, event) {
167
173
  event,
168
174
  });
169
175
  }
176
+ function snapshotVisualFindings(findings) {
177
+ if (findings.length > MAX_BROWSER_VISUAL_FINDINGS) {
178
+ throw new RuntimeAdapterError("operation", "browser visual findings exceed the per-frame limit");
179
+ }
180
+ const owned = findings.map((finding) => {
181
+ if (!Number.isSafeInteger(finding.nodeId) ||
182
+ finding.nodeId < 0 ||
183
+ finding.nodeId > 2 ** 32 - 1) {
184
+ throw new RuntimeAdapterError("operation", "browser visual finding has an invalid node identity");
185
+ }
186
+ visualFindingOrder(finding.issue);
187
+ return { nodeId: finding.nodeId, issue: finding.issue };
188
+ });
189
+ owned.sort(compareVisualFindings);
190
+ let previous;
191
+ for (const finding of owned) {
192
+ if (previous?.nodeId === finding.nodeId &&
193
+ previous.issue === finding.issue) {
194
+ throw new RuntimeAdapterError("operation", "browser visual findings contain a duplicate");
195
+ }
196
+ previous = finding;
197
+ Object.freeze(finding);
198
+ }
199
+ return Object.freeze(owned);
200
+ }
201
+ function compareVisualFindings(left, right) {
202
+ return (left.nodeId - right.nodeId ||
203
+ visualFindingOrder(left.issue) - visualFindingOrder(right.issue));
204
+ }
205
+ function visualFindingOrder(issue) {
206
+ switch (issue) {
207
+ case "emptyBox":
208
+ return 0;
209
+ case "clippedHorizontally":
210
+ return 1;
211
+ case "clippedVertically":
212
+ return 2;
213
+ default:
214
+ throw new RuntimeAdapterError("operation", "browser visual finding has an invalid issue");
215
+ }
216
+ }
170
217
  function invalidRequest(requestId, message) {
171
218
  return response(requestId, {
172
219
  type: "failed",
@@ -234,6 +281,13 @@ function planViolation(plan, mediaMode) {
234
281
  !hasCanonicalNodeOrder(plan.overlays)) {
235
282
  return "plan node collection is not in canonical order";
236
283
  }
284
+ const variantTextBytes = canonicalVariantFieldsTextBytes(plan.variantFields);
285
+ if (variantTextBytes === undefined) {
286
+ return "plan variant fields are not canonical";
287
+ }
288
+ if (!browserTextFitsBudget(plan.overlays, variantTextBytes)) {
289
+ return "plan text exceeds the browser request budget";
290
+ }
237
291
  const nodeIds = new Set();
238
292
  const authoredIds = new Set();
239
293
  const filmViolation = claimNode(plan.film, nodeIds, authoredIds);
@@ -358,11 +412,71 @@ function hasCanonicalNodeOrder(entries) {
358
412
  }
359
413
  return true;
360
414
  }
415
+ function canonicalVariantFieldsTextBytes(fields) {
416
+ let previous;
417
+ let textBytes = 0;
418
+ for (const field of fields) {
419
+ const valueBytes = canonicalVariantTextBytes(field.value);
420
+ if (!/^[a-z][A-Za-z0-9]{0,63}$/u.test(field.name) ||
421
+ (previous !== undefined && field.name <= previous) ||
422
+ valueBytes === undefined) {
423
+ return undefined;
424
+ }
425
+ textBytes += valueBytes;
426
+ previous = field.name;
427
+ }
428
+ return textBytes;
429
+ }
430
+ function canonicalVariantTextBytes(value) {
431
+ switch (value.kind) {
432
+ case "text": {
433
+ const bytes = UTF8.encode(value.value).byteLength;
434
+ return bytes <= MAX_VARIANT_TEXT_BYTES ? bytes : undefined;
435
+ }
436
+ case "integer":
437
+ return Number.isSafeInteger(value.value) ? 0 : undefined;
438
+ case "boolean":
439
+ return 0;
440
+ case "color":
441
+ return /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/u.test(value.value)
442
+ ? 0
443
+ : undefined;
444
+ }
445
+ }
446
+ function browserTextFitsBudget(overlays, variantTextBytes) {
447
+ let textBytes = variantTextBytes;
448
+ for (const overlay of overlays) {
449
+ textBytes += UTF8.encode(overlay.text).byteLength;
450
+ const track = overlay.captionTrack;
451
+ if (track !== undefined && track !== null) {
452
+ textBytes += UTF8.encode(track.id).byteLength;
453
+ textBytes += UTF8.encode(track.language).byteLength;
454
+ }
455
+ if (textBytes > MAX_BROWSER_TEXT_BYTES) {
456
+ return false;
457
+ }
458
+ }
459
+ return true;
460
+ }
361
461
  function overlayParentViolation(overlay, shots) {
362
462
  if (overlay.kind === "caption") {
363
- return overlay.shotId === undefined || overlay.shotId === null
364
- ? undefined
365
- : "plan caption names a structural parent";
463
+ if (overlay.shotId !== undefined && overlay.shotId !== null) {
464
+ return "plan caption names a structural parent";
465
+ }
466
+ const track = overlay.captionTrack;
467
+ if (track === undefined || track === null) {
468
+ return "plan caption omits track metadata";
469
+ }
470
+ if (track.id.length === 0 || /[\t\n\f\r ]/u.test(track.id)) {
471
+ return "plan caption track identity is invalid";
472
+ }
473
+ if (!/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/u.test(track.language)) {
474
+ return "plan caption language is invalid";
475
+ }
476
+ return undefined;
477
+ }
478
+ if (overlay.captionTrack !== undefined && overlay.captionTrack !== null) {
479
+ return "plan authored overlay names caption track metadata";
366
480
  }
367
481
  if (overlay.shotId === undefined || overlay.shotId === null) {
368
482
  return "plan overlay names an unknown shot";
@@ -420,6 +534,7 @@ function snapshotPlan(plan) {
420
534
  const scenes = Object.freeze(plan.scenes.map(snapshotScene));
421
535
  const shots = Object.freeze(plan.shots.map(snapshotShot));
422
536
  const transitions = Object.freeze(plan.transitions.map(snapshotTransition));
537
+ const variantFields = Object.freeze(plan.variantFields.map(snapshotVariantField));
423
538
  const videos = Object.freeze(plan.videos.map(snapshotVideo));
424
539
  const overlays = Object.freeze(plan.overlays.map(snapshotOverlay));
425
540
  return Object.freeze({
@@ -432,10 +547,17 @@ function snapshotPlan(plan) {
432
547
  scenes,
433
548
  shots,
434
549
  transitions,
550
+ variantFields,
435
551
  videos,
436
552
  overlays,
437
553
  });
438
554
  }
555
+ function snapshotVariantField(field) {
556
+ return Object.freeze({
557
+ name: field.name,
558
+ value: Object.freeze({ ...field.value }),
559
+ });
560
+ }
439
561
  function snapshotTransition(transition) {
440
562
  return Object.freeze({
441
563
  incomingShotId: transition.incomingShotId,
@@ -489,12 +611,20 @@ function snapshotFrameBoundaries([first, second, third, ...rest]) {
489
611
  return Object.freeze([first, second, third, ...rest]);
490
612
  }
491
613
  function snapshotOverlay(overlay) {
492
- return Object.freeze({
614
+ const snapshot = {
493
615
  node: snapshotNode(overlay.node),
494
616
  shotId: overlay.shotId ?? null,
495
617
  kind: overlay.kind,
496
618
  text: overlay.text,
497
619
  interval: Object.freeze({ ...overlay.interval }),
620
+ };
621
+ const track = overlay.captionTrack;
622
+ if (track === undefined || track === null) {
623
+ return Object.freeze(snapshot);
624
+ }
625
+ return Object.freeze({
626
+ ...snapshot,
627
+ captionTrack: Object.freeze({ ...track }),
498
628
  });
499
629
  }
500
630
  function snapshotNode(node) {
@@ -1,5 +1,5 @@
1
1
  export type { RuntimeFrame } from "./clock.js";
2
- export type { BrowserMediaLayout, BrowserMediaPlacement, } from "./generated/browser-response.js";
2
+ export type { BrowserMediaLayout, BrowserMediaPlacement, BrowserVisualFinding, BrowserVisualFindings, BrowserVisualIssue, } from "./generated/browser-response.js";
3
3
  export type { RuntimeVideo } from "./media.js";
4
4
  export type { PresentationFrameBehavior, PresentationTemporalCapability, PresentationVisualCapability, } from "./generated/bundle-layout.js";
5
5
  export type { RuntimeMediaMode, RuntimePlan } from "./session.js";