diff --git a/.claude/skills/aval-content-pipeline/SKILL.md b/.claude/skills/aval-content-pipeline/SKILL.md new file mode 100644 index 0000000..fc2920a --- /dev/null +++ b/.claude/skills/aval-content-pipeline/SKILL.md @@ -0,0 +1,94 @@ +--- +name: aval-content-pipeline +description: Production pipeline for authoring AVAL interactive video assets from AI-generated video (Grok Imagine or similar). Use when creating/planning .avl content, writing motion.json state graphs, crafting video-generation prompts, fixing loop seams or transition pops, or setting up the generate→normalize→compile→preview loop. +--- + +# AVAL Content Production Pipeline + +Guidance for producing `.avl` interactive video assets from AI-generated +clips. The format's core constraint drives everything: units are frame +ranges of source video, transitions commit at portal frames with +`exact-authored` continuity — **the player cuts, it never blends**. All +production work reduces to making boundary frames pixel-matched. + +## Format budgets (hard caps per asset, lower-only overrides) + +32 states · 64 edges · 96 units · 16 ports/body · 32 input bindings · +4 renditions · one fixed frame rate per asset (grass-rabbit: 24fps). +Source: `packages/format/src/constants.ts` (`FORMAT_DEFAULT_BUDGETS`), +`packages/graph/src/limits.ts` (`GRAPH_LIMITS`). + +## Workflow order + +1. **Graph first.** Write `motion.json` before generating any video. + For each interactive behavior author the triple `X-in` (finite), + `X-loop` (loop), `X-out` (finite), plus one shared `idle-loop` and an + optional `intro` one-shot. Template: `examples/grass-rabbit/motion.json` + (5 units, 4 states, 5 edges — copy the folder, swap ranges/source). + +2. **Hub-pose pattern** (the key scaling trick). Every clip starts AND + ends at one neutral "hub" pose. Idle = hub→subtle motion→hub; each + gesture = hub→action→hub. All boundaries then match by construction, + portal frames are trivial, and each new gesture costs one generation + + one `units[]` entry. This is what makes 100s-of-gestures characters + (talking heads etc.) feasible within the portal model. + +3. **Generation (Grok Imagine or similar):** + - Generate the hub still FIRST (image mode). It is the conditioning + anchor for every clip — never regenerate it mid-project. + - Always image→video conditioned on the hub still, never text→video + (text-only never reproduces a matching first frame). + - When a unit must end somewhere new (e.g. `hover-in` ending at the + hover pose), chain: last frame of clip A becomes the image + conditioning for clip B. + +4. **Prompt template** — every prompt needs all four ingredients: + - Camera lock: "static locked-off camera, tripod shot, no camera + movement, no zoom, no pan" + - Single action: "single continuous shot, no cuts" + exactly one + described motion + - Explicit start AND end pose: "begins motionless in the exact + starting pose … returns to the exact same resting pose and holds + still" + - Stability: "consistent lighting, background unchanged" + For loops add: "subtle idle motion, seamless loop, minimal movement, + character stays in place". + +5. **Post-processing:** + - Normalize: `ffmpeg -i clip.mp4 -r 24 -vf scale=1280:720 …` (one + fps/resolution across all clips before compiling). + - Cut at true pose matches, not clip ends: find each clip's + best-matching frame against the hub still (per-frame diff) and trim + there — AI clips rarely end exactly where prompted. + - Loop seams: ping-pong (forward+reversed) for subtle non-directional + idle motion, or optical-flow blend the seam + (`ffmpeg -vf minterpolate`) over the last/first few frames. + - Color-match every clip against the hub still (shared LUT) — + inter-clip color drift is the most common visible transition artifact. + - Concatenate to ONE master mp4 in unit order; the frame ranges become + `units[].range` (grass-rabbit's `sources[0]` is one continuous mp4). + +6. **Compile & preview loop:** + - `avl compile motion.json --out public/.avl --force` + (see `examples/grass-rabbit/package.json` scripts). + - Preview in the web playground/example first (instant); check the + Flutter example (`flutter/scripts/run.sh`) after. + - The compiler enforces budgets and continuity — treat its errors as + the authority on legal boundaries. + +## Debugging transition pops + +- Pop at portal: boundary frames don't match — re-trim both clips to the + hub frame, or re-chain clip B from clip A's actual last frame. +- Pop only in color/brightness: color drift — LUT-match the clips. +- Loop "breathes" or jumps: loop seam — ping-pong or minterpolate, or + regenerate with a stronger "minimal movement" prompt. +- Compiler rejects a range: frame math off by one — ranges are + [start, end) frame indices into the master source at the asset fps. + +## First-project checklist + +Copy `examples/grass-rabbit/` → make hub still → generate idle-loop + +one gesture clip → normalize/trim/concat → edit ranges in motion.json → +compile → playground → iterate. Scale out gestures only after the +3-state graph plays cleanly. diff --git a/.gitignore b/.gitignore index c0cf37a..82eaf2e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ test-results/ output/playwright/ *.tsbuildinfo .DS_Store +/flutter/examples/grass_rabbit/assets/mansion-woman.avl +/examples/mansion-woman diff --git a/docs/accessibility-and-motion.md b/docs/accessibility-and-motion.md index 55c892c..b7f8e5e 100644 --- a/docs/accessibility-and-motion.md +++ b/docs/accessibility-and-motion.md @@ -11,6 +11,13 @@ capabilities and resources permit it. Reduced motion still processes authored state changes and bindings, but an infinite body does not advance. The host fallback must convey an acceptable non-animated meaning. +A player embedding the runtime directly can also set `turnPolicy: "direct"`, so a +multi-step request along a [ring](./project/1.0.md#rings-and-turn-edges) lands in +its target without playing the intermediate states. One `turnstep` still reports +the final landing, which keeps a host's ring bookkeeping identical under either +policy. `chain` is the default and the only policy that preserves frame +continuity across the whole arc. + Light-DOM fallback remains usable without JavaScript. Supply meaningful alternative text when motion carries information, and empty alternative text when it is decorative. The element does not capture keyboard events, suppress diff --git a/docs/element-api.md b/docs/element-api.md index 3070e2d..306e39a 100644 --- a/docs/element-api.md +++ b/docs/element-api.md @@ -10,15 +10,23 @@ Assets are literal direct-child `` elements. Each requires `src` and `type='application/vnd.aval; codecs="..."'`; optional integrity applies to that source alone. Child order is preference order. -Core methods are `prepare()`, `setState()`, `send()`, `readyFor()`, `pause()`, -`resume()`, `getDiagnostics()`, and terminal `dispose()`. Runtime state is read -through `readiness`, `mode`, `staticReason`, `requestedState`, `visualState`, -`isTransitioning`, `paused`, `effectivelyVisible`, `stateNames`, `eventNames`, -and `inputBindings`. +Core methods are `prepare()`, `setState()`, `send()`, `readyFor()`, +`planFor()`, `pause()`, `resume()`, `getDiagnostics()`, and terminal +`dispose()`. Runtime state is read through `readiness`, `mode`, `staticReason`, +`requestedState`, `visualState`, `isTransitioning`, `paused`, +`effectivelyVisible`, `stateNames`, `eventNames`, `inputBindings`, and `rings`. + +`rings` lists the ordered state axes the asset declares, each as +`{ id, states, cyclic }`. `planFor(state)` is a dry run of `setState(state)`: it +returns the landings that request would visit, in order, `[]` when the state is +already held, or `null` when there is no route today. It never advances the +graph. Events are non-cancelable `CustomEvent` instances with immutable bounded details: `readinesschange`, `requestedstatechange`, `visualstatechange`, -`transitionstart`, `transitionend`, `underflow`, `fallback`, and `error`. Every +`transitionstart`, `transitionend`, `turnstep`, `underflow`, `fallback`, and +`error`. `turnstep` fires once per landing while a request walks a ring, with +`{ ring, from, to, remaining }`; its pixels are already drawn when it fires. Every event except `error` bubbles and is composed. Listen for `error` directly on the element; keeping that event local follows native media behavior and avoids colliding with page-wide error handlers. Every detail includes a positive diff --git a/docs/format/1.0.md b/docs/format/1.0.md index 571c529..a12c69d 100644 --- a/docs/format/1.0.md +++ b/docs/format/1.0.md @@ -50,3 +50,15 @@ Each codec alternative is a separate asset with its own integrity digest. Ordered alternatives are an HTML authoring concern; wire 1.0 deliberately does not impose cross-file identity or fallback policy. +## Rings + +A manifest may declare an optional `rings` array: ordered, optionally cyclic +axes of states whose ordered neighbour pairs each have an edge. Edges may carry +`ring`, a signed `step`, and `derived: true` when the compiler expanded them from +a ring rather than the author writing them. + +Rings are validated as fully walkable — every member resolves to a state, every +ordered adjacency has an edge in both directions, and no two rings claim the same +pair — so a player never discovers a missing step at request time. The key is +omitted entirely by assets which author no ring, which keeps assets compiled +before rings existed byte-identical. diff --git a/docs/project/1.0.md b/docs/project/1.0.md index 6a11989..75237c5 100644 --- a/docs/project/1.0.md +++ b/docs/project/1.0.md @@ -121,3 +121,56 @@ lookahead, hidden references, and multi-output chunks without changing the authored presentation timeline or creating dependencies across unit boundaries. +## Rings and turn edges + +A **ring** is an ordered, optionally cyclic set of states along one axis — eight +compass facings, a zoom ladder, a dial. Rather than authoring an edge for every +ordered pair, author the axis once and let the compiler expand it: + +```jsonc +{ + "rings": [ + { + "id": "facing.walk", + "states": ["walk_n", "walk_ne", "walk_e", "walk_se", + "walk_s", "walk_sw", "walk_w", "walk_nw"], + "cyclic": true, + "tieBreak": "forward", + "turn": { + "mode": "cut", + "start": { + "type": "portal", "sourcePort": "default", + "targetPort": "default", "maxWaitFrames": 8 + }, + "continuity": "exact-authored" + }, + "maxChainedSteps": 4, + "overrides": [ + { "from": "walk_nw", "to": "walk_n", "mode": "unit", "unit": "pivot.nw.n" } + ] + } + ] +} +``` + +`avl compile` derives one ordinary edge per ordered neighbour pair — 16 for the +eight-way cyclic ring above — so nothing downstream needs to understand rings in +order to play them. Derived edges are named `..` with the prefix +every member shares removed (`facing.walk.n.ne`), and carry `derived: true`, which +`avl inspect` reports alongside `ring` and `step`. + +`turn.mode` decides what a step plays: `cut` steps straight from one body to the +next at the authored portal boundary, while `unit` plays a bridge clip supplied +per step by an `overrides` entry. `turn.start` is the departure policy every step +shares. Precedence is **explicit edge > ring override > ring default**: an +authored edge between two ring neighbours keeps its own behavior, and the +shadowed step is reported as a build warning rather than silently dropped. + +Compilation fails, naming the ring and the states involved, when a member is not +a state, a member repeats, a ring is too short to be an axis (2) or to close a +cycle (3), `mode: "unit"` has no unit for every step, an override or turn edge is +not an adjacency, two rings claim the same pair, or a step's two bodies share no +compatible port. + +`maxChainedSteps` is the longest arc the runtime will walk; see +[states and triggers](../states-and-triggers.md) for how a request chooses one. diff --git a/docs/states-and-triggers.md b/docs/states-and-triggers.md index 84a4470..bae4fd5 100644 --- a/docs/states-and-triggers.md +++ b/docs/states-and-triggers.md @@ -28,3 +28,27 @@ Partial loops, finite bodies, held bodies, portals, finish routes, locked bridges, cuts, and resident reversible transitions are compiled graph behavior. They are not implemented by seeking a video element, so a loop seam does not pause for a media seek. + +## Rings: one request, several steps + +When an asset declares a ring, the states on it are one axis rather than a set of +unrelated destinations. `setState()` on a ring member walks the shorter arc one +authored step at a time: + +```ts +motion.planFor("walk_e"); // ["walk_ne", "walk_e"] from walk_n +await motion.setState("walk_e"); +``` + +Distances wrap only on a cyclic ring, equal-length arcs are decided by the ring's +`tieBreak` (so a half turn is deterministic), and an arc longer than the ring's +`maxChainedSteps` rejects with `RouteError` instead of walking further than the +author allowed. An explicit edge between two ring neighbours always wins over the +derived step. + +Each landing dispatches `turnstep` with `{ ring, from, to, remaining }`. The plan +is replanned at every step boundary: a new `setState()` mid-arc lets the step in +flight finish, then departs on a fresh arc from the state that actually landed — +so a rapid sweep produces continuous motion, with each superseded request +rejecting `AbortError` exactly as it does off a ring. No step seeks media; every +seam lands on frame 0 of the next body. diff --git a/etc/api/compiler.api.md b/etc/api/compiler.api.md index 6d1fbd7..1be5b92 100644 --- a/etc/api/compiler.api.md +++ b/etc/api/compiler.api.md @@ -76,6 +76,14 @@ export interface AssetInspection { readonly codec: VideoCodec_2; // (undocumented) readonly digestClaim: "all-internal-and-whole-file"; + readonly edges: readonly { + readonly id: string; + readonly from: string; + readonly to: string; + readonly ring?: string; + readonly step?: 1 | -1; + readonly derived?: true; + }[]; // (undocumented) readonly file: string; // (undocumented) @@ -97,6 +105,14 @@ export interface AssetInspection { readonly alphaLayout: AlphaLayout; }[]; // (undocumented) + readonly rings: readonly { + readonly id: string; + readonly states: readonly string[]; + readonly cyclic: boolean; + readonly tieBreak: "forward" | "backward"; + readonly maxChainedSteps: number; + }[]; + // (undocumented) readonly sha256: string; // (undocumented) readonly states: readonly string[]; @@ -732,7 +748,6 @@ export interface NormalizedSourceProject { readonly bindings: readonly SourceBinding[]; // (undocumented) readonly canvas: Canvas; - // (undocumented) readonly edges: readonly SourceEdge[]; // (undocumented) readonly encodings: readonly NormalizedVideoEncoding[]; @@ -742,6 +757,9 @@ export interface NormalizedSourceProject { readonly initialState: string; // (undocumented) readonly projectVersion: "1.0"; + readonly ringNotes?: readonly string[]; + // Warning: (ae-forgotten-export) The symbol "SourceRing" needs to be exported by the entry point index.d.ts + readonly rings?: readonly SourceRing[]; // (undocumented) readonly sources: readonly SourceDescriptor[]; // (undocumented) @@ -845,8 +863,10 @@ export type SourceDescriptor = { readonly frameCount: number; }; +// Warning: (ae-forgotten-export) The symbol "SourceTurnMembership" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -export type SourceEdge = { +export type SourceEdge = (SourceTurnMembership & { readonly id: string; readonly from: string; readonly to: string; @@ -857,7 +877,7 @@ export type SourceEdge = { readonly transition?: SourceTransition; readonly continuity: "exact-authored" | "exact-reverse"; readonly targetRunwayFrames?: never; -} | { +}) | (SourceTurnMembership & { readonly id: string; readonly from: string; readonly to: string; @@ -868,7 +888,7 @@ export type SourceEdge = { readonly transition?: never; readonly continuity: "cut"; readonly targetRunwayFrames: number; -}; +}); // @public (undocumented) export interface SourcePort { @@ -898,6 +918,7 @@ export interface SourceProject { readonly initialState: string; // (undocumented) readonly projectVersion: "1.0"; + readonly rings?: readonly SourceRing[]; // (undocumented) readonly sources: readonly SourceDescriptor[]; // (undocumented) diff --git a/etc/api/element.api.md b/etc/api/element.api.md index 86eecc8..7fb897b 100644 --- a/etc/api/element.api.md +++ b/etc/api/element.api.md @@ -111,6 +111,10 @@ export interface AvalDiagnostics { readonly requestedState: string | null; // (undocumented) readonly resizeGeneration: number; + // Warning: (ae-forgotten-export) The symbol "AvalRing" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly rings: readonly Readonly[]; // (undocumented) readonly runtime: Readonly<{ selectedRendition: string | null; @@ -208,6 +212,8 @@ export interface AvalElement extends HTMLElement { // (undocumented) readonly paused: boolean; // (undocumented) + planFor(state: string): readonly string[] | null; + // (undocumented) prepare(options?: Readonly): Promise; // (undocumented) readonly readiness: RuntimeReadiness; @@ -224,6 +230,8 @@ export interface AvalElement extends HTMLElement { // (undocumented) resume(): Promise; // (undocumented) + readonly rings: readonly Readonly[]; + // (undocumented) send(event: string): boolean; // (undocumented) setState(name: string): Promise; @@ -280,6 +288,10 @@ export interface AvalElementEventMap { readonly transitionend: CustomEvent>; // (undocumented) readonly transitionstart: CustomEvent>; + // Warning: (ae-forgotten-export) The symbol "AvalTurnStepDetail" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly turnstep: CustomEvent>; // (undocumented) readonly underflow: CustomEvent>; // (undocumented) diff --git a/etc/api/format.api.md b/etc/api/format.api.md index 880831c..434365a 100644 --- a/etc/api/format.api.md +++ b/etc/api/format.api.md @@ -547,6 +547,7 @@ export interface CompiledManifest { readonly readiness: Readiness; // (undocumented) readonly renditions: readonly ProductionRendition[]; + readonly rings?: readonly Ring[]; // (undocumented) readonly states: readonly State[]; // (undocumented) @@ -713,6 +714,10 @@ export interface FormatBudgets { // (undocumented) readonly maxReversibleFrames: number; // (undocumented) + readonly maxRings: number; + // (undocumented) + readonly maxRingStates: number; + // (undocumented) readonly maxStates: number; // (undocumented) readonly maxTotalUnitFrames: number; @@ -1661,6 +1666,20 @@ export interface ResidencyEndpoint { // @public export function resolveFormatBudgets(options?: FormatOptions): Readonly; +// @public +export interface Ring { + // (undocumented) + readonly cyclic: boolean; + // (undocumented) + readonly id: Id; + // (undocumented) + readonly maxChainedSteps: number; + // (undocumented) + readonly states: readonly Id[]; + // (undocumented) + readonly tieBreak: "forward" | "backward"; +} + // @public (undocumented) export function sameH265ProfileTierLevel(left: H265ProfileTierLevel, right: H265ProfileTierLevel): boolean; diff --git a/etc/api/graph.api.md b/etc/api/graph.api.md index 5eef24e..fe2855a 100644 --- a/etc/api/graph.api.md +++ b/etc/api/graph.api.md @@ -34,6 +34,9 @@ export const GRAPH_IDENTIFIER_PATTERN: RegExp; export const GRAPH_LIMITS: Readonly<{ maxStates: 32; maxEdges: 64; + maxRings: 8; + maxRingStates: 32; + maxChainedSteps: 16; maxPortsPerBody: 16; maxInputsPerTick: 32; maxRoutingOperationsPerTick: 64; @@ -66,8 +69,10 @@ export interface GraphEdgeDefinition { readonly from: GraphStateId; // (undocumented) readonly id: GraphEdgeId; + readonly ring?: GraphRingId; // (undocumented) readonly start: GraphStartPolicy; + readonly step?: GraphTurnStep; // (undocumented) readonly to: GraphStateId; // (undocumented) @@ -132,6 +137,26 @@ export type GraphPresentation = { readonly direction: "forward" | "reverse"; }; +// @public +export interface GraphRingDefinition { + // (undocumented) + readonly cyclic: boolean; + // (undocumented) + readonly id: GraphRingId; + // (undocumented) + readonly maxChainedSteps: number; + // (undocumented) + readonly states: readonly GraphStateId[]; + // (undocumented) + readonly tieBreak: GraphRingTieBreak; +} + +// @public (undocumented) +export type GraphRingId = string; + +// @public +export type GraphRingTieBreak = "forward" | "backward"; + // @public (undocumented) export type GraphSettlement = { readonly type: "resolve"; @@ -188,6 +213,9 @@ export type GraphTransitionDefinition = { readonly reverseOf?: GraphEdgeId; }; +// @public +export type GraphTurnStep = 1 | -1; + // @public (undocumented) export type GraphUnitId = string; @@ -204,6 +232,8 @@ export interface MotionGraphDefinition { // (undocumented) readonly initialState: GraphStateId; // (undocumented) + readonly rings?: readonly GraphRingDefinition[]; + // (undocumented) readonly states: readonly GraphStateDefinition[]; } @@ -239,6 +269,12 @@ export type MotionGraphEffect = { readonly edgeId: GraphEdgeId; readonly from: GraphStateId; readonly to: GraphStateId; +} | { + readonly type: "turnstep"; + readonly ring: GraphRingId; + readonly from: GraphStateId; + readonly to: GraphStateId; + readonly remaining: number; } | { readonly type: "fallback"; readonly reason: string; @@ -250,6 +286,7 @@ export type MotionGraphEffect = { // @public export class MotionGraphEngine { + constructor(options?: Readonly); // (undocumented) beginAnimated(): Readonly; // (undocumented) @@ -263,6 +300,7 @@ export class MotionGraphEngine { getTrace(): readonly Readonly[]; // (undocumented) install(definition: MotionGraphDefinition | ValidatedMotionGraph): Readonly; + planFor(target: GraphStateId): readonly GraphStateId[] | null; previewTick(options: MotionGraphTickOptions): Readonly; // (undocumented) recoverStatic(reason: string, options?: Readonly): Readonly; @@ -276,6 +314,14 @@ export class MotionGraphEngine { snapshot(): Readonly; // (undocumented) tick(options: MotionGraphTickOptions): Readonly; + // (undocumented) + get turnPolicy(): MotionGraphTurnPolicy; +} + +// @public (undocumented) +export interface MotionGraphEngineOptions { + // (undocumented) + readonly turnPolicy?: MotionGraphTurnPolicy; } // @public (undocumented) @@ -356,6 +402,8 @@ export interface MotionGraphSnapshot { readonly requestedState: GraphStateId | null; // (undocumented) readonly routeOperationsLastTick: number; + readonly turnRing: GraphRingId | null; + readonly turnStepsRemaining: number; // (undocumented) readonly visualState: GraphStateId | null; } @@ -382,6 +430,9 @@ export interface MotionGraphTraceRecord { readonly result: Readonly; } +// @public +export type MotionGraphTurnPolicy = "chain" | "direct"; + // @public (undocumented) export class MotionGraphValidationError extends MotionGraphError { constructor(message: string, options?: ErrorOptions); @@ -390,6 +441,36 @@ export class MotionGraphValidationError extends MotionGraphError { // @public export function nextBodyFrame(body: GraphBodyDefinition, currentFrame: number): Readonly; +// @public +export function planRingArc(ring: Readonly, from: GraphStateId, to: GraphStateId): Readonly | null; + +// Warning: (ae-forgotten-export) The symbol "ValidatedGraphIndexes" needs to be exported by the entry point index.d.ts +// +// @public +export function resolveRingRoute(indexes: ValidatedGraphIndexes, from: GraphStateId, to: GraphStateId): Readonly; + +// @public +export interface RingArc { + // (undocumented) + readonly direction: "forward" | "backward"; + readonly states: readonly GraphStateId[]; +} + +// @public +export type RingRoute = { + readonly kind: "none"; +} | { + readonly kind: "too-long"; + readonly ring: Readonly; + readonly distance: number; +} | { + readonly kind: "arc"; + readonly ring: Readonly; + readonly direction: "forward" | "backward"; + readonly states: readonly GraphStateId[]; + readonly steps: readonly Readonly[]; +}; + // @public (undocumented) export interface ValidatedMotionGraph { // (undocumented) diff --git a/etc/api/player-web.api.md b/etc/api/player-web.api.md index 89864f7..ffa293d 100644 --- a/etc/api/player-web.api.md +++ b/etc/api/player-web.api.md @@ -15,6 +15,7 @@ import { FormatHeader } from '@pixel-point/aval-format'; import { GraphBodyDefinition } from '@pixel-point/aval-graph'; import { GraphEdgeDefinition } from '@pixel-point/aval-graph'; import type { GraphPresentation } from '@pixel-point/aval-graph'; +import { GraphRingDefinition } from '@pixel-point/aval-graph'; import type { GraphSettlementError } from '@pixel-point/aval-graph'; import { GraphStartPolicy } from '@pixel-point/aval-graph'; import type { GraphStateDefinition } from '@pixel-point/aval-graph'; @@ -25,6 +26,7 @@ import type { MotionGraphReadiness } from '@pixel-point/aval-graph'; import type { MotionGraphResult } from '@pixel-point/aval-graph'; import type { MotionGraphSnapshot } from '@pixel-point/aval-graph'; import type { MotionGraphTickOptions } from '@pixel-point/aval-graph'; +import type { MotionGraphTurnPolicy } from '@pixel-point/aval-graph'; import { ParsedFrontIndex } from '@pixel-point/aval-format'; import { parseVideoCodecString } from '@pixel-point/aval-format'; import type { Port } from '@pixel-point/aval-format'; @@ -1995,6 +1997,7 @@ export class IntegratedPlayer { // (undocumented) participantSnapshot(): Readonly | null; pauseRealtime(): void; + planFor(target: string): readonly string[] | null; // (undocumented) prepare(options?: IntegratedPrepareOptions): Promise; readyFor(target: string): boolean; @@ -2004,6 +2007,7 @@ export class IntegratedPlayer { // (undocumented) requestState(target: string): Promise; resumeRealtime(): Promise; + get rings(): readonly Readonly[]; send(event: string): boolean; // (undocumented) setHostReducedMotion(reduced: boolean): Promise>; diff --git a/fixtures/rings/v1-eight-way-facing/README.md b/fixtures/rings/v1-eight-way-facing/README.md new file mode 100644 index 0000000..de59428 --- /dev/null +++ b/fixtures/rings/v1-eight-way-facing/README.md @@ -0,0 +1,60 @@ +# Eight-way facing ring (v1) + +Authoring fixture for the `rings` construct. Eight looping walk bodies, one per +compass facing, joined into a single cyclic ring: + +``` +walk_n -> walk_ne -> walk_e -> walk_se -> walk_s -> walk_sw -> walk_w -> walk_nw -> walk_n +``` + +The project authors **no edges at all**. `avl compile` expands the ring into the +16 turn edges which walk it (eight adjacencies, both directions), each flagged +`derived: true` in `avl inspect` output and named after the ring plus the two +members' distinct suffixes: + +``` +facing.walk.n.ne facing.walk.ne.n +facing.walk.ne.e facing.walk.e.ne +... facing.walk.n.nw +``` + +At runtime `setState("walk_e")` from `walk_n` chains two steps through +`walk_ne`; `setState("walk_s")` is an exact half turn, so `tieBreak: "forward"` +decides it deterministically. `maxChainedSteps: 4` refuses anything longer than +half the ring, which on a cyclic eight-way ring is every arc there is. + +## Media + +The project reads a `frames/` PNG sequence of 64 frames — eight frames per +facing, in ring order, each starting on its portal frame. The frames are not +checked in: this fixture exists to exercise ring expansion and graph planning +from the authored markup, which needs no pixels. Point `sources[0].directory` at +any 64-frame render of a walk cycle to compile it for real. + +## Verified by + +- `packages/compiler/test/source-ring-fixture.test.ts` — expansion, ids, and + budget of the checked-in markup. + +## Browser test bed + +Serve the **aval monorepo root** with Vite (bare package imports + decoder +module workers will not resolve under plain `python -m http.server`): + +```bash +# from /home/johndpope/Documents/GitHub/aval +./node_modules/.bin/vite --config fixtures/rings/v1-eight-way-facing/vite.config.js +# open http://localhost:8765/fixtures/rings/v1-eight-way-facing/test.html +``` + +Notes: + +- Placeholder frames live in `frames/` (64 opaque PNGs). Recompile with + `npm run avl -- compile fixtures/rings/v1-eight-way-facing/motion.json --out fixtures/rings/v1-eight-way-facing/public --force`. +- Ring steps are **authored hard-cut edges** that shadow the compiler's + derived portal edges. Portal multi-unit switches currently fail WebCodecs + decode on this short fixture; hard-cuts reconfigure cleanly. The `rings` + declaration remains so `planFor()` / ring metadata still work. +- `test.html` walks `planFor()` one hop at a time for reliable multi-direction + clicks. + diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0000.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0000.png new file mode 100644 index 0000000..094f3f0 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0000.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0001.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0001.png new file mode 100644 index 0000000..49fdbac Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0001.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0002.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0002.png new file mode 100644 index 0000000..c28e7ad Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0002.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0003.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0003.png new file mode 100644 index 0000000..a567f10 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0003.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0004.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0004.png new file mode 100644 index 0000000..97efe4c Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0004.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0005.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0005.png new file mode 100644 index 0000000..22e4e54 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0005.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0006.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0006.png new file mode 100644 index 0000000..5254160 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0006.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0007.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0007.png new file mode 100644 index 0000000..747298f Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0007.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0008.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0008.png new file mode 100644 index 0000000..fb598e9 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0008.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0009.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0009.png new file mode 100644 index 0000000..eab245e Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0009.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0010.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0010.png new file mode 100644 index 0000000..0ffb5bc Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0010.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0011.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0011.png new file mode 100644 index 0000000..7392043 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0011.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0012.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0012.png new file mode 100644 index 0000000..184e72c Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0012.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0013.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0013.png new file mode 100644 index 0000000..b8e7dae Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0013.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0014.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0014.png new file mode 100644 index 0000000..c31b133 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0014.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0015.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0015.png new file mode 100644 index 0000000..b781381 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0015.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0016.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0016.png new file mode 100644 index 0000000..c7a7fa9 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0016.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0017.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0017.png new file mode 100644 index 0000000..35c20ea Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0017.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0018.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0018.png new file mode 100644 index 0000000..47389e0 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0018.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0019.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0019.png new file mode 100644 index 0000000..b7e2b59 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0019.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0020.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0020.png new file mode 100644 index 0000000..e097333 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0020.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0021.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0021.png new file mode 100644 index 0000000..6764be4 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0021.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0022.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0022.png new file mode 100644 index 0000000..35ea5f4 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0022.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0023.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0023.png new file mode 100644 index 0000000..a7f1458 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0023.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0024.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0024.png new file mode 100644 index 0000000..50b357b Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0024.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0025.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0025.png new file mode 100644 index 0000000..2636d13 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0025.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0026.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0026.png new file mode 100644 index 0000000..6e450f7 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0026.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0027.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0027.png new file mode 100644 index 0000000..e060176 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0027.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0028.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0028.png new file mode 100644 index 0000000..ec3e00a Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0028.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0029.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0029.png new file mode 100644 index 0000000..fa6234b Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0029.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0030.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0030.png new file mode 100644 index 0000000..70a51d0 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0030.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0031.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0031.png new file mode 100644 index 0000000..9c23bdb Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0031.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0032.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0032.png new file mode 100644 index 0000000..a730a10 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0032.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0033.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0033.png new file mode 100644 index 0000000..eec899c Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0033.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0034.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0034.png new file mode 100644 index 0000000..1e3808f Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0034.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0035.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0035.png new file mode 100644 index 0000000..d70691a Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0035.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0036.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0036.png new file mode 100644 index 0000000..8a7a66c Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0036.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0037.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0037.png new file mode 100644 index 0000000..baf8ad8 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0037.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0038.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0038.png new file mode 100644 index 0000000..f7dacc6 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0038.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0039.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0039.png new file mode 100644 index 0000000..2bf1028 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0039.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0040.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0040.png new file mode 100644 index 0000000..eb62a61 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0040.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0041.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0041.png new file mode 100644 index 0000000..cf5219d Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0041.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0042.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0042.png new file mode 100644 index 0000000..79fbec9 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0042.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0043.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0043.png new file mode 100644 index 0000000..7fba485 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0043.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0044.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0044.png new file mode 100644 index 0000000..4f0f430 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0044.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0045.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0045.png new file mode 100644 index 0000000..d1f4bfe Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0045.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0046.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0046.png new file mode 100644 index 0000000..6536a21 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0046.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0047.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0047.png new file mode 100644 index 0000000..98755cc Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0047.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0048.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0048.png new file mode 100644 index 0000000..37c0942 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0048.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0049.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0049.png new file mode 100644 index 0000000..8a4db32 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0049.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0050.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0050.png new file mode 100644 index 0000000..6c69a1f Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0050.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0051.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0051.png new file mode 100644 index 0000000..48d4b5f Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0051.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0052.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0052.png new file mode 100644 index 0000000..fd3b84e Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0052.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0053.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0053.png new file mode 100644 index 0000000..f3bc09b Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0053.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0054.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0054.png new file mode 100644 index 0000000..7b632e2 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0054.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0055.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0055.png new file mode 100644 index 0000000..47860e6 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0055.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0056.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0056.png new file mode 100644 index 0000000..5ed2abd Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0056.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0057.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0057.png new file mode 100644 index 0000000..c89b258 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0057.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0058.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0058.png new file mode 100644 index 0000000..f9a9a03 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0058.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0059.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0059.png new file mode 100644 index 0000000..92b6d92 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0059.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0060.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0060.png new file mode 100644 index 0000000..75df37d Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0060.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0061.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0061.png new file mode 100644 index 0000000..f916ec7 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0061.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0062.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0062.png new file mode 100644 index 0000000..10a2830 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0062.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0063.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0063.png new file mode 100644 index 0000000..675e780 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0063.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0064.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0064.png new file mode 100644 index 0000000..ecad4ad Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0064.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0065.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0065.png new file mode 100644 index 0000000..5b01b33 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0065.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0066.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0066.png new file mode 100644 index 0000000..d3d9aed Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0066.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0067.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0067.png new file mode 100644 index 0000000..178825e Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0067.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0068.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0068.png new file mode 100644 index 0000000..75866f7 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0068.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0069.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0069.png new file mode 100644 index 0000000..ff375f1 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0069.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0070.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0070.png new file mode 100644 index 0000000..0b46941 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0070.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0071.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0071.png new file mode 100644 index 0000000..3bdb402 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0071.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0072.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0072.png new file mode 100644 index 0000000..fe324ac Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0072.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0073.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0073.png new file mode 100644 index 0000000..81b56da Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0073.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0074.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0074.png new file mode 100644 index 0000000..add331c Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0074.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0075.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0075.png new file mode 100644 index 0000000..bdf35c8 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0075.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0076.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0076.png new file mode 100644 index 0000000..8df5ea6 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0076.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0077.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0077.png new file mode 100644 index 0000000..a695bab Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0077.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0078.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0078.png new file mode 100644 index 0000000..7028046 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0078.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0079.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0079.png new file mode 100644 index 0000000..462f9ce Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0079.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0080.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0080.png new file mode 100644 index 0000000..a97b8ff Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0080.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0081.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0081.png new file mode 100644 index 0000000..1187eea Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0081.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0082.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0082.png new file mode 100644 index 0000000..c23f6e0 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0082.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0083.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0083.png new file mode 100644 index 0000000..6f9fe12 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0083.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0084.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0084.png new file mode 100644 index 0000000..9d31fed Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0084.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0085.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0085.png new file mode 100644 index 0000000..8ffd0a3 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0085.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0086.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0086.png new file mode 100644 index 0000000..4ade28b Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0086.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0087.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0087.png new file mode 100644 index 0000000..f833418 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0087.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0088.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0088.png new file mode 100644 index 0000000..3d1c134 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0088.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0089.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0089.png new file mode 100644 index 0000000..93b67b8 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0089.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0090.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0090.png new file mode 100644 index 0000000..70866e1 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0090.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0091.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0091.png new file mode 100644 index 0000000..e1b27d7 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0091.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0092.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0092.png new file mode 100644 index 0000000..07ffadb Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0092.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0093.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0093.png new file mode 100644 index 0000000..483d8d7 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0093.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0094.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0094.png new file mode 100644 index 0000000..e7f302b Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0094.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0095.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0095.png new file mode 100644 index 0000000..850989a Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0095.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0096.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0096.png new file mode 100644 index 0000000..82e619b Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0096.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0097.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0097.png new file mode 100644 index 0000000..dd69807 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0097.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0098.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0098.png new file mode 100644 index 0000000..588633a Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0098.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0099.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0099.png new file mode 100644 index 0000000..c6dad05 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0099.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0100.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0100.png new file mode 100644 index 0000000..eaefb49 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0100.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0101.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0101.png new file mode 100644 index 0000000..1e1adb9 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0101.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0102.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0102.png new file mode 100644 index 0000000..7a7c7bc Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0102.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0103.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0103.png new file mode 100644 index 0000000..347d691 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0103.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0104.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0104.png new file mode 100644 index 0000000..eff94b1 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0104.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0105.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0105.png new file mode 100644 index 0000000..2d1144d Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0105.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0106.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0106.png new file mode 100644 index 0000000..34f31f8 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0106.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0107.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0107.png new file mode 100644 index 0000000..016aa30 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0107.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0108.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0108.png new file mode 100644 index 0000000..30eb197 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0108.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0109.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0109.png new file mode 100644 index 0000000..6d91b4a Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0109.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0110.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0110.png new file mode 100644 index 0000000..af1bf5f Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0110.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0111.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0111.png new file mode 100644 index 0000000..58a2d61 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0111.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0112.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0112.png new file mode 100644 index 0000000..d47c6a8 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0112.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0113.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0113.png new file mode 100644 index 0000000..ad0e6f8 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0113.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0114.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0114.png new file mode 100644 index 0000000..2009f37 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0114.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0115.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0115.png new file mode 100644 index 0000000..a1651fe Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0115.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0116.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0116.png new file mode 100644 index 0000000..77b63b2 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0116.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0117.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0117.png new file mode 100644 index 0000000..b9bdd54 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0117.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0118.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0118.png new file mode 100644 index 0000000..1fa6fc4 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0118.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0119.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0119.png new file mode 100644 index 0000000..263fe09 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0119.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0120.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0120.png new file mode 100644 index 0000000..c7cbf36 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0120.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0121.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0121.png new file mode 100644 index 0000000..aeba465 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0121.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0122.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0122.png new file mode 100644 index 0000000..8cf2f8b Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0122.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0123.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0123.png new file mode 100644 index 0000000..1f9155b Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0123.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0124.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0124.png new file mode 100644 index 0000000..b0ea43f Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0124.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0125.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0125.png new file mode 100644 index 0000000..cbf9961 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0125.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0126.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0126.png new file mode 100644 index 0000000..decd1d0 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0126.png differ diff --git a/fixtures/rings/v1-eight-way-facing/frames/frame-0127.png b/fixtures/rings/v1-eight-way-facing/frames/frame-0127.png new file mode 100644 index 0000000..d48e604 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/frames/frame-0127.png differ diff --git a/fixtures/rings/v1-eight-way-facing/motion.json b/fixtures/rings/v1-eight-way-facing/motion.json new file mode 100644 index 0000000..e33ba14 --- /dev/null +++ b/fixtures/rings/v1-eight-way-facing/motion.json @@ -0,0 +1,514 @@ +{ + "alpha": "auto", + "bindings": [], + "canvas": { + "colorSpace": "srgb", + "fit": "contain", + "height": 256, + "pixelAspect": [ + 1, + 1 + ], + "width": 256 + }, + "edges": [ + { + "id": "facing.walk.n.ne", + "from": "walk_n", + "to": "walk_ne", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": 1 + }, + { + "id": "facing.walk.n.nw", + "from": "walk_n", + "to": "walk_nw", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": -1 + }, + { + "id": "facing.walk.ne.e", + "from": "walk_ne", + "to": "walk_e", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": 1 + }, + { + "id": "facing.walk.ne.n", + "from": "walk_ne", + "to": "walk_n", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": -1 + }, + { + "id": "facing.walk.e.se", + "from": "walk_e", + "to": "walk_se", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": 1 + }, + { + "id": "facing.walk.e.ne", + "from": "walk_e", + "to": "walk_ne", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": -1 + }, + { + "id": "facing.walk.se.s", + "from": "walk_se", + "to": "walk_s", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": 1 + }, + { + "id": "facing.walk.se.e", + "from": "walk_se", + "to": "walk_e", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": -1 + }, + { + "id": "facing.walk.s.sw", + "from": "walk_s", + "to": "walk_sw", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": 1 + }, + { + "id": "facing.walk.s.se", + "from": "walk_s", + "to": "walk_se", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": -1 + }, + { + "id": "facing.walk.sw.w", + "from": "walk_sw", + "to": "walk_w", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": 1 + }, + { + "id": "facing.walk.sw.s", + "from": "walk_sw", + "to": "walk_s", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": -1 + }, + { + "id": "facing.walk.w.nw", + "from": "walk_w", + "to": "walk_nw", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": 1 + }, + { + "id": "facing.walk.w.sw", + "from": "walk_w", + "to": "walk_sw", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": -1 + }, + { + "id": "facing.walk.nw.n", + "from": "walk_nw", + "to": "walk_n", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": 1 + }, + { + "id": "facing.walk.nw.w", + "from": "walk_nw", + "to": "walk_w", + "continuity": "cut", + "start": { + "type": "cut", + "targetPort": "default", + "maxWaitFrames": 1 + }, + "targetRunwayFrames": 6, + "kind": "turn", + "ring": "facing.walk", + "step": -1 + } + ], + "encodings": [ + { + "codec": "vp9", + "deadline": "good", + "cpuUsed": 4, + "threads": 4, + "renditions": [ + { + "crf": 33, + "height": 256, + "id": "walk.1x", + "width": 256 + } + ] + } + ], + "frameRate": { + "denominator": 1, + "numerator": 30 + }, + "initialState": "walk_n", + "projectVersion": "1.0", + "rings": [ + { + "cyclic": true, + "id": "facing.walk", + "maxChainedSteps": 4, + "states": [ + "walk_n", + "walk_ne", + "walk_e", + "walk_se", + "walk_s", + "walk_sw", + "walk_w", + "walk_nw" + ], + "tieBreak": "forward", + "turn": { + "continuity": "exact-authored", + "mode": "cut", + "start": { + "maxWaitFrames": 8, + "sourcePort": "default", + "targetPort": "default", + "type": "portal" + } + } + } + ], + "sources": [ + { + "digits": 4, + "directory": "frames", + "firstNumber": 0, + "frameCount": 128, + "id": "walk", + "prefix": "frame-", + "suffix": ".png", + "type": "png-sequence" + } + ], + "states": [ + { + "bodyUnit": "walk_n.body", + "id": "walk_n" + }, + { + "bodyUnit": "walk_ne.body", + "id": "walk_ne" + }, + { + "bodyUnit": "walk_e.body", + "id": "walk_e" + }, + { + "bodyUnit": "walk_se.body", + "id": "walk_se" + }, + { + "bodyUnit": "walk_s.body", + "id": "walk_s" + }, + { + "bodyUnit": "walk_sw.body", + "id": "walk_sw" + }, + { + "bodyUnit": "walk_w.body", + "id": "walk_w" + }, + { + "bodyUnit": "walk_nw.body", + "id": "walk_nw" + } + ], + "units": [ + { + "id": "walk_n.body", + "kind": "body", + "playback": "loop", + "ports": [ + { + "entryFrame": 0, + "id": "default", + "portalFrames": [ + 0, + 8 + ] + } + ], + "range": [ + 0, + 16 + ], + "source": "walk" + }, + { + "id": "walk_ne.body", + "kind": "body", + "playback": "loop", + "ports": [ + { + "entryFrame": 0, + "id": "default", + "portalFrames": [ + 0, + 8 + ] + } + ], + "range": [ + 16, + 32 + ], + "source": "walk" + }, + { + "id": "walk_e.body", + "kind": "body", + "playback": "loop", + "ports": [ + { + "entryFrame": 0, + "id": "default", + "portalFrames": [ + 0, + 8 + ] + } + ], + "range": [ + 32, + 48 + ], + "source": "walk" + }, + { + "id": "walk_se.body", + "kind": "body", + "playback": "loop", + "ports": [ + { + "entryFrame": 0, + "id": "default", + "portalFrames": [ + 0, + 8 + ] + } + ], + "range": [ + 48, + 64 + ], + "source": "walk" + }, + { + "id": "walk_s.body", + "kind": "body", + "playback": "loop", + "ports": [ + { + "entryFrame": 0, + "id": "default", + "portalFrames": [ + 0, + 8 + ] + } + ], + "range": [ + 64, + 80 + ], + "source": "walk" + }, + { + "id": "walk_sw.body", + "kind": "body", + "playback": "loop", + "ports": [ + { + "entryFrame": 0, + "id": "default", + "portalFrames": [ + 0, + 8 + ] + } + ], + "range": [ + 80, + 96 + ], + "source": "walk" + }, + { + "id": "walk_w.body", + "kind": "body", + "playback": "loop", + "ports": [ + { + "entryFrame": 0, + "id": "default", + "portalFrames": [ + 0, + 8 + ] + } + ], + "range": [ + 96, + 112 + ], + "source": "walk" + }, + { + "id": "walk_nw.body", + "kind": "body", + "playback": "loop", + "ports": [ + { + "entryFrame": 0, + "id": "default", + "portalFrames": [ + 0, + 8 + ] + } + ], + "range": [ + 112, + 128 + ], + "source": "walk" + } + ] +} diff --git a/fixtures/rings/v1-eight-way-facing/public/build.json b/fixtures/rings/v1-eight-way-facing/public/build.json new file mode 100644 index 0000000..1589fb1 --- /dev/null +++ b/fixtures/rings/v1-eight-way-facing/public/build.json @@ -0,0 +1 @@ +{"assets":[{"bytes":31846,"codec":"vp9","codecString":"vp09.00.11.08.01.01.01.01.00","integrity":"sha256-rX+Le7isJVWF7Rb9VNFnOG3ALZdk2MnOwLZZI4J9CQQ=","path":"vp9.avl","sha256":"ad7f8b7bb8ac255585ed16fd54d167386dc02d9764d8c9cec0b65923827d0904","type":"application/vnd.aval; codecs=\"vp09.00.11.08.01.01.01.01.00\""}],"encodings":[{"codec":"vp9","cpuUsed":4,"deadline":"good","renditions":[{"crf":33,"height":256,"id":"walk.1x","width":256}],"threads":4}],"invocations":[{"arguments":["-version"],"operation":"discover:ffmpeg-version","tool":"ffmpeg"},{"arguments":["-hide_banner","-encoders"],"operation":"discover:ffmpeg-encoders","tool":"ffmpeg"},{"arguments":["-version"],"operation":"discover:ffprobe-version","tool":"ffprobe"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","16x44","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","2","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","23","-b:v","0","-deadline","good","-cpu-used","4","-threads","1","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","2","-keyint_min","2","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"discover:ffmpeg-calibration","tool":"ffmpeg"},{"arguments":["-v","error","-protocol_whitelist","file,pipe","-threads","1","-f","image2","-framerate","30/1","-start_number","0","-select_streams","v","-read_intervals","%+#128","-show_entries","stream=index,width,height,pix_fmt,avg_frame_rate,r_frame_rate,time_base,nb_frames,duration,field_order,sample_aspect_ratio:stream_side_data=rotation:format=format_name,duration:frame=stream_index,best_effort_timestamp,duration","-of","compact=p=1:nk=0:escape=none","$SOURCE/walk"],"operation":"walk:probe","tool":"ffprobe"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","image2","-framerate","30/1","-start_number","0","-i","$SOURCE/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,0\\,127),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","128","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"walk:materialize-rgba16","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","rawvideo","-pixel_format","rgba64le","-video_size","256x256","-framerate","30/1","-i","$SPOOL/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,32\\,47),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","16","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"vp9:walk.1x:walk_e.body:scale-rgba","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","256x256","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","16","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","33","-b:v","0","-deadline","good","-cpu-used","4","-threads","4","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","16","-keyint_min","16","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"vp9:walk.1x:walk_e.body:encode","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","rawvideo","-pixel_format","rgba64le","-video_size","256x256","-framerate","30/1","-i","$SPOOL/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,0\\,15),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","16","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"vp9:walk.1x:walk_n.body:scale-rgba","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","256x256","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","16","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","33","-b:v","0","-deadline","good","-cpu-used","4","-threads","4","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","16","-keyint_min","16","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"vp9:walk.1x:walk_n.body:encode","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","rawvideo","-pixel_format","rgba64le","-video_size","256x256","-framerate","30/1","-i","$SPOOL/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,16\\,31),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","16","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"vp9:walk.1x:walk_ne.body:scale-rgba","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","256x256","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","16","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","33","-b:v","0","-deadline","good","-cpu-used","4","-threads","4","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","16","-keyint_min","16","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"vp9:walk.1x:walk_ne.body:encode","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","rawvideo","-pixel_format","rgba64le","-video_size","256x256","-framerate","30/1","-i","$SPOOL/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,112\\,127),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","16","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"vp9:walk.1x:walk_nw.body:scale-rgba","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","256x256","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","16","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","33","-b:v","0","-deadline","good","-cpu-used","4","-threads","4","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","16","-keyint_min","16","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"vp9:walk.1x:walk_nw.body:encode","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","rawvideo","-pixel_format","rgba64le","-video_size","256x256","-framerate","30/1","-i","$SPOOL/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,64\\,79),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","16","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"vp9:walk.1x:walk_s.body:scale-rgba","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","256x256","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","16","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","33","-b:v","0","-deadline","good","-cpu-used","4","-threads","4","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","16","-keyint_min","16","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"vp9:walk.1x:walk_s.body:encode","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","rawvideo","-pixel_format","rgba64le","-video_size","256x256","-framerate","30/1","-i","$SPOOL/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,48\\,63),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","16","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"vp9:walk.1x:walk_se.body:scale-rgba","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","256x256","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","16","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","33","-b:v","0","-deadline","good","-cpu-used","4","-threads","4","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","16","-keyint_min","16","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"vp9:walk.1x:walk_se.body:encode","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","rawvideo","-pixel_format","rgba64le","-video_size","256x256","-framerate","30/1","-i","$SPOOL/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,80\\,95),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","16","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"vp9:walk.1x:walk_sw.body:scale-rgba","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","256x256","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","16","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","33","-b:v","0","-deadline","good","-cpu-used","4","-threads","4","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","16","-keyint_min","16","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"vp9:walk.1x:walk_sw.body:encode","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","file,pipe","-f","rawvideo","-pixel_format","rgba64le","-video_size","256x256","-framerate","30/1","-i","$SPOOL/walk","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-threads","1","-filter_threads","1","-vf","select=between(n\\,96\\,111),scale=256:256:flags=lanczos+accurate_rnd+full_chroma_int:in_range=auto:out_range=full:in_color_matrix=auto:out_color_matrix=bt709,setsar=1,format=rgba64le","-frames:v","16","-fps_mode","passthrough","-f","rawvideo","-pix_fmt","rgba64le","pipe:1"],"operation":"vp9:walk.1x:walk_w.body:scale-rgba","tool":"ffmpeg"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","256x256","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","16","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","33","-b:v","0","-deadline","good","-cpu-used","4","-threads","4","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","16","-keyint_min","16","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"vp9:walk.1x:walk_w.body:encode","tool":"ffmpeg"},{"arguments":["-version"],"operation":"verify:ffmpeg-version","tool":"ffmpeg"},{"arguments":["-hide_banner","-encoders"],"operation":"verify:ffmpeg-encoders","tool":"ffmpeg"},{"arguments":["-version"],"operation":"verify:ffprobe-version","tool":"ffprobe"},{"arguments":["-nostdin","-hide_banner","-loglevel","error","-xerror","-protocol_whitelist","pipe","-f","rawvideo","-pixel_format","yuv420p","-video_size","16x44","-framerate","30/1","-i","pipe:0","-map","0:v:0","-an","-sn","-dn","-map_metadata","-1","-map_chapters","-1","-frames:v","2","-fps_mode","passthrough","-c:v","libvpx-vp9","-crf","23","-b:v","0","-deadline","good","-cpu-used","4","-threads","1","-pix_fmt","yuv420p","-color_range","tv","-color_primaries","bt709","-color_trc","bt709","-colorspace","bt709","-g","2","-keyint_min","2","-sc_threshold","0","-f","ivf","pipe:1"],"operation":"verify:ffmpeg-calibration","tool":"ffmpeg"}],"reportVersion":"1.0","sourceMarkup":"","toolchain":{"aggregateMemoryLimit":"derived","ffmpeg":{"calibrationSha256":"f157de67c93bc89ca80fa11494dc262f72a8d6231bd85810ed8655e3c8ec2650","configurationSha256":"1c6789307c901954b5afc6f2ca89309655334c1ebb05983da5813dc8b808fe45","encodersOutputSha256":"c1e15ebb2a1c2658fc03beb3426f6e327c894507f258d06d4d2f2c9df5a70d19","executableIdentity":{"ctimeNanoseconds":"1780918034123684446","device":"66307","inode":"65702534","mtimeNanoseconds":"1780918030595625429","size":432816},"executableSha256":"d124211a58f3e1423da0d23bf9bb5db83c5fd2a022d8317cc39b3e99e202ad86","version":"ffmpeg version 8.0.1 Copyright (c) 2000-2025 the FFmpeg developers","versionOutputSha256":"79811e7f37c2f916a185d66e0f7af304160b92577efd60ad2c7c475f356d1364"},"ffprobe":{"executableIdentity":{"ctimeNanoseconds":"1780918034123684446","device":"66307","inode":"65702535","mtimeNanoseconds":"1780918030597505619","size":204736},"executableSha256":"0fe4f0fc03ee9af28f28c1f9921725bbb4139bfe29047c94967b39634e4f1738","version":"ffprobe version 8.0.1 Copyright (c) 2007-2025 the FFmpeg developers","versionOutputSha256":"498114d3a658ab47dd669bada82c30fa5c76bc54102d4d7aeb5d004542b267d1"}},"warnings":["ring facing.walk step walk_n to walk_ne is shadowed by authored edge facing.walk.n.ne","ring facing.walk step walk_ne to walk_e is shadowed by authored edge facing.walk.ne.e","ring facing.walk step walk_e to walk_se is shadowed by authored edge facing.walk.e.se","ring facing.walk step walk_se to walk_s is shadowed by authored edge facing.walk.se.s","ring facing.walk step walk_s to walk_sw is shadowed by authored edge facing.walk.s.sw","ring facing.walk step walk_sw to walk_w is shadowed by authored edge facing.walk.sw.w","ring facing.walk step walk_w to walk_nw is shadowed by authored edge facing.walk.w.nw","ring facing.walk step walk_nw to walk_n is shadowed by authored edge facing.walk.nw.n","ring facing.walk step walk_ne to walk_n is shadowed by authored edge facing.walk.ne.n","ring facing.walk step walk_e to walk_ne is shadowed by authored edge facing.walk.e.ne","ring facing.walk step walk_se to walk_e is shadowed by authored edge facing.walk.se.e","ring facing.walk step walk_s to walk_se is shadowed by authored edge facing.walk.s.se","ring facing.walk step walk_sw to walk_s is shadowed by authored edge facing.walk.sw.s","ring facing.walk step walk_w to walk_sw is shadowed by authored edge facing.walk.w.sw","ring facing.walk step walk_nw to walk_w is shadowed by authored edge facing.walk.nw.w","ring facing.walk step walk_n to walk_nw is shadowed by authored edge facing.walk.n.nw"]} \ No newline at end of file diff --git a/fixtures/rings/v1-eight-way-facing/public/vp9.avl b/fixtures/rings/v1-eight-way-facing/public/vp9.avl new file mode 100644 index 0000000..0f76933 Binary files /dev/null and b/fixtures/rings/v1-eight-way-facing/public/vp9.avl differ diff --git a/fixtures/rings/v1-eight-way-facing/test.html b/fixtures/rings/v1-eight-way-facing/test.html new file mode 100644 index 0000000..8d7a9c9 --- /dev/null +++ b/fixtures/rings/v1-eight-way-facing/test.html @@ -0,0 +1,318 @@ + + + + + + Rings/Turn Edges Test — v1-eight-way-facing + + + +
+

State Control

+
walk_n
+
readiness: —
+ +
+
+ +
+ +
+ + +
+ + +
+ +
+ +
+
+ +

Turn Policy note

+ + +

planFor output

+
awaiting player...
+ +

Ring info

+
+ Ring: facing.walk · 8 states · cyclic
+ 16 derived turn edges (±1 step each)
+ maxChainedSteps: 4 +
+
+ + + + + diff --git a/fixtures/rings/v1-eight-way-facing/vite.config.js b/fixtures/rings/v1-eight-way-facing/vite.config.js new file mode 100644 index 0000000..9cc9449 --- /dev/null +++ b/fixtures/rings/v1-eight-way-facing/vite.config.js @@ -0,0 +1,53 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const fixtureDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(fixtureDir, "../../.."); + +/** + * Serve the monorepo root so: + * - URL stays /fixtures/rings/v1-eight-way-facing/test.html + * - bare package imports (@pixel-point/*) resolve via node_modules + * - decoder module Worker (new URL("./entry.js", import.meta.url)) is + * rewritten by Vite (plain http.server cannot do this → readiness-failure) + */ +export default defineConfig({ + root: repoRoot, + publicDir: false, + appType: "mpa", + plugins: [ + { + name: "avl-mime", + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url && /\.avl(\?|$)/.test(req.url)) { + res.setHeader("Content-Type", "application/vnd.aval"); + res.setHeader("Accept-Ranges", "bytes"); + } + next(); + }); + }, + }, + ], + server: { + port: 8765, + host: true, + strictPort: true, + fs: { + allow: [repoRoot], + }, + }, + worker: { + format: "es", + }, + optimizeDeps: { + include: [ + "@pixel-point/aval-element", + "@pixel-point/aval-element/auto", + "@pixel-point/aval-player-web", + "@pixel-point/aval-format", + "@pixel-point/aval-graph", + ], + }, +}); diff --git a/flutter/.gitignore b/flutter/.gitignore new file mode 100644 index 0000000..841661c --- /dev/null +++ b/flutter/.gitignore @@ -0,0 +1,7 @@ +# Dart/Flutter +.dart_tool/ +build/ +pubspec.lock + +# Rust +rust/*/target/ diff --git a/flutter/ARCHITECTURE.md b/flutter/ARCHITECTURE.md new file mode 100644 index 0000000..bfa0c25 --- /dev/null +++ b/flutter/ARCHITECTURE.md @@ -0,0 +1,926 @@ +# AVAL Flutter Port — Master Architecture Plan + +**v2 — Rust decode core.** This revision replaces the v1 decode strategy +(FFI to `libavcodec`, with VideoToolbox/MediaCodec as fallback) with a +Rust decode core (crate `aval_decode`, using the BSD-licensed `openh264` +decoder) as the primary strategy, compiled natively for iOS/Android/macOS/ +Windows/Linux and to WASM for Flutter Web. See §2 (decode strategy), the +new §3.3 (YUV pixel-format consequence for the renderer), §4 (concurrency +— Rust-owned thread pool instead of a hand-rolled Dart isolate protocol), +§6 (package layout — new `flutter/rust/aval_decode` crate), §7 (Phase 3 +now targets Rust/openh264, with a new Phase 3b WASM spike), and §8 (risk +register additions/updates for openh264, WASM build maturity, and the new +YUV→RGB conversion step). All other sections are unchanged from v1. + +Status: design document, no Dart implementation code included. +Scope: produce a production-grade Flutter port of the AVAL web player +(`packages/player-web`, `packages/element`) with 100% behavioral parity, +validated against `examples/grass-rabbit`. + +This document is the contract subsequent implementation sessions execute +against. It cites real file paths, type names, and line counts from the +TypeScript sources so that porting decisions can be checked against the +original rather than re-derived from memory. + +Sources analyzed (see per-section citations): +- `packages/player-web/src` — 63,969 LOC non-test (99,115 incl. tests), 195 files +- `packages/element/src` — 7,952 LOC non-test +- `packages/graph/src` — 3,437 LOC (pure state-graph engine, ported separately as `aval_graph`) +- `packages/format/src` — 11,077 LOC (pure container/AVC/PNG codec, ported separately as `aval_format`) +- `docs/format/0.1.md`, `docs/superpowers/specs/*`, `examples/grass-rabbit/*` +- `flutter/packages/aval_graph`, `flutter/packages/aval_format` — existing scaffolds (see §6) + +--- + +## 1. Runtime Anatomy of the Web Player + +### 1.1 Module graph + +``` +packages/graph (pure) @pixel-point/aval-graph + │ MotionGraphEngine, portal-search, route-plan, model.ts + ▼ +packages/format (pure) @pixel-point/aval-format + │ container parser, avc/ (Annex-B, SPS/PPS, inspector), png/ (strict decoder) + ▼ +packages/player-web/src/decoder-worker (thin platform shim over pure core) + │ protocol.ts, frame-credit-ledger.ts, core.ts, client.ts, sample-sequence.ts + │ ── Worker boundary ── + ▼ +packages/player-web/src/runtime (the bulk of the system, mostly pure) + │ path-scheduler*, decode-timeline, edge-lead, rational-time, submission-horizon + │ model.ts, motion-policy, cut-presentation-coordinator, reversible-presentation + │ frame-renderer(.ts) + frame-renderer-browser.ts (WebGL2), presentation-ring/-geometry + │ asset-catalog, avc-candidate-factory, verified-blob-store, page-resource-manager + │ integrated-player.ts (facade over ~15 collaborators) + ▼ +packages/element/src (DOM custom element) + │ aval-element.ts, element-reconciler.ts, engagement-controller.ts, + │ shadow-layers.ts, diagnostics.ts, public-types.ts + ▼ +examples/grass-rabbit/main.js (reference consumer) +``` + +`packages/graph` is the deterministic reducer that decides **what** state to +be in and **when** a transition is logically due (portal/finish/cut/reversal), +with zero DOM/codec/GL awareness — `MotionGraphEngine.request/send/tick` +return plain `MotionGraphResult { presentation, effects[], snapshot }` values. +`packages/player-web/src/runtime` consumes those results and decides **how** +to make pixels appear for them (decode-ahead, GPU compositing, resource +budgets). `packages/element` is the thinnest layer: DOM attribute +reflection, event dispatch, and desired/current-state reconciliation. + +### 1.2 LOC buckets (pure logic vs. platform-bound) + +Based on full reads of the decoder-worker, path-scheduler, renderer, and +element subsystems, plus directory-wide `wc -l`: + +| Bucket | Approx. LOC | Files (representative) | Portability | +|---|---|---|---| +| **A. Pure state-machine / algorithmic logic** | ~45,000–48,000 | `path-scheduler*.ts` (3,080), `decode-timeline.ts`, `edge-lead.ts`, `rational-time.ts`, `submission-horizon.ts` (599), `model.ts` (647), `motion-policy.ts`, `reversible-presentation.ts`, `cut-presentation-coordinator.ts` (946), `readiness-evaluator.ts`, `presentation-geometry.ts`, `presentation-ring.ts` (485), `frame-renderer.ts`'s orchestration shell (1,008, backend-agnostic), decoder-worker's `core.ts`/`client.ts`/`frame-credit-ledger.ts`/`sample-sequence.ts` (behind adapter interfaces), `integrated-player.ts` (1,029) and its ~15 collaborators, `packages/graph` (3,437), `packages/format` incl. `avc/` and `png/` (11,077) | Near-mechanical port to Dart | +| **B. Networking / fetch-bound** | ~8,000–9,000 | `range-asset-session.ts` (995), `verified-blob-store.ts` (967), `full-asset-fetch.ts` (615), `bounded-body-reader.ts` (711), `blob-assembly.ts`, `http-content-range.ts`, `http-entity-tag.ts`, `sha256-verifier.ts`, `load-watchdogs.ts` (719), `asset-catalog.ts` (743) | Moderate — rewrite against `dart:io`/`package:http` streaming + Range requests, algorithms port as-is | +| **C. GPU/WebGL2-bound** | ~1,000–1,500 | `frame-renderer-browser.ts` (905), `opaque-frame-renderer-browser.ts` (8, re-export shim), DOM-listener slice of `browser-context-recovery.ts` (469) | Hard — full rewrite as Flutter `FragmentProgram` (§3) | +| **D. WebCodecs/decoder-bound** | ~600–800 direct LOC, but unbounded architectural risk | `decoder-worker/entry.ts` (5), `factory.ts` (118), `host.ts` (98), `core.ts`'s default `VideoDecoder`/`EncodedVideoChunk` adapters (~50), `client-support.ts`'s frame wrapper (~80), `core-validation.ts`'s decoded-frame field reads (~100) | Hard — no Dart equivalent exists; needs new native decode engine (§2) | +| **E. Worker/threading plumbing** | ~250 | `factory.ts`, `host.ts`, `entry.ts`, `protocol.ts` message shapes | Maps to Dart isolates (§4) | +| **F. DOM/custom-element-bound** | ~2,000 of the element package's 7,952 | `shadow-layers.ts`, `shadow-style.ts`, `dom-event-bridge.ts`, `interaction-target.ts`, `automatic-inputs.ts`, `document-visibility-broker.ts`, `dpr-broker.ts`, `motion-preference-broker.ts`, `presentation-observer.ts` | Replace with Flutter widgets/gestures (§5); the remaining ~6,000 LOC (`element-desired-state.ts`, `element-current-predicates.ts`, `element-reconciler.ts`, `diagnostics.ts`, `public-types.ts`, error taxonomy) is pure reducer logic | +| `packages/player-web/src/experimental` | 5,820 | continuous-loop-decoder, resident-* prototypes, `webgl-frame-renderer.ts` | **Excluded from port** — superseded prototypes, not consumed by `integrated-player.ts`; skip unless a specific module is later found load-bearing | + +**Headline number: of the ~63,969 non-test LOC in `player-web` + the +7,952 in `element`, roughly 85–90% is pure TypeScript logic with no +browser-API dependency, already isolated behind a small number of adapter +interfaces** (`PathSchedulerWorkerAdapter`, `ManagedDecoderWorkerFrame`, +`FrameRendererBackend`, `DecoderWorkerClientPort`/`MessagePort`, +`WorkerVideoDecoderAdapter`, `CutPresentationRenderer`). This is the single +most important architectural fact for the port: the codebase was already +built adapter-first, so the Dart port can preserve almost the entire +class/module structure and only needs new implementations of ~7 narrow +interfaces against isolates/FFI/platform-channels/FragmentProgram. + +--- + +## 2. Flutter Decode Strategy + +### Requirement recap +AVAL needs **frame-accurate decode of arbitrary AVC access-unit ranges from +a custom container** (the `.avl` format, `docs/format/0.1.md` §6), driven +by a frame-credit backpressure protocol (`decoder-worker/frame-credit-ledger.ts`), +feeding a continuous monotonic decode timeline even across seekless loops +(`decode-timeline.ts`). This is **not** "play an mp4" — there is no +`AVAssetReader`/`MediaExtractor`-friendly file on disk; the runtime hands +individual access units (`DecoderWorkerSample { unitId, unitFrame, type: +"key"|"delta", data: ArrayBuffer }`) to the decoder one batch at a time and +expects RGBA frames back with strict FIFO/ordinal contiguity +(`presentation-ring.ts`'s underflow checks). `video_player` and any +MediaSource/HLS-oriented plugin are structurally unable to do this — they +own the demux/seek model; AVAL owns it and needs raw decoder access. + +The content itself is deliberately narrow: **Constrained Baseline AVC +(`avc1.42E020`, level 3.2), no B-frames, one reference frame, closed GOP per +unit** (`docs/superpowers/specs/2026-07-11-m5-opaque-avc-compiler-worker-design.md`). +This materially reduces decode-engine risk versus "general H.264." + +### (a) Rust decode core (`aval_decode` crate, `openh264`) — **PRIMARY (v2)** +A single Rust crate, `aval_decode` (location: `flutter/rust/aval_decode`, +§6), wraps the `openh264` crate — a safe Rust binding to Cisco's +**BSD-2-Clause-licensed** OpenH264 decoder — and exposes a small, +protocol-shaped API (`configure`, `submit_access_unit`, `take_frame`, +`release_frame`, `dispose`) that is a near-1:1 port of +`decoder-worker/core.ts` + `frame-credit-ledger.ts`, just running in Rust +instead of a JS Worker. This crate compiles to: +- native `cdylib`/staticlib per target triple for iOS, Android, macOS, + Windows, Linux (via `flutter_rust_bridge` + `cargokit`, or plain + `cbindgen`-generated C headers + `dart:ffi`), and +- **WASM** (`wasm32-unknown-unknown`, via `flutter_rust_bridge`'s web + codegen, which emits `wasm-bindgen` glue) for Flutter Web — the same + Rust source, one build matrix, satisfying the directive that "Flutter + web can run the same Rust core compiled to WASM." + +**Profile coverage check**: AVAL's format requires exactly Constrained +Baseline AVC, `avc1.42E020`, level 3.2, no B-frames, one reference frame, +closed GOP per unit (`docs/superpowers/specs/2026-07-11-m5-opaque-avc-compiler-worker-design.md`). +OpenH264 was built by Cisco specifically around Constrained Baseline +Profile (its *encoder* is CBP-only; WebRTC/Chrome uses it precisely for +CBP interop), and its *decoder* fully implements Baseline-profile syntax — +this is a clean, complete match with no missing syntax elements. Because +AVAL's units carry **no B-frames and a single reference frame**, decode +order equals display order — `Decoder::decode(&mut self, packet: &[u8])` +returns the decoded picture (or `None` while priming) synchronously per +submitted access unit, with **no internal reordering buffer**, which maps +even more directly onto "submit one AU, get one frame" than WebCodecs' +async-callback model did. + +- **Frame accuracy**: exact, same reasoning as v1's ffmpeg option — one + access unit from the `.avl` index maps to exactly one `decode()` call, no + seeking. +- **Decode-ahead control**: full — the ported `FrameCreditLedger` now runs + *inside Rust*, gating how many access units are outstanding before + `submit_access_unit` is called again, identical semantics to + `hasSubmissionCredit`. +- **Licensing**: OpenH264's source is BSD-2-Clause (no GPL/LGPL exposure at + all, resolving v1's biggest packaging risk outright — see Risk §8.2's + updated status). One nuance to document for legal review: Cisco's + *prebuilt* binary distribution carries MPEG-LA patent-royalty coverage + for redistributors; a from-source build (as this crate does, cross-compiled + per target) does not automatically carry that coverage, so patent + licensing should be confirmed with counsel independent of the (clean) + copyright licensing. +- **Binary size**: OpenH264 is small relative to a full ffmpeg build, + materially better for a WASM bundle's download-size budget. +- **All-platform + web coverage**: one Rust codebase across all six + targets (5 native + web), the strongest "one engine, one behavior" + story of any option evaluated, and it directly satisfies the new + Rust/WASM directive. + +**Pixel-format consequence** (new vs. v1): OpenH264 decodes to planar +**I420 (YUV 4:2:0)**, not RGBA — WebCodecs' `VideoFrame` did the YUV→RGB +conversion invisibly before this codebase's renderer ever saw a pixel. +With a Rust core, that conversion must now be designed explicitly. See +§3.3. + +**Isolate/thread boundary** (new vs. v1, see §4 for full detail): decode +no longer runs in a Dart `Isolate` running a hand-rolled worker protocol — +it runs on a **Rust-owned thread** (or thread pool), because +`flutter_rust_bridge`'s generated async bindings already dispatch blocking +Rust calls off the Dart UI thread automatically. Only Annex-B access-unit +bytes cross the FFI boundary inbound, and decoded frame planes cross it +outbound as Rust-owned buffers exposed to Dart as **external typed +data** (zero-copy `Uint8List` views backed by a `NativeFinalizer` that +frees the Rust allocation once Dart is done with it) — no manual +`Isolate.spawn`/`SendPort`/`TransferableTypedData` plumbing is needed the +way v1 assumed for a from-scratch Worker-protocol port (that plumbing is +now internal to `flutter_rust_bridge`, not hand-written). + +### (b) FFI to ffmpeg/libav via `package:ffi` — demoted to alternative +Still architecturally sound (as detailed in v1), but no longer recommended +as primary now that (a) exists: ffmpeg/libavcodec is a much larger +dependency (worse for WASM bundle size), carries GPL/LGPL build-config +risk that openh264 simply doesn't have, and offers no decode-quality or +frame-accuracy advantage for Constrained-Baseline-only content. Retain as +a documented alternative only if a future rendition profile needs decoder +features OpenH264 doesn't cover (e.g. a hypothetical higher-profile +rendition) — not needed for AVAL's current format. + +### (c) Platform channels to AVFoundation/VideoToolbox (iOS/macOS) + MediaCodec (Android) — next GPU phase (decode side) +v1 kept this as a *fallback* motivated by ffmpeg's licensing/packaging +risk; with (a) resolving that risk via a BSD-licensed decoder, this option +is **no longer necessary as a hedge** — but it remains worth building as a +**high-efficiency backend** for a different reason: OpenH264 is a +**software** decoder with no dedicated video-decode silicon path, so for +AVAL's actual usage pattern (small, often long-running hover/idle loops) a +hardware decoder is meaningfully better for battery/thermal on mobile, even +though OpenH264 is plenty fast for correctness and even modest real-time +margins. + +Now that §3's `FragmentProgram` compositor ships GPU-accelerated rendering, +this is the remaining CPU-bound stage in the pipeline. Concrete plan for +this phase: + +- **`DecoderAdapter` does not exist yet as code** — despite being referenced + throughout this document and in `aval_decode`'s doc comments + (`decoder.rs`, `error.rs`), `DecoderSession` today directly and concretely + owns an `openh264::decoder::Decoder`; there is no trait to implement + against. This phase starts by introducing that trait (openh264 becomes its + first implementor, behavior-preserving) before adding a second one. +- **VideoToolbox's decompression session API is plain C** + (`VTDecompressionSessionCreate`, `VTDecompressionSessionDecodeFrame`, + `VTDecompressionSessionInvalidate`), not Objective-C — Rust can bind it + directly via FFI (hand-written or `bindgen` against the VideoToolbox/ + CoreMedia/CoreVideo headers) with no Swift/ObjC bridge needed for the + decode call itself. Building a `CMSampleBuffer` from an Annex-B access + unit and reading back a `CVPixelBuffer` are the two non-trivial pieces. +- **Keep the existing FFI contract**: convert the decoded `CVPixelBuffer` + to the same RGBA shape `AvalDecodeFrame` already returns (CPU copy via + `CVPixelBufferLockBaseAddress`, matching today's contract) so this backend + plugs in entirely behind `aval_decode_take_frame` — **zero changes** to + `aval_ffi.dart`, `GpuFramePainter`, or the shader. A true zero-copy + GPU-surface hand-off (`Texture`/`TextureRegistry`) was already evaluated + and rejected in §3.2 for the render side and is not part of this phase; + the win here is decode CPU/battery cost, not another render path. +- MediaCodec (Android) is a separate, structurally different binding + (JNI, not a C API) and is a follow-up phase, not bundled with the + VideoToolbox work. +- Windows/Linux still have no equivalent first-party hardware-decode + surface, so OpenH264 remains their only backend regardless. + +Recommendation: ship OpenH264 as the default on all platforms for v1 parity +work, and land the VideoToolbox backend behind the (newly introduced) +`DecoderAdapter` interface as the next efficiency phase — selectable per +build or per device-capability probe — with MediaCodec following once +VideoToolbox is validated. + +### (d) `media_kit`/libmpv — still rejected +Unchanged from v1: `media_kit` wraps `libmpv`, a general-purpose *player* +abstraction (open a URL/file, seek, get position), not a transactional +"decode this exact access unit, hold credit, return exactly this frame" +surface. Useful only as a reference for FFI/native-library packaging +patterns; never as the production decode engine. + +### (e) Pure-Dart H.264 decode — still rejected +Unchanged from v1: a spec-compliant software H.264 decoder in Dart is a +multi-thousand-hour effort with no hardware acceleration, unable to +sustain real-time decode-ahead on mobile CPUs. Firmly rejected regardless +of decode-engine language choice elsewhere. + +### Recommendation (v2) +**Primary: a single Rust crate (`aval_decode`) wrapping `openh264`**, +compiled natively for iOS/Android/macOS/Windows/Linux via +`flutter_rust_bridge`/`cargokit`, and to WASM for Flutter Web via +`flutter_rust_bridge`'s web codegen (`wasm-bindgen`). This resolves v1's +licensing risk outright (BSD vs. GPL/LGPL), gives one engine and one +behavior across all six targets including web, and is a clean profile +match for AVAL's Constrained-Baseline-only content. + +**Web decode backend — a deliberate choice, not automatic**: Flutter Web +runs inside a real browser that already has hardware-accelerated +WebCodecs available. Two viable configurations: +- **Default/recommended: `VideoDecoder`/`EncodedVideoChunk` via + `dart:js_interop`** — hardware-accelerated, zero new WASM-toolchain risk, + and literally the reference implementation this whole port is validated + against. Port the decoder-worker's WebCodecs adapter near-verbatim + behind `dart:js_interop` rather than recompiling a software decoder to + WASM to redo work the browser already does natively and faster. +- **Optional fallback: the same `aval_decode` Rust core compiled to WASM** + — satisfies "one identical decode core everywhere," useful if a + deployment needs pixel-identical output across native and web for a + certification pipeline, or must support browsers/embeddings without + WebCodecs. Treat `openh264-sys2`'s `wasm32` C-toolchain build path as + **unvalidated until the Phase 3b spike** (§7) — WASM builds of OpenH264 + exist in the wider ecosystem (e.g. WebCodecs polyfills), but the Rust + crate's own `wasm32` target support has not been confirmed against this + project's toolchain and should not be assumed solved. + +Ship WebCodecs-via-interop as the default web backend; keep the +WASM-compiled Rust core as a documented, spiked-and-validated optional +path. **Fallback/alternative for native: ffmpeg FFI (b)** if a future +profile needs it. **Optional efficiency backend: VideoToolbox/MediaCodec +(c)**, post-parity. **Reject pure-Dart decode (e) and `media_kit` (d) as +the primary engine**, unchanged from v1. + +--- + +## 3. Rendering Strategy + +### 3.1 The web renderer, verbatim + +The WebGL2 packed-alpha compositor lives entirely in +`packages/player-web/src/runtime/frame-renderer-browser.ts` (905 lines); +`frame-renderer.ts` (1,008 lines) is a platform-neutral orchestration shell +around an injected `FrameRendererBackend` interface +(`allocate/upload/uploadFrame?/draw/readPixels?/dispose`), and +`opaque-frame-renderer.ts`/`opaque-frame-renderer-browser.ts` are literal +`@deprecated` re-export shims — **there is exactly one renderer +implementation**; "opaque" vs. "packed-alpha" is a data-driven branch +(`u_has_alpha`), not a second code path. + +**Vertex shader** (full-screen triangle, no VBO): +```glsl +#version 300 es +precision highp float; +const vec2 positions[3] = vec2[]( + vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0) +); +void main() { + vec2 position = positions[gl_VertexID]; + gl_Position = vec4(position, 0.0, 1.0); +} +``` + +**Fragment shader** (`FRAME_FRAGMENT_SHADER_SOURCE`): +```glsl +#version 300 es +precision highp float; +precision highp sampler2DArray; +uniform sampler2DArray u_frames; +uniform float u_layer; +uniform vec4 u_color_uv; +uniform vec4 u_alpha_uv; +uniform vec4 u_output_rect; +uniform float u_has_alpha; +out vec4 out_color; +void main() { + vec2 output_index = gl_FragCoord.xy - u_output_rect.xy - vec2(0.5); + vec2 output_span = max(u_output_rect.zw - vec2(1.0), vec2(1.0)); + vec2 sample_uv = output_index / output_span; + sample_uv.y = 1.0 - sample_uv.y; + if (u_output_rect.z <= 1.0) sample_uv.x = 0.5; + if (u_output_rect.w <= 1.0) sample_uv.y = 0.5; + sample_uv = clamp(sample_uv, vec2(0.0), vec2(1.0)); + vec2 color_uv = u_color_uv.xy + sample_uv * u_color_uv.zw; + vec3 color = texture(u_frames, vec3(color_uv, u_layer)).rgb; + float alpha = 1.0; + if (u_has_alpha > 0.5) { + vec2 alpha_uv = u_alpha_uv.xy + sample_uv * u_alpha_uv.zw; + alpha = clamp(texture(u_frames, vec3(alpha_uv, u_layer)).r, 0.0, 1.0); + } + out_color = vec4(color * alpha, alpha); +} +``` + +**Packing layout — vertical stacking, one decoded picture.** Derived in +`packages/format/src/avc/rendition-geometry.ts` +(`deriveAvcRenditionGeometryFromVisibleAtPath`): color occupies the top +pane (`visibleColorRect = [0,0,w,h]`), an 8px neutral gutter follows +(`PACKED_ALPHA_GUTTER = 8`), then the alpha pane sits directly below +(`visibleAlphaRect = [0, paneHeight+8, w, h]`), stored as luma broadcast +into RGB (shader reads only `.r`). Total decoded canvas height = +`2*paneHeight + 8`, padded to a multiple of 16 (`align16`). Color and alpha +are **guaranteed frame-synchronous because they occupy one AVC picture** — +this is the M6 design's core invariant ("color and alpha cannot drift +because they occupy one decoded picture"), eliminating dual-decoder sync +entirely. + +Fragment-shader logic, step by step: (1) convert `gl_FragCoord` into a +normalized 0..1 index within the drawn output rect (supports letterboxed +sub-rect draws with one draw call); (2) flip V (texture-space vs. top-down +frame convention); (3) clamp degenerate 1px spans to texel center; (4) +affine-remap the same normalized coordinate into two different UV +sub-rects of the *same* texture layer — one for color, one for alpha +(precomputed CPU-side via texel-center-to-texel-center mapping in +`frame-renderer-validation.ts`'s `deriveUvTransform`); (5) sample color +`.rgb` (no YUV→RGB math in-shader — the browser's WebCodecs `VideoFrame` → +`texSubImage3D` fast path or `copyTo(..., {format:"RGBA"})` already +produced RGBA; BT.709 limited-range is asserted upstream, not converted +here); (6) sample alpha from the alpha pane's `.r` channel if +`u_has_alpha`, else hardcode `1.0`; (7) **premultiply in-shader**: +`out_color = vec4(color * alpha, alpha)`. GL state is +`blendFunc(ONE, ONE_MINUS_SRC_ALPHA)` with a `premultipliedAlpha: true` +context — standard premultiplied "over" compositing end to end. + +Texture storage is `TEXTURE_2D_ARRAY` (`texStorage3D`, single mip, `LINEAR` +filter, `CLAMP_TO_EDGE`), split into a **resident** array (persistent +cached loop/portal/reversible frames) and a small **streaming** array +(`STREAMING_TEXTURE_LAYER_COUNT = 3` ring slots for fresh decode +continuation); `u_layer` selects which array layer to sample, `kind` +(`"resident"|"stream"`) selects which texture object is bound. + +`presentation-geometry.ts`'s `computePresentationGeometry` (100% pure +arithmetic) implements exactly the CSS `object-fit` vocabulary +(`contain|cover|fill|none`) plus non-square-pixel handling and DPR-aware +backing-size clamping — this maps directly onto Flutter's `BoxFit` model +and is portable near-verbatim. + +`browser-context-recovery.ts` treats a lost WebGL2 context as **terminal in +production** (`contextLossPolicy: "terminal"`) — it covers with the static +fallback and never attempts GPU recovery mid-session. Port this policy +as-is; do not build context-recovery machinery the web version itself +doesn't rely on. + +### 3.2 Flutter rendering options evaluated + +**Texture widget (platform `SurfaceTexture`/`IOSurface`/GL texture via +`TextureRegistry`)**: gives zero-copy GPU display, but the packed-alpha +unpack/premultiply math must run natively *before* the texture is +registered — i.e., duplicate the GLSL logic once per platform graphics API +(Metal for iOS/macOS, OpenGL ES/Vulkan for Android, D3D/ANGLE for +Windows). Fastest at steady state, highest implementation surface, and +risks the exact "shader drifts between platforms" failure mode the M6 spec +was designed to prevent for color/alpha sync. + +**`dart:ui.Image` upload + `Canvas.drawImage`**: simplest, one Dart/C code +path, but requires the unpack+premultiply to happen on CPU (in the FFI +decode boundary, in C, immediately after `avcodec_receive_frame`) before +`ui.decodeImageFromPixels`/`ImmutableBuffer`. Wastes the GPU's shader ALU +and adds one CPU memory-bandwidth pass per frame; acceptable at 720p/24–60fps +on modern hardware but throws away the entire reason the web version does +this compositing on GPU. + +**`FragmentProgram` (dart:ui `FragmentProgram`, `.frag` compiled to SkSL via +Impeller) driving a `CustomPainter`**: **recommended — implemented.** Shipped +in `flutter/examples/grass_rabbit` as `shaders/frame.frag` + +`lib/src/gpu_frame_painter.dart`'s `GpuFramePainter`, wired into `main.dart` +in place of the CPU `_FramePainter` (kept only as the fallback painter while +the shader program loads at startup). Verified on macOS: correct, +artifact-free compositing with playback and state transitions running +through the shader. The current `aval_decode` core has no packed-alpha +profile yet (§3.3), so today's shader always takes the `u_has_alpha == 0` +branch — `u_alpha_uv`/`u_has_alpha` stay in the uniform layout, unused, so a +future packed-alpha decode core only needs to start passing real values, not +touch the shader or painter structure. Upload the *raw* +packed decoded frame (one `ui.Image`, still vertically stacked +color/gutter/alpha, straight out of the decoder, no CPU compositing) and +run a fragment shader that is a near-verbatim transliteration of +`FRAME_FRAGMENT_SHADER_SOURCE`: +- Replace `gl_FragCoord` with the `FlutterFragCoord()` builtin. +- Drop `precision` qualifiers (not present in the Impeller GLSL-ES subset). +- `sampler2DArray` has **no Flutter/Impeller equivalent** — Impeller + fragment shaders take a fixed, statically-declared set of `sampler2D` + uniforms, no dynamic array indexing. Since color and alpha are already + packed into **one** decoded picture per frame, this is not a blocker: + the shader needs only **one** `sampler2D` per draw (the current frame's + image) plus the same `u_color_uv`/`u_alpha_uv`/`u_output_rect`/`u_has_alpha` + uniforms — the UV-remap-into-two-regions-of-one-texture logic ports + unchanged. +- The web's resident/streaming **texture array layer** selection + (`u_layer`) has no Impeller equivalent either — port it as: allocate N + separate `ui.Image` objects (Flutter holds many `Image`s cheaply; no + array-texture trick needed) and bind whichever `Image` is "the current + layer" as the shader's sampler input for that draw call, rather than + indexing a layer uniform. This is a clean 1:1 behavioral substitution, + not a compromise — GPU texture arrays exist in WebGL primarily to allow + one bind call to serve many logical frames; Flutter's per-draw image + binding achieves the identical visible result with a different resource + model. +- Blending: SkSL/Impeller's default `Paint.blendMode = BlendMode.srcOver` + over a premultiplied-alpha destination is the direct analog of + `blendFunc(ONE, ONE_MINUS_SRC_ALPHA)`; keep the shader's own + `color * alpha` premultiply exactly as written so the two agree. + +### 3.3 Pixel-format consequence of the Rust decode core (v2) + +The v1 renderer design assumed an RGBA buffer arriving at the FFI/upload +boundary, because WebCodecs' `VideoFrame` performs YUV→RGB conversion +invisibly on the web side before this codebase's shader ever runs. The +`openh264`-based Rust core (§2(a)) decodes to **planar I420 (YUV 4:2:0)** +— one full-resolution Y (luma) plane and two quarter-resolution U/V +(chroma) planes — so the Flutter port must add an explicit conversion step +that has no analog in the original GLSL. + +Two placements were evaluated: + +- **GPU-side (in the `FragmentProgram` shader)**: bind the Y and U/V planes + as separate `sampler2D` inputs, apply the standard BT.709 conversion + matrix per-pixel (chroma upsampling comes "for free" via `LINEAR` texture + filtering on the half-resolution U/V planes), then proceed with the same + packed-alpha UV-remap/premultiply logic. Lower CPU cost, but it *extends* + the shader beyond what was validated in v1 and introduces new surface + (colorspace-matrix correctness, chroma-siting/upsampling choices) right + where the M6 spec's tightest quality gate (mean alpha error ≤2/255, p99 + ≤8/255) lives. +- **CPU-side (in Rust, immediately after `decode()`, before crossing FFI)**: + convert I420→RGBA in Rust (SIMD-accelerated — e.g. a hand-rolled NEON/ + AVX2 kernel or a small crate such as `yuvutils-rs`) so the buffer + crossing the FFI boundary is shape-identical to what WebCodecs produced + in v1. **Recommended.** This keeps the Flutter `FragmentProgram` an + *unchanged* transliteration of `FRAME_FRAGMENT_SHADER_SOURCE` (the + already-analyzed, lower-risk piece from §3.2), at the cost of one CPU + conversion pass per frame — comfortably inside budget at AVAL's typical + resolutions (grass-rabbit is 1280×720) on every target platform, + including WASM (where SIMD is available via the `wasm32` SIMD128 + target feature). + +Treat GPU-side YUV conversion as a legitimate **future optimization** once +the CPU-conversion path is shipped and certified, not a v1-parity +requirement — minimizing new, unvalidated shader surface during the +initial port. + +### Recommendation — implemented (compositing stage), upload stage still CPU +Native/FFI decode produces a raw packed RGBA buffer per frame — with the +Rust core (§2(a)/§3.3), that means I420→RGBA conversion happens in Rust, +SIMD-accelerated, immediately after `openh264::Decoder::decode()` — then +that buffer is uploaded as one `ui.Image` per decoded frame (via +`ImmutableBuffer`/`ImageDescriptor`, zero *additional* CPU compositing) → +`CustomPainter` binds it into a `FragmentProgram` shader that is a direct +line-by-line port of the GLSL above. This preserves the exact per-pixel +math (UV remap, dual-region sampling, in-shader premultiply) that the M6 +design doc's alpha-quality gate (mean error ≤2/255, p99 ≤8/255) was built +to protect, while replacing only the two constructs (`sampler2DArray`, +`gl_FragCoord`) that have no Impeller equivalent with behaviorally +identical Flutter-native substitutions. + +**Status**: the compositing half of this recommendation is built exactly as +described (`GpuFramePainter`, §3.2). The upload half is not yet — grass_rabbit +still uploads via `ui.decodeImageFromPixels` (`rabbit_controller.dart`), the +simplest CPU-copy path, not the zero-copy `ImmutableBuffer`/`ImageDescriptor` +route named here. Replacing that upload call is a small, isolated follow-up +that does not touch the shader or painter — the GPU compositing win is +already realized independent of it. + +--- + +## 4. Concurrency Model + +### The web model +`decoder-worker/protocol.ts` defines a versioned, structured-clone-safe +message union (`DecoderWorkerCommand`/`DecoderWorkerEvent`) between main +thread and a dedicated `Worker`, transported through the minimal +`DecoderWorkerMessagePort`/`DecoderWorkerClientPort` interfaces +(`postMessage(message, transfer?)`, `addEventListener`) — **not** `Worker`/ +`MessagePort` directly, which is exactly the seam a Dart port replaces. +`ArrayBuffer`s (access-unit bytes, command → worker) and `VideoFrame`s +(decoded output, worker → main) are transferred, not copied +(`postMessage(msg, [transferable])`). + +`FrameCreditLedger` (`decoder-worker/frame-credit-ledger.ts`, 89 lines, zero +platform dependency) is the backpressure primitive: it tracks every +transferred `VideoFrame` as a `FrameLease {generation, decodedBytes}` keyed +by an incrementing `frameId`. `hasSubmissionCredit(submittedFrames, max)` +gates whether the worker may decode another chunk +(`submittedFrames + leases.size < maxOutstandingFrames`, ≤12 per +`DECODER_WORKER_HARD_LIMITS`); `lease()` additionally enforces a decoded-byte +budget. `release(frameId)` is called when the main thread finishes with (and +closes) a frame, replenishing credit. This is the sole flow-control +mechanism bounding in-flight decoded frames, independent of WebCodecs' +own internal `decodeQueueSize`. + +### Rust-core concurrency model (v2, recommended) + +With the Rust decode core (§2(a)), the decoder-worker's Worker/postMessage +protocol is superseded by `flutter_rust_bridge`'s own concurrency model +rather than hand-rolled onto a Dart `Isolate`: + +| Web construct | Rust/Flutter equivalent (v2) | +|---|---| +| `Worker` (dedicated decoder worker) | A Rust-owned thread (or small thread pool) inside `aval_decode`, scheduled by `flutter_rust_bridge`'s generated async runtime — Dart calls an `async` FRB binding function, which the FRB runtime automatically dispatches off the Dart UI isolate onto a native thread; no manual `Isolate.spawn` needed for this purpose | +| `postMessage(msg, [transferable])` | A direct FRB-generated async function call (e.g. `await api.submitAccessUnit(handle, bytes, timestamp)`), or an FRB `StreamSink`-backed stream for push-style frame delivery | +| `addEventListener("message", ...)` | `Stream` returned by an FRB-generated streaming API (FRB v2 supports Rust→Dart streams natively) | +| `DecoderWorkerMessagePort`/`DecoderWorkerClientPort` interfaces | Not needed as hand-written seams — FRB generates the binding layer from `#[frb]`-annotated Rust functions; the *protocol shape* (configure/submit/take-frame/release/dispose) is still ported deliberately to match `protocol.ts`'s command/event vocabulary, just expressed as ordinary async Rust functions rather than a message union | +| Transferred `ArrayBuffer` (access-unit bytes, in) | A Dart `Uint8List` passed into the FRB call; FRB marshals it to Rust with as few copies as its codec allows (a `ZeroCopyBuffer`-style wrapper where supported) | +| Transferred `VideoFrame` (decoded output, out) | **Rust-owned buffer exposed as Dart external typed data**: the decoded (post-YUV→RGB, §3.3) RGBA buffer stays allocated in Rust; Dart receives a `Uint8List` view over that native memory (`Pointer.asTypedList(length)` or FRB's equivalent zero-copy wrapper) with a `NativeFinalizer` registered so the Rust allocation is freed automatically once the Dart-side `Uint8List`/`ui.Image` upload is done with it — no bytes are copied across the boundary, and lifetime is GC-safe rather than manually managed | +| `DecoderWorkerCommand`/`Event` unions | Ordinary Rust function signatures + an FRB-generated Dart API class; the command/event *vocabulary* (configure, activate-generation, submit, abort-generation, release-frame, snapshot, dispose) is preserved as the shape of that API, not reimplemented as a message-passing union | +| `FrameCreditLedger` | Ported to **Rust**, running inside `aval_decode` alongside the decoder itself — credit accounting now lives in the same language/runtime as the hot path it gates, eliminating one full cross-language round trip per credit check that a Dart-side ledger would have required | +| `AbortSignal`/`DOMException` (generation cancellation, `path-scheduler-generation.ts`) | A `CancellationToken`-equivalent still lives in the **Dart** `aval_player` package (it gates the pure-Dart path-scheduler, which is unaffected by the decode-engine language choice) and is threaded into FRB calls as a generation/epoch parameter the Rust side checks before acting — not itself ported into Rust | + +For Flutter Web, the same `aval_decode` crate's FRB-generated **web** +bindings use `wasm-bindgen`/`dart:js_interop` under the hood instead of +`dart:ffi` — from the `aval_player`/`aval_flutter` call sites, the async +API surface is identical on native and web; only the binding +implementation differs, which is exactly the point of building on +`flutter_rust_bridge` rather than a bespoke per-target FFI layer. (If the +default WebCodecs-via-interop web backend from §2 is used instead, this +Rust-core concurrency section does not apply on web at all — see the +plain-FFI alternative below for that case's shape.) + +### Alternative: plain-FFI Dart isolate mapping (if not using `flutter_rust_bridge`) + +If the team instead chooses `cbindgen` + hand-written `dart:ffi` bindings +over `flutter_rust_bridge` (more control, more manual glue, no generated +async/stream layer), the v1-style explicit isolate protocol still applies +and should be built deliberately rather than assumed away: + +| Web construct | Dart equivalent | +|---|---| +| `Worker` (dedicated decoder worker) | `Isolate` spawned via `Isolate.spawn`, making the FFI calls into `aval_decode`'s `cdylib` from within that isolate | +| `postMessage(msg, [transferable])` | `SendPort.send(msg)` | +| `addEventListener("message", ...)` | `ReceivePort.listen(...)` | +| Transferred `ArrayBuffer` (access-unit bytes) | `TransferableTypedData.fromList([bytes])` on the sending side, `.materialize()` on the receiving side | +| Transferred `VideoFrame` (decoded output) | Rust-owned buffer wrapped as external typed data (`NativeFinalizer`-backed `Uint8List`, as above), sent isolate-to-isolate via `TransferableTypedData` if a copy-free hop is needed, or read directly if the FFI calls already happen on the UI-adjacent isolate that owns the texture upload | +| `FrameCreditLedger` | Still best implemented in Rust (adjacent to the decoder) even in this configuration; the Dart isolate is a thin caller, not a reimplementation site | + +Run FFI decode on a **dedicated `Isolate`**, never the UI isolate, in this +configuration — the point is identical to why the web uses a `Worker`: +keep heavy decode work off the thread that must hit 60fps compositing +deadlines. (Under the recommended `flutter_rust_bridge` configuration +above, this guarantee comes from FRB's own threading rather than a +manually spawned isolate.) + +--- + +## 5. Widget API + +### 5.1 Full public surface to mirror + +From `packages/element/src/public-types.ts` / `element-public-events.ts` +(tag `aval-player`, API major version 1): + +**Configuration** (constructor params / settable properties on +`AvalPlayerController`): +`src: String`, `integrity: String?` (`sha256-`), `crossOrigin` +(not meaningful in Flutter — replace with an HTTP-headers/auth callback, +see below), `motion: AvalMotion` (`auto|reduce|full`), `autoplay` +(`visible|manual`), `fit: AvalFit?` (`contain|cover|fill|none`), +`bindings` (`auto|none`), `state: String?` (declarative initial/target +state name), `interactionFor`/`interactionTarget` equivalent (a Flutter +`FocusNode`/`GlobalKey` naming the widget subtree that receives +engagement input, defaulting to the `AvalPlayer` widget itself), `width`/ +`height`. + +**Read-only staged state** (`ValueListenable`/getters on the controller): +`readiness`, `mode` (`animated|static|null`), `assurance` +(`"best-effort"|null`), `staticReason`, `requestedState`, `visualState`, +`isTransitioning`, `paused`, `effectivelyVisible`, `stateNames`, +`eventNames`, `inputBindings`. + +**Methods** (mirror exactly): +``` +Future prepare({Duration? timeout, /* cancellation */}); +Future setState(String name); +bool send(String event); +bool readyFor(String state); +void pause(); +Future resume(); +AvalDiagnostics getDiagnostics({bool trace = false}); +Future dispose(); +``` +Semantics carry over exactly: `setState()` returns the graph-authored +settlement future; `send()` is synchronous fire-and-forget (`true` only if +accepted); `state` remains declarative — calling `setState()` imperatively +does not rewrite the widget's `state` constructor argument (consistent with +Flutter's own "controller state can diverge from widget config" pattern). + +**Events** (`Stream` per event, or one discriminated `Stream` +— recommend per-event broadcast streams to match the DOM `addEventListener` +ergonomics developers coming from the web version will expect): +`readinesschange {generation, from, to, reason?}`, +`requestedstatechange {generation, from, to, sequence}`, +`visualstatechange {generation, from, to}`, +`transitionstart {generation, edge, from, to, sequence?}`, +`transitionend {generation, edge, from, to, sequence?}`, +`underflow {generation, incident, heldPresentationOrdinal, cumulativeCount}`, +`fallback {generation, reason, requestedState, visualState}`, +`error {generation, failure: AvalPublicFailure, fatal}`. +Every payload carries `generation` and must never leak source +URLs/tokens/bodies/ETags/credentials (same contract as the web docs). + +### 5.2 Readiness lifecycle + +Port `RuntimeReadiness` exactly: `unready → metadataReady → visualReady → +{interactiveReady | staticReady} → disposed | error` (terminal from any +phase). Port `element-public-state.ts`'s `stageReadiness` derivation rule +verbatim: on `interactiveReady`, `mode="animated"`, +`assurance="best-effort"`; on `staticReady`, `mode="static"` and +`staticReason` derives from (in order) explicit reason → +`motion=="reduce"` or (`motion=="auto"` && OS reduced-motion) → +not-effectively-visible → previous reason → `"readiness-failed"`. + +### 5.3 Diagnostics API + +Port `AvalDiagnostics` (`public-types.ts`) as an immutable Dart data class +with the same nested groups: `runtime{}` (selected rendition/profile, +transport mode, byte accounting, decoder-lease state, reclamation/context-loss +counters), `motion{}`, `playIntent{}`, `visibility{}`, `presentation{}` (fit, +CSS vs. backing size, effective DPR, clamp reasons), `counters` +(`AvalDiagnosticsCounters`), `cleanup`/`elementOwnership`/`terminalCleanup` +(port as-is — these are pure bookkeeping, valuable for leak detection in +Flutter too), and `elementTrace`/`runtimeTrace` (only populated when +`trace: true`, capped at 512 records). Diagnostics must remain a pure +read — never trigger fetch/retry/prepare/graph-advance as a side effect. + +### 5.4 Engagement bindings + +The web's `automatic-inputs.ts` listens to `pointerenter/pointerleave/ +focusin/focusout/click` and explicitly **suppresses touch from hover +semantics** (`isTouchPointer` check on `PointerEvent.pointerType`), routing +touch taps to `click → activate` instead. **Flutter's `MouseRegion` +already implements this exact distinction natively** — `onEnter`/`onExit` +fire only for mouse-class pointers; touch does not generate hover events in +Flutter's gesture arena at all. This means the hard part of the web's +engagement logic (touch/hover disambiguation) is essentially free in +Flutter: + +- `pointer.enter`/`pointer.leave` → `MouseRegion(onEnter, onExit)`. +- `focus.in`/`focus.out` → `Focus(onFocusChange: ...)` wrapping the + interaction target subtree. +- `activate` (native `click`, which is also how the web handles touch taps) + → `GestureDetector(onTap: ...)`. +- `engagement.on`/`engagement.off` → OR-aggregate of the above three (port + `EngagementController.sample(pointer, focus)`'s edge-triggered emit logic + verbatim — emit only on boolean transitions, not on every input event). +- `visible`/`hidden` bindings → Flutter `VisibilityDetector`-style + intersection/`AppLifecycleState` observation, mirroring + `document-visibility-broker.ts`/`presentation-observer.ts`. + +**Proposed touch engagement mapping** (the web has no "hover" concept for +touch at all — it only maps taps to `activate`): default to the same +behavior for exact parity (`bindings="auto"` on touch = `activate` only, +no synthetic hover). Optionally expose a Flutter-only opt-in +`touchEngagementMode: none | tapToggle` where `tapToggle` maps a tap to +toggling `engagement.on`/`engagement.off` (useful for hover-only-authored +content on touch devices) — **default `none`** to keep byte-for-byte parity +with the web's documented behavior, and treat `tapToggle` as an explicitly +non-default, documented deviation. + +### 5.5 Fallback / two-layer model + +Port `shadow-layers.ts`'s invariant exactly: a persistent fallback/poster +layer beneath the animated layer, where the animated layer is **only ever +revealed after a first frame has actually been drawn** +(`markAnimatedDrawn` before `revealAnimated`, else throw) — in Flutter, +implement as a `Stack` with the poster `Image`/placeholder always mounted +underneath the `CustomPainter`-driven animated layer, gated by a +`ValueNotifier animatedDrawn` that the renderer sets exactly once per +generation after its first successful paint. Reduced-motion, fatal error, +and visibility-suspended all force the poster layer back to front, +mirroring `showFallbackAfterFatal`/`coverFallback`/`resetSource`. + +--- + +## 6. Package Layout + +``` +flutter/ +├── rust/ +│ └── aval_decode/ (Rust crate, workspace member — NEW in v2) +│ Wraps the `openh264` crate (BSD-licensed H.264 decoder). Exposes +│ a small async API — configure/submit_access_unit/take_frame/ +│ release_frame/dispose — that is a near-1:1 port of +│ decoder-worker/{protocol.ts, core.ts, frame-credit-ledger.ts, +│ sample-sequence.ts}, plus the I420→RGBA SIMD conversion step +│ (§3.3). Annotated with `#[frb]` for flutter_rust_bridge codegen. +│ Build targets: native cdylib/staticlib per platform triple +│ (iOS/Android/macOS/Windows/Linux) via cargokit, and wasm32 (via +│ flutter_rust_bridge's web codegen / wasm-bindgen) as an optional +│ web backend (§2). Depends on no other package in this tree — it +│ only needs raw Annex-B access-unit bytes in and SPS/PPS +│ parameters (extracted by aval_format) to configure the decoder. +│ +└── packages/ + ├── aval_graph/ (pure Dart, no Flutter dep) — ported in parallel + │ Deterministic state-graph reducer. Port of packages/graph/src: + │ MotionGraphEngine, model.ts (GraphStateDefinition, GraphEdgeDefinition, + │ GraphStartPolicy, GraphTransitionDefinition, GraphPresentation), + │ portal-search.ts, route-plan.ts, intent-router.ts, engine-state.ts. + │ Existing scaffold: pubspec.yaml, lib/src/errors.dart, lib/src/limits.dart. + │ + ├── aval_format/ (pure Dart, depends on aval_graph) — ported in parallel + │ Container parser + codec inspection. Port of packages/format/src: + │ header.ts, layout.ts, access-unit-index.ts, sample-plan.ts, model.ts, + │ manifest-*-schema.ts, reference-frame.ts, + │ avc/ (annex-b.ts, inspector.ts, parameter-sets.ts, rendition-geometry.ts, + │ canonicalize.ts, slice-header.ts, bit-reader.ts), + │ png/ (strict decode.ts, deflate*, unfilter.ts, crc32.ts, chunks.ts). + │ Existing scaffold: pubspec.yaml (depends on aval_graph via path), + │ lib/src/errors.dart. lib/src/avc/ and lib/src/png/ are empty — full + │ surface still to be ported. + │ + ├── aval_player/ (pure Dart + dart:ffi; NO Flutter/dart:ui dep) + │ The runtime engine. Port of packages/player-web/src/{decoder-worker,runtime} + │ minus anything WebGL2/DOM-specific. Depends on aval_graph + aval_format. + │ Defines the platform-seam interfaces a concrete backend must implement: + │ - DecoderAdapter (≈ WorkerVideoDecoderAdapter / PathSchedulerWorkerAdapter + │ — the concrete implementation calls into + │ flutter_rust_bridge-generated bindings for aval_decode) + │ - RendererBackend (≈ FrameRendererBackend, generic over the + │ concrete image/texture type so this package + │ stays dart:ui-free) + │ - NetworkAdapter (≈ range-asset-session.ts's fetch abstraction) + │ Contains: path-scheduler family, decode-timeline, edge-lead, rational-time, + │ submission-horizon, model.ts equivalent, motion-policy, reversible-presentation, + │ cut-presentation-coordinator, readiness-evaluator, presentation-geometry, + │ presentation-ring, frame-renderer orchestration shell, asset-catalog, + │ avc-candidate-factory, verified-blob-store, page-resource-manager, + │ integrated-player facade. Headless-testable (no widget tests needed for + │ ~90% of this package's logic). Note: the `FrameCreditLedger` itself now + │ lives in Rust (flutter/rust/aval_decode, §4), not here — aval_player's + │ DecoderAdapter only needs a thin async caller. + │ + └── aval_flutter/ (Flutter package; depends on aval_player, aval_format, aval_graph) + Platform bindings + widget layer. + - AvalPlayer widget, AvalPlayerController (ChangeNotifier) + - FragmentProgram-based CustomPainter implementing RendererBackend + - flutter_rust_bridge-generated DecoderAdapter implementation calling + flutter/rust/aval_decode (native FFI on iOS/Android/macOS/Windows/Linux; + wasm-bindgen/dart:js_interop on web if the WASM backend is selected), + plus a default dart:js_interop WebCodecs DecoderAdapter for web (§2), + and an optional platform-channel (VideoToolbox/MediaCodec) adapter + as a post-parity efficiency backend (§2(c)) + - dart:io/package:http NetworkAdapter implementation (Range requests, + ETag/integrity verification ported from format's checked-integer style) + - MouseRegion/Focus/GestureDetector engagement wiring (§5.4) + - getDiagnostics() surface, error-event Stream plumbing + - cargokit build-script integration (per-platform native builds) and + flutter_rust_bridge codegen invocation (`flutter_rust_bridge_codegen generate`) + wired into this package's build +``` + +**Dependency graph**: `aval_graph ← aval_format ← aval_player ← aval_flutter`, +with `flutter/rust/aval_decode` sitting *outside* that Dart dependency +chain entirely (it has no Dart-side dependency of its own; it is only +depended *on*, via generated bindings, from `aval_flutter`). `aval_flutter` +also depends directly on `aval_graph`/`aval_format` for type reuse, which +is transitively available anyway. + +Because `aval_graph` and `aval_format` are being ported by parallel +workers, `aval_player`'s implementation must bind against a **frozen Dart +interface contract**, not the TypeScript source directly: specifically +`MotionGraphEngine`'s public methods (`install/beginAnimated/resumeAnimated/ +request/send/tick/dispose/failStatic/recoverStatic`) and its `model.ts` +types, plus `aval_format`'s parsed manifest/access-unit-index types and the +`AvcIncrementalInspector`/PNG decoder outputs. Recommend a short contract +freeze meeting before Phase 2 (§7) begins, validated by cross-running the +TypeScript packages' own golden test fixtures translated 1:1 into the Dart +packages' test suites (not reinvented from scratch). Similarly, freeze +`aval_decode`'s FRB-exposed async API surface (configure/submit_access_unit/ +take_frame/release_frame/dispose signatures and error/result types) before +`aval_player`'s `DecoderAdapter` implementation is written against it, so +the Rust crate and its Dart caller can be built in parallel too. + +--- + +## 7. Phased Implementation Plan + +Each phase lists exit criteria and the exact source modules it ports. +Session estimates assume focused agent-driven implementation sessions (not +wall-clock days) and **exclude** `aval_graph`/`aval_format` work already +covered by the parallel porting effort, though several phases gate on that +work being complete. + +| # | Phase | Ports from | Exit criteria | Est. sessions | +|---|---|---|---|---| +| 0 | **Scaffolding & contracts** | n/a | `flutter analyze` green across all 4 packages; `DecoderAdapter`/`RendererBackend`/`NetworkAdapter` abstract interfaces defined in `aval_player`; empty `AvalPlayer` widget compiles in a demo app | 1 | +| 1 | **Parse grass-rabbit.avl** | (integration checkpoint on `aval_format`/`aval_graph`) | Dart parses `examples/grass-rabbit/public/grass-rabbit.avl` header+manifest+access-unit-index; unit/state/edge/rendition lists match the TS parse of the same file (cross-check against `grass-rabbit.avl.build.json`) | 1 | +| 2 | **Path scheduler ported (no real decode/render)** | `rational-time.ts`, `decode-timeline.ts`, `edge-lead.ts`, `submission-horizon.ts`, `path-scheduler*.ts` (all), `path-sequence.ts` | Given a fake `DecoderAdapter`, the scheduler produces the identical `PathFramePlan` sequence as the TS version for grass-rabbit's `motion.json` edges (golden-trace diff), including loop wrap and portal boundary selection | 2–3 | +| 3 | **Native AVC decode of one access unit (Rust core)** | New `aval_decode` Rust crate (`flutter/rust/aval_decode`) wrapping `openh264`; `avc/annex-b.ts`+`parameter-sets.ts` (from `aval_format`, for SPS/PPS extraction); the I420→RGBA SIMD conversion step (§3.3) | `openh264::Decoder` decodes one IDR access unit from grass-rabbit's packed-alpha rendition; the Rust-side I420→RGBA conversion output visually/PSNR-matches a browser-captured reference frame at the same index; native FFI call from Dart via `flutter_rust_bridge` round-trips successfully on at least one desktop platform | 2–3 | +| 3b | **WASM decode spike (validation only)** | Same `aval_decode` crate, `wasm32-unknown-unknown` target via `flutter_rust_bridge` web codegen | `openh264-sys2`'s C build compiles for `wasm32` and decodes the same access unit as Phase 3 in a browser (Flutter Web or plain wasm-bindgen harness) with matching output; **explicitly a go/no-go spike** — if this fails or the toolchain proves too fragile, fall back to WebCodecs-via-interop as the *only* web backend (§2) rather than blocking on it | 1–2 | +| 4 | **"Grass-rabbit renders one frame"** (named milestone) | `avc/rendition-geometry.ts` (packing rects), new `FragmentProgram` shader (§3.2, unchanged from v1 given the CPU-side YUV conversion in Phase 3) | One static packed-alpha frame renders pixel-correct (unpack, premultiply, color) in a minimal Flutter app, comparable side-by-side to a browser screenshot at the same frame index | 2 | +| 5 | **Decode concurrency + frame-credit + streaming decode-ahead (Rust-hosted)** | `decoder-worker/{protocol.ts,frame-credit-ledger.ts,core.ts,client.ts,sample-sequence.ts}` ported into `aval_decode` (Rust) rather than a Dart isolate protocol (§4); `flutter_rust_bridge` async/stream bindings wired into `aval_player`'s `DecoderAdapter` | grass-rabbit's `idle-loop` unit (frames 30–100) seekless-loops continuously in Flutter at 24fps with decode-ahead; rational timestamps show zero drift after 10 minutes of looping vs. the TS `DecodeTimeline` formula; Rust-side `FrameCreditLedger` correctly backpressures submission | 3–4 | +| 6 | **Presentation ring + resident/streaming split + geometry** | `presentation-ring.ts`, `presentation-geometry.ts` | No visible stutter/frame-drop at 24fps on a representative low-end device; `contain/cover/fill` fit modes visually match web reference screenshots | 2 | +| 7 | **Graph integration: one-shot intro + motion policy** | `model.ts`, `motion-policy.ts`, `integrated-player.ts` facade (subset), `aval_graph`'s `MotionGraphEngine` | Cold start plays the `intro` one-shot unit exactly once, then joins `idle-loop`; presentation-kind sequence matches the TS reference trace | 2–3 | +| 8 | **Portal transitions + engagement bindings** | `submission-horizon.ts`'s portal logic (already ported in Phase 2, now wired live), `MouseRegion`/`Focus`/`GestureDetector` wiring (§5.4) | Hovering the widget triggers `idle→entering→hover` at exactly authored portal frame 69 (frame-accurate, verified via trace capture); hover-leave triggers `hover→exiting→idle` identically. This is the task's named "frame-accurate portal transitions" milestone | 3 | +| 9 | **Widget API + readiness/events/diagnostics parity** | `element/src/*` (public-types, element-public-state, diagnostics, error taxonomy, shadow-layers two-layer model) | A Flutter demo reproduces `examples/grass-rabbit/main.js` behavior (one-shot hint-icon dismissal, state-label badge via `visualstatechange`, readiness-gated first paint) with equivalent Dart code | 2 | +| 10 | **Remaining transition types + cross-platform hardening** | `cut-presentation-coordinator.ts`, `reversible-presentation.ts`, `page-resource-manager.ts`/`page-reclamation.ts`, `verified-blob-store.ts` (sha256/integrity), `browser-context-recovery.ts`'s terminal-loss policy | Cut and reversible transitions work on a synthetic test asset (grass-rabbit itself doesn't exercise them); certification-style test matrix passes on iOS/Android/macOS/Windows/Linux with no dropped frames at portal boundaries under simulated jitter | 4–6 | +| 11 | **Performance/parity certification** | n/a (validation phase) | Automated pixel-diff harness vs. browser reference frames across the full `motion.json`, passing the M6 alpha-quality gate (mean ≤2/255, p99 ≤8/255); memory/GPU budget audit; docs finalized | 2–3 | +| 12 | **GPU-accelerated rendering (`FragmentProgram` compositor)** (§3.2) | New `shaders/frame.frag`, `GpuFramePainter` | ✅ **Done** in `flutter/examples/grass_rabbit` — shader compositing replaces the CPU `Canvas.drawImageRect` path; verified rendering correctly on macOS. Upload stage (`ui.decodeImageFromPixels` → `ImmutableBuffer`/`ImageDescriptor`) remains a small open follow-up (§3, Recommendation) | done | +| 13 | **Hardware decode backend (VideoToolbox)** (§2(c)) | New `DecoderAdapter` trait + `VideoToolboxAdapter` in `aval_decode`; VideoToolbox/CoreMedia/CoreVideo C API bindings | `DecoderAdapter` trait introduced with `openh264` as its first implementor (behavior-preserving); `VideoToolboxAdapter` decodes the same grass-rabbit access units as Phase 3, output byte-identical (or within the M6 alpha/color gate) to the OpenH264 path through the unchanged `aval_decode_take_frame` FFI contract; measured battery/CPU improvement on at least one iOS device | 3–4 | +| 14 | **Hardware decode backend (MediaCodec, Android)** (§2(c)) | JNI bindings to Android `MediaCodec` | Same exit bar as Phase 13, ported to Android's JNI-based API instead of a C ABI; gated on Phase 13 validating the `DecoderAdapter` split cleanly | 3–4 | + +**Total estimate: ~27–34 agent sessions** (v2 adds the ~1–2 session Phase +3b WASM spike vs. v1's estimate), excluding the parallel +`aval_graph`/`aval_format` porting effort (Phase 1 gates on that work). +Phases 12–14 (GPU rendering, hardware decode) are additive efficiency work +layered on top of that estimate, not part of the original v1/v2 scope. + +--- + +## 8. Risk Register + +| # | Risk | Mitigation | +|---|---|---| +| 1 | Decoder latency variance across platforms/hardware breaks frame-accurate portal/finish timing | Never gate transition commit on wall-clock decode latency — port `edge-lead.ts`'s `leadReady`/`planEdgeLead` exactly: transitions commit only once required lead frames are *already resident*, so latency variance affects buffering depth, never frame accuracy. Tune per-platform ring-capacity/decode-ahead defaults rather than reusing the web's fixed constants | +| 2 | *(v2, largely resolved)* v1's ffmpeg-licensing risk is avoided by choosing `openh264` (BSD-2-Clause) as the primary decoder — no GPL/LGPL build-config concerns. Residual nuance: Cisco's *prebuilt* binaries carry MPEG-LA patent-royalty coverage for redistribution that a from-source cross-compiled build (as this crate does) may not automatically carry | Confirm patent-licensing posture with counsel for a from-source `openh264` build specifically (not just copyright licensing, which is clean); if that posture is unacceptable, the demoted ffmpeg-FFI alternative (§2(b)) or the platform-channel backend (§2(c)) remain available behind the same `DecoderAdapter` interface | +| 3 | `FragmentProgram`/Impeller platform coverage gaps or SkSL-vs-GLSL semantic differences (precision, `FlutterFragCoord()` origin, texture wrap modes) cause pixel mismatches vs. the WebGL2 reference | Build an automated pixel-diff golden-frame harness (Phase 11) comparing Flutter-rendered frames against browser-captured reference frames per commit, mirroring `docs/certification/1.0.0`; treat any diff exceeding the M6 spec's alpha-quality gate as build-blocking | +| 4 | FRB/FFI call round-trip latency (or, in the plain-FFI alternative, isolate message-passing latency) exceeds the frame-credit ledger's assumed timing budgets, causing jitter the web version doesn't have | Benchmark FRB async-call round-trip latency early (Phase 3/5) before committing to ring-capacity constants; running the credit ledger *inside Rust* (§4) removes one cross-language hop per credit check versus a Dart-side ledger, but the Dart↔Rust call boundary itself still needs measuring, not assuming, parity with the web's `postMessage` | +| 5 | No existing Flutter plugin (`video_player`, `media_kit`) supports arbitrary-AU random-access decode from a custom container — this is being built from scratch, though the Rust `openh264` binding itself is a maintained, production-used crate (e.g. in WebRTC-adjacent Rust projects), reducing decoder-correctness risk relative to a from-scratch decoder | Treat Phase 3 (native decode spike) as the highest-priority, timeboxed validation before committing further engineering; `media_kit`'s FFI/packaging patterns remain a useful reference for cargokit-style build integration even though its player abstraction itself is rejected | +| 6 | Behavioral drift between the parallel-ported `aval_graph`/`aval_format` and what `aval_player` actually needs | Freeze the exact Dart interface contract (§6) before Phase 2 begins; validate against the TypeScript packages' own golden test fixtures translated 1:1, not reinvented | +| 7 | WebGL2 `sampler2DArray` has no Impeller equivalent, risking silent behavioral differences in resident/streaming frame selection | Explicit documented substitution (§3.2): separate bound `ui.Image` objects per logical layer instead of array-texture layers; validate via the same golden-frame harness used for risk #3 | +| 8 | Reduced-motion signal differs across platforms (web `prefers-reduced-motion` vs. Flutter's `MediaQuery.disableAnimations` vs. each OS's real accessibility API) | Enumerate each platform's true reduced-motion signal early (iOS "Reduce Motion", Android "Remove animations", not just Flutter's `MediaQuery` proxy which may not reflect the OS setting identically everywhere); design `AvalMotion.auto` against real per-platform queries | +| 9 | Ported byte-budget/resource accounting (M2 spec's ≤24/48/64 MiB caps) has no real GPU-memory-pressure signal wired in, risking OOM on mobile in ways the web version never needed to handle | Port the byte-budget math as-is but add platform memory-pressure hooks (iOS `didReceiveMemoryWarning`, Android `onTrimMemory`) into the ported `page-reclamation.ts` coordinator | +| 10 | *(v2, decided)* Flutter Web has two viable decode backends (WebCodecs-via-interop, WASM-compiled `aval_decode`) that could drift from each other if both are maintained long-term | Ship WebCodecs-via-`dart:js_interop` as the *only* default web backend (§2) — it is hardware-accelerated and is the literal reference implementation; keep the WASM Rust-core path as an explicitly optional, separately-gated build target (Phase 3b) for certification/offline/no-WebCodecs scenarios only, not a second backend maintained at parity by default | +| 11 | *(v2, now actively planned — see Phase 13/14)* `openh264` is a **software** decoder with no hardware-acceleration path, costing more CPU/battery than a hardware decoder on mobile, especially across AVAL's long-running hover/idle loop content | Ship OpenH264 as the default for v1 parity (correctness first); the `DecoderAdapter` trait does not exist in code yet, so Phase 13 introduces it (openh264 as first implementor) before adding `VideoToolboxAdapter` behind it, gated on measured battery/thermal impact rather than assumed | +| 12 | *(new, v2)* `openh264-sys2`'s `wasm32` C-toolchain build path is not battle-tested for this project; the Phase 3b spike could fail or prove fragile to maintain across Rust/Emscripten/`wasm-bindgen` toolchain upgrades | Timebox Phase 3b explicitly as a go/no-go spike (§7); if it fails, permanently drop the WASM-Rust web backend and rely solely on WebCodecs-via-interop for web (already the recommended default) — no architecture changes needed elsewhere if this path is abandoned | +| 13 | *(new, v2)* The I420→RGBA conversion step (§3.3), which has no analog in the original WebGL2 pipeline, could introduce color-space or chroma-upsampling errors (wrong BT.709 coefficients, incorrect chroma siting) invisible until compared against the browser reference | Validate the Rust-side I420→RGBA conversion against the same M6 alpha/color-quality gate (mean ≤2/255, p99 ≤8/255) using the Phase 4/11 pixel-diff harness before trusting it as "done"; treat this conversion as a first-class, independently-tested unit (input: a known I420 test pattern; expected: a known RGBA output), not an incidental detail of the decode step | + +--- + +## Appendix: Key Type/File Citations + +- `packages/player-web/src/decoder-worker/protocol.ts` — `DecoderWorkerCommand`/`DecoderWorkerEvent`, `DecoderWorkerSample`, `DECODER_WORKER_HARD_LIMITS` +- `packages/player-web/src/decoder-worker/frame-credit-ledger.ts` — `FrameCreditLedger`, `FrameLease`, `hasSubmissionCredit` +- `packages/player-web/src/runtime/path-scheduler.ts` (909 LOC) — `PathScheduler`, `#calculateRouteDecision`, `#beginReplacementGeneration` +- `packages/player-web/src/runtime/submission-horizon.ts` (599 LOC) — `planSubmissionHorizon`, `SubmissionHorizonDecision` +- `packages/player-web/src/runtime/decode-timeline.ts` — `DecodeTimeline`, `#nextOrdinal`, `activateNextGeneration` +- `packages/player-web/src/runtime/rational-time.ts` — `RationalFrameRate`, `timestampForFrame`, `divideRoundHalfUp` +- `packages/player-web/src/runtime/frame-renderer-browser.ts` (905 LOC) — `FRAME_FRAGMENT_SHADER_SOURCE`, `BrowserFrameBackend` +- `packages/player-web/src/runtime/cut-presentation-coordinator.ts` (946 LOC) — `CutPresentationCoordinator`, `#commitStagedActivation` +- `packages/player-web/src/runtime/reversible-presentation.ts` — `ReversiblePresentationCoordinator` +- `packages/player-web/src/runtime/integrated-player.ts` (1,029 LOC) — `IntegratedPlayer` +- `packages/graph/src/model.ts` / `engine.ts` — `MotionGraphEngine`, `GraphStartPolicy`, `GraphPresentation` +- `packages/format/src/avc/rendition-geometry.ts` — `deriveAvcRenditionGeometryFromVisibleAtPath`, `PACKED_ALPHA_GUTTER` +- `packages/element/src/public-types.ts` / `element-public-events.ts` — `AvalElementEventMap`, `AvalDiagnostics` +- `packages/element/src/automatic-inputs.ts` — `INPUT_EVENTS`, `isTouchPointer` +- `examples/grass-rabbit/motion.json` — reference state graph (5 units, 4 states, 5 edges) +- `flutter/packages/aval_graph`, `flutter/packages/aval_format` — existing scaffolds (pubspec + `errors.dart`/`limits.dart` only) + +### External Rust/Flutter ecosystem references (v2) + +- `openh264` / `openh264-sys2` (Rust bindings to Cisco's BSD-2-Clause + OpenH264 codec) — primary decode engine, §2(a) +- `flutter_rust_bridge` — Rust↔Dart binding codegen with native FFI *and* + web (`wasm-bindgen`) targets from one Rust source; recommended over + plain `cbindgen`+`dart:ffi` for its built-in async/stream support and + dual native/web codegen +- `cargokit` — per-platform Cargo build integration for Flutter plugins + (iOS/Android/macOS/Windows/Linux native builds); used to compile + `flutter/rust/aval_decode` into each platform's build system +- Dart `NativeFinalizer` / `Pointer.asTypedList` — the zero-copy, + GC-safe mechanism for exposing Rust-owned decoded-frame buffers to Dart + as external typed data (§4) diff --git a/flutter/examples/README.md b/flutter/examples/README.md new file mode 100644 index 0000000..b7a1091 --- /dev/null +++ b/flutter/examples/README.md @@ -0,0 +1,55 @@ +# AVAL Flutter examples + +Flutter ports of the AVAL web examples (`../../examples/*`). Each Flutter +example is a self-contained app that reuses the shared pure-Dart packages +(`packages/aval_graph`, `packages/aval_format`) and — where decode is involved — +the Rust `aval_decode` core via `dart:ffi`. + +## Parity table + +The seven web examples, what each demonstrates, and the current Flutter status. + +| Web example (`examples/…`) | What it demonstrates | Flutter status | +|---|---|---| +| **grass-rabbit** | The canonical reference consumer: one 1280×720/24fps `.avl` with five units (`intro`, `idle-loop`, `hover-in`, `hover-loop`, `hover-out`) and four states (`idle`/`entering`/`hover`/`exiting`). Auto-loads via `@pixel-point/aval-element`, reveals on readiness, tracks the intro one-shot, updates a state badge from `visualstatechange`, and dismisses a one-shot interaction hotspot on first hover. | **Partial — `grass_rabbit/`.** FFI decode of **all five units** (Dart↔Rust round-trip, 311 AUs), graph-driven unit playback (`intro`→`idle-loop`; hover plays `hover-in`→`hover-loop`→`hover-out` so the rabbit actually hops in), 24fps Ticker `CustomPainter`, and a hover-driven state label. Unit switching is an **approximation** — frame-accurate portal scheduling is pending `aval_player`. macOS only; opaque asset so no packed-alpha unpack. See its README. | +| **idle-hover-states** | Illustrative two-state (`idle`/`selected`) authored asset with hover/engagement bindings; asset is a placeholder not checked in. | Not started. | +| **zero-config-loop** | Zero-configuration looping asset (`orbit.avl` + `orbit.png` poster) with no author code — the element just loops; asset placeholders. | Not started. | +| **plain-html** | Framework-free HTML/CSS/JS integration with a package-aware dev server, no inline-script/style CSP exception; asset placeholders. | Not started. | +| **react-ref** | React integration kept at the app boundary: the public custom-element definition function, a typed ref, native DOM event listeners, a controlled authored `state`, and an author-owned slotted fallback — no React wrapper package. | Not started. | +| **network-integrity** | Loading one immutable hosted asset over the network with SHA-256 integrity verification and a fallback image; origin/asset/token are placeholders (not a live endpoint). | Not started. | +| **end-user-playground** | Permanent, checked-in two-state asset exercising the full public `@pixel-point/aval-element` API: hover/focus input bindings plus buttons that toggle `idle`/`engaged`. | Not started. | + +## Running + +Use the shared runner from `flutter/`: + +```sh +./scripts/run.sh [example] [flutter-run-args…] # default example: grass_rabbit +``` + +It builds the Rust decode core (`cargo build --release`) and launches the chosen +example with `--dart-define=AVAL_DECODE_LIB=`. + +## Notes + +- macOS is the only target that must work at this stage. +- `aval_player` (the runtime engine package) is under active development and is + intentionally **not** a dependency of these examples yet — they wire the graph + and the decoder directly. + +## rings_eight_way + +Compass demo for **rings + turn edges** (Dart `planFor` / sequential `request`). + +```bash +cd flutter/examples/rings_eight_way && flutter pub get && flutter run -d chrome +``` + +## rings_climbing + +Advanced climbing **motion atlas** demo (Grok Imagine stills + stamina ring + +action spokes). See `rings_climbing/README.md`. + +```bash +cd flutter/examples/rings_climbing && flutter pub get && flutter run -d chrome +``` diff --git a/flutter/examples/grass_rabbit/.gitignore b/flutter/examples/grass_rabbit/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/flutter/examples/grass_rabbit/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/flutter/examples/grass_rabbit/.metadata b/flutter/examples/grass_rabbit/.metadata new file mode 100644 index 0000000..339f86d --- /dev/null +++ b/flutter/examples/grass_rabbit/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "924134a44c189315be2148659913dda1671cbe99" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + - platform: web + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter/examples/grass_rabbit/README.md b/flutter/examples/grass_rabbit/README.md new file mode 100644 index 0000000..53aa339 --- /dev/null +++ b/flutter/examples/grass_rabbit/README.md @@ -0,0 +1,117 @@ +# grass_rabbit (Flutter, macOS) + +The first **runnable** Flutter example of the AVAL port. It proves the +Phase 3 exit criterion — a live Dart↔Rust FFI decode round-trip — and shows +real decoded frames looping while the pure-Dart state graph reacts to hover. + +What it does, end to end: + +1. **Loads** `assets/grass-rabbit.avl` as a bundled Flutter asset. + > This file is a **copy** of the web example's asset, + > `examples/grass-rabbit/public/grass-rabbit.avl` (1.13 MB). Keep them in sync + > if the source asset is regenerated. +2. **Parses** it with `package:aval_format` (`parseFrontIndex`): header, + JSON manifest, and the fixed-record access-unit index. +3. **Installs** the parsed graph into `aval_graph`'s `MotionGraphEngine` + (`install` → `beginAnimated`) and surfaces the state names + current state. +4. **Decodes** all five units (`intro`, `idle-loop`, `hover-in`, `hover-loop`, + `hover-out` — 311 access units total) through the Rust `aval_decode` core via + **hand-written `dart:ffi`** bindings (`lib/src/aval_ffi.dart`, a 1:1 mirror of + `rust/aval_decode/src/ffi.rs`): per unit, `configure` → `activate_generation` + → per-AU `submit_access_unit` / `take_frame` / `release_frame`. Each RGBA + buffer becomes a `ui.Image`, bucketed by unit id. +5. **Displays** the frames graph-driven, painted by a Ticker-driven + `CustomPainter` gated to the manifest frame rate (24 fps), `BoxFit.contain`. + The displayed unit follows the graph state: `intro` on start, then + `idle → idle-loop` (loop), `entering → hover-in` (finite, holds last frame), + `hover → hover-loop` (loop), `exiting → hover-out` (finite). Hovering makes the + rabbit actually hop in — the rabbit is authored into the hover units, not idle. +6. **Reacts to hover**: a `MouseRegion` over the video sends + `hover.enter` / `hover.leave` to the graph; the label shows the committed + `visualState` and, while a transition is pending, the `requestedState` + (`idle → entering → hover → exiting`). + +## Run + +From `flutter/`: + +```sh +./scripts/run.sh # builds aval_decode, then flutter run -d macos +# or explicitly: +./scripts/run.sh grass_rabbit -d macos +``` + +The runner builds the Rust dylib (`cargo build --release`) and passes its +absolute path via `--dart-define=AVAL_DECODE_LIB=`. The Dart side reads +it with `String.fromEnvironment('AVAL_DECODE_LIB')` and falls back to +`../../rust/aval_decode/target/release/libaval_decode.dylib` (relative to this +example dir) when the define is absent. + +## Verifying without a display + +```sh +# Full FFI decode round-trip (Phase 3 proof), headless: +AVAL_DECODE_LIB= dart run tool/decode_check.dart +# Manifest / access-unit inspection: +dart run tool/inspect.dart +# Parse + graph-reaction + widget smoke tests: +flutter test +# The macOS build (exit criterion): +flutter build macos --debug --dart-define=AVAL_DECODE_LIB= +``` + +## macOS sandbox + +`com.apple.security.app-sandbox` is set to **`false`** in both +`macos/Runner/DebugProfile.entitlements` and `macos/Runner/Release.entitlements`. +A sandboxed macOS app may only `dlopen()` libraries from inside its own +`.app` bundle. This milestone loads the freshly-built cargo artifact from an +**arbitrary absolute path** in the repo tree (the one passed via +`--dart-define`), which the sandbox would block. A production build would bundle +the dylib inside the app and re-enable the sandbox; that packaging (cargokit / +flutter_rust_bridge) is a later phase. + +## Known simplifications (this milestone) + +- **Opaque asset, no packed-alpha unpack.** `grass-rabbit.avl`'s rendition is + `avc-annexb-opaque-v1` (`AvcOpaqueRenditionV01`, coded 1280×720) — a plain + opaque video with **no** alpha pane. The packed-alpha vertical-stacking + layout (color / 8 px gutter / alpha) described in ARCHITECTURE.md §3.1 does + **not** apply to this asset, so no CPU unpack or premultiply is performed; the + decoded RGBA (alpha = 255 from the Rust core) is displayed directly. The full + packed-alpha `FragmentProgram` shader is a later phase. +- **Graph-driven unit playback is an approximation, not frame-accurate.** The + displayed unit follows `visualState` (see step 5), and the unit-local frame + counter is reset whenever the unit changes. This is *not* the true Phase 7–8 + portal-frame-accurate scheduling (that arrives with `aval_player`): the switch + happens when the graph commits the state, not at an exact authored portal + frame, and the video's frame counter is independent of the graph's + `presentation.frameIndex`. Good enough to see the rabbit hop in/out on hover; + not certified frame parity. +- **Decode-ahead / frame-credit streaming not used.** All 311 frames (across all + five units) are decoded up front into `ui.Image`s, one decoder session per unit + (each native frame released synchronously right after the copy). The Rust + `FrameCreditLedger` is exercised but never stressed. Phase 5 wires streaming + decode-ahead. +- **`NativeFinalizer` is on the session, not per frame.** `aval_decode_release_frame` + takes two arguments (handle + frame_id) and so cannot serve as a single-token + `NativeFinalizerFunction` callback; frames are released manually after copy. + The `NativeFinalizer` is instead wired to `aval_decode_session_destroy` + (single-pointer signature — exact ABI match) as a GC-safe backstop for the + session handle. See `lib/src/aval_ffi.dart`. +- **macOS only.** Other platforms are out of scope for this milestone. +- **No `aval_player` dependency.** That package is under active development by + another worker and is deliberately not depended on yet. + +## Layout + +``` +lib/ + main.dart UI: Ticker, CustomPainter video, MouseRegion, state badge + src/aval_ffi.dart hand-written dart:ffi bindings for aval_decode's C ABI + src/rabbit_controller.dart parse + FFI decode + graph orchestration +tool/ + inspect.dart prints manifest / access-unit facts + decode_check.dart headless FFI decode round-trip proof +assets/grass-rabbit.avl copy of the web example's asset +``` diff --git a/flutter/examples/grass_rabbit/analysis_options.yaml b/flutter/examples/grass_rabbit/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/flutter/examples/grass_rabbit/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/flutter/examples/grass_rabbit/assets/audio/great.m4a b/flutter/examples/grass_rabbit/assets/audio/great.m4a new file mode 100644 index 0000000..0892ae9 Binary files /dev/null and b/flutter/examples/grass_rabbit/assets/audio/great.m4a differ diff --git a/flutter/examples/grass_rabbit/assets/audio/hi.m4a b/flutter/examples/grass_rabbit/assets/audio/hi.m4a new file mode 100644 index 0000000..78da5e6 Binary files /dev/null and b/flutter/examples/grass_rabbit/assets/audio/hi.m4a differ diff --git a/flutter/examples/grass_rabbit/assets/audio/idle-loop.m4a b/flutter/examples/grass_rabbit/assets/audio/idle-loop.m4a new file mode 100644 index 0000000..f90fb3f Binary files /dev/null and b/flutter/examples/grass_rabbit/assets/audio/idle-loop.m4a differ diff --git a/flutter/examples/grass_rabbit/assets/grass-rabbit.avl b/flutter/examples/grass_rabbit/assets/grass-rabbit.avl new file mode 100644 index 0000000..5491aae Binary files /dev/null and b/flutter/examples/grass_rabbit/assets/grass-rabbit.avl differ diff --git a/flutter/examples/grass_rabbit/ios/.gitignore b/flutter/examples/grass_rabbit/ios/.gitignore new file mode 100644 index 0000000..41a7895 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/.gitignore @@ -0,0 +1,35 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 +Flutter/AvalDecode.local.xcconfig diff --git a/flutter/examples/grass_rabbit/ios/Flutter/AppFrameworkInfo.plist b/flutter/examples/grass_rabbit/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/flutter/examples/grass_rabbit/ios/Flutter/AvalDecode.xcconfig b/flutter/examples/grass_rabbit/ios/Flutter/AvalDecode.xcconfig new file mode 100644 index 0000000..b296819 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Flutter/AvalDecode.xcconfig @@ -0,0 +1,6 @@ +// Force-load the Rust aval_decode static library into Runner so Dart can +// resolve symbols via DynamicLibrary.process() (iOS forbids host-path dlopen). +// Path is written by scripts/run.sh before `flutter run` / `flutter build ipa`. +// +// Do not commit AvalDecode.local.xcconfig (generated); this file is the include hook. +#include? "AvalDecode.local.xcconfig" diff --git a/flutter/examples/grass_rabbit/ios/Flutter/Debug.xcconfig b/flutter/examples/grass_rabbit/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..0c7c0d7 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Generated.xcconfig" +#include "AvalDecode.xcconfig" diff --git a/flutter/examples/grass_rabbit/ios/Flutter/Release.xcconfig b/flutter/examples/grass_rabbit/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..0c7c0d7 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Generated.xcconfig" +#include "AvalDecode.xcconfig" diff --git a/flutter/examples/grass_rabbit/ios/Native/libaval_decode.a b/flutter/examples/grass_rabbit/ios/Native/libaval_decode.a new file mode 100644 index 0000000..70db1dd Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Native/libaval_decode.a differ diff --git a/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.pbxproj b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0c7e10e --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,706 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + ADEC0DE1A000000100000001 /* AvalDecodeLink.m in Sources */ = {isa = PBXBuildFile; fileRef = ADEC0DE1A000000100000002 /* AvalDecodeLink.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + ADEC0DE1A000000100000002 /* AvalDecodeLink.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AvalDecodeLink.m; sourceTree = ""; }; + ADEC0DE1A000000100000004 /* libaval_decode.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libaval_decode.a; path = Native/libaval_decode.a; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + ADEC0DE1A000000100000005 /* Native */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + ADEC0DE1A000000100000002 /* AvalDecodeLink.m */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + ADEC0DE1A000000100000005 /* Native */ = { + isa = PBXGroup; + children = ( + ADEC0DE1A000000100000004 /* libaval_decode.a */, + ); + name = Native; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ADEC0DE1A000000100000001 /* AvalDecodeLink.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = KL43C67G5Z; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Native", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-laval_decode", + "-lc++", + "-framework", + VideoToolbox, + "-framework", + CoreMedia, + "-framework", + CoreVideo, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.grassRabbit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.grassRabbit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.grassRabbit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.grassRabbit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = KL43C67G5Z; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Native", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-laval_decode", + "-lc++", + "-framework", + VideoToolbox, + "-framework", + CoreMedia, + "-framework", + CoreVideo, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.grassRabbit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = KL43C67G5Z; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Native", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-laval_decode", + "-lc++", + "-framework", + VideoToolbox, + "-framework", + CoreMedia, + "-framework", + CoreVideo, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.grassRabbit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..445444b --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner/AppDelegate.swift b/flutter/examples/grass_rabbit/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/flutter/examples/grass_rabbit/ios/Runner/AvalDecodeLink.m b/flutter/examples/grass_rabbit/ios/Runner/AvalDecodeLink.m new file mode 100644 index 0000000..88df94b --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/AvalDecodeLink.m @@ -0,0 +1,50 @@ +// Forces the linker to pull aval_decode C ABI symbols from libaval_decode.a +// into the Runner binary so Dart DynamicLibrary.process() can resolve them. +// Without this, dead-strip drops the static archive (nothing in Swift/ObjC +// references those symbols). + +#import +#import + +extern void *aval_decode_session_create(void); +extern void aval_decode_session_destroy(void *); +extern int32_t aval_decode_configure(void *, const void *); +extern int32_t aval_decode_activate_generation(void *, uint64_t); +extern int32_t aval_decode_submit_chunk(void *, uint64_t, const void *, void *); +extern int32_t aval_decode_take_frame(void *, void *); +extern int32_t aval_decode_release_frame(void *, uint64_t); +extern int32_t aval_decode_dispose(void *); + +// A local (stack) array of these function pointers is not enough to keep the +// archive members linked in: Clang proved the array was never read after its +// last write and dead-store-eliminated the whole thing at compile time, even +// though it was declared `volatile` — so no undefined-symbol reference ever +// reached the object file, and the linker never pulled in the Rust code. +// A `used` global with external linkage survives both that compile-time DCE +// and the link-time `-dead_strip` pass, so the relocations to these symbols +// are guaranteed to remain and must be resolved against libaval_decode.a. +__attribute__((used)) +void *const aval_decode_link_symbols[] = { + (void *)aval_decode_session_create, + (void *)aval_decode_session_destroy, + (void *)aval_decode_configure, + (void *)aval_decode_activate_generation, + (void *)aval_decode_submit_chunk, + (void *)aval_decode_take_frame, + (void *)aval_decode_release_frame, + (void *)aval_decode_dispose, +}; + +__attribute__((used, visibility("default"))) +void aval_decode_force_link(void) { + (void)aval_decode_link_symbols; +} + +@interface AvalDecodeLinkBootstrap : NSObject +@end + +@implementation AvalDecodeLinkBootstrap ++ (void)load { + aval_decode_force_link(); +} +@end diff --git a/flutter/examples/grass_rabbit/ios/Runner/Base.lproj/LaunchScreen.storyboard b/flutter/examples/grass_rabbit/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner/Base.lproj/Main.storyboard b/flutter/examples/grass_rabbit/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner/Info.plist b/flutter/examples/grass_rabbit/ios/Runner/Info.plist new file mode 100644 index 0000000..0c6b8f1 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Grass Rabbit + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + grass_rabbit + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/flutter/examples/grass_rabbit/ios/Runner/Runner-Bridging-Header.h b/flutter/examples/grass_rabbit/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/flutter/examples/grass_rabbit/ios/Runner/SceneDelegate.swift b/flutter/examples/grass_rabbit/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/flutter/examples/grass_rabbit/ios/RunnerTests/RunnerTests.swift b/flutter/examples/grass_rabbit/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/flutter/examples/grass_rabbit/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/flutter/examples/grass_rabbit/lib/main.dart b/flutter/examples/grass_rabbit/lib/main.dart new file mode 100644 index 0000000..7d19cbe --- /dev/null +++ b/flutter/examples/grass_rabbit/lib/main.dart @@ -0,0 +1,443 @@ +// AVAL Flutter port — grass-rabbit example. +// +// A thin consumer of the aval_flutter package's AvalView widget (the Flutter +// equivalent of the web player's element): the mansion-woman +// asset is decoded per-platform (Rust FFI natively, WebCodecs on web) and +// driven by the aval_graph MotionGraphEngine; this app only supplies chrome +// (maximize/zoom, state badge, hints) and side-band unit audio. + +import 'package:aval_flutter/aval_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'src/unit_audio.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const GrassRabbitApp()); +} + +class GrassRabbitApp extends StatelessWidget { + const GrassRabbitApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'AVAL — grass-rabbit', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true), + home: const RabbitPage(), + ); + } +} + +class RabbitPage extends StatefulWidget { + const RabbitPage({super.key}); + + @override + State createState() => _RabbitPageState(); +} + +class _RabbitPageState extends State { + final AvalPlayerController _controller = AvalPlayerController(); + final UnitAudioPlayer _audio = UnitAudioPlayer(); + + /// One AvalView instance shared by the compact and maximized layouts: the + /// GlobalKey reparents (rather than recreates) its state on toggle, so the + /// presentation clock and audio don't restart. + final GlobalKey _avalViewKey = GlobalKey(); + + /// When true, video fills the window/screen (contain + pinch-zoom). The app + /// starts maximized so the whole scene is the immediate presentation. + bool _maximized = true; + + /// Pinch/pan transform while maximized. + final TransformationController _zoomController = TransformationController(); + + @override + void initState() { + super.initState(); + // App starts maximized (see [_maximized]); apply immersive chrome to match. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + _controller.addListener(_onControllerChanged); + _controller.loadAsset('assets/mansion-woman.avl/h264.avl'); + } + + void _onControllerChanged() { + if (mounted) setState(() {}); + } + + void _setMaximized(bool value) { + if (_maximized == value) return; + setState(() { + _maximized = value; + // Reset zoom when entering/leaving maximized mode. + _zoomController.value = Matrix4.identity(); + }); + // Immersive chrome on mobile; no-op / harmless on macOS desktop. + if (value) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } + } + + void _resetZoom() { + _zoomController.value = Matrix4.identity(); + } + + @override + void dispose() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + _zoomController.dispose(); + _controller.removeListener(_onControllerChanged); + _controller.dispose(); + _audio.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF101014), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_controller.error != null) { + return Center( + child: _ErrorView( + error: _controller.error!, + decoder: _controller.decoderDescription, + ), + ); + } + if (!_controller.loaded) { + return const Center(child: _LoadingView()); + } + if (_maximized) { + return _buildMaximized(); + } + return SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight - 48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('AVAL · mansion-woman', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + '${_controller.totalFrames} frames · ' + '${_controller.unitFrames.length} units · ' + '${_controller.canvasWidth}×${_controller.canvasHeight} · ' + '${_controller.frameRateNumerator}fps · ' + '${_controller.decoderDescription}', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Colors.white54), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.center, + child: _buildVideo(maxWidth: 720), + ), + const SizedBox(height: 16), + _StateBadge(controller: _controller), + const SizedBox(height: 12), + Text( + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android + ? 'Long-press → "hi" · Tap → "great"' + : 'Hover → "hi" · Tap → "great" · Drives the state graph', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Colors.white38), + ), + ], + ), + ), + ); + }, + ), + ); + } + + /// Full-screen presentation: video **covers** the display (no letterbox), + /// with pinch-to-zoom / pan. Double-tap resets zoom. + Widget _buildMaximized() { + final size = MediaQuery.sizeOf(context); + // Use full physical screen (including under notch) so cover truly fills. + final availH = size.height; + final availW = size.width; + final padding = MediaQuery.paddingOf(context); + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: InteractiveViewer( + transformationController: _zoomController, + minScale: 1.0, + maxScale: 5.0, + // Clamp panning to the content edges so dragging can never reveal + // the black background behind the video (no over-pan into the void). + boundaryMargin: EdgeInsets.zero, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: availW, + height: availH, + child: _buildVideoSurface( + borderRadius: 0, + showBorder: false, + // contain (not cover) so the *whole* landscape frame is the + // zoom/pan coordinate space. cover would crop the frame to a + // center slice before zooming, making the rest of the scene + // unreachable. Letterbox bars at scale 1.0 in portrait are the + // honest framing of a 16:9 video and vanish as you zoom in. + fit: BoxFit.contain, + // Double-tap resets zoom without firing "great". + onDoubleTap: _resetZoom, + ), + ), + ), + ), + Positioned( + top: padding.top + 8, + right: padding.right + 8, + child: _MaximizeButton( + maximized: true, + onPressed: () => _setMaximized(false), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: padding.bottom + 12, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _StateBadge(controller: _controller), + const SizedBox(height: 8), + Text( + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android + ? 'Long-press → "hi" · Tap → "great"' + : 'Hover → "hi" · Tap → "great"', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white70, + shadows: const [ + Shadow(blurRadius: 6, color: Colors.black87), + ], + ), + ), + const SizedBox(height: 4), + Text( + 'Pinch to zoom · Double-tap to reset', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white54, + shadows: const [ + Shadow(blurRadius: 6, color: Colors.black87), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildVideo({required double maxWidth}) { + final aspect = _controller.canvasWidth / _controller.canvasHeight; + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth.isFinite + ? constraints.maxWidth.clamp(0.0, maxWidth) + : maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + SizedBox( + width: width, + child: AspectRatio( + aspectRatio: aspect, + child: _buildVideoSurface( + borderRadius: 12, + showBorder: true, + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: _MaximizeButton( + maximized: false, + onPressed: () => _setMaximized(true), + ), + ), + ], + ); + }, + ); + } + + Widget _buildVideoSurface({ + required double borderRadius, + required bool showBorder, + BoxFit fit = BoxFit.contain, + VoidCallback? onDoubleTap, + }) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + border: showBorder ? Border.all(color: Colors.white12) : null, + color: Colors.black, + ), + clipBehavior: Clip.antiAlias, + child: AvalView( + key: _avalViewKey, + controller: _controller, + fit: fit, + onDoubleTap: onDoubleTap, + // Side-band audio: the compiled .avl is video-only; AAC clips live + // beside it as Flutter assets and follow unit switches. + onUnitChanged: (unitId, looping) => + _audio.playUnit(unitId, loop: looping), + ), + ); + } +} + +/// Toggle between compact and full-height video presentation. +class _MaximizeButton extends StatelessWidget { + const _MaximizeButton({required this.maximized, required this.onPressed}); + + final bool maximized; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.black.withValues(alpha: 0.55), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + tooltip: maximized ? 'Exit fullscreen' : 'Fullscreen · zoom', + icon: Icon( + maximized ? Icons.fullscreen_exit : Icons.fullscreen, + color: Colors.white, + ), + onPressed: onPressed, + ), + ); + } +} + +/// Shows the graph's visual state, plus the requested state while a transition +/// is pending — the label proves the MotionGraphEngine reacts to hover. +class _StateBadge extends StatelessWidget { + const _StateBadge({required this.controller}); + + final AvalPlayerController controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.ticks, + builder: (context, value, child) { + final visual = controller.visualState; + final requested = controller.requestedState; + final pending = requested != visual; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + decoration: BoxDecoration( + color: pending ? const Color(0xFF3A2E12) : const Color(0xFF16261A), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: pending ? Colors.amberAccent : Colors.greenAccent, + width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('state: ', + style: TextStyle(color: Colors.white.withValues(alpha: 0.6))), + Text(visual, + style: const TextStyle( + fontWeight: FontWeight.w700, fontSize: 16)), + if (pending) ...[ + const Text(' → ', + style: TextStyle(color: Colors.amberAccent)), + Text(requested, + style: const TextStyle( + color: Colors.amberAccent, fontSize: 16)), + ], + ], + ), + ); + }, + ); + } +} + +class _LoadingView extends StatelessWidget { + const _LoadingView(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text('Decoding idle-loop via $unitDecoderDescription…'), + ], + ); + } +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.error, required this.decoder}); + + final Object error; + final String decoder; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.redAccent, size: 40), + const SizedBox(height: 16), + const Text('Failed to load grass-rabbit', + style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text('$error', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 16), + SelectableText('decoder: $decoder', + style: const TextStyle(color: Colors.white38, fontSize: 12)), + ], + ), + ); + } +} diff --git a/flutter/examples/grass_rabbit/lib/src/unit_audio.dart b/flutter/examples/grass_rabbit/lib/src/unit_audio.dart new file mode 100644 index 0000000..8591fbb --- /dev/null +++ b/flutter/examples/grass_rabbit/lib/src/unit_audio.dart @@ -0,0 +1,140 @@ +/// Per-unit AAC clips extracted from the mansion master timeline, played in +/// lockstep with graph unit switches. The compiled `.avl` is video-only; audio +/// lives beside it as Flutter assets. +/// +/// Exactly **one** clip plays at a time. Unit switches fully tear down the +/// previous `AudioPlayer` so a looping idle ambient track cannot keep running +/// under a spoken "hi"/"great" line (just_audio loop + setAsset races on macOS). +library; + +import 'package:audio_session/audio_session.dart'; +import 'package:flutter/foundation.dart'; +import 'package:just_audio/just_audio.dart'; + +/// Plays the AAC clip for the unit currently on screen. +/// +/// Asset layout (from `motion.json` source ranges @ 24 fps): +/// - `idle-loop` → frames [0, 240) → 0–10s +/// - `hi` → frames [240, 480) → 10–20s +/// - `great` → frames [480, 720) → 20–30s +class UnitAudioPlayer { + UnitAudioPlayer(); + + AudioPlayer _player = AudioPlayer(); + String? _unitId; + bool _sessionReady = false; + + /// Bumped on every [playUnit]/stop]/dispose] so in-flight async work bails out. + int _generation = 0; + + static const Map _assetForUnit = { + 'idle-loop': 'assets/audio/idle-loop.m4a', + 'hi': 'assets/audio/hi.m4a', + 'great': 'assets/audio/great.m4a', + }; + + Future _ensureSession() async { + if (_sessionReady) return; + final session = await AudioSession.instance; + // Exclusive playback — do not mix with other sessions or leftover streams. + await session.configure(const AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playback, + avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.none, + avAudioSessionMode: AVAudioSessionMode.defaultMode, + androidAudioAttributes: AndroidAudioAttributes( + contentType: AndroidAudioContentType.movie, + usage: AndroidAudioUsage.media, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gain, + )); + await session.setActive(true); + _sessionReady = true; + } + + /// Hard-stop: disable loop, stop, dispose player, create a fresh one. + /// Guarantees no ambient loop can keep feeding the audio device. + Future _resetPlayer() async { + final old = _player; + _player = AudioPlayer(); + try { + await old.setLoopMode(LoopMode.off); + } catch (_) {} + try { + await old.stop(); + } catch (_) {} + try { + await old.dispose(); + } catch (_) {} + } + + /// Starts (or switches to) the clip for [unitId]. Looping units loop audio; + /// finite units play once and then silence. + Future playUnit(String unitId, {required bool loop}) async { + final gen = ++_generation; + + final asset = _assetForUnit[unitId]; + if (asset == null) { + await stop(); + return; + } + + // Already on this unit and still playing — do not restart (keeps idle loop + // seamless across brief false unit re-entries). + if (_unitId == unitId && _player.playing) { + return; + } + + final previous = _unitId; + try { + await _ensureSession(); + if (gen != _generation) return; + + // Always tear down the previous player when the unit changes so a looping + // idle clip cannot mix under the next unit's audio. + if (previous != unitId) { + await _resetPlayer(); + if (gen != _generation) return; + } + + _unitId = unitId; + await _player.setLoopMode(loop ? LoopMode.one : LoopMode.off); + if (gen != _generation) return; + await _player.setAsset(asset); + if (gen != _generation) return; + await _player.setVolume(1.0); + await _player.seek(Duration.zero); + if (gen != _generation) return; + await _player.play(); + if (gen != _generation) { + // A newer request won the race — stop this player. + try { + await _player.stop(); + } catch (_) {} + return; + } + debugPrint('[audio] $previous → $unitId (loop=$loop)'); + } catch (e, st) { + if (gen == _generation) { + debugPrint('[audio] play failed for $unitId: $e\n$st'); + } + } + } + + Future stop() async { + _generation++; + _unitId = null; + await _resetPlayer(); + } + + Future dispose() async { + _generation++; + _unitId = null; + try { + await _player.setLoopMode(LoopMode.off); + await _player.stop(); + await _player.dispose(); + } catch (_) {} + } + + String? get currentUnitId => _unitId; +} diff --git a/flutter/examples/grass_rabbit/macos/.gitignore b/flutter/examples/grass_rabbit/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/flutter/examples/grass_rabbit/macos/Flutter/Flutter-Debug.xcconfig b/flutter/examples/grass_rabbit/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/flutter/examples/grass_rabbit/macos/Flutter/Flutter-Release.xcconfig b/flutter/examples/grass_rabbit/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/flutter/examples/grass_rabbit/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/examples/grass_rabbit/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..b7af1d0 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import audio_session +import just_audio + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) +} diff --git a/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/project.pbxproj b/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c2e8238 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* grass_rabbit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "grass_rabbit.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* grass_rabbit.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* grass_rabbit.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.aval.grassRabbit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/grass_rabbit.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/grass_rabbit"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.aval.grassRabbit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/grass_rabbit.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/grass_rabbit"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.aval.grassRabbit.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/grass_rabbit.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/grass_rabbit"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..7c45c66 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/examples/grass_rabbit/macos/Runner.xcworkspace/contents.xcworkspacedata b/flutter/examples/grass_rabbit/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/flutter/examples/grass_rabbit/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter/examples/grass_rabbit/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter/examples/grass_rabbit/macos/Runner/AppDelegate.swift b/flutter/examples/grass_rabbit/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/flutter/examples/grass_rabbit/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/flutter/examples/grass_rabbit/macos/Runner/Base.lproj/MainMenu.xib b/flutter/examples/grass_rabbit/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/examples/grass_rabbit/macos/Runner/Configs/AppInfo.xcconfig b/flutter/examples/grass_rabbit/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..05742dc --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = grass_rabbit + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.aval.grassRabbit + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.aval. All rights reserved. diff --git a/flutter/examples/grass_rabbit/macos/Runner/Configs/Debug.xcconfig b/flutter/examples/grass_rabbit/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/flutter/examples/grass_rabbit/macos/Runner/Configs/Release.xcconfig b/flutter/examples/grass_rabbit/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/flutter/examples/grass_rabbit/macos/Runner/Configs/Warnings.xcconfig b/flutter/examples/grass_rabbit/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/flutter/examples/grass_rabbit/macos/Runner/DebugProfile.entitlements b/flutter/examples/grass_rabbit/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..0bd7c48 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/flutter/examples/grass_rabbit/macos/Runner/Info.plist b/flutter/examples/grass_rabbit/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/flutter/examples/grass_rabbit/macos/Runner/MainFlutterWindow.swift b/flutter/examples/grass_rabbit/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/flutter/examples/grass_rabbit/macos/Runner/Release.entitlements b/flutter/examples/grass_rabbit/macos/Runner/Release.entitlements new file mode 100644 index 0000000..58b3033 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.app-sandbox + + + diff --git a/flutter/examples/grass_rabbit/macos/RunnerTests/RunnerTests.swift b/flutter/examples/grass_rabbit/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/flutter/examples/grass_rabbit/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/flutter/examples/grass_rabbit/pubspec.yaml b/flutter/examples/grass_rabbit/pubspec.yaml new file mode 100644 index 0000000..52bbe70 --- /dev/null +++ b/flutter/examples/grass_rabbit/pubspec.yaml @@ -0,0 +1,103 @@ +name: grass_rabbit +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The AVAL widget layer (AvalView) plus the pure-Dart AVAL packages it + # re-exposes. Path deps, not published. + aval_flutter: + path: ../../packages/aval_flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + just_audio: ^0.10.6 + audio_session: ^0.2.4 + +dev_dependencies: + flutter_test: + sdk: flutter + + # Tests parse .avl fixtures and drive the graph engine directly. + aval_format: + path: ../../packages/aval_format + aval_graph: + path: ../../packages/aval_graph + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/mansion-woman.avl/h264.avl + - assets/audio/idle-loop.m4a + - assets/audio/hi.m4a + - assets/audio/great.m4a + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/flutter/examples/grass_rabbit/test/widget_test.dart b/flutter/examples/grass_rabbit/test/widget_test.dart new file mode 100644 index 0000000..e1777cc --- /dev/null +++ b/flutter/examples/grass_rabbit/test/widget_test.dart @@ -0,0 +1,122 @@ +// Smoke tests for the grass-rabbit example. +// +// The graph tests are the important, deterministic ones: they exercise the +// same parse + MotionGraphEngine wiring AvalView uses (no FFI, no widgets) +// against the format-1.0 mansion-woman asset the app ships, and prove the +// graph transitions idle -> hi -> idle from a bound input event. + +import 'dart:io'; + +import 'package:aval_flutter/aval_flutter.dart'; +import 'package:aval_format/aval_format.dart'; +import 'package:aval_graph/aval_graph.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grass_rabbit/main.dart'; + +const _assetPath = 'assets/mansion-woman.avl/h264.avl'; + +void main() { + test('parse mansion-woman.avl: header + manifest + access-unit index', () { + final bytes = File(_assetPath).readAsBytesSync(); + final parsed = parseFrontIndex(bytes); + expect(parsed.manifest.canvas.width, 1280); + expect(parsed.manifest.canvas.height, 720); + expect(parsed.manifest.frameRate.numerator, 24); + expect(parsed.records, isNotEmpty); + expect(parsed.manifest.units.map((u) => u.id), + containsAll(['idle-loop', 'hi', 'great'])); + // Input bindings drive the AvalView gesture mapping. + expect( + {for (final b in parsed.manifest.bindings) b.source: b.event}, + {'activate': 'great', 'engagement.on': 'hi'}, + ); + }); + + test('graph reacts to engagement: idle -> hi -> idle', () { + final bytes = File(_assetPath).readAsBytesSync(); + final parsed = parseFrontIndex(bytes); + + final engine = MotionGraphEngine() + ..install(parsed.graph) + ..beginAnimated(); + + String visual() { + final s = engine.snapshot(); + return s.visualState ?? parsed.manifest.initialState; + } + + var ordinal = BigInt.zero; + void tick() { + engine.tick(MotionGraphTickOptions(contentOrdinal: ordinal)); + ordinal += BigInt.one; + } + + for (var i = 0; i < 60; i++) { + tick(); + } + expect(visual(), 'idle'); + + // engagement.on -> "hi": requested state flips immediately; visual + // commits at the portal. + engine.send('hi'); + expect(engine.snapshot().requestedState, 'hi'); + + var reachedHi = false; + for (var i = 0; i < 1000 && !reachedHi; i++) { + tick(); + if (visual() == 'hi') reachedHi = true; + } + expect(reachedHi, isTrue, reason: 'never reached "hi"'); + + // "hi" is a finite 240-frame unit whose completion edge returns to idle. + var backToIdle = false; + for (var i = 0; i < 1000 && !backToIdle; i++) { + tick(); + if (visual() == 'idle') backToIdle = true; + } + expect(backToIdle, isTrue, reason: 'never completed back to "idle"'); + }); + + test('displayed unit follows the graph: idle-loop -> hi -> idle-loop', () { + final bytes = File(_assetPath).readAsBytesSync(); + final parsed = parseFrontIndex(bytes); + + // Graph + bindings only — no video bytes, no decoder. + final controller = AvalPlayerController() + ..installGraph(parsed.graph, bindings: parsed.manifest.bindings); + + // State→unit mapping and loop kinds are derived from the manifest graph. + expect(controller.isLoopUnit('idle-loop'), isTrue); + expect(controller.isLoopUnit('hi'), isFalse); + expect(controller.isLoopUnit('great'), isFalse); + + for (var i = 0; i < 60; i++) { + controller.tickGraph(); + } + expect(controller.currentUnitId(), 'idle-loop'); + + // The engagement.on binding resolves to the "hi" event. + controller.sendSource('engagement.on'); + var sawHi = false; + for (var i = 0; i < 1000 && !sawHi; i++) { + controller.tickGraph(); + if (controller.currentUnitId() == 'hi') sawHi = true; + } + expect(sawHi, isTrue, reason: 'video never switched to hi'); + + var backToIdle = false; + for (var i = 0; i < 1000 && !backToIdle; i++) { + controller.tickGraph(); + if (controller.currentUnitId() == 'idle-loop') backToIdle = true; + } + expect(backToIdle, isTrue, reason: 'video never returned to idle-loop'); + + controller.dispose(); + }); + + testWidgets('app builds without throwing', (tester) async { + await tester.pumpWidget(const GrassRabbitApp()); + // First frame shows the loading view while decode runs. + expect(find.byType(GrassRabbitApp), findsOneWidget); + }); +} diff --git a/flutter/examples/grass_rabbit/tool/decode_check.dart b/flutter/examples/grass_rabbit/tool/decode_check.dart new file mode 100644 index 0000000..5426958 --- /dev/null +++ b/flutter/examples/grass_rabbit/tool/decode_check.dart @@ -0,0 +1,79 @@ +// Headless proof of the Phase 3 Dart<->Rust FFI decode round-trip. +// Reuses the example's real FFI bindings (lib/src/aval_ffi.dart) and the +// aval_format parser to decode the idle-loop unit from grass-rabbit.avl. +// +// Run: dart run tool/decode_check.dart +// (uses AVAL_DECODE_LIB env var if set, else the default cargo artifact) +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart'; +import 'package:aval_flutter/src/ffi/aval_ffi.dart'; + +void main() { + final libPath = Platform.environment['AVAL_DECODE_LIB'] ?? + '../../rust/aval_decode/target/release/libaval_decode.dylib'; + stdout.writeln('dylib: $libPath'); + + final bytes = File('assets/grass-rabbit.avl').readAsBytesSync(); + final parsed = parseFrontIndex(bytes); + final manifest = parsed.manifest; + final rendition = manifest.renditions.first; + + final bindings = AvalDecodeBindings.open(libPath); + final expectedLen = rendition.codedWidth * rendition.codedHeight * 4; + var allOk = true; + var grandTotal = 0; + + for (var u = 0; u < manifest.units.length; u++) { + final unit = manifest.units[u]; + final recs = parsed.records + .where((r) => r.unitIndex == u && r.renditionIndex == 0) + .toList() + ..sort((a, b) => a.frameIndex.compareTo(b.frameIndex)); + + final session = AvalDecoderSession.create(bindings); + var decoded = 0; + var lenOk = true; + try { + session.configure( + codedWidth: rendition.codedWidth, codedHeight: rendition.codedHeight); + session.activateGeneration(1); + for (var i = 0; i < recs.length; i++) { + final r = recs[i]; + final au = Uint8List.sublistView( + bytes, r.payloadOffset, r.payloadOffset + r.payloadLength); + final fid = session.submit( + ordinal: i, + timestamp: i, + duration: 1, + unitFrame: i, + unitFrameCount: unit.frameCount, + isKey: r.key, + data: au, + unitId: unit.id, + ); + if (fid == null) continue; + session.takeFrame((v) { + if (v.rgba.length != expectedLen) lenOk = false; + decoded++; + }); + } + } finally { + session.disposeSession(); + } + grandTotal += decoded; + final ok = decoded == recs.length && lenOk; + allOk = allOk && ok; + stdout.writeln('unit "${unit.id}": decoded $decoded/${recs.length} ' + '(${ok ? "OK" : "FAIL"})'); + } + + stdout.writeln('total frames decoded via FFI: $grandTotal'); + if (allOk) { + stdout.writeln('OK: FFI decode round-trip verified for all units'); + } else { + stderr.writeln('FAIL: decode mismatch'); + exit(1); + } +} diff --git a/flutter/examples/grass_rabbit/tool/inspect.dart b/flutter/examples/grass_rabbit/tool/inspect.dart new file mode 100644 index 0000000..32279a8 --- /dev/null +++ b/flutter/examples/grass_rabbit/tool/inspect.dart @@ -0,0 +1,33 @@ +// Standalone inspector: parses the bundled .avl and prints the manifest facts +// this example relies on (coded dims, units, idle-loop access-unit records). +// Run: dart run tool/inspect.dart +import 'dart:io'; +import 'package:aval_format/aval_format.dart'; + +void main() { + final bytes = File('assets/grass-rabbit.avl').readAsBytesSync(); + final parsed = parseFrontIndex(bytes); + final m = parsed.manifest; + stdout.writeln('canvas ${m.canvas.width}x${m.canvas.height} fit=${m.canvas.fit}'); + stdout.writeln('frameRate ${m.frameRate.numerator}/${m.frameRate.denominator}'); + stdout.writeln('initialState ${m.initialState}'); + for (final r in m.renditions) { + stdout.writeln('rendition ${r.id} profile=${r.profile} codec=${r.codec} ' + 'coded=${r.codedWidth}x${r.codedHeight} type=${r.runtimeType}'); + if (r is AvcPackedAlphaRenditionV01) { + stdout.writeln(' colorRect=${r.colorRect.x},${r.colorRect.y},${r.colorRect.width},${r.colorRect.height}' + ' alphaRect=${r.alphaRect.x},${r.alphaRect.y},${r.alphaRect.width},${r.alphaRect.height}'); + } + } + for (var i = 0; i < m.units.length; i++) { + final u = m.units[i]; + stdout.writeln('unit[$i] ${u.id} kind=${u.kind} frameCount=${u.frameCount}'); + } + stdout.writeln('states: ${m.states.map((s) => s.id).join(", ")}'); + stdout.writeln('total records: ${parsed.records.length}'); + final idleIdx = m.units.indexWhere((u) => u.id == 'idle-loop'); + final idle = parsed.records.where((r) => r.unitIndex == idleIdx && r.renditionIndex == 0).toList() + ..sort((a, b) => a.frameIndex.compareTo(b.frameIndex)); + stdout.writeln('idle-loop unitIndex=$idleIdx records=${idle.length} ' + 'firstKey=${idle.first.key} firstOff=${idle.first.payloadOffset} firstLen=${idle.first.payloadLength}'); +} diff --git a/flutter/examples/grass_rabbit/web/favicon.png b/flutter/examples/grass_rabbit/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/flutter/examples/grass_rabbit/web/favicon.png differ diff --git a/flutter/examples/grass_rabbit/web/icons/Icon-192.png b/flutter/examples/grass_rabbit/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/flutter/examples/grass_rabbit/web/icons/Icon-192.png differ diff --git a/flutter/examples/grass_rabbit/web/icons/Icon-512.png b/flutter/examples/grass_rabbit/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/flutter/examples/grass_rabbit/web/icons/Icon-512.png differ diff --git a/flutter/examples/grass_rabbit/web/icons/Icon-maskable-192.png b/flutter/examples/grass_rabbit/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/flutter/examples/grass_rabbit/web/icons/Icon-maskable-192.png differ diff --git a/flutter/examples/grass_rabbit/web/icons/Icon-maskable-512.png b/flutter/examples/grass_rabbit/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/flutter/examples/grass_rabbit/web/icons/Icon-maskable-512.png differ diff --git a/flutter/examples/grass_rabbit/web/index.html b/flutter/examples/grass_rabbit/web/index.html new file mode 100644 index 0000000..f48cc93 --- /dev/null +++ b/flutter/examples/grass_rabbit/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + grass_rabbit + + + + + + + diff --git a/flutter/examples/grass_rabbit/web/manifest.json b/flutter/examples/grass_rabbit/web/manifest.json new file mode 100644 index 0000000..05d9233 --- /dev/null +++ b/flutter/examples/grass_rabbit/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "grass_rabbit", + "short_name": "grass_rabbit", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/flutter/examples/rings_climbing/ATLAS_PROMPTS.md b/flutter/examples/rings_climbing/ATLAS_PROMPTS.md new file mode 100644 index 0000000..3339a7c --- /dev/null +++ b/flutter/examples/rings_climbing/ATLAS_PROMPTS.md @@ -0,0 +1,20 @@ +# Climbing atlas — camera: **behind** + +**Primary identity:** rear-view still from user (`refs/character_rear_source.jpg`). + +| Asset | Role | +| --- | --- | +| `refs/character_rear_flat.jpg` | Standing back, solid green | +| `portal/portal_pose.jpg` | Portal hang **from behind** (demo default) | +| `portal/portal_pose_rear.jpg` | Same (alias) | +| `loops/*` | Stamina + action loops, camera behind | +| `pivots/*` | Forward pivot ends, camera behind | +| `oneshots/*` | Reach / dyno / fall ends, camera behind | + +Front-facing locks (if present) are secondary: `character_base.jpg`, `character_user_source.webp`. + +Regenerate rule: always `image_edit` from `portal_pose_rear` / rear source so hair + harness stay consistent from the back. +--- +## Jumps (rear) +- oneshots/jump_left.jpg, jump_right.jpg, jump_up.jpg (apex) +- loops/jump_left_charge.jpg, jump_right_charge.jpg, jump_up_charge.jpg (coil) diff --git a/flutter/examples/rings_climbing/README.md b/flutter/examples/rings_climbing/README.md new file mode 100644 index 0000000..6473b18 --- /dev/null +++ b/flutter/examples/rings_climbing/README.md @@ -0,0 +1,65 @@ +# rings_climbing — climbing motion atlas + +Advanced AVAL **rings** demo built from the climbing prompt kit. + +**Identity lock:** user-supplied climber — **camera from behind** (rear standing ++ portal hang + full atlas via Grok Imagine `image_edit`). Front refs kept +under `assets/atlas/refs/` for turnaround, but the demo stage uses rear stills. + +| Layer | What | +| --- | --- | +| **Stamina ring** | `hang_secure` → `hang_strained` → `hang_failing` (cyclic, shared portal pose) | +| **Action pivots** | `shake_out`, `lock_off_hold`, `dyno_charge` — **symmetric reversible** | +| **One-shots** | `reach_rh_up`, `dyno_leap`, `fall` — hard-cut (no mid-flight reverse) | +| **Stills** | Grok Imagine atlas under `assets/atlas/` | + +## Reversibility (PRD v3) + +| Mode | Used for | Mid-turn reverse? | +| --- | --- | --- | +| **Pivot-symmetric** | stamina adjacencies + action spokes | **Yes** — shared `unitId`, `+1` base / `-1` inverse with `reverseOf` | +| **Hard-cut** | one-shots (reach / leap / fall) | **No** (AC15) | + +Canonical rule: along ring order, **+1 = base** (`exact-authored`, forward); +**-1 = inverse** (`exact-reverse`, reverse, `reverseOf: base.id`). Edge ids use +dots (`stamina.hang_secure.hang_strained`) — `:` is illegal in graph ids. + +`maxWaitFrames` for portals is **3** (floor for 8-frame loop, portals `[0, 4]`). + +Multi-hop host path still uses `planFor` + sequential `request` until full +engine `continueTurn` + ring-intent clear on reversal land in Dart (see PRD +§4). Graph **pairs validate** and populate `inverseEdgesById` today. + +## Run + +```bash +cd flutter/examples/rings_climbing +flutter pub get +flutter run -d chrome # or linux / macos +flutter test +``` + +## Atlas layout + +``` +assets/atlas/ + refs/ character_base.jpg, turnaround.jpg, character_user_source.webp + portal/ portal_pose.jpg ← connection contract + loops/ hang_*, shake_out, lock_off_hold, dyno_charge + pivots/ pivot_hang_to_* (forward-authored stills; engine plays reverse) + oneshots/ reach_rh_up, dyno_leap, fall +``` + +## Production notes (from prompt kit) + +1. **Portal pose** shared across hang_* loops so stamina can degrade without bridge clips. +2. **Pivots** authored forward only; engine plays reverse via shared unit (group-size-2). +3. **One-shots** end settled (compiler portal on final frame). +4. Real video: i2v from stills → trim to portals → ProRes 4444 / PNG, never H.264 for alpha. +5. See `ATLAS_PROMPTS.md` for generation order. + +## Related + +- PRD v3 rings + reversibility (this session) +- Dart graph: `flutter/packages/aval_graph` (`planRingArc`, `planFor`, `validateReversiblePairs`) +- Simpler compass (hard-cut only): `flutter/examples/rings_eight_way/` diff --git a/flutter/examples/rings_climbing/analysis_options.yaml b/flutter/examples/rings_climbing/analysis_options.yaml new file mode 100644 index 0000000..a95184c --- /dev/null +++ b/flutter/examples/rings_climbing/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: false diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/dyno_charge.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/dyno_charge.jpg new file mode 100644 index 0000000..e8ce237 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/dyno_charge.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/hang_failing.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/hang_failing.jpg new file mode 100644 index 0000000..6757d11 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/hang_failing.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/hang_secure.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/hang_secure.jpg new file mode 100644 index 0000000..76145d2 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/hang_secure.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/hang_secure_portal.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/hang_secure_portal.jpg new file mode 100644 index 0000000..ddf346f Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/hang_secure_portal.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/hang_strained.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/hang_strained.jpg new file mode 100644 index 0000000..1d892a6 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/hang_strained.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/jump_left_charge.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/jump_left_charge.jpg new file mode 100644 index 0000000..8cc5e5f Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/jump_left_charge.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/jump_right_charge.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/jump_right_charge.jpg new file mode 100644 index 0000000..a3547af Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/jump_right_charge.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/jump_up_charge.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/jump_up_charge.jpg new file mode 100644 index 0000000..8fcf1fc Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/jump_up_charge.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/lock_off_hold.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/lock_off_hold.jpg new file mode 100644 index 0000000..cfca1f3 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/lock_off_hold.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/loops/shake_out.jpg b/flutter/examples/rings_climbing/assets/atlas/loops/shake_out.jpg new file mode 100644 index 0000000..9a2874a Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/loops/shake_out.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/oneshots/dyno_leap.jpg b/flutter/examples/rings_climbing/assets/atlas/oneshots/dyno_leap.jpg new file mode 100644 index 0000000..4003dc8 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/oneshots/dyno_leap.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/oneshots/fall.jpg b/flutter/examples/rings_climbing/assets/atlas/oneshots/fall.jpg new file mode 100644 index 0000000..f60a6f1 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/oneshots/fall.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_left.jpg b/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_left.jpg new file mode 100644 index 0000000..9d851db Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_left.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_right.jpg b/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_right.jpg new file mode 100644 index 0000000..2cff9ab Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_right.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_up.jpg b/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_up.jpg new file mode 100644 index 0000000..746f1ad Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/oneshots/jump_up.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/oneshots/reach_rh_up.jpg b/flutter/examples/rings_climbing/assets/atlas/oneshots/reach_rh_up.jpg new file mode 100644 index 0000000..3a90a15 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/oneshots/reach_rh_up.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_charge.jpg b/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_charge.jpg new file mode 100644 index 0000000..07636d2 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_charge.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_lead.jpg b/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_lead.jpg new file mode 100644 index 0000000..8aacef2 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_lead.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_lockoff.jpg b/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_lockoff.jpg new file mode 100644 index 0000000..5a0abeb Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/pivots/pivot_hang_to_lockoff.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/portal/portal_pose.jpg b/flutter/examples/rings_climbing/assets/atlas/portal/portal_pose.jpg new file mode 100644 index 0000000..ddf346f Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/portal/portal_pose.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/portal/portal_pose_rear.jpg b/flutter/examples/rings_climbing/assets/atlas/portal/portal_pose_rear.jpg new file mode 100644 index 0000000..ddf346f Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/portal/portal_pose_rear.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/refs/character_base.jpg b/flutter/examples/rings_climbing/assets/atlas/refs/character_base.jpg new file mode 100644 index 0000000..791af24 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/refs/character_base.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/refs/character_base.webp b/flutter/examples/rings_climbing/assets/atlas/refs/character_base.webp new file mode 100644 index 0000000..0ff2752 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/refs/character_base.webp differ diff --git a/flutter/examples/rings_climbing/assets/atlas/refs/character_base_flat.jpg b/flutter/examples/rings_climbing/assets/atlas/refs/character_base_flat.jpg new file mode 100644 index 0000000..791af24 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/refs/character_base_flat.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/refs/character_rear_flat.jpg b/flutter/examples/rings_climbing/assets/atlas/refs/character_rear_flat.jpg new file mode 100644 index 0000000..a514ce6 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/refs/character_rear_flat.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/refs/character_rear_source.jpg b/flutter/examples/rings_climbing/assets/atlas/refs/character_rear_source.jpg new file mode 100644 index 0000000..b8257c7 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/refs/character_rear_source.jpg differ diff --git a/flutter/examples/rings_climbing/assets/atlas/refs/character_user_source.webp b/flutter/examples/rings_climbing/assets/atlas/refs/character_user_source.webp new file mode 100644 index 0000000..0ff2752 Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/refs/character_user_source.webp differ diff --git a/flutter/examples/rings_climbing/assets/atlas/refs/turnaround.jpg b/flutter/examples/rings_climbing/assets/atlas/refs/turnaround.jpg new file mode 100644 index 0000000..3dbbcad Binary files /dev/null and b/flutter/examples/rings_climbing/assets/atlas/refs/turnaround.jpg differ diff --git a/flutter/examples/rings_climbing/lib/climbing_graph.dart b/flutter/examples/rings_climbing/lib/climbing_graph.dart new file mode 100644 index 0000000..862027b --- /dev/null +++ b/flutter/examples/rings_climbing/lib/climbing_graph.dart @@ -0,0 +1,342 @@ +/// Climbing motion atlas graph — stamina ring + action branches. +/// +/// **Reversibility (PRD v3):** ring adjacencies and action pivots use +/// **pivot-symmetric** reversible units — one pivot `unitId` serves both +/// directions (forward base + reverse inverse with `reverseOf`). Hard-cuts +/// remain only for one-shots (reach / dyno / fall) where mid-flight reverse +/// is not wanted. +/// +/// Canonical rule: along declared ring order, **+1 is the base** +/// (`exact-authored`, `direction: forward`, no `reverseOf`); **-1 is the +/// inverse** (`exact-reverse`, `direction: reverse`, `reverseOf: base.id`). +library; + +import 'package:aval_graph/aval_graph.dart'; + +/// Loop / dwell states (portal-compatible). +const kHangSecure = 'hang_secure'; +const kHangStrained = 'hang_strained'; +const kHangFailing = 'hang_failing'; +const kShakeOut = 'shake_out'; +const kLockOff = 'lock_off_hold'; +const kDynoCharge = 'dyno_charge'; +const kJumpLeftCharge = 'jump_left_charge'; +const kJumpRightCharge = 'jump_right_charge'; +const kJumpUpCharge = 'jump_up_charge'; + +/// Finite one-shot end states (held final pose). +const kReachRhUp = 'reach_rh_up'; +const kDynoLeap = 'dyno_leap'; +const kJumpLeft = 'jump_left'; +const kJumpRight = 'jump_right'; +const kJumpUp = 'jump_up'; +const kFall = 'fall'; + +const kAllStates = [ + kHangSecure, + kHangStrained, + kHangFailing, + kShakeOut, + kLockOff, + kDynoCharge, + kJumpLeftCharge, + kJumpRightCharge, + kJumpUpCharge, + kReachRhUp, + kDynoLeap, + kJumpLeft, + kJumpRight, + kJumpUp, + kFall, +]; + +/// Display labels for the UI. +const kStateLabels = { + kHangSecure: 'Secure', + kHangStrained: 'Strained', + kHangFailing: 'Failing', + kShakeOut: 'Shake-out', + kLockOff: 'Lock-off', + kDynoCharge: 'Charge', + kJumpLeftCharge: 'Coil ←', + kJumpRightCharge: 'Coil →', + kJumpUpCharge: 'Coil ↑', + kReachRhUp: 'Reach RH↑', + kDynoLeap: 'Dyno leap', + kJumpLeft: 'Jump ←', + kJumpRight: 'Jump →', + kJumpUp: 'Jump ↑', + kFall: 'Fall', +}; + +/// Asset path relative to package assets/ for each visual state. +const kStateAssets = { + kHangSecure: 'assets/atlas/loops/hang_secure.jpg', + kHangStrained: 'assets/atlas/loops/hang_strained.jpg', + kHangFailing: 'assets/atlas/loops/hang_failing.jpg', + kShakeOut: 'assets/atlas/loops/shake_out.jpg', + kLockOff: 'assets/atlas/loops/lock_off_hold.jpg', + kDynoCharge: 'assets/atlas/loops/dyno_charge.jpg', + kJumpLeftCharge: 'assets/atlas/loops/jump_left_charge.jpg', + kJumpRightCharge: 'assets/atlas/loops/jump_right_charge.jpg', + kJumpUpCharge: 'assets/atlas/loops/jump_up_charge.jpg', + kReachRhUp: 'assets/atlas/oneshots/reach_rh_up.jpg', + kDynoLeap: 'assets/atlas/oneshots/dyno_leap.jpg', + kJumpLeft: 'assets/atlas/oneshots/jump_left.jpg', + kJumpRight: 'assets/atlas/oneshots/jump_right.jpg', + kJumpUp: 'assets/atlas/oneshots/jump_up.jpg', + kFall: 'assets/atlas/oneshots/fall.jpg', +}; + +/// Optional mid-pivot stills (for transition previews). +const kPivotAssets = { + 'pivot_hang_to_lead': 'assets/atlas/pivots/pivot_hang_to_lead.jpg', + 'pivot_hang_to_lockoff': 'assets/atlas/pivots/pivot_hang_to_lockoff.jpg', + 'pivot_hang_to_charge': 'assets/atlas/pivots/pivot_hang_to_charge.jpg', +}; + +/// Shared pivot frame count (short reverse-safe clips). +const kPivotFrames = 18; + +/// Portal start maxWait for 8-frame loop with portals [0, 4]: +/// greatest gap = 4 → minimum maxWaitFrames = 3 (validate floor). +const kPortalMaxWait = 3; + +/// Build a validated motion graph for the climbing atlas. +ValidatedMotionGraph buildClimbingGraph() { + return validateMotionGraphDefinition(_climbingGraphJson()); +} + +/// How many reversible `unitId` groups the graph owns (each = one pivot clip). +int countReversiblePivotUnits(ValidatedMotionGraph graph) { + final units = {}; + for (final edge in graph.definition.edges) { + final t = edge.transition; + if (t is GraphTransitionReversible) units.add(t.unitId); + } + return units.length; +} + +Map _climbingGraphJson() { + Map loopBody(String id, {int frames = 8}) => { + 'id': id, + 'body': { + 'unitId': '$id.body', + 'kind': 'loop', + 'frameCount': frames, + 'ports': [ + { + 'id': 'default', + 'entryFrame': 0, + // 2 portals → maxWait floor = 3 on an 8-frame loop. + 'portalFrames': [0, frames ~/ 2], + } + ], + }, + }; + + Map finiteBody(String id, {int frames = 8}) => { + 'id': id, + 'body': { + 'unitId': '$id.body', + 'kind': 'finite', + 'frameCount': frames, + 'ports': [ + { + 'id': 'default', + 'entryFrame': 0, + // Final frame is a portal so one-shots can depart again. + 'portalFrames': [0, frames - 1], + } + ], + }, + }; + + /// Portal-gated start (not hard-cut). Source port is specialized per edge. + Map portalStart() => { + 'type': 'portal', + 'sourcePort': 'default', + 'targetPort': 'default', + 'maxWaitFrames': kPortalMaxWait, + }; + + /// Symmetric pivot pair: +1 base / -1 inverse, shared [unitId] (PRD §1.2). + List> pivotPair({ + required String from, + required String to, + required String unitId, + String? ring, + int frameCount = kPivotFrames, + }) { + // Dot-separated ids (GRAPH_IDENTIFIER_PATTERN forbids `:`). + final baseId = ring != null + ? '$ring.$from.$to' + : 'pivot.$from.$to'; + final invId = ring != null + ? '$ring.$to.$from' + : 'pivot.$to.$from'; + return [ + { + 'id': baseId, + 'from': from, + 'to': to, + if (ring != null) 'ring': ring, + if (ring != null) 'step': 1, + 'start': portalStart(), + 'transition': { + 'kind': 'reversible', + 'unitId': unitId, + 'frameCount': frameCount, + 'direction': 'forward', + // no reverseOf — this is the base + }, + 'continuity': 'exact-authored', + }, + { + 'id': invId, + 'from': to, + 'to': from, + if (ring != null) 'ring': ring, + if (ring != null) 'step': -1, + 'start': portalStart(), + 'transition': { + 'kind': 'reversible', + 'unitId': unitId, + 'frameCount': frameCount, + 'direction': 'reverse', + 'reverseOf': baseId, + }, + 'continuity': 'exact-reverse', + }, + ]; + } + + /// One-shot / recovery: hard-cut (no mid-flight reverse — intentional). + Map hardCut(String id, String from, String to) => { + 'id': id, + 'from': from, + 'to': to, + 'start': { + 'type': 'cut', + 'targetPort': 'default', + 'maxWaitFrames': 1, + }, + 'continuity': 'cut', + }; + + final states = >[ + loopBody(kHangSecure), + loopBody(kHangStrained), + loopBody(kHangFailing), + loopBody(kShakeOut), + loopBody(kLockOff), + loopBody(kDynoCharge), + loopBody(kJumpLeftCharge), + loopBody(kJumpRightCharge), + loopBody(kJumpUpCharge), + finiteBody(kReachRhUp), + finiteBody(kDynoLeap), + finiteBody(kJumpLeft), + finiteBody(kJumpRight), + finiteBody(kJumpUp), + finiteBody(kFall), + ]; + + final edges = >[]; + + // Stamina ring (3 states → 3 symmetric pivot units → 6 edges). + // Order: secure → strained → failing → secure. + const stamina = [kHangSecure, kHangStrained, kHangFailing]; + for (var i = 0; i < stamina.length; i++) { + final a = stamina[i]; + final b = stamina[(i + 1) % stamina.length]; + edges.addAll( + pivotPair( + from: a, + to: b, + unitId: 'pivot.stamina.$a.$b', + ring: 'stamina', + ), + ); + } + + // Action pivots from hang_secure (symmetric = mid-flight reverse OK). + edges.addAll( + pivotPair( + from: kHangSecure, + to: kShakeOut, + unitId: 'pivot.secure.shake_out', + ), + ); + edges.addAll( + pivotPair( + from: kHangSecure, + to: kLockOff, + unitId: 'pivot.secure.lock_off', + ), + ); + edges.addAll( + pivotPair( + from: kHangSecure, + to: kDynoCharge, + unitId: 'pivot.secure.charge', + ), + ); + // Directional jump coils (symmetric pivots — reverse-safe load poses). + edges.addAll( + pivotPair( + from: kHangSecure, + to: kJumpLeftCharge, + unitId: 'pivot.secure.jump_left_charge', + ), + ); + edges.addAll( + pivotPair( + from: kHangSecure, + to: kJumpRightCharge, + unitId: 'pivot.secure.jump_right_charge', + ), + ); + edges.addAll( + pivotPair( + from: kHangSecure, + to: kJumpUpCharge, + unitId: 'pivot.secure.jump_up_charge', + ), + ); + + // One-shots: hard-cut (AC15 — no inverse, no mid-step reverse). + edges.add(hardCut('action.secure.reach', kHangSecure, kReachRhUp)); + edges.add(hardCut('action.reach.secure', kReachRhUp, kHangSecure)); + edges.add(hardCut('action.charge.leap', kDynoCharge, kDynoLeap)); + edges.add(hardCut('action.leap.secure', kDynoLeap, kHangSecure)); + // Jump L/R/Up: charge → apex → catch back to secure. + edges.add(hardCut('action.jlc.jl', kJumpLeftCharge, kJumpLeft)); + edges.add(hardCut('action.jl.secure', kJumpLeft, kHangSecure)); + edges.add(hardCut('action.jrc.jr', kJumpRightCharge, kJumpRight)); + edges.add(hardCut('action.jr.secure', kJumpRight, kHangSecure)); + edges.add(hardCut('action.juc.ju', kJumpUpCharge, kJumpUp)); + edges.add(hardCut('action.ju.secure', kJumpUp, kHangSecure)); + // Direct jump from secure (skips coil when plan allows). + edges.add(hardCut('action.secure.jl', kHangSecure, kJumpLeft)); + edges.add(hardCut('action.secure.jr', kHangSecure, kJumpRight)); + edges.add(hardCut('action.secure.ju', kHangSecure, kJumpUp)); + edges.add(hardCut('action.failing.fall', kHangFailing, kFall)); + edges.add(hardCut('action.secure.fall', kHangSecure, kFall)); + edges.add(hardCut('action.fall.secure', kFall, kHangSecure)); + + return { + 'initialState': kHangSecure, + 'states': states, + 'edges': edges, + 'rings': [ + { + 'id': 'stamina', + 'states': stamina, + 'cyclic': true, + 'tieBreak': 'forward', + 'maxChainedSteps': 2, + }, + ], + }; +} diff --git a/flutter/examples/rings_climbing/lib/main.dart b/flutter/examples/rings_climbing/lib/main.dart new file mode 100644 index 0000000..b2c84b1 --- /dev/null +++ b/flutter/examples/rings_climbing/lib/main.dart @@ -0,0 +1,548 @@ +// Advanced AVAL rings demo — climbing motion atlas. +// +// Graph: cyclic **stamina** ring (secure → strained → failing) plus action +// spokes (shake-out, lock-off, charge, reach, dyno, fall). Stills from Grok +// Imagine stand in for portal-aligned loop / one-shot clips until real video +// is compiled into an .avl. +// +// Multi-hop uses planFor() + sequential request() (same pattern as the web +// rings test bed). + +import 'dart:async'; + +import 'package:aval_flutter/aval_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'climbing_graph.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ClimbingRingsApp()); +} + +class ClimbingRingsApp extends StatelessWidget { + const ClimbingRingsApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'AVAL — climbing rings atlas', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true).copyWith( + scaffoldBackgroundColor: const Color(0xFF0E0E12), + ), + home: const ClimbingPage(), + ); + } +} + +class ClimbingPage extends StatefulWidget { + const ClimbingPage({super.key}); + + @override + State createState() => _ClimbingPageState(); +} + +class _ClimbingPageState extends State { + final AvalPlayerController _controller = AvalPlayerController(); + final List _log = []; + final Map _images = {}; + + bool _busy = false; + String? _pending; + String _visual = kHangSecure; + Timer? _tick; + + @override + void initState() { + super.initState(); + _boot(); + } + + Future _boot() async { + try { + final graph = buildClimbingGraph(); + _controller.installGraph(graph); + _controller.loaded = true; + _logLine('graph: stamina ring + action spokes installed'); + await _preloadImages(); + _visual = _controller.visualState; + if (_visual.isEmpty) _visual = kHangSecure; + _tick = Timer.periodic(const Duration(milliseconds: 33), (_) { + if (!_controller.loaded) return; + _controller.tickGraph(); + final v = _controller.visualState; + if (v.isNotEmpty && v != _visual && mounted) { + setState(() => _visual = v); + } + }); + _logLine('atlas stills ready · planFor + sequential request'); + } catch (e, st) { + _logLine('boot failed: $e'); + debugPrint('$e\n$st'); + } + if (mounted) setState(() {}); + } + + Future _preloadImages() async { + for (final entry in kStateAssets.entries) { + try { + final data = await rootBundle.load(entry.value); + _images[entry.key] = MemoryImage( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ); + } catch (e) { + _logLine('missing asset ${entry.value}: $e'); + } + } + for (final entry in kPivotAssets.entries) { + try { + final data = await rootBundle.load(entry.value); + _images[entry.key] = MemoryImage( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ); + } catch (_) {} + } + } + + void _logLine(String text) { + _log.insert(0, '[${_log.length.toString().padLeft(3, '0')}] $text'); + if (_log.length > 100) _log.removeLast(); + } + + Future _go(String target) async { + if (_busy) { + _pending = target; + _logLine('queued → $target'); + if (mounted) setState(() {}); + return; + } + _busy = true; + if (mounted) setState(() {}); + try { + while (true) { + final plan = _controller.planFor(target); + _logLine('planFor("$target") = $plan'); + if (plan == null) { + _logLine('unreachable: $target'); + break; + } + if (plan.isEmpty) { + _logLine('already at $target'); + break; + } + for (final step in plan) { + final result = _controller.request(step); + _logLine( + 'request("$step") ok=${result?.accepted} ' + 'visual=${_controller.visualState}', + ); + for (var i = 0; i < 10; i++) { + _controller.tickGraph(); + await Future.delayed(const Duration(milliseconds: 32)); + } + if (mounted) { + setState(() => _visual = _controller.visualState); + } + } + final next = _pending; + _pending = null; + if (next == null || next == _controller.visualState) break; + target = next; + } + } finally { + _busy = false; + if (mounted) { + setState(() => _visual = _controller.visualState); + } + } + } + + @override + void dispose() { + _tick?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final plan = _controller.planFor(_pending ?? _visual); + final image = _images[_visual]; + + return Scaffold( + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final narrow = constraints.maxWidth < 720; + final stage = _Stage( + image: image, + visual: _visual, + busy: _busy, + ); + final side = _SidePanel( + visual: _visual, + busy: _busy, + pending: _pending, + plan: plan, + log: _log, + onGo: _go, + ); + if (narrow) { + return Column( + children: [ + Expanded(flex: 3, child: stage), + Expanded(flex: 4, child: side), + ], + ); + } + return Row( + children: [ + SizedBox(width: 340, child: side), + const VerticalDivider(width: 1), + Expanded(child: stage), + ], + ); + }, + ), + ), + ); + } +} + +class _Stage extends StatelessWidget { + const _Stage({ + required this.image, + required this.visual, + required this.busy, + }); + + final ImageProvider? image; + final String visual; + final bool busy; + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFF121218), + child: Center( + child: AspectRatio( + aspectRatio: 9 / 16, + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF00B140), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF2A2A34)), + ), + ), + if (image != null) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image( + image: image!, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ), + ) + else + Center( + child: Text( + kStateLabels[visual] ?? visual, + style: const TextStyle(fontSize: 28, color: Colors.white70), + ), + ), + Positioned( + left: 12, + bottom: 12, + child: _Badge( + text: kStateLabels[visual] ?? visual, + accent: busy, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.text, required this.accent}); + + final String text; + final bool accent; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: accent ? const Color(0xFF88FF88) : const Color(0xFF444444), + ), + ), + child: Text( + text, + style: TextStyle( + color: accent ? const Color(0xFF88FF88) : Colors.white, + fontFamily: 'monospace', + fontSize: 13, + ), + ), + ); + } +} + +class _SidePanel extends StatelessWidget { + const _SidePanel({ + required this.visual, + required this.busy, + required this.pending, + required this.plan, + required this.log, + required this.onGo, + }); + + final String visual; + final bool busy; + final String? pending; + final List? plan; + final List log; + final ValueChanged onGo; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'CLIMBING RINGS ATLAS', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + letterSpacing: 1.2, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + Text( + kStateLabels[visual] ?? visual, + style: const TextStyle( + color: Color(0xFF88FF88), + fontSize: 22, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + Text( + busy ? 'walking plan…' : (pending != null ? 'queued $pending' : 'idle'), + style: const TextStyle(color: Color(0xFF888899), fontSize: 12), + ), + const SizedBox(height: 20), + const _SectionTitle('Stamina ring (portal-shared)'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final s in [kHangSecure, kHangStrained, kHangFailing]) + _Chip( + label: kStateLabels[s]!, + selected: visual == s, + onTap: () => onGo(s), + ), + ], + ), + const SizedBox(height: 20), + const _SectionTitle('Action loops'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final s in [kShakeOut, kLockOff, kDynoCharge]) + _Chip( + label: kStateLabels[s]!, + selected: visual == s, + onTap: () => onGo(s), + ), + ], + ), + const SizedBox(height: 20), + const _SectionTitle('Jumps (rear camera)'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final s in [ + kJumpLeftCharge, + kJumpLeft, + kJumpUpCharge, + kJumpUp, + kJumpRightCharge, + kJumpRight, + ]) + _Chip( + label: kStateLabels[s]!, + selected: visual == s, + onTap: () => onGo(s), + ), + ], + ), + const SizedBox(height: 20), + const _SectionTitle('One-shots (finite)'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final s in [kReachRhUp, kDynoLeap, kFall]) + _Chip( + label: kStateLabels[s]!, + selected: visual == s, + danger: s == kFall, + onTap: () => onGo(s), + ), + ], + ), + const SizedBox(height: 20), + const _SectionTitle('planFor'), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0A1A0A), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF2A3A2A)), + ), + child: Text( + plan == null + ? 'null' + : plan!.isEmpty + ? '[] (already there)' + : plan!.join(' → '), + style: const TextStyle( + color: Color(0xFF88FF88), + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + const SizedBox(height: 12), + const Text( + 'Ring: stamina · 3 states · cyclic · maxChainedSteps 2\n' + 'Portal pose shared across hang_* loops\n' + 'Pivots forward-only; one-shots end settled', + style: TextStyle( + color: Color(0xFF8888FF), + fontSize: 11, + fontFamily: 'monospace', + height: 1.45, + ), + ), + const SizedBox(height: 20), + const _SectionTitle('Event log'), + const SizedBox(height: 8), + Container( + height: 200, + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF1A1A0A), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF3A3A2A)), + ), + child: ListView.builder( + itemCount: log.length, + itemBuilder: (_, i) => Text( + log[i], + style: const TextStyle( + color: Color(0xFFDDDD88), + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ), + ), + ], + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 11, + letterSpacing: 1.1, + ), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({ + required this.label, + required this.selected, + required this.onTap, + this.danger = false, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + final bool danger; + + @override + Widget build(BuildContext context) { + final border = selected + ? const Color(0xFF88FF88) + : danger + ? const Color(0xFF884444) + : const Color(0xFF444455); + final bg = selected + ? const Color(0xFF1A3A1A) + : danger + ? const Color(0xFF2A1515) + : const Color(0xFF1C1C24); + return Material( + color: bg, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + constraints: const BoxConstraints(minWidth: 88, minHeight: 44), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: border), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: selected ? const Color(0xFF88FF88) : Colors.white70, + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + ), + ); + } +} diff --git a/flutter/examples/rings_climbing/pubspec.lock b/flutter/examples/rings_climbing/pubspec.lock new file mode 100644 index 0000000..bb87109 --- /dev/null +++ b/flutter/examples/rings_climbing/pubspec.lock @@ -0,0 +1,234 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + aval_flutter: + dependency: "direct main" + description: + path: "../../packages/aval_flutter" + relative: true + source: path + version: "0.1.0" + aval_format: + dependency: "direct main" + description: + path: "../../packages/aval_format" + relative: true + source: path + version: "1.0.0" + aval_graph: + dependency: "direct main" + description: + path: "../../packages/aval_graph" + relative: true + source: path + version: "1.0.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.9.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/flutter/examples/rings_climbing/pubspec.yaml b/flutter/examples/rings_climbing/pubspec.yaml new file mode 100644 index 0000000..c07b878 --- /dev/null +++ b/flutter/examples/rings_climbing/pubspec.yaml @@ -0,0 +1,31 @@ +name: rings_climbing +description: Advanced AVAL rings demo — climbing motion atlas (portal loops + pivots + one-shots). +publish_to: "none" +version: 1.0.0+1 + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + aval_flutter: + path: ../../packages/aval_flutter + aval_graph: + path: ../../packages/aval_graph + aval_format: + path: ../../packages/aval_format + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + assets: + - assets/atlas/refs/ + - assets/atlas/portal/ + - assets/atlas/loops/ + - assets/atlas/pivots/ + - assets/atlas/oneshots/ diff --git a/flutter/examples/rings_climbing/test/climbing_graph_test.dart b/flutter/examples/rings_climbing/test/climbing_graph_test.dart new file mode 100644 index 0000000..ad52d37 --- /dev/null +++ b/flutter/examples/rings_climbing/test/climbing_graph_test.dart @@ -0,0 +1,130 @@ +import 'package:aval_graph/aval_graph.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rings_climbing/climbing_graph.dart'; + +void main() { + late ValidatedMotionGraph graph; + late MotionGraphEngine engine; + + setUp(() { + graph = buildClimbingGraph(); + engine = MotionGraphEngine(); + engine.install(graph); + engine.beginAnimated(); + }); + + test('climbing graph validates with stamina ring', () { + expect(graph.definition.states.length, kAllStates.length); + expect(graph.definition.rings, isNotNull); + expect(graph.definition.rings!.single.id, 'stamina'); + expect(graph.definition.rings!.single.states, [ + kHangSecure, + kHangStrained, + kHangFailing, + ]); + }); + + test('planFor walks stamina ring', () { + expect(engine.planFor(kHangSecure), isEmpty); + expect(engine.planFor(kHangStrained), [kHangStrained]); + // 3-state cyclic: secure→failing is one step backward (shorter arc). + expect(engine.planFor(kHangFailing), [kHangFailing]); + }); + + test('planFor reaches action spokes', () { + expect(engine.planFor(kShakeOut), [kShakeOut]); + expect(engine.planFor(kFall), [kFall]); + }); + + /// PRD v3 AC12: pivot pairs share unitId, opposite dirs, one reverseOf. + test('AC12 pivot-symmetric reversible pairs', () { + final byUnit = >{}; + for (final edge in graph.definition.edges) { + final t = edge.transition; + if (t is! GraphTransitionReversible) continue; + (byUnit[t.unitId] ??= []).add(edge); + } + + expect(byUnit, isNotEmpty, reason: 'expected pivot units'); + // 3 stamina + 3 action + 3 jump coils = 9 pivot units. + expect(countReversiblePivotUnits(graph), 9); + + for (final entry in byUnit.entries) { + final pair = entry.value; + expect( + pair.length, + 2, + reason: 'unit ${entry.key} must have exactly 2 edges', + ); + final a = pair[0].transition! as GraphTransitionReversible; + final b = pair[1].transition! as GraphTransitionReversible; + expect(a.frameCount, b.frameCount); + expect( + {a.direction, b.direction}, + {TransitionDirection.forward, TransitionDirection.reverse}, + ); + + final withRev = pair + .where( + (e) => + (e.transition! as GraphTransitionReversible).reverseOf != null, + ) + .toList(); + expect(withRev.length, 1, reason: 'exactly one reverseOf'); + final inverse = withRev.single; + final invT = inverse.transition! as GraphTransitionReversible; + final base = pair.singleWhere((e) => e.id == invT.reverseOf); + expect(base.from, inverse.to); + expect(base.to, inverse.from); + expect(base.continuity, GraphContinuity.exactAuthored); + expect(inverse.continuity, GraphContinuity.exactReverse); + expect( + (base.transition! as GraphTransitionReversible).direction, + TransitionDirection.forward, + ); + expect(invT.direction, TransitionDirection.reverse); + } + }); + + /// PRD v3 AC15: hard-cut / one-shot edges have no inverse. + test('AC15 one-shots have no reversible inverse', () { + final oneShotIds = { + 'action.secure.reach', + 'action.reach.secure', + 'action.secure.fall', + 'action.failing.fall', + 'action.fall.secure', + }; + for (final edge in graph.definition.edges) { + if (!oneShotIds.contains(edge.id)) continue; + expect(edge.transition, isNull, reason: edge.id); + expect(edge.continuity, GraphContinuity.cut); + expect(edge.start, isA()); + } + }); + + test('every reverseOf points at a real base edge id', () { + final byId = { + for (final e in graph.definition.edges) e.id: e, + }; + var inverseCount = 0; + for (final edge in graph.definition.edges) { + final t = edge.transition; + if (t is! GraphTransitionReversible || t.reverseOf == null) continue; + inverseCount++; + expect(byId.containsKey(t.reverseOf), isTrue, reason: edge.id); + final base = byId[t.reverseOf!]!; + expect(base.from, edge.to); + expect(base.to, edge.from); + } + expect(inverseCount, 9); // one inverse per pivot unit + }); + + test('planFor jump left/right/up from secure', () { + expect(engine.planFor(kJumpLeft), [kJumpLeft]); + expect(engine.planFor(kJumpRight), [kJumpRight]); + expect(engine.planFor(kJumpUp), [kJumpUp]); + expect(engine.planFor(kJumpLeftCharge), [kJumpLeftCharge]); + expect(engine.planFor(kJumpUpCharge), [kJumpUpCharge]); + }); +} diff --git a/flutter/examples/rings_eight_way/README.md b/flutter/examples/rings_eight_way/README.md new file mode 100644 index 0000000..6e7c375 --- /dev/null +++ b/flutter/examples/rings_eight_way/README.md @@ -0,0 +1,29 @@ +# rings_eight_way + +Flutter demo for **AVAL rings + turn edges** (Dart port of the web fixture at +`fixtures/rings/v1-eight-way-facing/`). + +## What it exercises + +- `MotionGraphEngine.planFor(target)` — multi-hop dry run on a cyclic 8-way ring +- Sequential `request(step)` for each landing (hard-cut edges) +- Compass UI equivalent to the web `test.html` + +## Run + +```bash +cd flutter/examples/rings_eight_way +flutter pub get +flutter run -d chrome # or linux / macos +``` + +## Packages + +| Package | Role | +| --- | --- | +| `aval_graph` | rings (`planRingArc` / `planFor`), turn edges | +| `aval_flutter` | `AvalPlayerController.planFor` / `request` | +| `aval_format` | optional `.avl` parse when format rings land | + +Graph install does **not** require the VP9 avl to succeed — colored placeholders +render while decode backends catch up. diff --git a/flutter/examples/rings_eight_way/analysis_options.yaml b/flutter/examples/rings_eight_way/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/flutter/examples/rings_eight_way/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/flutter/examples/rings_eight_way/assets/rings.vp9.avl b/flutter/examples/rings_eight_way/assets/rings.vp9.avl new file mode 100644 index 0000000..0f76933 Binary files /dev/null and b/flutter/examples/rings_eight_way/assets/rings.vp9.avl differ diff --git a/flutter/examples/rings_eight_way/lib/main.dart b/flutter/examples/rings_eight_way/lib/main.dart new file mode 100644 index 0000000..4a29bbb --- /dev/null +++ b/flutter/examples/rings_eight_way/lib/main.dart @@ -0,0 +1,448 @@ +// AVAL Flutter — rings / turn-edges compass demo. +// +// Ports the web fixture at `fixtures/rings/v1-eight-way-facing/test.html`: +// 8 walk facings on a cyclic ring, `planFor()` multi-hop, sequential +// `request()` for reliable hard-cut unit switches. +// +// Video decode of the placeholder VP9 avl is best-effort (format rings + VP9 +// decode backends vary by platform). The compass + planFor + request path is +// always driven by the pure-Dart MotionGraphEngine. + +import 'dart:async'; + +import 'package:aval_flutter/aval_flutter.dart'; +import 'package:aval_graph/aval_graph.dart'; +import 'package:flutter/material.dart'; + +const _facings = [ + 'walk_n', + 'walk_ne', + 'walk_e', + 'walk_se', + 'walk_s', + 'walk_sw', + 'walk_w', + 'walk_nw', +]; + +const _colors = { + 'walk_n': Color(0xFFDC3C3C), + 'walk_ne': Color(0xFFDC8C3C), + 'walk_e': Color(0xFFC8C83C), + 'walk_se': Color(0xFF3CC850), + 'walk_s': Color(0xFF3CA0DC), + 'walk_sw': Color(0xFF5050DC), + 'walk_w': Color(0xFFA03CDC), + 'walk_nw': Color(0xFFDC3CB4), +}; + +const _labels = { + 'walk_n': 'N', + 'walk_ne': 'NE', + 'walk_e': 'E', + 'walk_se': 'SE', + 'walk_s': 'S', + 'walk_sw': 'SW', + 'walk_w': 'W', + 'walk_nw': 'NW', +}; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const RingsApp()); +} + +class RingsApp extends StatelessWidget { + const RingsApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'AVAL — rings eight-way', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true), + home: const RingsPage(), + ); + } +} + +class RingsPage extends StatefulWidget { + const RingsPage({super.key}); + + @override + State createState() => _RingsPageState(); +} + +class _RingsPageState extends State { + final AvalPlayerController _controller = AvalPlayerController(); + final List _log = []; + bool _busy = false; + String? _pending; + Timer? _tick; + + @override + void initState() { + super.initState(); + _boot(); + } + + Future _boot() async { + // Prefer the pure graph path so planFor/rings work even if format rings + // adaptation is not yet wired for this avl. + try { + final graph = validateMotionGraphDefinition(_facingGraphJson()); + _controller.installGraph(graph); + _controller.loaded = true; + _logLine('graph installed (rings + 16 turn edges)'); + // Best-effort avl load for real decode when platform supports it. + unawaited(_tryLoadAvl()); + } catch (e, st) { + _logLine('boot failed: $e'); + debugPrint('$e\n$st'); + } + _tick = Timer.periodic(const Duration(milliseconds: 33), (_) { + if (!_controller.loaded) return; + _controller.tickGraph(); + if (mounted) setState(() {}); + }); + if (mounted) setState(() {}); + } + + Future _tryLoadAvl() async { + try { + await _controller.loadAsset('assets/rings.vp9.avl'); + _logLine('avl load ok — decoder: ${_controller.decoderDescription}'); + } catch (e) { + _logLine('avl load skipped/failed (placeholder UI still works): $e'); + } + if (mounted) setState(() {}); + } + + void _logLine(String text) { + _log.insert(0, text); + if (_log.length > 80) _log.removeLast(); + } + + Future _go(String target) async { + if (_busy) { + _pending = target; + _logLine('queued $target'); + return; + } + _busy = true; + try { + while (true) { + final plan = _controller.planFor(target); + _logLine('planFor("$target") = $plan'); + if (plan == null) { + _logLine('unreachable $target'); + break; + } + if (plan.isEmpty) { + _logLine('already at $target'); + break; + } + for (final step in plan) { + final result = _controller.request(step); + _logLine( + 'request("$step") accepted=${result?.accepted} ' + '→ visual=${_controller.visualState}', + ); + // Hard-cut unit hops need a couple of ticks + unit decode. + for (var i = 0; i < 12; i++) { + _controller.tickGraph(); + await Future.delayed(const Duration(milliseconds: 40)); + } + await _controller.ensureUnitDecoded(_controller.currentUnitId()); + } + final next = _pending; + _pending = null; + if (next == null || next == _controller.visualState) break; + target = next; + } + } finally { + _busy = false; + if (mounted) setState(() {}); + } + } + + @override + void dispose() { + _tick?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final visual = _controller.visualState; + final color = _colors[visual] ?? const Color(0xFF333333); + final planPreview = _controller.planFor( + _pending ?? visual, + ); + + return Scaffold( + backgroundColor: const Color(0xFF111111), + body: Row( + children: [ + SizedBox( + width: 300, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + visual.isEmpty ? '—' : visual, + style: const TextStyle( + color: Color(0xFF88FF88), + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + 'readiness: ${_controller.loaded ? "ready" : "loading"}' + '${_busy ? " · walking" : ""}', + style: const TextStyle( + color: Color(0xFF888888), + fontSize: 11, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + _DirPad( + active: visual, + onSelect: _go, + ), + const SizedBox(height: 16), + const Text( + 'planFor', + style: TextStyle( + color: Color(0xFF999999), + fontSize: 12, + letterSpacing: 1, + ), + ), + Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF0A1A0A), + border: Border.all(color: const Color(0xFF2A3A2A)), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + planPreview == null + ? 'null' + : planPreview.isEmpty + ? '[]' + : planPreview.join('\n'), + style: const TextStyle( + color: Color(0xFF88FF88), + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(height: 16), + const Text( + 'Ring: facing.walk · 8 states · cyclic\n' + '16 turn edges · maxChainedSteps: 4', + style: TextStyle( + color: Color(0xFF8888FF), + fontSize: 11, + fontFamily: 'monospace', + height: 1.4, + ), + ), + ], + ), + ), + const VerticalDivider(width: 1, color: Color(0xFF333333)), + Expanded( + child: Column( + children: [ + Expanded( + child: Center( + child: Container( + width: 256, + height: 256, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _labels[visual] ?? '?', + style: const TextStyle( + color: Colors.white, + fontSize: 48, + fontWeight: FontWeight.bold, + ), + ), + Text( + visual, + style: const TextStyle( + color: Colors.white70, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ), + ), + Container( + height: 180, + width: double.infinity, + color: const Color(0xFF1A1A0A), + padding: const EdgeInsets.all(10), + child: ListView.builder( + itemCount: _log.length, + itemBuilder: (context, i) => Text( + _log[i], + style: const TextStyle( + color: Color(0xFFDDDD88), + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _DirPad extends StatelessWidget { + const _DirPad({required this.active, required this.onSelect}); + + final String active; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + Widget cell(String? state) { + if (state == null) return const SizedBox(width: 72, height: 48); + final selected = state == active; + return Padding( + padding: const EdgeInsets.all(2), + child: Material( + color: selected ? const Color(0xFF2A4A2A) : const Color(0xFF222222), + borderRadius: BorderRadius.circular(4), + child: InkWell( + onTap: () => onSelect(state), + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: 72, + height: 48, + child: Center( + child: Text( + _labels[state]!, + style: TextStyle( + color: selected + ? const Color(0xFF88FF88) + : const Color(0xFFCCCCCC), + fontFamily: 'monospace', + ), + ), + ), + ), + ), + ), + ); + } + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [cell(null), cell('walk_n'), cell(null)], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [cell('walk_nw'), cell(null), cell('walk_ne')], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [cell('walk_w'), cell(null), cell('walk_e')], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [cell('walk_sw'), cell(null), cell('walk_se')], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [cell(null), cell('walk_s'), cell(null)], + ), + ], + ); + } +} + +/// Authoring graph matching the rings fixture (hard-cut turn edges + ring). +Map _facingGraphJson() { + final states = >[ + for (final id in _facings) + { + 'id': id, + 'body': { + 'unitId': '$id.body', + 'kind': 'loop', + 'frameCount': 16, + 'ports': [ + { + 'id': 'default', + 'entryFrame': 0, + 'portalFrames': [0, 8], + } + ], + }, + }, + ]; + final edges = >[]; + for (var i = 0; i < _facings.length; i += 1) { + final from = _facings[i]; + for (final step in [1, -1]) { + final to = _facings[(i + step + _facings.length) % _facings.length]; + final short = from.replaceFirst('walk_', ''); + final shortTo = to.replaceFirst('walk_', ''); + edges.add({ + 'id': 'facing.walk.$short.$shortTo', + 'from': from, + 'to': to, + 'start': { + 'type': 'cut', + 'targetPort': 'default', + 'maxWaitFrames': 1, + }, + 'continuity': 'cut', + 'ring': 'facing.walk', + 'step': step, + }); + } + } + return { + 'initialState': 'walk_n', + 'states': states, + 'edges': edges, + 'rings': [ + { + 'id': 'facing.walk', + 'states': _facings, + 'cyclic': true, + 'tieBreak': 'forward', + 'maxChainedSteps': 4, + } + ], + }; +} diff --git a/flutter/examples/rings_eight_way/pubspec.lock b/flutter/examples/rings_eight_way/pubspec.lock new file mode 100644 index 0000000..bb87109 --- /dev/null +++ b/flutter/examples/rings_eight_way/pubspec.lock @@ -0,0 +1,234 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + aval_flutter: + dependency: "direct main" + description: + path: "../../packages/aval_flutter" + relative: true + source: path + version: "0.1.0" + aval_format: + dependency: "direct main" + description: + path: "../../packages/aval_format" + relative: true + source: path + version: "1.0.0" + aval_graph: + dependency: "direct main" + description: + path: "../../packages/aval_graph" + relative: true + source: path + version: "1.0.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.9.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/flutter/examples/rings_eight_way/pubspec.yaml b/flutter/examples/rings_eight_way/pubspec.yaml new file mode 100644 index 0000000..c35a0ef --- /dev/null +++ b/flutter/examples/rings_eight_way/pubspec.yaml @@ -0,0 +1,27 @@ +name: rings_eight_way +description: AVAL rings/turn-edges compass demo (Dart port of the web fixture). +publish_to: "none" +version: 1.0.0+1 + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + aval_flutter: + path: ../../packages/aval_flutter + aval_graph: + path: ../../packages/aval_graph + aval_format: + path: ../../packages/aval_format + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + assets: + - assets/rings.vp9.avl diff --git a/flutter/packages/aval_flutter/lib/aval_flutter.dart b/flutter/packages/aval_flutter/lib/aval_flutter.dart new file mode 100644 index 0000000..d1f87c6 --- /dev/null +++ b/flutter/packages/aval_flutter/lib/aval_flutter.dart @@ -0,0 +1,14 @@ +/// Drop-in Flutter widget for AVAL interactive video. +/// +/// The Flutter equivalent of the web player's `` custom element: +/// +/// ```dart +/// AvalView(asset: 'assets/my-character.avl/h264.avl') +/// ``` +library; + +export 'src/aval_player_controller.dart' show AvalPlayerController; +export 'src/aval_view.dart' show AvalView; +export 'src/decode/unit_decoder.dart' show unitDecoderDescription; +export 'src/frame_painter.dart' + show CpuFramePainter, GpuFramePainter, loadFramePaintProgram; diff --git a/flutter/packages/aval_flutter/lib/src/aval_player_controller.dart b/flutter/packages/aval_flutter/lib/src/aval_player_controller.dart new file mode 100644 index 0000000..fe2bb2f --- /dev/null +++ b/flutter/packages/aval_flutter/lib/src/aval_player_controller.dart @@ -0,0 +1,367 @@ +/// Loads an `.avl` asset, lazily decodes units via the platform decoder, and +/// drives the aval_graph MotionGraphEngine so the displayed unit follows the +/// graph state. +/// +/// Generalized from the grass_rabbit example's `RabbitController`: the +/// state→unit mapping, loop kinds, intro unit, and input bindings that were +/// hardcoded there are all derived from the manifest here. +/// +/// High-res (1280×720) RGBA is ~3.7 MiB/frame — keeping all units decoded at +/// once OOMs on desktop. Only the active unit is retained; others are decoded +/// on demand and previous units are evicted. +library; + +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:aval_format/aval_format.dart'; +import 'package:aval_graph/aval_graph.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; + +import 'decode/unit_decoder.dart'; + +class AvalPlayerController extends ChangeNotifier { + /// Decoded frames per unit id currently held in RAM. + final Map> unitFrames = >{}; + + final MotionGraphEngine engine = MotionGraphEngine(); + ValidatedMotionGraph? _graph; + + /// Bumped once per authored frame (see [tickGraph]); a cheap listenable for + /// per-tick UI (state badges, debug overlays) without notifying the whole + /// controller. + final ValueNotifier ticks = ValueNotifier(0); + + bool loaded = false; + Object? error; + + int canvasWidth = 1280; + int canvasHeight = 720; + int frameRateNumerator = 24; + int frameRateDenominator = 1; + + int codedWidth = 1280; + int codedHeight = 720; + + BigInt _contentOrdinal = BigInt.zero; + + Uint8List? _assetBytes; + CompiledManifest? _manifest; + List? _records; + AvalUnitDecoder? _decoder; + String _codecString = 'avc1.42E020'; + final Map> _decodeInFlight = >{}; + + /// Body unit for each graph state (from the manifest graph definition). + final Map _unitForState = {}; + + /// Units whose body kind is `loop` (others play once and hold last frame). + final Set _loopUnitIds = {}; + + /// The initial state's one-shot intro unit, if authored. + String? _introUnitId; + + /// Graph event for each input binding source (`activate`, `engagement.on`, + /// …) from the manifest's `bindings` array. + final Map _eventForSource = {}; + + /// Human-readable decode backend (for error/diagnostic UI). + String get decoderDescription => unitDecoderDescription; + + /// The graph state names, in manifest order. + List get stateNames => + _graph?.definition.states.map((s) => s.id).toList() ?? const []; + + /// The state currently being presented (updates when a transition commits). + String get visualState { + final snap = engine.snapshot(); + if (snap.presentation is GraphPresentationIntro) return 'intro'; + return snap.visualState ?? _graph?.definition.initialState ?? ''; + } + + /// The state the graph is heading toward (updates immediately on an input + /// event, before the transition commits). + String get requestedState { + final snap = engine.snapshot(); + return snap.requestedState ?? visualState; + } + + bool get isTransitioning => engine.snapshot().isTransitioning; + + int get totalFrames => + unitFrames.values.fold(0, (sum, list) => sum + list.length); + + /// Authoring frame count for a unit (from the manifest), even if not decoded. + int authoredFrameCount(String unitId) { + final unit = _manifest?.units.where((u) => u.id == unitId).firstOrNull; + return unit?.frameCount ?? 0; + } + + /// The unit the graph says should be on screen right now: the intro unit + /// while the initial state's one-shot plays, then the body unit for the + /// current state. + String currentUnitId() { + final snap = engine.snapshot(); + final initial = _graph?.definition.initialState ?? ''; + if (snap.presentation is GraphPresentationIntro) { + return _introUnitId ?? _unitForState[initial] ?? initial; + } + final vs = snap.visualState ?? initial; + return _unitForState[vs] ?? vs; + } + + bool isLoopUnit(String unitId) => _loopUnitIds.contains(unitId); + + /// Resolves a unit-local frame counter to a real frame index: loop units + /// wrap; finite units clamp to (and hold) the last frame. + int frameIndexInUnit(String unitId, int localFrame) { + final frames = unitFrames[unitId]; + if (frames == null || frames.isEmpty) return 0; + if (isLoopUnit(unitId)) return localFrame % frames.length; + return localFrame >= frames.length ? frames.length - 1 : localFrame; + } + + ui.Image? imageFor(String unitId, int localFrame) { + final frames = unitFrames[unitId]; + if (frames == null || frames.isEmpty) return null; + return frames[frameIndexInUnit(unitId, localFrame)]; + } + + /// Whether [unitId] currently has decoded frames ready to paint. + bool isUnitReady(String unitId) => unitFrames[unitId]?.isNotEmpty == true; + + /// Ensures [unitId] is decoded. Other units stay in memory until this unit + /// is ready, then they are evicted — avoids a black gap during the ~1s + /// decode. + Future ensureUnitDecoded(String unitId) { + if (unitId.isEmpty) return Future.value(); + if (unitFrames[unitId]?.isNotEmpty == true) { + // Already cached — free anything else so we do not hold 2 full units. + _evictUnitsExcept(unitId); + return Future.value(); + } + return _decodeInFlight.putIfAbsent(unitId, () async { + try { + // Decode first with the previous unit still resident so the UI can + // hold its last frame; only then evict. + await _decodeUnit(unitId); + _evictUnitsExcept(unitId); + } finally { + _decodeInFlight.remove(unitId); + } + }); + } + + /// Loads the `.avl` at [assetKey] from the root bundle. + Future loadAsset(String assetKey) async { + try { + final data = await rootBundle.load(assetKey); + await loadBytes( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes)); + } catch (e, st) { + error = e; + debugPrint('[aval] load failed: $e\n$st'); + notifyListeners(); + } + } + + /// Parses [bytes] as a format-1.0 `.avl` container and starts the graph. + Future loadBytes(Uint8List bytes) async { + try { + final parsed = parseFrontIndex(bytes); + final manifest = parsed.manifest; + canvasWidth = manifest.canvas.width; + canvasHeight = manifest.canvas.height; + frameRateNumerator = manifest.frameRate.numerator; + frameRateDenominator = manifest.frameRate.denominator; + + // Visible surface (matches decoder output after SPS crop when present). + final rendition = manifest.renditions.first; + final visible = rendition.alphaLayout.colorRect; + codedWidth = visible.width; + codedHeight = visible.height; + _codecString = rendition.codec; + + _assetBytes = bytes; + _manifest = manifest; + _records = parsed.records; + _decoder = createUnitDecoder(); + + installGraph(parsed.graph, bindings: manifest.bindings); + + // Decode only the initial unit so startup stays under RAM budget. + final initialUnit = currentUnitId(); + await ensureUnitDecoded(initialUnit); + + loaded = true; + notifyListeners(); + debugPrint( + '[aval] ready: canvas ${canvasWidth}x$canvasHeight, ' + 'coded ${codedWidth}x$codedHeight, unit "$initialUnit" ' + '${unitFrames[initialUnit]?.length ?? 0} frames, ' + 'bindings $_eventForSource', + ); + } catch (e, st) { + error = e; + debugPrint('[aval] load failed: $e\n$st'); + notifyListeners(); + } + } + + /// Installs a parsed graph (and optional input [bindings]) and starts the + /// engine — without any video bytes or decoder attached. [loadBytes] calls + /// this; logic tests can call it directly to drive the graph decode-free. + void installGraph(ValidatedMotionGraph graph, {List? bindings}) { + _graph = graph; + _unitForState.clear(); + _loopUnitIds.clear(); + _eventForSource.clear(); + for (final state in graph.definition.states) { + _unitForState[state.id] = state.body.unitId; + if (state.body.kind == GraphBodyKind.loop) { + _loopUnitIds.add(state.body.unitId); + } + } + final initial = graph.definition.states + .where((s) => s.id == graph.definition.initialState) + .firstOrNull; + _introUnitId = initial?.initialUnit?.unitId; + for (final binding in bindings ?? const []) { + _eventForSource[binding.source] = binding.event; + } + engine.install(graph); + engine.beginAnimated(); + } + + Future _decodeUnit(String unitId) async { + final bytes = _assetBytes; + final manifest = _manifest; + final records = _records; + final decoder = _decoder; + if (bytes == null || + manifest == null || + records == null || + decoder == null) { + throw StateError('decode before load completed'); + } + + final unit = manifest.units.where((u) => u.id == unitId).firstOrNull; + if (unit == null || unit.chunks.isEmpty) { + debugPrint('[aval] unknown/empty unit "$unitId"'); + return; + } + + final span = unit.chunks.first; + final unitRecords = []; + for (var c = 0; c < span.chunkCount; c++) { + final idx = span.chunkStart + c; + if (idx < 0 || idx >= records.length) break; + unitRecords.add(records[idx]); + } + if (unitRecords.isEmpty) return; + if (!unitRecords.first.randomAccess) { + throw StateError('unit $unitId first chunk is not a key frame'); + } + + final chunks = [ + for (final record in unitRecords) + EncodedUnitChunk( + data: Uint8List.sublistView( + bytes, + record.byteOffset, + record.byteOffset + record.byteLength, + ), + presentationTimestamp: record.presentationTimestamp, + duration: record.duration == 0 ? 1 : record.duration, + randomAccess: record.randomAccess, + displayedFrameCount: + record.displayedFrameCount == 0 ? 1 : record.displayedFrameCount, + ), + ]; + + final ordered = await decoder.decodeUnit( + unitId: unit.id, + unitFrameCount: unit.frameCount, + codedWidth: codedWidth, + codedHeight: codedHeight, + codecString: _codecString, + chunks: chunks, + ); + unitFrames[unitId] = ordered; + debugPrint( + '[aval] decoded unit "$unitId": ${ordered.length}/${unitRecords.length} ' + 'frames @ ${codedWidth}x$codedHeight', + ); + notifyListeners(); + } + + void _evictUnitsExcept(String keep) { + final toRemove = + unitFrames.keys.where((id) => id != keep).toList(growable: false); + for (final id in toRemove) { + final list = unitFrames.remove(id); + if (list == null) continue; + for (final img in list) { + img.dispose(); + } + debugPrint('[aval] evicted unit "$id"'); + } + } + + /// Advances the graph by exactly one authored frame. Called at the manifest + /// frame rate. + void tickGraph() { + if (_graph == null) return; + engine.tick(MotionGraphTickOptions(contentOrdinal: _contentOrdinal)); + _contentOrdinal += BigInt.one; + ticks.value = ticks.value + 1; + } + + /// Sends the graph event bound to an input [source] (`activate`, + /// `engagement.on`, `pointer.enter`, …), if the manifest binds one. + void sendSource(String source) { + if (_graph == null) return; + final event = _eventForSource[source]; + if (event != null) engine.send(event); + } + + /// Sends a graph event by name directly. + void send(String event) { + if (_graph != null) engine.send(event); + } + + /// Dry-run landings for a [request] toward [target], or `null` if unreachable. + /// Empty means already at [target]. Does not advance the graph. + List? planFor(String target) { + if (_graph == null) return null; + return engine.planFor(target); + } + + /// Requests a graph state by id (turn / locomotion). Preloads the body unit + /// for [target] so the first painted frame is ready after the edge commits. + MotionGraphResult? request(String target) { + if (_graph == null) return null; + final result = engine.request(target); + final unit = _unitForState[target]; + if (unit != null) { + // Fire-and-forget decode for the landing unit. + unawaited(ensureUnitDecoded(unit)); + } + notifyListeners(); + return result; + } + + @override + void dispose() { + for (final list in unitFrames.values) { + for (final img in list) { + img.dispose(); + } + } + unitFrames.clear(); + ticks.dispose(); + super.dispose(); + } +} diff --git a/flutter/packages/aval_flutter/lib/src/aval_view.dart b/flutter/packages/aval_flutter/lib/src/aval_view.dart new file mode 100644 index 0000000..0af48dd --- /dev/null +++ b/flutter/packages/aval_flutter/lib/src/aval_view.dart @@ -0,0 +1,247 @@ +/// The drop-in AVAL widget — Flutter's equivalent of the web player's +/// `` custom element. +/// +/// ```dart +/// AvalView(asset: 'assets/mansion-woman.avl/h264.avl') +/// ``` +/// +/// Owns the presentation clock (a [Ticker] gated to the manifest frame rate), +/// paints decoded frames through the GPU fragment-shader compositor (CPU +/// fallback while the program loads), and translates Flutter gestures into +/// the manifest's input-binding sources the same way the DOM element +/// translates pointer/focus events: +/// +/// - tap → `activate` +/// - mouse enter/leave → `pointer.enter`/`pointer.leave` + +/// `engagement.on`/`engagement.off` +/// - long-press → `engagement.on` (touch stand-in for hover) +library; + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import 'aval_player_controller.dart'; +import 'frame_painter.dart'; + +class AvalView extends StatefulWidget { + const AvalView({ + super.key, + this.asset, + this.controller, + this.fit = BoxFit.contain, + this.interactive = true, + this.onUnitChanged, + this.onDoubleTap, + this.loadingBuilder, + this.errorBuilder, + }) : assert(asset != null || controller != null, + 'AvalView needs an asset key or a pre-loaded controller'); + + /// Root-bundle key of the `.avl` to load (when [controller] is null or + /// not yet loaded). + final String? asset; + + /// External controller, for apps that want to read graph state or send + /// events. When null, the view creates and owns one internally. + final AvalPlayerController? controller; + + final BoxFit fit; + + /// Wire gestures to the manifest's input bindings. Disable to drive the + /// graph purely via [controller]. + final bool interactive; + + /// Fires when the displayed unit switches (including the first unit), + /// e.g. to run side-band audio in lockstep. + final void Function(String unitId, bool looping)? onUnitChanged; + + /// Optional double-tap passthrough (e.g. reset a surrounding zoom). Kept + /// separate from `activate` so double-tap does not fire the graph event. + final VoidCallback? onDoubleTap; + + final WidgetBuilder? loadingBuilder; + final Widget Function(BuildContext, Object error)? errorBuilder; + + @override + State createState() => _AvalViewState(); +} + +class _AvalViewState extends State + with SingleTickerProviderStateMixin { + late final AvalPlayerController _controller; + late final bool _ownsController; + late final Ticker _ticker; + + final ValueNotifier _displayImage = ValueNotifier(null); + ui.FragmentProgram? _frameProgram; + + String _currentUnit = ''; + int _unitLocalFrame = 0; + Duration? _lastTick; + double _accumulatedMicros = 0; + + /// After a unit switch, hold the previous paint until the new unit is + /// ready, then show its first frame once before advancing. + bool _awaitingFirstFrame = false; + bool _hovering = false; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? AvalPlayerController(); + _ownsController = widget.controller == null; + _ticker = createTicker(_onTick); + _controller.addListener(_onControllerChanged); + loadFramePaintProgram().then((program) { + if (!mounted) return; + setState(() => _frameProgram = program); + }).catchError((Object e) { + debugPrint('[aval] fragment program load failed, CPU fallback: $e'); + }); + if (_controller.loaded) { + _start(); + } else if (widget.asset != null) { + _controller.loadAsset(widget.asset!).then((_) { + if (mounted && _controller.loaded) _start(); + }); + } + } + + void _start() { + _currentUnit = _controller.currentUnitId(); + _displayImage.value = _controller.imageFor(_currentUnit, 0); + _notifyUnitChanged(_currentUnit); + if (!_ticker.isActive) _ticker.start(); + setState(() {}); + } + + /// User callbacks must not be able to kill the presentation clock. + void _notifyUnitChanged(String unitId) { + try { + widget.onUnitChanged?.call(unitId, _controller.isLoopUnit(unitId)); + } catch (e, st) { + debugPrint('[aval] onUnitChanged threw: $e\n$st'); + } + } + + void _onControllerChanged() { + // Load completion / decode progress / errors all arrive here. + if (!mounted) return; + setState(() {}); + } + + void _onTick(Duration elapsed) { + if (!_controller.loaded || _controller.totalFrames == 0) return; + final last = _lastTick; + _lastTick = elapsed; + if (last == null) return; + + // Gate the display/graph clock to the manifest frame rate rather than + // the display refresh rate. + final frameMicros = 1e6 * + _controller.frameRateDenominator / + _controller.frameRateNumerator; + _accumulatedMicros += (elapsed - last).inMicroseconds; + while (_accumulatedMicros >= frameMicros) { + _accumulatedMicros -= frameMicros; + // Graph: advance one authored frame so completion/portal boundaries + // fire. + _controller.tickGraph(); + // Video: follow the graph. When the graph's unit changes, restart the + // unit-local frame counter. + final unit = _controller.currentUnitId(); + if (unit != _currentUnit) { + _currentUnit = unit; + _unitLocalFrame = 0; + _awaitingFirstFrame = true; + _notifyUnitChanged(unit); + // High-res units are decoded on demand; keep painting the previous + // frame until the new unit has frames (no black flash). + unawaited(_controller.ensureUnitDecoded(unit)); + } else if (_controller.isUnitReady(unit) && !_awaitingFirstFrame) { + _unitLocalFrame++; + } + // Only advance the painted frame when the unit is ready; otherwise + // hold whatever is currently on screen (last frame of previous unit). + final next = _controller.imageFor(unit, _unitLocalFrame); + if (next != null) { + _displayImage.value = next; + _awaitingFirstFrame = false; + } + } + } + + void _setHover(bool hovering) { + if (_hovering == hovering) return; + _hovering = hovering; + _controller.sendSource(hovering ? 'pointer.enter' : 'pointer.leave'); + _controller.sendSource(hovering ? 'engagement.on' : 'engagement.off'); + } + + @override + void dispose() { + _controller.removeListener(_onControllerChanged); + _ticker.dispose(); + _displayImage.dispose(); + if (_ownsController) _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final error = _controller.error; + if (error != null) { + return widget.errorBuilder?.call(context, error) ?? + Center( + child: Text( + 'AVAL load failed: $error\n(${_controller.decoderDescription})', + textAlign: TextAlign.center, + ), + ); + } + if (!_controller.loaded) { + return widget.loadingBuilder?.call(context) ?? + const Center(child: CircularProgressIndicator()); + } + + Widget surface = RepaintBoundary( + child: CustomPaint( + painter: _frameProgram != null + ? GpuFramePainter( + image: _displayImage, + program: _frameProgram!, + fit: widget.fit, + ) + : CpuFramePainter(image: _displayImage, fit: widget.fit), + // Video repaints every frame, so the layer must never be + // raster-cached. Without this, an ancestor Transform (pan/zoom) + // triggers the raster cache to reuse a frozen snapshot of the video. + willChange: true, + child: const SizedBox.expand(), + ), + ); + + if (!widget.interactive && widget.onDoubleTap == null) return surface; + return MouseRegion( + onEnter: widget.interactive ? (_) => _setHover(true) : null, + onExit: widget.interactive ? (_) => _setHover(false) : null, + child: GestureDetector( + // The paint subtree has no hit-testable render objects of its own, so + // claim the whole surface for gestures. + behavior: HitTestBehavior.opaque, + onTap: widget.interactive + ? () => _controller.sendSource('activate') + : null, + // Touch has no hover: long-press stands in for engagement.on. + onLongPress: widget.interactive + ? () => _controller.sendSource('engagement.on') + : null, + onDoubleTap: widget.onDoubleTap, + child: surface, + ), + ); + } +} diff --git a/flutter/packages/aval_flutter/lib/src/decode/unit_decoder.dart b/flutter/packages/aval_flutter/lib/src/decode/unit_decoder.dart new file mode 100644 index 0000000..937da69 --- /dev/null +++ b/flutter/packages/aval_flutter/lib/src/decode/unit_decoder.dart @@ -0,0 +1,10 @@ +/// Platform-selecting entrypoint for the per-unit frame decoder. +/// +/// Native (`dart:ffi` available): the `aval_decode` Rust core. +/// Web (dart2js and dart2wasm): browser WebCodecs `VideoDecoder`. +library; + +export 'unit_decoder_types.dart'; +export 'unit_decoder_io.dart' + if (dart.library.js_interop) 'unit_decoder_web.dart' + show createUnitDecoder, unitDecoderDescription; diff --git a/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_io.dart b/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_io.dart new file mode 100644 index 0000000..73f7e68 --- /dev/null +++ b/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_io.dart @@ -0,0 +1,144 @@ +/// Native [AvalUnitDecoder]: the `aval_decode` Rust core via `dart:ffi`. +/// +/// This is the decode loop that used to live inline in +/// `rabbit_controller.dart` (`_decodeUnit`), moved behind the platform +/// interface so the web build can substitute WebCodecs. +library; + +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; + +import '../ffi/aval_ffi.dart'; +import 'unit_decoder_types.dart'; + +/// Resolved path to the aval_decode shared library. Set by +/// `--dart-define=AVAL_DECODE_LIB=` (see scripts/run.sh); otherwise +/// falls back to the default cargo release artifact relative to the example +/// dir. +/// +/// On iOS the crate is statically linked into the Runner binary (see +/// `ios/Flutter/*AvalDecode.xcconfig`); `avalDecodeUseProcess` is true and the +/// path is unused. +const String _libDefine = String.fromEnvironment('AVAL_DECODE_LIB'); +const bool avalDecodeUseProcess = + bool.fromEnvironment('AVAL_DECODE_USE_PROCESS', defaultValue: false); +const String _libFallback = + '../../rust/aval_decode/target/release/libaval_decode.dylib'; + +String get avalDecodeLibPath => + _libDefine.isNotEmpty ? _libDefine : _libFallback; + +/// Opens the native decoder: process lookup on iOS (static link), else dylib +/// path. +AvalDecodeBindings openAvalDecodeBindings() { + if (avalDecodeUseProcess || Platform.isIOS) { + debugPrint('[rabbit] opening aval_decode via DynamicLibrary.process()'); + return AvalDecodeBindings.openProcess(); + } + debugPrint('[rabbit] opening dylib: $avalDecodeLibPath'); + return AvalDecodeBindings.open(avalDecodeLibPath); +} + +AvalUnitDecoder createUnitDecoder() => FfiUnitDecoder(); + +/// Backend description surfaced in the error view (`main.dart`). +String get unitDecoderDescription => 'aval_decode FFI ($avalDecodeLibPath)'; + +class FfiUnitDecoder implements AvalUnitDecoder { + AvalDecodeBindings? _bindings; + + AvalDecodeBindings get bindings => _bindings ??= openAvalDecodeBindings(); + + @override + String get description => unitDecoderDescription; + + @override + Future> decodeUnit({ + required String unitId, + required int unitFrameCount, + required int codedWidth, + required int codedHeight, + required String codecString, + required List chunks, + }) async { + // Slot frames by presentation index so B-frame decode order still paints + // in display order. + final byPresentation = {}; + final session = AvalDecoderSession.create(bindings); + try { + session.configure(codedWidth: codedWidth, codedHeight: codedHeight); + session.activateGeneration(1); + for (var i = 0; i < chunks.length; i++) { + final chunk = chunks[i]; + final presentationIndex = chunk.presentationTimestamp; + final frameId = session.submit( + decodeIndex: i, + unitChunkCount: chunks.length, + unitFrameCount: unitFrameCount, + presentationTimestamp: presentationIndex, + duration: chunk.duration, + randomAccess: chunk.randomAccess, + data: chunk.data, + unitId: unitId, + presentationIndices: [presentationIndex], + presentationOrdinalBase: 0, + displayedFrameCount: chunk.displayedFrameCount, + ); + if (frameId == null) continue; + final image = session.takeFrame>( + (view) => _rgbaToImage( + Uint8List.fromList(view.rgba), + view.width, + view.height, + ), + ); + if (image == null) continue; + final img = await image; + // Prefer the decoder's unit_frame when available; fall back to PTS. + byPresentation[presentationIndex] = img; + } + // Drain any frames still queued after the last submit (B-frame tail). + while (true) { + final image = session.takeFrame>( + (view) => _rgbaToImage( + Uint8List.fromList(view.rgba), + view.width, + view.height, + ), + ); + if (image == null) break; + final img = await image; + // Without a presentation index on take, append at next free slot. + var slot = byPresentation.length; + while (byPresentation.containsKey(slot)) { + slot++; + } + byPresentation[slot] = img; + } + } finally { + session.disposeSession(); + } + + final ordered = []; + final keys = byPresentation.keys.toList()..sort(); + for (final k in keys) { + ordered.add(byPresentation[k]!); + } + return ordered; + } + + static Future _rgbaToImage(Uint8List rgba, int width, int height) { + final completer = Completer(); + ui.decodeImageFromPixels( + rgba, + width, + height, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } +} diff --git a/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_types.dart b/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_types.dart new file mode 100644 index 0000000..56c3e3d --- /dev/null +++ b/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_types.dart @@ -0,0 +1,43 @@ +/// Platform-neutral types for the per-unit frame decoder. +/// +/// `rabbit_controller.dart` assembles [EncodedUnitChunk]s from the parsed +/// `.avl` records and hands them to an [AvalUnitDecoder]; the implementation +/// is chosen by conditional import in `unit_decoder.dart` (Rust FFI on +/// native, WebCodecs on web). +library; + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +/// One format-1.0 encoded chunk (Annex-B access unit) of a unit. +class EncodedUnitChunk { + EncodedUnitChunk({ + required this.data, + required this.presentationTimestamp, + required this.duration, + required this.randomAccess, + required this.displayedFrameCount, + }); + + /// Annex-B bytes (a view into the asset buffer — do not mutate). + final Uint8List data; + final int presentationTimestamp; + final int duration; + final bool randomAccess; + final int displayedFrameCount; +} + +/// Decodes one unit's chunks into presentation-ordered RGBA [ui.Image]s. +abstract interface class AvalUnitDecoder { + Future> decodeUnit({ + required String unitId, + required int unitFrameCount, + required int codedWidth, + required int codedHeight, + required String codecString, + required List chunks, + }); + + /// Human-readable description of the decode backend (for error views). + String get description; +} diff --git a/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_web.dart b/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_web.dart new file mode 100644 index 0000000..628d463 --- /dev/null +++ b/flutter/packages/aval_flutter/lib/src/decode/unit_decoder_web.dart @@ -0,0 +1,200 @@ +/// Web [AvalUnitDecoder]: browser WebCodecs `VideoDecoder` via `dart:js_interop`. +/// +/// The `.avl` chunks are H.264 Annex-B access units; per the WebCodecs spec a +/// `VideoDecoderConfig` with no `description` means Annex-B input, so the +/// chunks are submitted as-is — no WASM codec or transcoding involved. This is +/// the same decode engine the original TypeScript player +/// (`packages/player-web/src/decoder-worker`) uses. +/// +/// Compiles under both dart2js and dart2wasm (`flutter build web --wasm`): +/// only `dart:js_interop`, no `dart:html`. +library; + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'unit_decoder_types.dart'; + +AvalUnitDecoder createUnitDecoder() => WebCodecsUnitDecoder(); + +/// Backend description surfaced in the error view (`main.dart`). +String get unitDecoderDescription => 'WebCodecs VideoDecoder'; + +class WebCodecsUnitDecoder implements AvalUnitDecoder { + @override + String get description => unitDecoderDescription; + + @override + Future> decodeUnit({ + required String unitId, + required int unitFrameCount, + required int codedWidth, + required int codedHeight, + required String codecString, + required List chunks, + }) async { + final byPresentation = {}; + final copies = >[]; + Object? decodeError; + + final decoder = VideoDecoder( + VideoDecoderInit( + output: (VideoFrame frame) { + final timestamp = frame.timestamp; + copies.add( + _frameToImage(frame).then((img) { + byPresentation[timestamp] = img; + }).catchError((Object e) { + decodeError ??= e; + }), + ); + }.toJS, + error: (JSObject e) { + decodeError ??= StateError('VideoDecoder error: $e'); + }.toJS, + ), + ); + + try { + decoder.configure( + VideoDecoderConfig( + codec: codecString, + codedWidth: codedWidth, + codedHeight: codedHeight, + optimizeForLatency: true, + ), + ); + for (final chunk in chunks) { + decoder.decode( + EncodedVideoChunk( + EncodedVideoChunkInit( + type: chunk.randomAccess ? 'key' : 'delta', + timestamp: chunk.presentationTimestamp, + duration: chunk.duration, + data: chunk.data.toJS, + ), + ), + ); + } + await decoder.flush().toDart; + } finally { + if (decoder.state != 'closed') decoder.close(); + } + await Future.wait(copies); + + if (decodeError != null) { + throw StateError('WebCodecs decode of unit "$unitId" failed: ' + '$decodeError (codec $codecString)'); + } + + final keys = byPresentation.keys.toList()..sort(); + return [for (final k in keys) byPresentation[k]!]; + } + + /// Copies one decoded [VideoFrame] out as an RGBA [ui.Image] and closes it. + static Future _frameToImage(VideoFrame frame) async { + final width = frame.displayWidth; + final height = frame.displayHeight; + try { + // copyTo with a format converts YUV→RGBA in the browser; with no + // explicit layout the plane is tightly packed (stride == width * 4). + // + // The destination must stay a JS-heap array: under dart2wasm, + // `Uint8List.toJS` COPIES (Dart typed data lives in the wasm heap), so + // handing copyTo a throwaway copy leaves the Dart buffer all zeros — + // a fully transparent image. + final options = VideoFrameCopyToOptions(format: 'RGBA'); + final jsBuffer = Uint8List(frame.allocationSize(options)).toJS; + await frame.copyTo(jsBuffer, options).toDart; + return _rgbaToImage(jsBuffer.toDart, width, height); + } finally { + frame.close(); + } + } + + /// `ui.decodeImageFromPixels` renders blank images under the wasm + /// renderers; `ImageDescriptor.raw` is the supported path on web. + static Future _rgbaToImage( + Uint8List rgba, int width, int height) async { + final buffer = await ui.ImmutableBuffer.fromUint8List(rgba); + final descriptor = ui.ImageDescriptor.raw( + buffer, + width: width, + height: height, + pixelFormat: ui.PixelFormat.rgba8888, + ); + try { + final codec = await descriptor.instantiateCodec(); + try { + final frame = await codec.getNextFrame(); + return frame.image; + } finally { + codec.dispose(); + } + } finally { + descriptor.dispose(); + buffer.dispose(); + } + } +} + +// --------------------------------------------------------------------------- +// Minimal WebCodecs interop — only the members used above. +// Mirrors https://www.w3.org/TR/webcodecs/. +// --------------------------------------------------------------------------- + +@JS('VideoDecoder') +extension type VideoDecoder._(JSObject _) implements JSObject { + external VideoDecoder(VideoDecoderInit init); + external String get state; + external void configure(VideoDecoderConfig config); + external void decode(EncodedVideoChunk chunk); + external JSPromise flush(); + external void close(); +} + +extension type VideoDecoderInit._(JSObject _) implements JSObject { + external VideoDecoderInit({JSFunction output, JSFunction error}); +} + +extension type VideoDecoderConfig._(JSObject _) implements JSObject { + external VideoDecoderConfig({ + String codec, + int codedWidth, + int codedHeight, + bool optimizeForLatency, + }); +} + +@JS('EncodedVideoChunk') +extension type EncodedVideoChunk._(JSObject _) implements JSObject { + external EncodedVideoChunk(EncodedVideoChunkInit init); +} + +extension type EncodedVideoChunkInit._(JSObject _) implements JSObject { + external EncodedVideoChunkInit({ + String type, + int timestamp, + int duration, + JSUint8Array data, + }); +} + +@JS('VideoFrame') +extension type VideoFrame._(JSObject _) implements JSObject { + external int get timestamp; + external int get displayWidth; + external int get displayHeight; + external int allocationSize(VideoFrameCopyToOptions options); + external JSPromise copyTo( + JSUint8Array destination, + VideoFrameCopyToOptions options, + ); + external void close(); +} + +extension type VideoFrameCopyToOptions._(JSObject _) implements JSObject { + external VideoFrameCopyToOptions({String format}); +} diff --git a/flutter/packages/aval_flutter/lib/src/ffi/aval_ffi.dart b/flutter/packages/aval_flutter/lib/src/ffi/aval_ffi.dart new file mode 100644 index 0000000..fdb5ac1 --- /dev/null +++ b/flutter/packages/aval_flutter/lib/src/ffi/aval_ffi.dart @@ -0,0 +1,395 @@ +/// Hand-written `dart:ffi` bindings for the `aval_decode` Rust C ABI. +/// +/// Mirrors `flutter/rust/aval_decode/src/ffi.rs` exactly. No code generation +/// and no `flutter_rust_bridge` — plain `dart:ffi` keeps this example +/// dependency-free (only `package:ffi` for `malloc`). +/// +/// Frame ownership (see ffi.rs module docs): a decoded frame's RGBA bytes stay +/// owned by the Rust session between `takeFrame` and `releaseFrame`. This +/// example copies each frame into a `ui.Image` and releases it synchronously, +/// so the native buffer never outlives the copy. The `NativeFinalizer` here is +/// wired to `aval_decode_session_destroy` (a single-pointer C signature that +/// matches the `NativeFinalizerFunction` ABI exactly) as a GC-safe backstop for +/// the *session* handle; per-frame `aval_decode_release_frame` takes two +/// arguments (handle + frame_id) and so cannot itself be a `NativeFinalizer` +/// callback — it is called manually right after each copy instead. +library; + +import 'dart:ffi' as ffi; + +import 'package:ffi/ffi.dart'; + +// --------------------------------------------------------------------------- +// AvalDecodeStatus (error.rs) — repr(C) enum, C int (4 bytes). +// --------------------------------------------------------------------------- +const int statusOk = 0; +const int statusNullPointer = 1; +const int statusInvalidArgument = 2; +const int statusDecodeFailed = 3; +const int statusNoFrameAvailable = 4; +const int statusDecodedByteBudgetExceeded = 5; +const int statusDecoderOutputInvalid = 6; +const int statusFrameReleaseInvalid = 7; +const int statusPanicked = 8; +const int statusUnsupported = 9; + +String statusName(int status) => switch (status) { + statusOk => 'ok', + statusNullPointer => 'null pointer', + statusInvalidArgument => 'invalid argument', + statusDecodeFailed => 'decode failed', + statusNoFrameAvailable => 'no frame available', + statusDecodedByteBudgetExceeded => 'decoded byte budget exceeded', + statusDecoderOutputInvalid => 'decoder output invalid', + statusFrameReleaseInvalid => 'frame release invalid', + statusPanicked => 'panicked at FFI boundary', + statusUnsupported => 'unsupported codec configuration', + _ => 'unknown status $status', + }; + +// --------------------------------------------------------------------------- +// #[repr(C)] structs (ffi.rs). Dart inserts the same field padding as Rust +// repr(C) on a 64-bit target, so the layouts match byte-for-byte. +// --------------------------------------------------------------------------- + +/// Mirrors `AvalDecodeConfig`. +final class AvalDecodeConfig extends ffi.Struct { + /// Codec family (`0=h264, 1=h265, 2=vp9, 3=av1`). Only `0` configures. + @ffi.Uint32() + external int codec; + /// Luma bit depth (must be `8` for H.264). + @ffi.Uint32() + external int bitDepth; + @ffi.Uint32() + external int codedWidth; + @ffi.Uint32() + external int codedHeight; + @ffi.Uint32() + external int maxOutstandingFrames; + @ffi.Uint64() + external int maxDecodedBytes; +} + +/// Mirrors `AvalDecodeChunk` (format-1.0 wire `DecoderWorkerSample`). +final class AvalDecodeChunk extends ffi.Struct { + @ffi.Uint64() + external int unitInstance; + @ffi.Uint64() + external int decodeIndex; + @ffi.Uint64() + external int unitChunkCount; + @ffi.Uint64() + external int unitFrameCount; + @ffi.Uint64() + external int presentationOrdinalBase; + @ffi.Uint64() + external int presentationTimestamp; + @ffi.Uint64() + external int duration; + @ffi.Uint64() + external int displayedFrameCount; + @ffi.Uint8() + external int randomAccess; + external ffi.Pointer data; + @ffi.IntPtr() + external int dataLen; + external ffi.Pointer unitId; + @ffi.IntPtr() + external int unitIdLen; + external ffi.Pointer presentationIndices; + @ffi.IntPtr() + external int presentationIndicesLen; +} + +/// Mirrors `AvalSubmitResult`. +final class AvalSubmitResult extends ffi.Struct { + @ffi.Uint8() + external int producedFrame; + @ffi.Uint64() + external int frameId; +} + +/// Mirrors `AvalDecodeFrame`. +final class AvalDecodeFrame extends ffi.Struct { + @ffi.Uint64() + external int frameId; + external ffi.Pointer data; + @ffi.IntPtr() + external int len; + @ffi.Uint32() + external int width; + @ffi.Uint32() + external int height; + @ffi.Uint64() + external int ordinal; + @ffi.Uint64() + external int timestamp; + @ffi.Uint64() + external int duration; + @ffi.Uint64() + external int unitInstance; + @ffi.Uint64() + external int unitFrame; + @ffi.Uint64() + external int decodeIndex; +} + +// --------------------------------------------------------------------------- +// Native function typedefs. +// --------------------------------------------------------------------------- +typedef _CreateNative = ffi.Pointer Function(); +typedef _DestroyNative = ffi.Void Function(ffi.Pointer); +typedef _DestroyDart = void Function(ffi.Pointer); +typedef _ConfigureNative = ffi.Int32 Function( + ffi.Pointer, ffi.Pointer); +typedef _ConfigureDart = int Function( + ffi.Pointer, ffi.Pointer); +typedef _GenNative = ffi.Int32 Function(ffi.Pointer, ffi.Uint64); +typedef _GenDart = int Function(ffi.Pointer, int); +typedef _SubmitNative = ffi.Int32 Function(ffi.Pointer, ffi.Uint64, + ffi.Pointer, ffi.Pointer); +typedef _SubmitDart = int Function(ffi.Pointer, int, + ffi.Pointer, ffi.Pointer); +typedef _TakeNative = ffi.Int32 Function( + ffi.Pointer, ffi.Pointer); +typedef _TakeDart = int Function( + ffi.Pointer, ffi.Pointer); +typedef _ReleaseNative = ffi.Int32 Function(ffi.Pointer, ffi.Uint64); +typedef _ReleaseDart = int Function(ffi.Pointer, int); +typedef _DisposeNative = ffi.Int32 Function(ffi.Pointer); +typedef _DisposeDart = int Function(ffi.Pointer); + +/// Thrown when a native call returns a non-`Ok` status. +class AvalDecodeException implements Exception { + AvalDecodeException(this.op, this.status); + final String op; + final int status; + @override + String toString() => 'AvalDecodeException($op -> ${statusName(status)})'; +} + +/// Loads and binds the `aval_decode` shared library. +class AvalDecodeBindings { + AvalDecodeBindings._(ffi.DynamicLibrary lib) + : sessionCreate = lib.lookupFunction<_CreateNative, _CreateNative>( + 'aval_decode_session_create'), + sessionDestroy = lib.lookupFunction<_DestroyNative, _DestroyDart>( + 'aval_decode_session_destroy'), + configure = lib.lookupFunction<_ConfigureNative, _ConfigureDart>( + 'aval_decode_configure'), + activateGeneration = lib.lookupFunction<_GenNative, _GenDart>( + 'aval_decode_activate_generation'), + submitChunk = lib.lookupFunction<_SubmitNative, _SubmitDart>( + 'aval_decode_submit_chunk'), + takeFrame = + lib.lookupFunction<_TakeNative, _TakeDart>('aval_decode_take_frame'), + releaseFrame = lib.lookupFunction<_ReleaseNative, _ReleaseDart>( + 'aval_decode_release_frame'), + dispose = + lib.lookupFunction<_DisposeNative, _DisposeDart>('aval_decode_dispose'), + destroyPointer = lib.lookup>( + 'aval_decode_session_destroy'); + + factory AvalDecodeBindings.open(String path) => + AvalDecodeBindings._(ffi.DynamicLibrary.open(path)); + + /// Looks up symbols in the current process (used when `aval_decode` is + /// statically linked into the iOS Runner binary via `-force_load`). + factory AvalDecodeBindings.openProcess() => + AvalDecodeBindings._(ffi.DynamicLibrary.process()); + + final ffi.Pointer Function() sessionCreate; + final void Function(ffi.Pointer) sessionDestroy; + final int Function(ffi.Pointer, ffi.Pointer) + configure; + final int Function(ffi.Pointer, int) activateGeneration; + final int Function(ffi.Pointer, int, ffi.Pointer, + ffi.Pointer) submitChunk; + final int Function(ffi.Pointer, ffi.Pointer) + takeFrame; + final int Function(ffi.Pointer, int) releaseFrame; + final int Function(ffi.Pointer) dispose; + + /// Native function pointer for `aval_decode_session_destroy`, usable as a + /// [ffi.NativeFinalizer] callback (single-pointer signature). + final ffi.Pointer)>> + destroyPointer; +} + +/// High-level, safe wrapper around one decoder session. +class AvalDecoderSession implements ffi.Finalizable { + AvalDecoderSession._(this._bindings, this._handle) { + _finalizer.attach(this, _handle.cast(), detach: this); + } + + factory AvalDecoderSession.create(AvalDecodeBindings bindings) { + final handle = bindings.sessionCreate(); + if (handle == ffi.nullptr) { + throw StateError('aval_decode_session_create returned null'); + } + return AvalDecoderSession._(bindings, handle); + } + + final AvalDecodeBindings _bindings; + final ffi.Pointer _handle; + bool _destroyed = false; + + late final ffi.NativeFinalizer _finalizer = + ffi.NativeFinalizer(_bindings.destroyPointer.cast()); + + void _check(String op, int status) { + if (status != statusOk) throw AvalDecodeException(op, status); + } + + void configure({ + required int codedWidth, + required int codedHeight, + int codec = 0, // H.264 + int bitDepth = 8, + int maxOutstandingFrames = 4, + int? maxDecodedBytes, + }) { + final cfg = calloc(); + try { + cfg.ref + ..codec = codec + ..bitDepth = bitDepth + ..codedWidth = codedWidth + ..codedHeight = codedHeight + ..maxOutstandingFrames = maxOutstandingFrames + ..maxDecodedBytes = + maxDecodedBytes ?? codedWidth * codedHeight * 4 * maxOutstandingFrames; + _check('configure', _bindings.configure(_handle, cfg)); + } finally { + calloc.free(cfg); + } + } + + void activateGeneration(int generation) => _check( + 'activateGeneration', _bindings.activateGeneration(_handle, generation)); + + /// Submits one format-1.0 encoded chunk. For H.264, [displayedFrameCount] is + /// typically 1 and [presentationIndices] is a single index. + /// + /// Returns the produced frame id, or null while the decoder is priming. + int? submit({ + required int decodeIndex, + required int unitChunkCount, + required int unitFrameCount, + required int presentationTimestamp, + required int duration, + required bool randomAccess, + required List data, + required String unitId, + required List presentationIndices, + int unitInstance = 0, + int presentationOrdinalBase = 0, + int displayedFrameCount = 1, + int generation = 1, + }) { + if (presentationIndices.length != displayedFrameCount) { + throw ArgumentError( + 'presentationIndices.length (${presentationIndices.length}) ' + 'must equal displayedFrameCount ($displayedFrameCount)'); + } + final chunk = calloc(); + final dataPtr = calloc(data.length); + final unitIdBytes = unitId.codeUnits; + final unitIdPtr = calloc(unitIdBytes.length); + final indicesPtr = displayedFrameCount > 0 + ? calloc(displayedFrameCount) + : ffi.nullptr; + final result = calloc(); + try { + dataPtr.asTypedList(data.length).setAll(0, data); + unitIdPtr.asTypedList(unitIdBytes.length).setAll(0, unitIdBytes); + if (displayedFrameCount > 0) { + final indices = indicesPtr.asTypedList(displayedFrameCount); + for (var i = 0; i < displayedFrameCount; i++) { + indices[i] = presentationIndices[i]; + } + } + chunk.ref + ..unitInstance = unitInstance + ..decodeIndex = decodeIndex + ..unitChunkCount = unitChunkCount + ..unitFrameCount = unitFrameCount + ..presentationOrdinalBase = presentationOrdinalBase + ..presentationTimestamp = presentationTimestamp + ..duration = duration + ..displayedFrameCount = displayedFrameCount + ..randomAccess = randomAccess ? 1 : 0 + ..data = dataPtr + ..dataLen = data.length + ..unitId = unitIdPtr + ..unitIdLen = unitIdBytes.length + ..presentationIndices = indicesPtr + ..presentationIndicesLen = displayedFrameCount; + _check( + 'submit', _bindings.submitChunk(_handle, generation, chunk, result)); + return result.ref.producedFrame != 0 ? result.ref.frameId : null; + } finally { + calloc.free(chunk); + calloc.free(dataPtr); + calloc.free(unitIdPtr); + if (indicesPtr != ffi.nullptr) calloc.free(indicesPtr); + calloc.free(result); + } + } + + /// Takes the next ready frame, invokes [use] with a zero-copy view over the + /// Rust-owned RGBA bytes, then releases the frame. Returns null if no frame + /// is queued. The view must not be retained past [use]; copy inside it. + R? takeFrame(R Function(DecodedFrameView view) use) { + final out = calloc(); + try { + final status = _bindings.takeFrame(_handle, out); + if (status == statusNoFrameAvailable) return null; + _check('takeFrame', status); + final f = out.ref; + final view = DecodedFrameView( + rgba: f.data.asTypedList(f.len), + width: f.width, + height: f.height, + ordinal: f.ordinal, + unitFrame: f.unitFrame, + decodeIndex: f.decodeIndex, + ); + try { + return use(view); + } finally { + _check('releaseFrame', _bindings.releaseFrame(_handle, f.frameId)); + } + } finally { + calloc.free(out); + } + } + + void disposeSession() { + if (_destroyed) return; + _bindings.dispose(_handle); + _finalizer.detach(this); + _bindings.sessionDestroy(_handle); + _destroyed = true; + } +} + +/// A borrowed view over a Rust-owned decoded frame. Valid only for the duration +/// of the [AvalDecoderSession.takeFrame] callback. +class DecodedFrameView { + DecodedFrameView({ + required this.rgba, + required this.width, + required this.height, + required this.ordinal, + required this.unitFrame, + required this.decodeIndex, + }); + + final List rgba; + final int width; + final int height; + final int ordinal; + final int unitFrame; + final int decodeIndex; +} diff --git a/flutter/packages/aval_flutter/lib/src/frame_painter.dart b/flutter/packages/aval_flutter/lib/src/frame_painter.dart new file mode 100644 index 0000000..a903b98 --- /dev/null +++ b/flutter/packages/aval_flutter/lib/src/frame_painter.dart @@ -0,0 +1,120 @@ +/// Frame compositors for the decoded AVAL picture. +/// +/// [GpuFramePainter] runs the fit/UV-remap math in a `dart:ui` +/// `FragmentProgram` (Impeller/SkSL) shader, per `flutter/ARCHITECTURE.md` +/// §3.2 — the same work the web player does in a WebGL2 fragment shader. +/// [CpuFramePainter] is the `Canvas.drawImageRect` fallback used until the +/// program has loaded (or if it fails to). +library; + +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +/// Loads and caches the compiled `shaders/frame.frag` program once per +/// isolate. `FragmentProgram.fromAsset` itself already memoizes by asset +/// key, but caching the [Future] here avoids redundant concurrent loads. +Future loadFramePaintProgram() => _programFuture ??= + ui.FragmentProgram.fromAsset('packages/aval_flutter/shaders/frame.frag'); + +Future? _programFuture; + +/// Paints the current decoded frame with a GPU fragment shader instead of +/// [Canvas.drawImageRect]. Uniform layout mirrors the web player's +/// `FRAME_FRAGMENT_SHADER_SOURCE` (`u_color_uv`, `u_alpha_uv`, +/// `u_output_rect`, `u_has_alpha`) so a future packed-alpha `aval_decode` +/// profile only needs to start passing `alphaUv`/`hasAlpha`, not touch the +/// shader or this painter's structure. +class GpuFramePainter extends CustomPainter { + GpuFramePainter({ + required this.image, + required this.program, + this.fit = BoxFit.contain, + }) : super(repaint: image); + + final ValueListenable image; + final ui.FragmentProgram program; + final BoxFit fit; + + @override + void paint(Canvas canvas, Size size) { + final img = image.value; + if (img == null) return; + + final srcSize = Size(img.width.toDouble(), img.height.toDouble()); + final fitted = applyBoxFit(fit, srcSize, size); + final inputSubrect = Alignment.center.inscribe( + fitted.source, + Offset.zero & srcSize, + ); + final dstRect = Alignment.center.inscribe( + fitted.destination, + Offset.zero & size, + ); + if (dstRect.width <= 0 || dstRect.height <= 0) return; + + final shader = program.fragmentShader() + // u_color_uv: normalized offset/scale of the fitted source crop within + // the full decoded picture. + ..setFloat(0, inputSubrect.left / srcSize.width) + ..setFloat(1, inputSubrect.top / srcSize.height) + ..setFloat(2, inputSubrect.width / srcSize.width) + ..setFloat(3, inputSubrect.height / srcSize.height) + // u_alpha_uv: unused while u_has_alpha is 0; zeroed for determinism. + ..setFloat(4, 0) + ..setFloat(5, 0) + ..setFloat(6, 0) + ..setFloat(7, 0) + // u_output_rect: destination rect in this painter's local coordinate + // space, matching FlutterFragCoord()'s coordinate system. + ..setFloat(8, dstRect.left) + ..setFloat(9, dstRect.top) + ..setFloat(10, dstRect.width) + ..setFloat(11, dstRect.height) + // u_has_alpha: aval_decode has no packed-alpha profile yet. + ..setFloat(12, 0) + ..setImageSampler(0, img); + + canvas.drawRect(dstRect, Paint()..shader = shader); + } + + @override + bool shouldRepaint(GpuFramePainter oldDelegate) => + oldDelegate.image != image || + oldDelegate.fit != fit || + oldDelegate.program != program; +} + +/// CPU-path fallback compositor used while the fragment program loads. +class CpuFramePainter extends CustomPainter { + CpuFramePainter({required this.image, this.fit = BoxFit.contain}) + : super(repaint: image); + + final ValueListenable image; + final BoxFit fit; + + @override + void paint(Canvas canvas, Size size) { + final img = image.value; + if (img == null) return; + final src = + Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()); + final fitted = applyBoxFit(fit, src.size, size); + final inputSubrect = Alignment.center.inscribe(fitted.source, src); + final dstRect = Alignment.center.inscribe( + fitted.destination, + Offset.zero & size, + ); + canvas.drawImageRect( + img, + inputSubrect, + dstRect, + Paint()..filterQuality = FilterQuality.high, + ); + } + + @override + bool shouldRepaint(CpuFramePainter oldDelegate) => + oldDelegate.image != image || oldDelegate.fit != fit; +} diff --git a/flutter/packages/aval_flutter/pubspec.yaml b/flutter/packages/aval_flutter/pubspec.yaml new file mode 100644 index 0000000..50fe3df --- /dev/null +++ b/flutter/packages/aval_flutter/pubspec.yaml @@ -0,0 +1,30 @@ +name: aval_flutter +description: > + Drop-in Flutter widget for AVAL interactive video — the Flutter equivalent + of the web player's custom element. Wraps the aval_format + container parser, the aval_graph MotionGraphEngine, per-platform frame + decoding (aval_decode Rust FFI on native, WebCodecs on web), and a GPU + fragment-shader compositor behind a single AvalView widget. +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + aval_format: + path: ../aval_format + aval_graph: + path: ../aval_graph + ffi: ^2.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + shaders: + - shaders/frame.frag diff --git a/flutter/packages/aval_flutter/shaders/frame.frag b/flutter/packages/aval_flutter/shaders/frame.frag new file mode 100644 index 0000000..8eb7e00 --- /dev/null +++ b/flutter/packages/aval_flutter/shaders/frame.frag @@ -0,0 +1,43 @@ +// GPU frame compositor for the decoded aval_decode RGBA picture. +// +// Line-by-line port of the web player's FRAME_FRAGMENT_SHADER_SOURCE +// (packages/player-web/src/runtime/frame-renderer-browser.ts), adapted per +// flutter/ARCHITECTURE.md §3.2: +// - FlutterFragCoord() replaces gl_FragCoord. +// - No precision qualifiers (not present in the Impeller GLSL-ES subset). +// - One sampler2D (aval_decode has no packed-alpha profile yet, so +// u_has_alpha is always 0 today); u_alpha_uv/u_has_alpha stay in the +// uniform layout so a future packed-alpha decode core needs only to +// flip that one value, not touch this shader. +// - No V-flip: Skia/Impeller image textures already use a top-left UV +// origin matching Canvas.drawImage, unlike WebGL's bottom-left origin. +#include + +uniform vec4 u_color_uv; +uniform vec4 u_alpha_uv; +uniform vec4 u_output_rect; +uniform float u_has_alpha; + +uniform sampler2D u_frame; + +out vec4 fragColor; + +void main() { + vec2 output_index = FlutterFragCoord().xy - u_output_rect.xy - vec2(0.5); + vec2 output_span = max(u_output_rect.zw - vec2(1.0), vec2(1.0)); + vec2 sample_uv = output_index / output_span; + if (u_output_rect.z <= 1.0) sample_uv.x = 0.5; + if (u_output_rect.w <= 1.0) sample_uv.y = 0.5; + sample_uv = clamp(sample_uv, vec2(0.0), vec2(1.0)); + + vec2 color_uv = u_color_uv.xy + sample_uv * u_color_uv.zw; + vec3 color = texture(u_frame, color_uv).rgb; + + float alpha = 1.0; + if (u_has_alpha > 0.5) { + vec2 alpha_uv = u_alpha_uv.xy + sample_uv * u_alpha_uv.zw; + alpha = clamp(texture(u_frame, alpha_uv).r, 0.0, 1.0); + } + + fragColor = vec4(color * alpha, alpha); +} diff --git a/flutter/packages/aval_format/analysis_options.yaml b/flutter/packages/aval_format/analysis_options.yaml new file mode 100644 index 0000000..a3e657b --- /dev/null +++ b/flutter/packages/aval_format/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + todo: ignore diff --git a/flutter/packages/aval_format/lib/aval_format.dart b/flutter/packages/aval_format/lib/aval_format.dart new file mode 100644 index 0000000..3c76b8a --- /dev/null +++ b/flutter/packages/aval_format/lib/aval_format.dart @@ -0,0 +1,62 @@ +/// Canonical parser, validator, and types for AVAL binary assets. +/// +/// Pure Dart port of `@pixel-point/aval-format`, mirroring +/// `packages/format/src/index.ts`. +library; + +export 'src/constants.dart' + show + chunkIndexHeaderLength, + chunkIndexMagic, + chunkIndexRecordLength, + formatAlignment, + formatDefaultBudgets, + formatHeaderLength, + formatMagic, + formatVersionMajor, + formatVersionMinor, + identifierPattern, + sha256HexPattern, + resolveFormatBudgets; +export 'src/errors.dart' + show FormatError, FormatErrorCode, FormatErrorDetails, isFormatError; +export 'src/canonical_json.dart' + show + parseStrictJson, + serializeCanonicalJson, + serializeCanonicalJsonWithLimits, + CanonicalJsonWriteLimits, + compareUtf8Strings; +export 'src/h264/index.dart'; +export 'src/graph_adapter.dart' show adaptManifestToMotionGraph; +export 'src/header.dart' show parseHeader; +export 'src/chunk_plan.dart' + show + createCanonicalChunkPlan, + validateCanonicalChunkSpans, + CanonicalChunkPlan, + CanonicalChunkSlot, + CanonicalChunkSpan; +export 'src/video/codec_string.dart' + show + isVideoCodecString, + parseVideoCodecString, + videoBitstreamByCodec, + videoCodecs, + ParsedVideoCodecString; +export 'src/video/geometry.dart' + show deriveVideoRenditionGeometry, packedAlphaGutter; +export 'src/compile_bundle_report.dart'; +export 'src/video/model.dart' + show VideoRenditionGeometry, VideoRenditionGeometryInput, VideoStoragePolicy; +export 'src/h265/index.dart'; +export 'src/vp9/index.dart'; +export 'src/av1/index.dart'; +export 'src/png/crc32.dart' show adler32, crc32; +export 'src/png/decode.dart' + show decodePngRgba, decodePngRgbaFromInflated, PngRgbaDecodeResult; +export 'src/png/profile.dart' + show validatePngProfile, PngDecodePlan, PngProfileValidationInput; +export 'src/model.dart'; +export 'src/parser.dart' show parseFrontIndex, validateCompleteAsset; +export 'src/writer.dart' show writeCanonicalAsset; diff --git a/flutter/packages/aval_format/lib/src/access_unit_index.dart b/flutter/packages/aval_format/lib/src/access_unit_index.dart new file mode 100644 index 0000000..c25b3e1 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/access_unit_index.dart @@ -0,0 +1,306 @@ +/// Fixed-record version-1.0 decode-order encoded-chunk index codec. +/// +/// Dart port of `packages/format/src/access-unit-index.ts`. +library; + +import 'dart:typed_data'; + +import 'checked_integer.dart'; +import 'chunk_plan.dart'; +import 'constants.dart'; +import 'errors.dart'; +import 'model.dart'; + +const int _randomAccessFlag = 0x00000001; + +int _recordByteOffset(int ordinal, int maximum) { + return checkedAdd( + chunkIndexHeaderLength, + checkedMultiply(ordinal, chunkIndexRecordLength, maximum, 'encoded-chunk record offset'), + maximum, + 'encoded-chunk record offset', + ); +} + +Never _fail(String message, [int? offset]) { + throw FormatError( + FormatErrorCode.indexInvalid, + message, + offset == null ? null : FormatErrorDetails(offset: offset), + ); +} + +void _assertMagic(Uint8List bytes) { + for (var index = 0; index < chunkIndexMagic.length; index += 1) { + if (bytes[index] != chunkIndexMagic[index]) { + _fail('encoded-chunk index magic must be AVLI', index); + } + } +} + +CanonicalChunkPlan _canonicalChunkPlan( + CompiledManifest manifest, [ + FormatOptions? options, +]) { + final budgets = resolveFormatBudgets(options); + try { + return createCanonicalChunkPlan( + manifest.renditions, + manifest.units, + budgets.maxChunkRecords, + budgets.maxTotalUnitFrames, + ); + } on FormatError catch (error) { + if (error.code == FormatErrorCode.budgetExceeded || + error.code == FormatErrorCode.integerUnsafe) { + rethrow; + } + throw FormatError( + FormatErrorCode.indexInvalid, + error.message, + error.path == null ? null : FormatErrorDetails(path: error.path), + ); + } catch (_) { + _fail('manifest chunk plan could not be derived'); + } +} + +void _validateRecordSequence( + List records, + CanonicalChunkPlan plan, [ + FormatOptions? options, +]) { + final budgets = resolveFormatBudgets(options); + if (records.length != plan.recordCount) { + _fail( + 'encoded-chunk record count must be ${plan.recordCount}, received ${records.length}', + 8, + ); + } + + for (final span in plan.spans) { + var displayedFrames = 0; + final end = span.chunkStart + span.chunkCount; + for (var ordinal = span.chunkStart; ordinal < end; ordinal += 1) { + final record = ordinal < records.length ? records[ordinal] : null; + final offset = _recordByteOffset(ordinal, budgets.maxIndexBytes); + if (record == null) _fail('encoded-chunk record is missing', offset); + if (record.byteLength < 1) { + _fail('encoded-chunk byte length must be positive', offset + 8); + } + if (record.byteLength > budgets.maxChunkBytes) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'encoded-chunk byte length exceeds the active limit of ${budgets.maxChunkBytes}', + FormatErrorDetails(offset: offset + 8), + ); + } + if (ordinal == span.chunkStart && !record.randomAccess) { + _fail('every unit must begin with a random-access chunk', offset + 32); + } + if (record.displayedFrameCount > 0 && record.duration == 0) { + _fail('a displayed encoded chunk must have a positive duration', offset + 24); + } + final lastTimestamp = BigInt.from(record.presentationTimestamp) + + BigInt.from(record.duration) * + BigInt.from(record.displayedFrameCount - 1 > 0 + ? record.displayedFrameCount - 1 + : 0); + if (lastTimestamp > BigInt.from(maxSafeInteger)) { + _fail('encoded-chunk presentation timeline exceeds the safe integer range', + offset + 16); + } + displayedFrames = checkedAdd( + displayedFrames, + record.displayedFrameCount, + budgets.maxTotalUnitFrames, + 'unit displayed frame count', + ); + } + if (displayedFrames != span.frameCount) { + _fail( + 'unit ${span.unitId} rendition ${span.renditionId} must display exactly ${span.frameCount} frames', + _recordByteOffset(span.chunkStart, budgets.maxIndexBytes) + 12, + ); + } + } +} + +EncodedChunkRecord _parseRecord(Uint8List bytes, int ordinal, [FormatOptions? options]) { + final budgets = resolveFormatBudgets(options); + final offset = _recordByteOffset(ordinal, budgets.maxIndexBytes); + final byteOffset = readUint64LE( + bytes, + offset, + budgets.maxFileBytes, + FormatErrorCode.indexInvalid, + 'encoded-chunk byte offset', + ); + final byteLength = + readUint32LE(bytes, offset + 8, FormatErrorCode.indexInvalid, 'encoded-chunk byte length'); + final displayedFrameCount = readUint32LE( + bytes, offset + 12, FormatErrorCode.indexInvalid, 'encoded-chunk displayed frame count'); + final presentationTimestamp = readUint64LE( + bytes, + offset + 16, + maxSafeInteger, + FormatErrorCode.indexInvalid, + 'encoded-chunk presentation timestamp', + ); + final duration = readUint64LE( + bytes, + offset + 24, + maxSafeInteger, + FormatErrorCode.indexInvalid, + 'encoded-chunk duration', + ); + final flags = + readUint32LE(bytes, offset + 32, FormatErrorCode.indexInvalid, 'encoded-chunk flags'); + if ((flags & ~_randomAccessFlag) != 0) { + _fail('encoded-chunk record uses unknown flag bits', offset + 32); + } + for (var reserved = offset + 36; reserved < offset + 48; reserved += 1) { + if (bytes[reserved] != 0) { + _fail('encoded-chunk record reserved bytes must be zero', reserved); + } + } + return EncodedChunkRecord( + byteOffset: byteOffset, + byteLength: byteLength, + presentationTimestamp: presentationTimestamp, + duration: duration, + randomAccess: (flags & _randomAccessFlag) != 0, + displayedFrameCount: displayedFrameCount, + ); +} + +/// Parse the exact fixed-width 1.0 decode-order chunk index. +List parseEncodedChunkIndex( + Uint8List bytes, + CompiledManifest manifest, [ + FormatOptions? options, +]) { + try { + final budgets = resolveFormatBudgets(options); + requireByteRange( + bytes, + 0, + chunkIndexHeaderLength, + FormatErrorCode.indexInvalid, + 'encoded-chunk index header', + ); + _assertMagic(bytes); + final recordSize = + readUint16LE(bytes, 4, FormatErrorCode.indexInvalid, 'encoded-chunk record size'); + if (recordSize != chunkIndexRecordLength) { + _fail('encoded-chunk record size must be $chunkIndexRecordLength', 4); + } + if (readUint16LE(bytes, 6, FormatErrorCode.indexInvalid, 'index reserved field') != 0) { + _fail('encoded-chunk index reserved field must be zero', 6); + } + final chunkCount = + readUint32LE(bytes, 8, FormatErrorCode.indexInvalid, 'encoded-chunk count'); + if (readUint32LE(bytes, 12, FormatErrorCode.indexInvalid, 'index reserved field') != 0) { + _fail('encoded-chunk index reserved field must be zero', 12); + } + if (chunkCount > budgets.maxChunkRecords) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'encoded-chunk count exceeds the active limit of ${budgets.maxChunkRecords}', + const FormatErrorDetails(offset: 8), + ); + } + final expectedLength = checkedAdd( + chunkIndexHeaderLength, + checkedMultiply( + chunkCount, + chunkIndexRecordLength, + budgets.maxIndexBytes, + 'encoded-chunk records length', + ), + budgets.maxIndexBytes, + 'encoded-chunk index length', + ); + if (bytes.length != expectedLength) { + _fail( + 'encoded-chunk index length must be exactly $expectedLength bytes', + bytes.length < expectedLength ? bytes.length : expectedLength, + ); + } + final plan = _canonicalChunkPlan(manifest, options); + if (chunkCount != plan.recordCount) { + _fail('encoded-chunk count must match the manifest count of ${plan.recordCount}', 8); + } + final records = []; + for (var ordinal = 0; ordinal < chunkCount; ordinal += 1) { + records.add(_parseRecord(bytes, ordinal, options)); + } + _validateRecordSequence(records, plan, options); + return records; + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.indexInvalid, 'encoded-chunk index could not be parsed'); + } +} + +/// Encode the exact fixed-width 1.0 decode-order chunk index. +Uint8List encodeEncodedChunkIndex( + List records, + CompiledManifest manifest, [ + FormatOptions? options, +]) { + try { + final budgets = resolveFormatBudgets(options); + if (records.length > budgets.maxChunkRecords) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'encoded-chunk count exceeds the active limit', + const FormatErrorDetails(offset: 8), + ); + } + final length = checkedAdd( + chunkIndexHeaderLength, + checkedMultiply( + records.length, + chunkIndexRecordLength, + budgets.maxIndexBytes, + 'encoded-chunk records length', + ), + budgets.maxIndexBytes, + 'encoded-chunk index length', + ); + final bytes = Uint8List(length); + bytes.setRange(0, chunkIndexMagic.length, chunkIndexMagic); + writeUint16LE(bytes, 4, chunkIndexRecordLength, FormatErrorCode.indexInvalid, + 'encoded-chunk record size'); + writeUint16LE(bytes, 6, 0, FormatErrorCode.indexInvalid, 'index reserved field'); + writeUint32LE(bytes, 8, records.length, FormatErrorCode.indexInvalid, 'encoded-chunk count'); + writeUint32LE(bytes, 12, 0, FormatErrorCode.indexInvalid, 'index reserved field'); + + for (var ordinal = 0; ordinal < records.length; ordinal += 1) { + final record = records[ordinal]; + final offset = _recordByteOffset(ordinal, budgets.maxIndexBytes); + writeUint64LE(bytes, offset, record.byteOffset, FormatErrorCode.indexInvalid, + 'encoded-chunk byte offset'); + writeUint32LE(bytes, offset + 8, record.byteLength, FormatErrorCode.indexInvalid, + 'encoded-chunk byte length'); + writeUint32LE(bytes, offset + 12, record.displayedFrameCount, + FormatErrorCode.indexInvalid, 'encoded-chunk displayed frame count'); + writeUint64LE(bytes, offset + 16, record.presentationTimestamp, + FormatErrorCode.indexInvalid, 'encoded-chunk presentation timestamp'); + writeUint64LE(bytes, offset + 24, record.duration, FormatErrorCode.indexInvalid, + 'encoded-chunk duration'); + writeUint32LE(bytes, offset + 32, record.randomAccess ? _randomAccessFlag : 0, + FormatErrorCode.indexInvalid, 'encoded-chunk flags'); + } + parseEncodedChunkIndex(bytes, manifest, options); + return bytes; + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.indexInvalid, 'encoded-chunk index could not be encoded'); + } +} diff --git a/flutter/packages/aval_format/lib/src/av1/bit_reader.dart b/flutter/packages/aval_format/lib/src/av1/bit_reader.dart new file mode 100644 index 0000000..c3fae6d --- /dev/null +++ b/flutter/packages/aval_format/lib/src/av1/bit_reader.dart @@ -0,0 +1,62 @@ +/// Bounded MSB-first AV1 syntax reader. +/// +/// Dart port of `packages/format/src/av1/bit-reader.ts`. +library; + +import 'dart:typed_data'; + +import '../errors.dart'; + +/// Bounded MSB-first AV1 syntax reader. +class Av1BitReader { + Av1BitReader(this._bytes, this._path); + + final Uint8List _bytes; + final String _path; + int _bitOffset = 0; + + int get bitOffset => _bitOffset; + + int get bitsRemaining => _bytes.length * 8 - _bitOffset; + + bool readBit(String label) { + if (_bitOffset >= _bytes.length * 8) { + _fail('truncated $label'); + } + final byte = _bytes[_bitOffset ~/ 8]; + final shift = 7 - (_bitOffset % 8); + _bitOffset += 1; + return ((byte >> shift) & 1) == 1; + } + + int readBits(int width, String label) { + if (width < 0 || width > 32) { + _fail('invalid bit width for $label'); + } + if (bitsRemaining < width) _fail('truncated $label'); + var value = 0; + for (var index = 0; index < width; index += 1) { + value = value * 2 + (readBit(label) ? 1 : 0); + } + return value; + } + + void readTrailingBits() { + if (!readBit('trailing_one_bit')) { + _fail('trailing_one_bit must equal one'); + } + while (bitsRemaining > 0) { + if (readBit('trailing_zero_bit')) { + _fail('trailing_zero_bit must equal zero'); + } + } + } + + Never _fail(String message) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 $message', + FormatErrorDetails(path: _path, offset: _bitOffset ~/ 8), + ); + } +} diff --git a/flutter/packages/aval_format/lib/src/av1/codec.dart b/flutter/packages/aval_format/lib/src/av1/codec.dart new file mode 100644 index 0000000..216e47a --- /dev/null +++ b/flutter/packages/aval_format/lib/src/av1/codec.dart @@ -0,0 +1,30 @@ +/// AV1 codec-string derivation and identification. +/// +/// Dart port of `packages/format/src/av1/codec.ts`. +library; + +import '../errors.dart'; +import 'sequence_header.dart'; + +/// Fully-qualified AV1 codec string, +/// e.g. `"av01.0.00M.08.0.110.01.01.01.0"`. +typedef Av1Codec = String; + +Av1Codec av1CodecFromSequence(Av1SequenceHeader sequence) { + if (sequence.level < 0 || sequence.level > 31) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 level is invalid', + ); + } + final level = sequence.level.toString().padLeft(2, '0'); + final bitDepth = sequence.bitDepth.toString().padLeft(2, '0'); + return 'av01.0.$level${sequence.tier}.$bitDepth.0.11${sequence.chromaSamplePosition}.01.01.01.0'; +} + +final RegExp _av1CodecPattern = RegExp( + r'^av01\.0\.(?:0[0-9]|[12][0-9]|3[01])[MH]\.(?:08|10)\.0\.11[0-3]\.01\.01\.01\.0$', +); + +bool isAv1Codec(Object? value) => + value is String && _av1CodecPattern.hasMatch(value); diff --git a/flutter/packages/aval_format/lib/src/av1/frame_header.dart b/flutter/packages/aval_format/lib/src/av1/frame_header.dart new file mode 100644 index 0000000..8b172a6 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/av1/frame_header.dart @@ -0,0 +1,104 @@ +/// AV1 frame-header prefix parsing (random access / display semantics). +/// +/// Dart port of `packages/format/src/av1/frame-header.ts`. +library; + +import 'dart:typed_data'; + +import '../errors.dart'; +import 'bit_reader.dart'; +import 'sequence_header.dart'; + +/// AV1 frame type, modeled as a string-literal union in the TypeScript source. +typedef Av1FrameType = String; + +class Av1FrameHeaderPrefix { + const Av1FrameHeaderPrefix({ + required this.frameType, + required this.key, + required this.randomAccess, + required this.showFrame, + required this.showExistingFrame, + required this.displayedFrameCount, + }); + + final Av1FrameType frameType; + final bool key; + final bool randomAccess; + final bool showFrame; + final bool showExistingFrame; + final int displayedFrameCount; + + @override + bool operator ==(Object other) => + other is Av1FrameHeaderPrefix && + other.frameType == frameType && + other.key == key && + other.randomAccess == randomAccess && + other.showFrame == showFrame && + other.showExistingFrame == showExistingFrame && + other.displayedFrameCount == displayedFrameCount; + + @override + int get hashCode => Object.hash(frameType, key, randomAccess, showFrame, + showExistingFrame, displayedFrameCount); +} + +/// Parse the frame-header prefix that determines random access and display. +Av1FrameHeaderPrefix parseAv1FrameHeaderPrefix( + Uint8List payload, + Av1SequenceHeader sequence, [ + String path = 'av1.frameHeader', +]) { + if (payload.isEmpty) { + _invalid('frame header is empty', path); + } + if (sequence.reducedStillPictureHeader) { + return const Av1FrameHeaderPrefix( + frameType: 'key', + key: true, + randomAccess: true, + showFrame: true, + showExistingFrame: false, + displayedFrameCount: 1, + ); + } + + final reader = Av1BitReader(payload, path); + final showExistingFrame = reader.readBit('show_existing_frame'); + if (showExistingFrame) { + reader.readBits(3, 'frame_to_show_map_idx'); + return const Av1FrameHeaderPrefix( + frameType: 'show-existing', + key: false, + randomAccess: false, + showFrame: true, + showExistingFrame: true, + displayedFrameCount: 1, + ); + } + + final rawFrameType = reader.readBits(2, 'frame_type'); + const frameTypes = ['key', 'inter', 'intra-only', 'switch']; + final frameType = + rawFrameType < frameTypes.length ? frameTypes[rawFrameType] : null; + if (frameType == null) _invalid('frame type is invalid', path); + final showFrame = reader.readBit('show_frame'); + if (!showFrame) reader.readBit('showable_frame'); + return Av1FrameHeaderPrefix( + frameType: frameType, + key: frameType == 'key', + randomAccess: frameType == 'key' && showFrame, + showFrame: showFrame, + showExistingFrame: false, + displayedFrameCount: showFrame ? 1 : 0, + ); +} + +Never _invalid(String message, String path) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 $message', + FormatErrorDetails(path: path), + ); +} diff --git a/flutter/packages/aval_format/lib/src/av1/index.dart b/flutter/packages/aval_format/lib/src/av1/index.dart new file mode 100644 index 0000000..fb78afe --- /dev/null +++ b/flutter/packages/aval_format/lib/src/av1/index.dart @@ -0,0 +1,33 @@ +/// AV1 Main-profile subsystem public surface. +/// +/// Dart port of `packages/format/src/av1/index.ts`. Mirrors its export list. +library; + +export 'bit_reader.dart' show Av1BitReader; +export 'codec.dart' show av1CodecFromSequence, isAv1Codec, Av1Codec; +export 'frame_header.dart' + show parseAv1FrameHeaderPrefix, Av1FrameHeaderPrefix, Av1FrameType; +export 'inspector.dart' + show + inspectAv1Rendition, + Av1ChunkInput, + Av1ChunkInspection, + Av1RenditionInspection, + Av1RenditionInspectionInput, + Av1UnitInput, + Av1UnitInspection; +export 'leb128.dart' show encodeAv1Leb128, readAv1Leb128, Av1Leb128; +export 'obu.dart' + show + av1ObuFrame, + av1ObuFrameHeader, + av1ObuMetadata, + av1ObuPadding, + av1ObuRedundantFrameHeader, + av1ObuSequenceHeader, + av1ObuTemporalDelimiter, + av1ObuTileGroup, + av1ObuTileList, + parseAv1LowOverheadObus, + Av1Obu; +export 'sequence_header.dart' show parseAv1SequenceHeader, Av1SequenceHeader; diff --git a/flutter/packages/aval_format/lib/src/av1/inspector.dart b/flutter/packages/aval_format/lib/src/av1/inspector.dart new file mode 100644 index 0000000..167fd23 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/av1/inspector.dart @@ -0,0 +1,222 @@ +/// AV1 low-overhead temporal-unit inspection preserving hidden frames. +/// +/// Dart port of `packages/format/src/av1/inspector.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import '../errors.dart'; +import 'codec.dart'; +import 'frame_header.dart'; +import 'obu.dart'; +import 'sequence_header.dart'; + +class Av1ChunkInput { + const Av1ChunkInput({ + required this.bytes, + required this.key, + required this.timestamp, + }); + + final Uint8List bytes; + final bool key; + final int timestamp; +} + +class Av1UnitInput { + const Av1UnitInput({ + required this.id, + required this.chunks, + required this.expectedDisplayedFrames, + }); + + final String id; + final List chunks; + final int expectedDisplayedFrames; +} + +class Av1RenditionInspectionInput { + const Av1RenditionInspectionInput({ + required this.width, + required this.height, + required this.bitDepth, + required this.units, + }); + + final int width; + final int height; + final int bitDepth; + final List units; +} + +class Av1ChunkInspection { + const Av1ChunkInspection({ + required this.timestamp, + required this.chunkType, + required this.frames, + required this.displayedFrameCount, + }); + + final int timestamp; + final String chunkType; + final List frames; + final int displayedFrameCount; +} + +class Av1UnitInspection { + const Av1UnitInspection({ + required this.id, + required this.chunks, + required this.displayedFrameCount, + }); + + final String id; + final List chunks; + final int displayedFrameCount; +} + +class Av1RenditionInspection { + const Av1RenditionInspection({ + required this.codec, + required this.sequence, + required this.units, + }); + + final Av1Codec codec; + final Av1SequenceHeader sequence; + final List units; +} + +/// Inspect low-overhead AV1 temporal units, including hidden/show-existing frames. +Av1RenditionInspection inspectAv1Rendition(Av1RenditionInspectionInput input) { + _requirePositiveInteger(input.width, 'width'); + _requirePositiveInteger(input.height, 'height'); + if (input.bitDepth != 8 && input.bitDepth != 10) { + _invalid('bit depth is invalid', 'bitDepth'); + } + if (input.units.isEmpty) _invalid('rendition requires units', 'units'); + + Av1SequenceHeader? stableSequence; + final unitIds = {}; + final units = []; + for (var unitIndex = 0; unitIndex < input.units.length; unitIndex += 1) { + final unit = input.units[unitIndex]; + final unitPath = 'units[$unitIndex]'; + if (unit.id.isEmpty) { + _invalid('unit id is invalid', '$unitPath.id'); + } + if (unitIds.contains(unit.id)) { + _invalid('unit id is duplicated', '$unitPath.id'); + } + unitIds.add(unit.id); + _requirePositiveInteger( + unit.expectedDisplayedFrames, '$unitPath.expectedDisplayedFrames'); + if (unit.chunks.isEmpty) { + _invalid('unit requires chunks', '$unitPath.chunks'); + } + + var displayedFrameCount = 0; + final chunks = []; + for (var chunkIndex = 0; chunkIndex < unit.chunks.length; chunkIndex += 1) { + final chunk = unit.chunks[chunkIndex]; + final chunkPath = '$unitPath.chunks[$chunkIndex]'; + if (chunk.timestamp < 0 || chunk.timestamp > maxSafeInteger) { + _invalid('chunk timestamp is invalid', '$chunkPath.timestamp'); + } + final obus = parseAv1LowOverheadObus(chunk.bytes, '$chunkPath.bytes'); + for (var obuIndex = 0; obuIndex < obus.length; obuIndex += 1) { + final obu = obus[obuIndex]; + if (obu.type != av1ObuSequenceHeader) continue; + final sequence = parseAv1SequenceHeader( + obu.payload, '$chunkPath.obus[$obuIndex]'); + if (stableSequence == null) { + stableSequence = sequence; + _validateSequence(sequence, input); + } else if (sequence != stableSequence) { + _invalid('sequence header changes within the rendition', + '$chunkPath.obus[$obuIndex]'); + } + } + final currentSequence = stableSequence; + if (currentSequence == null) { + _invalid('frame data precedes the sequence header', chunkPath); + } + final frames = []; + var frameIndex = 0; + for (final obu in obus) { + if (obu.type != av1ObuFrame && obu.type != av1ObuFrameHeader) continue; + frames.add(parseAv1FrameHeaderPrefix( + obu.payload, + currentSequence, + '$chunkPath.frames[$frameIndex]', + )); + frameIndex += 1; + } + if (frames.isEmpty) _invalid('chunk contains no frame header', chunkPath); + final first = frames[0]; + if (chunkIndex == 0 && !first.randomAccess) { + _invalid('unit must start at a shown key frame', chunkPath); + } + if (chunk.key != frames.any((frame) => frame.key)) { + _invalid('chunk key assertion disagrees with the bitstream', + '$chunkPath.key'); + } + final chunkDisplayedFrames = frames.fold( + 0, + (total, frame) => total + frame.displayedFrameCount, + ); + displayedFrameCount += chunkDisplayedFrames; + chunks.add(Av1ChunkInspection( + timestamp: chunk.timestamp, + chunkType: chunk.key ? 'key' : 'delta', + frames: frames, + displayedFrameCount: chunkDisplayedFrames, + )); + } + if (displayedFrameCount != unit.expectedDisplayedFrames) { + _invalid('displayed frame count disagrees with the authored unit', + unitPath); + } + units.add(Av1UnitInspection( + id: unit.id, + chunks: chunks, + displayedFrameCount: displayedFrameCount, + )); + } + final resolvedSequence = stableSequence; + if (resolvedSequence == null) { + _invalid('rendition has no sequence header', 'units'); + } + return Av1RenditionInspection( + codec: av1CodecFromSequence(resolvedSequence), + sequence: resolvedSequence, + units: units, + ); +} + +void _validateSequence( + Av1SequenceHeader sequence, Av1RenditionInspectionInput input) { + if (sequence.maxWidth != input.width || sequence.maxHeight != input.height) { + _invalid( + 'sequence dimensions disagree with the rendition', 'sequenceHeader'); + } + if (sequence.bitDepth != input.bitDepth) { + _invalid( + 'sequence bit depth disagrees with the rendition', 'sequenceHeader'); + } +} + +void _requirePositiveInteger(int value, String path) { + if (value <= 0 || value > maxSafeInteger) { + _invalid('value must be a positive safe integer', path); + } +} + +Never _invalid(String message, String path) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 $message', + FormatErrorDetails(path: path), + ); +} diff --git a/flutter/packages/aval_format/lib/src/av1/leb128.dart b/flutter/packages/aval_format/lib/src/av1/leb128.dart new file mode 100644 index 0000000..2c35663 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/av1/leb128.dart @@ -0,0 +1,89 @@ +/// Canonical unsigned LEB128 reading/writing bounded to safe integers. +/// +/// Dart port of `packages/format/src/av1/leb128.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import '../errors.dart'; + +class Av1Leb128 { + const Av1Leb128({required this.value, required this.length}); + + final int value; + final int length; + + @override + bool operator ==(Object other) => + other is Av1Leb128 && other.value == value && other.length == length; + + @override + int get hashCode => Object.hash(value, length); +} + +/// Read a canonical unsigned LEB128 value bounded to safe integers. +Av1Leb128 readAv1Leb128(Uint8List bytes, int offset, + [String path = 'av1.leb128']) { + if (offset < 0 || offset > maxSafeInteger) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 LEB128 input is invalid', + FormatErrorDetails(path: path), + ); + } + var value = 0; + var length = 0; + for (; length < 8; length += 1) { + if (offset + length >= bytes.length) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 LEB128 is truncated', + FormatErrorDetails(path: path, offset: offset + length), + ); + } + final byte = bytes[offset + length]; + value |= (byte & 0x7f) << (length * 7); + if ((byte & 0x80) == 0) { + final byteLength = length + 1; + if (byteLength > 1 && value < (1 << ((byteLength - 1) * 7))) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 LEB128 is non-canonical', + FormatErrorDetails(path: path, offset: offset), + ); + } + if (value > maxSafeInteger) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 LEB128 is unsafe', + FormatErrorDetails(path: path, offset: offset), + ); + } + return Av1Leb128(value: value, length: byteLength); + } + } + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 LEB128 exceeds eight bytes', + FormatErrorDetails(path: path, offset: offset), + ); +} + +Uint8List encodeAv1Leb128(int value) { + if (value < 0 || value > maxSafeInteger) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 LEB128 value is invalid', + ); + } + final bytes = []; + var remaining = value; + do { + var byte = remaining & 0x7f; + remaining >>= 7; + if (remaining != 0) byte |= 0x80; + bytes.add(byte); + } while (remaining != 0); + return Uint8List.fromList(bytes); +} diff --git a/flutter/packages/aval_format/lib/src/av1/obu.dart b/flutter/packages/aval_format/lib/src/av1/obu.dart new file mode 100644 index 0000000..0dabeed --- /dev/null +++ b/flutter/packages/aval_format/lib/src/av1/obu.dart @@ -0,0 +1,133 @@ +/// AV1 low-overhead OBU parsing into owned payloads. +/// +/// Dart port of `packages/format/src/av1/obu.ts`. +library; + +import 'dart:typed_data'; + +import '../errors.dart'; +import 'leb128.dart'; + +const int av1ObuSequenceHeader = 1; +const int av1ObuTemporalDelimiter = 2; +const int av1ObuFrameHeader = 3; +const int av1ObuTileGroup = 4; +const int av1ObuMetadata = 5; +const int av1ObuFrame = 6; +const int av1ObuRedundantFrameHeader = 7; +const int av1ObuTileList = 8; +const int av1ObuPadding = 15; + +const Set _allowedObuTypes = { + av1ObuSequenceHeader, + av1ObuTemporalDelimiter, + av1ObuFrameHeader, + av1ObuTileGroup, + av1ObuMetadata, + av1ObuFrame, + av1ObuRedundantFrameHeader, + av1ObuPadding, +}; + +class Av1Obu { + const Av1Obu({ + required this.type, + required this.temporalId, + required this.spatialId, + required this.payload, + }); + + final int type; + final int temporalId; + final int spatialId; + final Uint8List payload; + + @override + bool operator ==(Object other) { + if (other is! Av1Obu) return false; + if (other.type != type || + other.temporalId != temporalId || + other.spatialId != spatialId || + other.payload.length != payload.length) { + return false; + } + for (var index = 0; index < payload.length; index += 1) { + if (other.payload[index] != payload[index]) return false; + } + return true; + } + + @override + int get hashCode => + Object.hash(type, temporalId, spatialId, Object.hashAll(payload)); +} + +/// Parse one low-overhead temporal unit into owned OBU payloads. +List parseAv1LowOverheadObus(Uint8List bytes, + [String path = 'av1.temporalUnit']) { + _requireAv1(bytes.isNotEmpty, path, 'temporal unit is empty'); + final output = []; + var cursor = 0; + while (cursor < bytes.length) { + final headerOffset = cursor; + _requireAv1(cursor < bytes.length, path, 'OBU header is truncated', cursor); + final header = bytes[cursor]; + cursor += 1; + _requireAv1((header & 0x80) == 0, path, 'obu_forbidden_bit must be zero', + headerOffset); + final type = (header >> 3) & 0x0f; + final extension = (header & 0x04) != 0; + final hasSize = (header & 0x02) != 0; + _requireAv1((header & 0x01) == 0, path, 'OBU reserved bit must be zero', + headerOffset); + _requireAv1(hasSize, path, 'low-overhead OBU requires a size field', + headerOffset); + _requireAv1(_allowedObuTypes.contains(type), path, + 'OBU type $type is unsupported', headerOffset); + _requireAv1(type != av1ObuTileList, path, 'tile-list OBU is unsupported', + headerOffset); + + var temporalId = 0; + var spatialId = 0; + if (extension) { + _requireAv1(cursor < bytes.length, path, 'OBU extension is truncated', + cursor); + final extensionByte = bytes[cursor]; + cursor += 1; + temporalId = extensionByte >> 5; + spatialId = (extensionByte >> 3) & 0x03; + _requireAv1((extensionByte & 0x07) == 0, path, + 'OBU extension reserved bits must be zero', cursor - 1); + _requireAv1(temporalId == 0 && spatialId == 0, path, + 'scalable AV1 layers are unsupported', cursor - 1); + } + + final size = readAv1Leb128(bytes, cursor, '$path.obuSize'); + cursor += size.length; + _requireAv1(size.value <= bytes.length - cursor, path, + 'OBU payload is truncated', cursor); + final payload = bytes.sublist(cursor, cursor + size.value); + cursor += size.value; + if (type == av1ObuTemporalDelimiter) { + _requireAv1(payload.isEmpty, path, + 'temporal delimiter payload must be empty', headerOffset); + } + output.add(Av1Obu( + type: type, + temporalId: temporalId, + spatialId: spatialId, + payload: payload, + )); + } + return output; +} + +void _requireAv1(bool condition, String path, String message, [int? offset]) { + if (!condition) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 $message', + FormatErrorDetails(path: path, offset: offset), + ); + } +} diff --git a/flutter/packages/aval_format/lib/src/av1/sequence_header.dart b/flutter/packages/aval_format/lib/src/av1/sequence_header.dart new file mode 100644 index 0000000..133515e --- /dev/null +++ b/flutter/packages/aval_format/lib/src/av1/sequence_header.dart @@ -0,0 +1,231 @@ +/// AV1 single-layer Main-profile sequence-header parsing. +/// +/// Dart port of `packages/format/src/av1/sequence-header.ts`. +library; + +import 'dart:typed_data'; + +import '../errors.dart'; +import 'bit_reader.dart'; + +class Av1SequenceHeader { + const Av1SequenceHeader({ + required this.profile, + required this.level, + required this.tier, + required this.bitDepth, + required this.maxWidth, + required this.maxHeight, + required this.monochrome, + required this.subsamplingX, + required this.subsamplingY, + required this.chromaSamplePosition, + required this.colorPrimaries, + required this.transferCharacteristics, + required this.matrixCoefficients, + required this.fullRange, + required this.reducedStillPictureHeader, + required this.frameIdNumbersPresent, + required this.filmGrainParamsPresent, + }); + + final int profile; + final int level; + final String tier; + final int bitDepth; + final int maxWidth; + final int maxHeight; + final bool monochrome; + final int subsamplingX; + final int subsamplingY; + final int chromaSamplePosition; + final int colorPrimaries; + final int transferCharacteristics; + final int matrixCoefficients; + final bool fullRange; + final bool reducedStillPictureHeader; + final bool frameIdNumbersPresent; + final bool filmGrainParamsPresent; + + @override + bool operator ==(Object other) => + other is Av1SequenceHeader && + other.profile == profile && + other.level == level && + other.tier == tier && + other.bitDepth == bitDepth && + other.maxWidth == maxWidth && + other.maxHeight == maxHeight && + other.monochrome == monochrome && + other.subsamplingX == subsamplingX && + other.subsamplingY == subsamplingY && + other.chromaSamplePosition == chromaSamplePosition && + other.colorPrimaries == colorPrimaries && + other.transferCharacteristics == transferCharacteristics && + other.matrixCoefficients == matrixCoefficients && + other.fullRange == fullRange && + other.reducedStillPictureHeader == reducedStillPictureHeader && + other.frameIdNumbersPresent == frameIdNumbersPresent && + other.filmGrainParamsPresent == filmGrainParamsPresent; + + @override + int get hashCode => Object.hashAll([ + profile, + level, + tier, + bitDepth, + maxWidth, + maxHeight, + monochrome, + subsamplingX, + subsamplingY, + chromaSamplePosition, + colorPrimaries, + transferCharacteristics, + matrixCoefficients, + fullRange, + reducedStillPictureHeader, + frameIdNumbersPresent, + filmGrainParamsPresent, + ]); +} + +/// Parse the single-layer Main-profile sequence-header subset emitted by AVAL. +Av1SequenceHeader parseAv1SequenceHeader(Uint8List payload, + [String path = 'av1.sequenceHeader']) { + if (payload.isEmpty) { + _invalid('sequence header is empty', path); + } + final reader = Av1BitReader(payload, path); + final profile = reader.readBits(3, 'seq_profile'); + _requireAv1(profile == 0, path, 'only Main profile is supported'); + final stillPicture = reader.readBit('still_picture'); + final reducedStillPictureHeader = + reader.readBit('reduced_still_picture_header'); + _requireAv1(!reducedStillPictureHeader || stillPicture, path, + 'reduced header requires still_picture'); + + int level; + var tier = 'M'; + if (reducedStillPictureHeader) { + level = reader.readBits(5, 'seq_level_idx_0'); + } else { + _requireAv1(!reader.readBit('timing_info_present_flag'), path, + 'timing info is unsupported'); + final initialDisplayDelayPresent = + reader.readBit('initial_display_delay_present_flag'); + _requireAv1( + reader.readBits(5, 'operating_points_cnt_minus_1') == 0, + path, + 'multiple operating points are unsupported', + ); + _requireAv1(reader.readBits(12, 'operating_point_idc_0') == 0, path, + 'scalable operating points are unsupported'); + level = reader.readBits(5, 'seq_level_idx_0'); + if (level > 7) tier = reader.readBit('seq_tier_0') ? 'H' : 'M'; + if (initialDisplayDelayPresent) { + final present = + reader.readBit('initial_display_delay_present_for_this_op_0'); + if (present) reader.readBits(4, 'initial_display_delay_minus_1_0'); + } + } + + final widthBits = reader.readBits(4, 'frame_width_bits_minus_1') + 1; + final heightBits = reader.readBits(4, 'frame_height_bits_minus_1') + 1; + final maxWidth = reader.readBits(widthBits, 'max_frame_width_minus_1') + 1; + final maxHeight = reader.readBits(heightBits, 'max_frame_height_minus_1') + 1; + var frameIdNumbersPresent = false; + if (!reducedStillPictureHeader) { + frameIdNumbersPresent = reader.readBit('frame_id_numbers_present_flag'); + if (frameIdNumbersPresent) { + reader.readBits(4, 'delta_frame_id_length_minus_2'); + reader.readBits(3, 'additional_frame_id_length_minus_1'); + } + } + + reader.readBit('use_128x128_superblock'); + reader.readBit('enable_filter_intra'); + reader.readBit('enable_intra_edge_filter'); + if (!reducedStillPictureHeader) { + reader.readBit('enable_interintra_compound'); + reader.readBit('enable_masked_compound'); + reader.readBit('enable_warped_motion'); + reader.readBit('enable_dual_filter'); + final enableOrderHint = reader.readBit('enable_order_hint'); + if (enableOrderHint) { + reader.readBit('enable_jnt_comp'); + reader.readBit('enable_ref_frame_mvs'); + } + final chooseScreenContentTools = + reader.readBit('seq_choose_screen_content_tools'); + final forceScreenContentTools = chooseScreenContentTools + ? 2 + : (reader.readBit('seq_force_screen_content_tools') ? 1 : 0); + if (forceScreenContentTools > 0) { + final chooseIntegerMv = reader.readBit('seq_choose_integer_mv'); + if (!chooseIntegerMv) reader.readBit('seq_force_integer_mv'); + } + if (enableOrderHint) reader.readBits(3, 'order_hint_bits_minus_1'); + } + reader.readBit('enable_superres'); + reader.readBit('enable_cdef'); + reader.readBit('enable_restoration'); + + final highBitdepth = reader.readBit('high_bitdepth'); + final bitDepth = highBitdepth ? 10 : 8; + final monochrome = reader.readBit('mono_chrome'); + _requireAv1(!monochrome, path, 'monochrome output is unsupported'); + final colorDescriptionPresent = + reader.readBit('color_description_present_flag'); + _requireAv1(colorDescriptionPresent, path, + 'explicit BT.709 color description is required'); + final colorPrimaries = reader.readBits(8, 'color_primaries'); + final transferCharacteristics = + reader.readBits(8, 'transfer_characteristics'); + final matrixCoefficients = reader.readBits(8, 'matrix_coefficients'); + _requireAv1( + colorPrimaries == 1 && + transferCharacteristics == 1 && + matrixCoefficients == 1, + path, + 'color description must be BT.709', + ); + _requireAv1(!reader.readBit('color_range'), path, + 'limited color range is required'); + final chromaSamplePosition = reader.readBits(2, 'chroma_sample_position'); + reader.readBit('separate_uv_delta_q'); + final filmGrainParamsPresent = reader.readBit('film_grain_params_present'); + reader.readTrailingBits(); + + return Av1SequenceHeader( + profile: 0, + level: level, + tier: tier, + bitDepth: bitDepth, + maxWidth: maxWidth, + maxHeight: maxHeight, + monochrome: false, + subsamplingX: 1, + subsamplingY: 1, + chromaSamplePosition: chromaSamplePosition, + colorPrimaries: 1, + transferCharacteristics: 1, + matrixCoefficients: 1, + fullRange: false, + reducedStillPictureHeader: reducedStillPictureHeader, + frameIdNumbersPresent: frameIdNumbersPresent, + filmGrainParamsPresent: filmGrainParamsPresent, + ); +} + +void _requireAv1(bool condition, String path, String message) { + if (!condition) _invalid(message, path); +} + +Never _invalid(String message, String path) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'AV1 $message', + FormatErrorDetails(path: path), + ); +} diff --git a/flutter/packages/aval_format/lib/src/canonical_json.dart b/flutter/packages/aval_format/lib/src/canonical_json.dart new file mode 100644 index 0000000..2988e3c --- /dev/null +++ b/flutter/packages/aval_format/lib/src/canonical_json.dart @@ -0,0 +1,789 @@ +/// Canonical JSON codec: one legal byte-serialization per value. +/// +/// Dart port of `packages/format/src/canonical-json.ts`. Values are +/// represented with plain Dart `Object?` (String, bool, int, List, +/// Map) rather than a dedicated sealed type, mirroring the +/// TS `CanonicalJsonValue` structural union (`null | boolean | number | +/// string | array | object`) — Dart's dynamic-typed collections are the +/// direct structural analogue here, and the parser/writer below are the +/// sole authority on shape the same way they are in TS. +library; + +import 'dart:typed_data'; + +import 'checked_integer.dart' show maxSafeInteger; +import 'constants.dart' show resolveFormatBudgets; +import 'errors.dart'; +import 'model.dart' show FormatBudgets, FormatOptions; +import 'utf8.dart'; + +class CanonicalJsonWriteLimits { + const CanonicalJsonWriteLimits({ + required this.maxBytes, + required this.maxDepth, + required this.maxNodes, + required this.maxStringBytes, + }); + + final int maxBytes; + final int maxDepth; + final int maxNodes; + final int maxStringBytes; +} + +class _WriterBudgets { + const _WriterBudgets({ + required this.maxManifestBytes, + required this.maxJsonDepth, + required this.maxJsonNodes, + required this.maxJsonStringBytes, + }); + + final int maxManifestBytes; + final int maxJsonDepth; + final int maxJsonNodes; + final int maxJsonStringBytes; +} + +const CanonicalJsonWriteLimits _maxCanonicalWriteLimits = CanonicalJsonWriteLimits( + maxBytes: 9007199254740991, + maxDepth: 128, + maxNodes: 9007199254740991, + maxStringBytes: 32 * 1024 * 1024, +); +const int _writerPageBytes = 64 * 1024; + +const Set _dangerousKeys = {'__proto__', 'prototype', 'constructor'}; + +Never _fail(FormatErrorCode code, String message, [int? offset]) { + throw FormatError( + code, + message, + offset == null ? null : FormatErrorDetails(offset: offset), + ); +} + +Never _failInputUnicode(String message, [int? offset]) => + _fail(FormatErrorCode.inputInvalid, message); + +Never _failJsonUnicode(String message, [int? offset]) => + _fail(FormatErrorCode.jsonInvalid, message, offset); + +List _encodeBoundedKey(String value, int maximum) { + var byteLength = 0; + var offset = 0; + while (offset < value.length) { + final scalar = readStringScalar(value, offset, _failInputUnicode); + final width = utf8ScalarWidth(scalar.codePoint); + if (byteLength > maximum - width) { + _fail(FormatErrorCode.budgetExceeded, 'JSON string budget exceeded'); + } + byteLength += width; + offset += scalar.width; + } + return encodeUtf8String(value, _failInputUnicode); +} + +/// Compares decoded strings using unsigned lexicographic UTF-8 byte order. +int compareUtf8Strings(String left, String right) { + try { + return compareBytes( + encodeUtf8String(left, _failInputUnicode), + encodeUtf8String(right, _failInputUnicode), + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.inputInvalid, + 'Could not compare UTF-8 strings', + ); + } +} + +class _CanonicalJsonParser { + _CanonicalJsonParser(this._bytes, this._budgets); + + final Uint8List _bytes; + final FormatBudgets _budgets; + int _offset = 0; + int _nodes = 0; + + Object? parse() { + if (_bytes.length >= 3 && + _bytes[0] == 0xef && + _bytes[1] == 0xbb && + _bytes[2] == 0xbf) { + _fail(FormatErrorCode.jsonInvalid, 'A UTF-8 BOM is not permitted', 0); + } + + _skipWhitespace(); + final value = _parseValue(1); + _skipWhitespace(); + if (_offset != _bytes.length) { + _fail(FormatErrorCode.jsonInvalid, 'Unexpected trailing JSON data', _offset); + } + return value; + } + + int? _byteAt(int offset) => offset < _bytes.length ? _bytes[offset] : null; + + Object? _parseValue(int depth) { + if (depth > _budgets.maxJsonDepth) { + _fail(FormatErrorCode.budgetExceeded, 'JSON depth budget exceeded', _offset); + } + _nodes += 1; + if (_nodes > _budgets.maxJsonNodes) { + _fail(FormatErrorCode.budgetExceeded, 'JSON node budget exceeded', _offset); + } + + final byte = _byteAt(_offset); + switch (byte) { + case 0x22: + return _parseString(); + case 0x5b: + return _parseArray(depth); + case 0x7b: + return _parseObject(depth); + case 0x74: + _parseLiteral('true'); + return true; + case 0x66: + _parseLiteral('false'); + return false; + case 0x6e: + _parseLiteral('null'); + return null; + case 0x2d: + case 0x30: + case 0x31: + case 0x32: + case 0x33: + case 0x34: + case 0x35: + case 0x36: + case 0x37: + case 0x38: + case 0x39: + return _parseInteger(); + default: + _fail(FormatErrorCode.jsonInvalid, 'Expected a JSON value', _offset); + } + } + + void _parseLiteral(String literal) { + for (var index = 0; index < literal.length; index += 1) { + if (_byteAt(_offset + index) != literal.codeUnitAt(index)) { + _fail(FormatErrorCode.jsonInvalid, 'Invalid JSON literal', _offset + index); + } + } + _offset += literal.length; + } + + int _parseInteger() { + final start = _offset; + final negative = _byteAt(_offset) == 0x2d; + if (negative) _offset += 1; + + final firstDigit = _byteAt(_offset); + if (firstDigit == null || firstDigit < 0x30 || firstDigit > 0x39) { + _fail(FormatErrorCode.jsonInvalid, 'Expected a digit after minus', _offset); + } + + var magnitude = 0; + if (firstDigit == 0x30) { + _offset += 1; + final next = _byteAt(_offset); + if (next != null && next >= 0x30 && next <= 0x39) { + _fail( + FormatErrorCode.jsonNoncanonical, + 'Leading zeroes are not canonical', + _offset, + ); + } + } else { + while (true) { + final byte = _byteAt(_offset); + if (byte == null || byte < 0x30 || byte > 0x39) break; + final digit = byte - 0x30; + if (magnitude > ((maxSafeInteger - digit) / 10).floor()) { + _fail(FormatErrorCode.integerUnsafe, 'JSON integer is not safe', start); + } + magnitude = magnitude * 10 + digit; + _offset += 1; + } + } + + final suffix = _byteAt(_offset); + if (suffix == 0x2e || suffix == 0x45 || suffix == 0x65) { + _fail( + FormatErrorCode.jsonNoncanonical, + 'Fractions and exponents are not canonical integers', + _offset, + ); + } + if (negative && magnitude == 0) { + _fail(FormatErrorCode.jsonNoncanonical, 'Negative zero is not canonical', start); + } + return negative ? -magnitude : magnitude; + } + + List _parseArray(int depth) { + _offset += 1; + final values = []; + _skipWhitespace(); + if (_byteAt(_offset) == 0x5d) { + _offset += 1; + return values; + } + + while (true) { + values.add(_parseValue(depth + 1)); + _skipWhitespace(); + final delimiter = _byteAt(_offset); + if (delimiter == 0x5d) { + _offset += 1; + return values; + } + if (delimiter != 0x2c) { + _fail( + FormatErrorCode.jsonInvalid, + 'Expected a comma or closing bracket', + _offset, + ); + } + _offset += 1; + _skipWhitespace(); + } + } + + Map _parseObject(int depth) { + _offset += 1; + final value = {}; + final keys = {}; + _skipWhitespace(); + if (_byteAt(_offset) == 0x7d) { + _offset += 1; + return value; + } + + while (true) { + if (_byteAt(_offset) != 0x22) { + _fail(FormatErrorCode.jsonInvalid, 'Expected a quoted object key', _offset); + } + final keyOffset = _offset; + final key = _parseString(); + if (_dangerousKeys.contains(key)) { + _fail( + FormatErrorCode.jsonDangerousKey, + 'Dangerous object key $key is forbidden', + keyOffset, + ); + } + if (keys.contains(key)) { + _fail( + FormatErrorCode.jsonDuplicateKey, + 'Duplicate decoded object key $key', + keyOffset, + ); + } + keys.add(key); + + _skipWhitespace(); + if (_byteAt(_offset) != 0x3a) { + _fail(FormatErrorCode.jsonInvalid, 'Expected a colon after object key', _offset); + } + _offset += 1; + _skipWhitespace(); + value[key] = _parseValue(depth + 1); + _skipWhitespace(); + + final delimiter = _byteAt(_offset); + if (delimiter == 0x7d) { + _offset += 1; + return value; + } + if (delimiter != 0x2c) { + _fail( + FormatErrorCode.jsonInvalid, + 'Expected a comma or closing brace', + _offset, + ); + } + _offset += 1; + _skipWhitespace(); + } + } + + String _parseString() { + final start = _offset; + _offset += 1; + final scalars = StringBuffer(); + var decodedBytes = 0; + + while (_offset < _bytes.length) { + final byte = _byteAt(_offset); + if (byte == null) break; + if (byte == 0x22) { + _offset += 1; + return scalars.toString(); + } + if (byte == 0x5c) { + final escaped = _parseEscape(); + decodedBytes += utf8ScalarWidth(escaped); + _checkStringBudget(decodedBytes, start); + scalars.writeCharCode(escaped); + continue; + } + if (byte < 0x20) { + _fail( + FormatErrorCode.jsonInvalid, + 'Unescaped control character in string', + _offset, + ); + } + final scalar = readUtf8Scalar(_bytes, _offset, _failJsonUnicode); + decodedBytes += scalar.width; + _checkStringBudget(decodedBytes, start); + scalars.writeCharCode(scalar.codePoint); + _offset += scalar.width; + } + + _fail(FormatErrorCode.jsonInvalid, 'Unterminated JSON string', start); + } + + int _parseEscape() { + final escapeOffset = _offset; + _offset += 1; + final escaped = _byteAt(_offset); + _offset += 1; + switch (escaped) { + case 0x22: + return 0x22; + case 0x2f: + return 0x2f; + case 0x5c: + return 0x5c; + case 0x62: + return 0x08; + case 0x66: + return 0x0c; + case 0x6e: + return 0x0a; + case 0x72: + return 0x0d; + case 0x74: + return 0x09; + case 0x75: + return _parseUnicodeEscape(escapeOffset); + default: + _fail(FormatErrorCode.jsonInvalid, 'Invalid JSON string escape', escapeOffset); + } + } + + int _parseUnicodeEscape(int escapeOffset) { + final first = _readHexQuad(escapeOffset); + if (isLowSurrogate(first)) { + _fail(FormatErrorCode.jsonInvalid, 'Lone low surrogate escape', escapeOffset); + } + if (!isHighSurrogate(first)) return first; + + if (_byteAt(_offset) != 0x5c || _byteAt(_offset + 1) != 0x75) { + _fail(FormatErrorCode.jsonInvalid, 'Lone high surrogate escape', escapeOffset); + } + _offset += 2; + final second = _readHexQuad(_offset - 2); + if (!isLowSurrogate(second)) { + _fail(FormatErrorCode.jsonInvalid, 'Invalid surrogate pair escape', escapeOffset); + } + return decodeSurrogatePair(first, second); + } + + int _readHexQuad(int escapeOffset) { + var value = 0; + for (var index = 0; index < 4; index += 1) { + final byte = _byteAt(_offset); + if (byte == null) { + _fail(FormatErrorCode.jsonInvalid, 'Truncated Unicode escape', escapeOffset); + } + int digit; + if (byte >= 0x30 && byte <= 0x39) { + digit = byte - 0x30; + } else if (byte >= 0x41 && byte <= 0x46) { + digit = byte - 0x41 + 10; + } else if (byte >= 0x61 && byte <= 0x66) { + digit = byte - 0x61 + 10; + } else { + _fail(FormatErrorCode.jsonInvalid, 'Invalid Unicode escape', _offset); + } + value = value * 16 + digit; + _offset += 1; + } + return value; + } + + void _checkStringBudget(int decodedBytes, int offset) { + if (decodedBytes > _budgets.maxJsonStringBytes) { + _fail(FormatErrorCode.budgetExceeded, 'JSON string budget exceeded', offset); + } + } + + void _skipWhitespace() { + while (true) { + final byte = _byteAt(_offset); + if (byte != 0x20 && byte != 0x09 && byte != 0x0a && byte != 0x0d) return; + _offset += 1; + } + } +} + +class _EncodedKey { + const _EncodedKey(this.key, this.bytes); + + final String key; + final List bytes; +} + +class _CanonicalJsonWriter { + _CanonicalJsonWriter(this._budgets); + + final _WriterBudgets _budgets; + final List _pages = []; + final Set _active = {}; + Uint8List _current = Uint8List(_writerPageBytes); + int _currentLength = 0; + int _byteLength = 0; + int _nodes = 0; + + Uint8List serialize(Object? value) { + _writeValue(value, 1); + final output = Uint8List(_byteLength); + var offset = 0; + for (final page in _pages) { + output.setRange(offset, offset + page.length, page); + offset += page.length; + } + output.setRange(offset, offset + _currentLength, _current); + return output; + } + + void _writeValue(Object? value, int depth) { + if (depth > _budgets.maxJsonDepth) { + _fail(FormatErrorCode.budgetExceeded, 'JSON depth budget exceeded'); + } + _nodes += 1; + if (_nodes > _budgets.maxJsonNodes) { + _fail(FormatErrorCode.budgetExceeded, 'JSON node budget exceeded'); + } + + if (value == null) { + _pushAscii('null'); + return; + } + if (value is bool) { + _pushAscii(value ? 'true' : 'false'); + return; + } + if (value is String) { + _writeString(value); + return; + } + if (value is int) { + if (value == 0 && value.isNegative) { + _fail(FormatErrorCode.inputInvalid, 'Negative zero is not canonical'); + } + _pushAscii(value.toString()); + return; + } + if (value is double) { + _fail( + FormatErrorCode.integerUnsafe, + 'JSON numbers must be safe integers', + ); + } + if (value is List) { + if (_active.contains(value)) { + _fail(FormatErrorCode.inputInvalid, 'Canonical JSON cannot contain cycles'); + } + _active.add(value); + try { + _writeArray(value, depth); + } finally { + _active.remove(value); + } + return; + } + if (value is Map) { + if (_active.contains(value)) { + _fail(FormatErrorCode.inputInvalid, 'Canonical JSON cannot contain cycles'); + } + _active.add(value); + try { + _writeObject(value, depth); + } finally { + _active.remove(value); + } + return; + } + _fail(FormatErrorCode.inputInvalid, 'Value is not representable as canonical JSON'); + } + + void _writeArray(List value, int depth) { + _pushByte(0x5b); + for (var index = 0; index < value.length; index += 1) { + if (index != 0) _pushByte(0x2c); + _writeValue(value[index], depth + 1); + } + _pushByte(0x5d); + } + + void _writeObject(Map value, int depth) { + final remainingNodes = _budgets.maxJsonNodes - _nodes; + if (value.length > remainingNodes) { + _fail(FormatErrorCode.budgetExceeded, 'JSON node budget exceeded'); + } + final encodedKeys = <_EncodedKey>[]; + var retainedKeyBytes = 0; + for (final rawKey in value.keys) { + if (rawKey is! String) { + _fail(FormatErrorCode.inputInvalid, 'Non-string keys are not canonical JSON'); + } + if (_dangerousKeys.contains(rawKey)) { + _fail( + FormatErrorCode.jsonDangerousKey, + 'Dangerous object key $rawKey is forbidden', + ); + } + final bytes = _encodeBoundedKey(rawKey, _budgets.maxJsonStringBytes); + final remainingManifestBytes = _budgets.maxManifestBytes - _byteLength; + if (retainedKeyBytes > remainingManifestBytes - bytes.length) { + _fail(FormatErrorCode.budgetExceeded, 'Manifest byte budget exceeded'); + } + retainedKeyBytes += bytes.length; + encodedKeys.add(_EncodedKey(rawKey, bytes)); + } + encodedKeys.sort((left, right) => compareBytes(left.bytes, right.bytes)); + + _pushByte(0x7b); + for (var index = 0; index < encodedKeys.length; index += 1) { + final encodedKey = encodedKeys[index]; + if (index != 0) _pushByte(0x2c); + _writeString(encodedKey.key); + _pushByte(0x3a); + _writeValue(value[encodedKey.key], depth + 1); + } + _pushByte(0x7d); + } + + void _writeString(String value) { + _pushByte(0x22); + var decodedBytes = 0; + var offset = 0; + while (offset < value.length) { + final scalar = readStringScalar(value, offset, _failInputUnicode); + final codePoint = scalar.codePoint; + decodedBytes += utf8ScalarWidth(codePoint); + if (decodedBytes > _budgets.maxJsonStringBytes) { + _fail(FormatErrorCode.budgetExceeded, 'JSON string budget exceeded'); + } + + switch (codePoint) { + case 0x08: + _pushAscii('\\b'); + break; + case 0x09: + _pushAscii('\\t'); + break; + case 0x0a: + _pushAscii('\\n'); + break; + case 0x0c: + _pushAscii('\\f'); + break; + case 0x0d: + _pushAscii('\\r'); + break; + case 0x22: + _pushAscii('\\"'); + break; + case 0x5c: + _pushAscii('\\\\'); + break; + default: + if (codePoint < 0x20) { + _pushAscii('\\u00${codePoint.toRadixString(16).padLeft(2, '0')}'); + } else { + _pushScalar(codePoint); + } + } + offset += scalar.width; + } + _pushByte(0x22); + } + + void _pushScalar(int codePoint) { + final encoded = []; + pushUtf8Scalar(encoded, codePoint); + _reserve(encoded.length); + for (final byte in encoded) { + _appendByte(byte); + } + } + + void _pushAscii(String value) { + _reserve(value.length); + for (var index = 0; index < value.length; index += 1) { + _appendByte(value.codeUnitAt(index)); + } + } + + void _pushByte(int value) { + _reserve(1); + _appendByte(value); + } + + void _appendByte(int value) { + if (_currentLength == _current.length) { + _pages.add(_current); + _current = Uint8List(_writerPageBytes); + _currentLength = 0; + } + _current[_currentLength] = value; + _currentLength += 1; + _byteLength += 1; + } + + void _reserve(int length) { + if (_byteLength > _budgets.maxManifestBytes - length) { + _fail(FormatErrorCode.budgetExceeded, 'Manifest byte budget exceeded'); + } + } +} + +/// Serializes a JSON-compatible value into the one canonical UTF-8 form. +Uint8List serializeCanonicalJson(Object? value, [FormatOptions? options]) { + try { + final budgets = resolveFormatBudgets(options); + return _CanonicalJsonWriter(_toWriterBudgets(budgets)).serialize(value); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.inputInvalid, + 'Could not serialize canonical JSON', + ); + } +} + +/// Serializes trusted high-cardinality JSON with the same canonical owner +/// while retaining explicit hard upper limits independent from on-wire +/// budgets. +Uint8List serializeCanonicalJsonWithLimits( + Object? value, + CanonicalJsonWriteLimits limits, +) { + try { + final budgets = _resolveCanonicalJsonWriteLimits(limits); + return _CanonicalJsonWriter(budgets).serialize(value); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.inputInvalid, + 'Could not serialize canonical JSON', + ); + } +} + +_WriterBudgets _resolveCanonicalJsonWriteLimits(CanonicalJsonWriteLimits limits) { + final entries = { + 'maxBytes': limits.maxBytes, + 'maxDepth': limits.maxDepth, + 'maxNodes': limits.maxNodes, + 'maxStringBytes': limits.maxStringBytes, + }; + final maxima = { + 'maxBytes': _maxCanonicalWriteLimits.maxBytes, + 'maxDepth': _maxCanonicalWriteLimits.maxDepth, + 'maxNodes': _maxCanonicalWriteLimits.maxNodes, + 'maxStringBytes': _maxCanonicalWriteLimits.maxStringBytes, + }; + for (final key in entries.keys) { + final value = entries[key]!; + final maximum = maxima[key]!; + if (value < 1 || value > maximum) { + _fail( + FormatErrorCode.inputInvalid, + '$key must be an integer from 1 through $maximum', + ); + } + } + if (limits.maxStringBytes > limits.maxBytes) { + _fail(FormatErrorCode.inputInvalid, 'maxStringBytes may not exceed maxBytes'); + } + return _WriterBudgets( + maxManifestBytes: limits.maxBytes, + maxJsonDepth: limits.maxDepth, + maxJsonNodes: limits.maxNodes, + maxJsonStringBytes: limits.maxStringBytes, + ); +} + +_WriterBudgets _toWriterBudgets(FormatBudgets budgets) => _WriterBudgets( + maxManifestBytes: budgets.maxManifestBytes, + maxJsonDepth: budgets.maxJsonDepth, + maxJsonNodes: budgets.maxJsonNodes, + maxJsonStringBytes: budgets.maxJsonStringBytes, + ); + +/// Parses canonical UTF-8 JSON without a general-purpose JSON parser, +/// rejects alternate byte spellings, and returns a value tree matching the +/// exact input bytes. +Object? parseCanonicalJson(Uint8List bytes, [FormatOptions? options]) { + try { + final budgets = resolveFormatBudgets(options); + if (bytes.length > budgets.maxManifestBytes) { + _fail(FormatErrorCode.budgetExceeded, 'Manifest byte budget exceeded', 0); + } + final value = _CanonicalJsonParser(bytes, budgets).parse(); + final canonical = _CanonicalJsonWriter(_toWriterBudgets(budgets)).serialize(value); + final comparedLength = + bytes.length < canonical.length ? bytes.length : canonical.length; + var mismatch = comparedLength; + for (var index = 0; index < comparedLength; index += 1) { + if (bytes[index] != canonical[index]) { + mismatch = index; + break; + } + } + if (mismatch != comparedLength || bytes.length != canonical.length) { + _fail( + FormatErrorCode.jsonNoncanonical, + 'JSON bytes do not match canonical serialization', + mismatch, + ); + } + return value; + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.jsonInvalid, 'Could not parse canonical JSON'); + } +} + +/// Parses bounded strict UTF-8 JSON while allowing insignificant whitespace +/// and object-key order. Numbers remain safe integers and duplicate/ +/// dangerous keys retain the canonical parser's rejection behavior. +Object? parseStrictJson(Uint8List bytes, [FormatOptions? options]) { + try { + final budgets = resolveFormatBudgets(options); + if (bytes.length > budgets.maxManifestBytes) { + _fail(FormatErrorCode.budgetExceeded, 'JSON byte budget exceeded', 0); + } + return _CanonicalJsonParser(bytes, budgets).parse(); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.jsonInvalid, 'Could not parse strict JSON'); + } +} diff --git a/flutter/packages/aval_format/lib/src/checked_integer.dart b/flutter/packages/aval_format/lib/src/checked_integer.dart new file mode 100644 index 0000000..9419ea9 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/checked_integer.dart @@ -0,0 +1,327 @@ +/// Bounds-checked integer arithmetic and little-endian byte codecs. +/// +/// Dart port of `packages/format/src/checked-integer.ts`. Dart's `int` is a +/// native 64-bit signed integer on the VM (and arbitrary precision is not +/// needed here), so `Number.isSafeInteger` bounds in the TypeScript source +/// are reproduced with explicit `<= maxSafeInteger` checks against 2^53-1 to +/// keep identical acceptance/rejection behavior byte-for-byte, even though +/// Dart ints have more native headroom than JS doubles. +library; + +import 'dart:typed_data'; + +import 'errors.dart'; + +const int _uint8Max = 0xff; +const int _uint16Max = 0xffff; +const int _uint32Max = 0xffffffff; +const int maxSafeInteger = 9007199254740991; // 2^53 - 1, matches Number.MAX_SAFE_INTEGER + +FormatError _integerError(String label) => FormatError( + FormatErrorCode.integerUnsafe, + '$label must be a nonnegative safe integer', + ); + +/// Checks that [value] is a nonnegative integer within the JS safe-integer +/// range, matching `Number.isSafeInteger(value) && value >= 0`. +int checkedNonNegativeInteger(int value, [String label = 'value']) { + if (value < 0 || value > maxSafeInteger) { + throw _integerError(label); + } + return value; +} + +int _checkedLimit(int limit) => checkedNonNegativeInteger(limit, 'limit'); + +int _enforceLimit(int value, int limit, String label) { + if (value > _checkedLimit(limit)) { + throw FormatError( + FormatErrorCode.budgetExceeded, + '$label exceeds the active limit of $limit', + ); + } + return value; +} + +int checkedAdd( + int left, + int right, [ + int limit = maxSafeInteger, + String label = 'sum', +]) { + final safeLeft = checkedNonNegativeInteger(left, '$label left operand'); + final safeRight = checkedNonNegativeInteger(right, '$label right operand'); + if (safeLeft > maxSafeInteger - safeRight) { + throw FormatError( + FormatErrorCode.integerUnsafe, + '$label exceeds safe integer range', + ); + } + return _enforceLimit(safeLeft + safeRight, limit, label); +} + +int checkedMultiply( + int left, + int right, [ + int limit = maxSafeInteger, + String label = 'product', +]) { + final safeLeft = checkedNonNegativeInteger(left, '$label left operand'); + final safeRight = checkedNonNegativeInteger(right, '$label right operand'); + if (safeLeft != 0 && safeRight > (maxSafeInteger / safeLeft).floor()) { + throw FormatError( + FormatErrorCode.integerUnsafe, + '$label exceeds safe integer range', + ); + } + return _enforceLimit(safeLeft * safeRight, limit, label); +} + +int align8( + int value, [ + int limit = maxSafeInteger, + String label = 'aligned value', +]) { + final safeValue = checkedNonNegativeInteger(value, label); + final remainder = safeValue % 8; + return remainder == 0 + ? _enforceLimit(safeValue, limit, label) + : checkedAdd(safeValue, 8 - remainder, limit, label); +} + +int checkedRangeEnd( + int offset, + int length, [ + int limit = maxSafeInteger, + String label = 'range end', +]) { + return checkedAdd(offset, length, limit, label); +} + +bool rangeContains( + int outerOffset, + int outerLength, + int innerOffset, + int innerLength, [ + int limit = maxSafeInteger, +]) { + final outerEnd = + checkedRangeEnd(outerOffset, outerLength, limit, 'outer range end'); + final innerEnd = + checkedRangeEnd(innerOffset, innerLength, limit, 'inner range end'); + return innerOffset >= outerOffset && innerEnd <= outerEnd; +} + +/// Converts a nonnegative [BigInt] into a safe-integer [int], matching the +/// TS `bigintToSafeNumber` overflow/range behavior exactly. +int bigintToSafeNumber( + BigInt value, [ + int limit = maxSafeInteger, + String label = 'integer', +]) { + if (value < BigInt.zero || value > BigInt.from(maxSafeInteger)) { + throw FormatError( + FormatErrorCode.integerUnsafe, + '$label exceeds safe integer range', + ); + } + final numberValue = value.toInt(); + return _enforceLimit(numberValue, limit, label); +} + +int requireByteRange( + Uint8List bytes, + int offset, + int length, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'byte range', +]) { + try { + final end = checkedRangeEnd(offset, length, maxSafeInteger, label); + if (end > bytes.lengthInBytes) { + throw FormatError( + code, + '$label is truncated', + FormatErrorDetails( + offset: offset >= 0 + ? (offset < bytes.lengthInBytes ? offset : bytes.lengthInBytes) + : 0, + ), + ); + } + return end; + } on FormatError catch (error) { + if (error.code == code) rethrow; + throw FormatError( + code, + '$label is invalid', + FormatErrorDetails(offset: offset >= 0 ? offset : 0), + ); + } +} + +int _checkedUnsigned( + int value, + int maximum, + FormatErrorCode code, + String label, + int offset, +) { + if (value < 0 || value > maximum) { + throw FormatError( + code, + '$label is outside its unsigned range', + FormatErrorDetails(offset: offset), + ); + } + return value; +} + +int readUint8( + Uint8List bytes, + int offset, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint8', +]) { + requireByteRange(bytes, offset, 1, code, label); + return bytes[offset]; +} + +int readUint16LE( + Uint8List bytes, + int offset, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint16', +]) { + requireByteRange(bytes, offset, 2, code, label); + return bytes[offset] + bytes[offset + 1] * 0x100; +} + +int readUint32LE( + Uint8List bytes, + int offset, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint32', +]) { + requireByteRange(bytes, offset, 4, code, label); + return bytes[offset] + + bytes[offset + 1] * 0x100 + + bytes[offset + 2] * 0x10000 + + bytes[offset + 3] * 0x1000000; +} + +BigInt readUint64LEBigInt( + Uint8List bytes, + int offset, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint64', +]) { + requireByteRange(bytes, offset, 8, code, label); + var result = BigInt.zero; + for (var index = 7; index >= 0; index -= 1) { + result = (result << 8) | BigInt.from(bytes[offset + index]); + } + return result; +} + +int readUint64LE( + Uint8List bytes, + int offset, [ + int limit = maxSafeInteger, + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint64', +]) { + final value = readUint64LEBigInt(bytes, offset, code, label); + try { + return bigintToSafeNumber(value, limit, label); + } on FormatError catch (error) { + throw FormatError( + error.code, + error.message, + FormatErrorDetails(offset: offset), + ); + } +} + +void writeUint8( + Uint8List bytes, + int offset, + int value, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint8', +]) { + requireByteRange(bytes, offset, 1, code, label); + bytes[offset] = _checkedUnsigned(value, _uint8Max, code, label, offset); +} + +void writeUint16LE( + Uint8List bytes, + int offset, + int value, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint16', +]) { + requireByteRange(bytes, offset, 2, code, label); + final checked = _checkedUnsigned(value, _uint16Max, code, label, offset); + bytes[offset] = checked & _uint8Max; + bytes[offset + 1] = (checked >> 8) & _uint8Max; +} + +void writeUint32LE( + Uint8List bytes, + int offset, + int value, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint32', +]) { + requireByteRange(bytes, offset, 4, code, label); + final checked = _checkedUnsigned(value, _uint32Max, code, label, offset); + bytes[offset] = checked & _uint8Max; + bytes[offset + 1] = (checked >> 8) & _uint8Max; + bytes[offset + 2] = (checked >> 16) & _uint8Max; + bytes[offset + 3] = (checked >> 24) & _uint8Max; +} + +/// Accepts either an [int] or a [BigInt] value, matching the TS +/// `number | bigint` union parameter. +void writeUint64LE( + Uint8List bytes, + int offset, + Object value, [ + FormatErrorCode code = FormatErrorCode.inputInvalid, + String label = 'uint64', +]) { + requireByteRange(bytes, offset, 8, code, label); + BigInt checked; + if (value is int) { + if (value < 0) { + throw FormatError( + code, + '$label must be a nonnegative safe integer', + FormatErrorDetails(offset: offset), + ); + } + checked = BigInt.from(value); + } else if (value is BigInt) { + final uint64Max = (BigInt.one << 64) - BigInt.one; + if (value < BigInt.zero || value > uint64Max) { + throw FormatError( + code, + '$label is outside the uint64 range', + FormatErrorDetails(offset: offset), + ); + } + checked = value; + } else { + throw FormatError( + code, + '$label is outside the uint64 range', + FormatErrorDetails(offset: offset), + ); + } + + for (var index = 0; index < 8; index += 1) { + bytes[offset + index] = (checked & BigInt.from(0xff)).toInt(); + checked = checked >> 8; + } +} diff --git a/flutter/packages/aval_format/lib/src/chunk_plan.dart b/flutter/packages/aval_format/lib/src/chunk_plan.dart new file mode 100644 index 0000000..7ed60f4 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/chunk_plan.dart @@ -0,0 +1,273 @@ +/// Canonical rendition -> unit -> decode-order chunk plan. +/// +/// Dart port of `packages/format/src/chunk-plan.ts`. +library; + +import 'dart:convert' show jsonEncode; + +import 'checked_integer.dart' show checkedAdd, maxSafeInteger; +import 'errors.dart'; +import 'model.dart' show ProductionRendition, Unit, UnitChunkSpan; + +const int _uint32Max = 0xffffffff; + +class CanonicalChunkSlot { + const CanonicalChunkSlot({ + required this.ordinal, + required this.renditionIndex, + required this.renditionId, + required this.unitIndex, + required this.unitId, + required this.decodeIndex, + required this.randomAccessRequired, + }); + + final int ordinal; + final int renditionIndex; + final String renditionId; + final int unitIndex; + final String unitId; + final int decodeIndex; + final bool randomAccessRequired; +} + +class CanonicalChunkSpan { + const CanonicalChunkSpan({ + required this.renditionIndex, + required this.renditionId, + required this.unitIndex, + required this.unitId, + required this.chunkStart, + required this.chunkCount, + required this.frameCount, + }); + + final int renditionIndex; + final String renditionId; + final int unitIndex; + final String unitId; + final int chunkStart; + final int chunkCount; + final int frameCount; +} + +class CanonicalChunkPlan { + const CanonicalChunkPlan({ + required this.renditionCount, + required this.unitCount, + required this.totalFrameCount, + required this.recordCount, + required this.spans, + required this.unitSpans, + }); + + final int renditionCount; + final int unitCount; + final int totalFrameCount; + final int recordCount; + final List spans; + final List> unitSpans; + + CanonicalChunkSlot recordAt(int index) { + if (index < 0 || index > maxSafeInteger || index >= recordCount) { + throw FormatError(FormatErrorCode.integerUnsafe, + 'chunk record index is outside the canonical plan'); + } + var low = 0; + var high = spans.length - 1; + while (low <= high) { + final middle = low + ((high - low) ~/ 2); + final span = spans[middle]; + if (index < span.chunkStart) { + high = middle - 1; + } else if (index >= span.chunkStart + span.chunkCount) { + low = middle + 1; + } else { + final decodeIndex = index - span.chunkStart; + return CanonicalChunkSlot( + ordinal: index, + renditionIndex: span.renditionIndex, + renditionId: span.renditionId, + unitIndex: span.unitIndex, + unitId: span.unitId, + decodeIndex: decodeIndex, + randomAccessRequired: decodeIndex == 0, + ); + } + } + throw FormatError( + FormatErrorCode.integerUnsafe, 'canonical chunk span lookup failed'); + } + + Iterable records() sync* { + for (final span in spans) { + for (var decodeIndex = 0; decodeIndex < span.chunkCount; decodeIndex += 1) { + yield CanonicalChunkSlot( + ordinal: span.chunkStart + decodeIndex, + renditionIndex: span.renditionIndex, + renditionId: span.renditionId, + unitIndex: span.unitIndex, + unitId: span.unitId, + decodeIndex: decodeIndex, + randomAccessRequired: decodeIndex == 0, + ); + } + } + } +} + +/// Own the sole rendition -> unit -> decode-order chunk traversal. +CanonicalChunkPlan createCanonicalChunkPlan( + List renditions, + List units, + int maximumRecords, [ + int? maximumTotalFrames, +]) { + final maxTotalFrames = maximumTotalFrames ?? maximumRecords; + _requireMaximum(maximumRecords, 'maximum chunk records'); + _requireMaximum(maxTotalFrames, 'maximum total frames'); + if (renditions.isEmpty) { + _manifestInvalid('at least one rendition is required', 'renditions'); + } + if (renditions.length > maximumRecords) { + _budget('rendition count cannot fit the chunk record budget'); + } + if (units.isEmpty) _manifestInvalid('at least one unit is required', 'units'); + if (units.length > maxTotalFrames) { + _budget('unit count cannot fit the frame budget'); + } + + var totalFrameCount = 0; + for (var index = 0; index < units.length; index += 1) { + final unit = units[index]; + if (!_positiveSafe(unit.frameCount)) { + _manifestInvalid( + 'must be a positive safe integer', 'units[$index].frameCount'); + } + totalFrameCount = + checkedAdd(totalFrameCount, unit.frameCount, _uint32Max, 'total unit frames'); + if (totalFrameCount > maxTotalFrames) { + _budget('total unit frames exceed the active budget'); + } + } + + final spans = []; + final unitSpans = + List>.generate(units.length, (_) => []); + var ordinal = 0; + for (var renditionIndex = 0; renditionIndex < renditions.length; renditionIndex += 1) { + final rendition = renditions[renditionIndex]; + for (var unitIndex = 0; unitIndex < units.length; unitIndex += 1) { + final unit = units[unitIndex]; + final path = 'units[$unitIndex].chunks[$renditionIndex]'; + if (renditionIndex >= unit.chunks.length) { + _manifestInvalid('chunk span is missing', path); + } + final descriptor = unit.chunks[renditionIndex]; + if (descriptor.rendition != rendition.id) { + _manifestInvalid( + 'rendition must be ${jsonEncode(rendition.id)}', '$path.rendition'); + } + if (!_positiveSafe(descriptor.chunkCount)) { + _manifestInvalid('must be a positive safe integer', '$path.chunkCount'); + } + if (descriptor.chunkStart != ordinal) { + _manifestInvalid('must be the canonical ordinal $ordinal', '$path.chunkStart'); + } + if (descriptor.frameCount != unit.frameCount) { + _manifestInvalid('must equal the unit frameCount', '$path.frameCount'); + } + final span = CanonicalChunkSpan( + renditionIndex: renditionIndex, + renditionId: rendition.id, + unitIndex: unitIndex, + unitId: unit.id, + chunkStart: ordinal, + chunkCount: descriptor.chunkCount, + frameCount: descriptor.frameCount, + ); + spans.add(span); + unitSpans[unitIndex].add(span); + ordinal = checkedAdd(ordinal, descriptor.chunkCount, _uint32Max, 'chunk span end'); + if (ordinal > maximumRecords) { + _budget('chunk record count exceeds the active budget'); + } + } + } + + return CanonicalChunkPlan( + renditionCount: renditions.length, + unitCount: units.length, + totalFrameCount: totalFrameCount, + recordCount: ordinal, + spans: spans, + unitSpans: unitSpans, + ); +} + +/// Assert that every unit carries one canonical span per authored rendition. +void validateCanonicalChunkSpans( + CanonicalChunkPlan plan, + List units, [ + FormatErrorCode code = FormatErrorCode.manifestInvalid, +]) { + for (final expected in plan.spans) { + final unitChunks = + expected.unitIndex < units.length ? units[expected.unitIndex].chunks : null; + final descriptor = unitChunks != null && expected.renditionIndex < unitChunks.length + ? unitChunks[expected.renditionIndex] + : null; + if (descriptor == null || + descriptor.rendition != expected.renditionId || + descriptor.chunkStart != expected.chunkStart || + descriptor.chunkCount != expected.chunkCount || + descriptor.frameCount != expected.frameCount) { + throw FormatError( + code, + 'unit ${expected.unitId} chunk span is not canonical', + FormatErrorDetails( + path: 'units[${expected.unitIndex}].chunks[${expected.renditionIndex}]'), + ); + } + } + for (var unitIndex = 0; unitIndex < units.length; unitIndex += 1) { + if (units[unitIndex].chunks.length != plan.renditionCount) { + throw FormatError( + code, + 'unit must declare exactly one chunk span per rendition', + FormatErrorDetails(path: 'units[$unitIndex].chunks'), + ); + } + } +} + +UnitChunkSpan chunkSpanDescriptor(CanonicalChunkSpan span, String sha256) { + return UnitChunkSpan( + rendition: span.renditionId, + chunkStart: span.chunkStart, + chunkCount: span.chunkCount, + frameCount: span.frameCount, + sha256: sha256, + ); +} + +bool _positiveSafe(int value) => value > 0 && value <= maxSafeInteger; + +void _requireMaximum(int value, String label) { + if (value < 0 || value > maxSafeInteger) { + throw FormatError( + FormatErrorCode.integerUnsafe, '$label must be a nonnegative safe integer'); + } +} + +Never _manifestInvalid(String message, [String? path]) { + throw FormatError( + FormatErrorCode.manifestInvalid, + message, + path == null ? null : FormatErrorDetails(path: path), + ); +} + +Never _budget(String message) { + throw FormatError(FormatErrorCode.budgetExceeded, message); +} diff --git a/flutter/packages/aval_format/lib/src/compile_bundle_report.dart b/flutter/packages/aval_format/lib/src/compile_bundle_report.dart new file mode 100644 index 0000000..e53f045 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/compile_bundle_report.dart @@ -0,0 +1,970 @@ +/// Validate, detach, and immutably rebuild one compiler-published build +/// report (the browser-facing `build.json` contract). +/// +/// Dart port of `packages/format/src/compile-bundle-report.ts`. The TypeScript +/// source operates on an `unknown` in-memory JavaScript value and rejects +/// malformed input by throwing a native `TypeError` whose message is +/// `compile bundle report: `. There is no Dart analogue of a +/// message-carrying `TypeError`, so the faithful equivalent here throws +/// `dart:core`'s [FormatException] (semantically "data does not have the +/// expected format"), preserving the exact message string. See the `_invalid` +/// helper below. +/// +/// Where the TypeScript relied on structural/anonymous object types, this port +/// introduces named classes to match the codebase's "interfaces -> immutable +/// classes" convention: [CompileBundleReportLimits] (the frozen limits +/// object), [CompileBundleReportAv1Tiles] (the inline `{columns, rows}` type), +/// and [CompileBundleReportFfmpegTool] (the `CompileBundleReportTool & +/// {...three extra digests}` intersection). +library; + +import 'checked_integer.dart' show maxSafeInteger; +import 'canonical_json.dart' + show CanonicalJsonWriteLimits, serializeCanonicalJsonWithLimits; +import 'constants.dart' show identifierPattern, sha256HexPattern; +import 'model.dart' show VideoBitDepth, VideoCodec; +import 'package:aval_format/src/video/codec_string.dart' + show isVideoCodecString, videoCodecs; + +// --- Local pattern constants (mirroring the TS module-level regexes). --- +// Only the non-shared patterns are defined here; SHA-256 hex and identifier +// patterns are reused from `constants.dart` exactly as the TS imports them +// from `./constants.js`. + +final RegExp _integrity = RegExp(r'^sha256-[A-Za-z0-9+/]{43}=$', unicode: true); +final RegExp _pathOrUrl = RegExp( + r'''(?:^|[\s"'(=])(?:https?://|file:|[A-Za-z]:[\\/]|\\\\|/(?!/)|\.\.?[\\/]|~[\\/])''', + unicode: true, +); +final RegExp _controlCharacter = + RegExp(r'[\u0000-\u001f\u007f]', unicode: true); +final RegExp _decimalText = RegExp(r'^(?:0|[1-9][0-9]*)$', unicode: true); + +// The TS error message embeds `String(pattern)`, i.e. the JavaScript regex +// literal source with flags. These strings reproduce that exact rendering. +const String _integrityText = r'/^sha256-[A-Za-z0-9+/]{43}=$/u'; +const String _decimalTextText = r'/^(?:0|[1-9][0-9]*)$/u'; +const String _sha256Text = r'/^[0-9a-f]{64}$/'; +const String _identifierText = r'/^[a-z][a-z0-9._-]{0,63}$/'; + +const String _base64Alphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const List compileBundleH264Presets = [ + 'ultrafast', + 'superfast', + 'veryfast', + 'faster', + 'fast', + 'medium', + 'slow', + 'slower', + 'veryslow', + 'placebo', +]; +const List compileBundleH265Presets = compileBundleH264Presets; +const List compileBundleVp9Deadlines = ['best', 'good', 'realtime']; + +/// The frozen `COMPILE_BUNDLE_REPORT_LIMITS` object. `maxAssets` is +/// `videoCodecs.length` exactly as in TS, so this is a `final` (not `const`) +/// binding. +class CompileBundleReportLimits { + const CompileBundleReportLimits({ + required this.maxAssets, + required this.maxInvocations, + required this.maxInvocationArguments, + required this.maxWarnings, + required this.maxOperationCodeUnits, + required this.maxFreeTextCodeUnits, + required this.serialization, + }); + + final int maxAssets; + final int maxInvocations; + final int maxInvocationArguments; + final int maxWarnings; + final int maxOperationCodeUnits; + final int maxFreeTextCodeUnits; + + /// The `serialization` sub-object maps 1:1 onto the write-limits type + /// consumed by [serializeCanonicalJsonWithLimits]. + final CanonicalJsonWriteLimits serialization; +} + +final CompileBundleReportLimits compileBundleReportLimits = + CompileBundleReportLimits( + maxAssets: videoCodecs.length, + maxInvocations: 16384, + maxInvocationArguments: 512, + maxWarnings: 4096, + maxOperationCodeUnits: 256, + maxFreeTextCodeUnits: 32 * 1024, + serialization: const CanonicalJsonWriteLimits( + maxBytes: 64 * 1024 * 1024, + maxDepth: 64, + maxNodes: 2000000, + maxStringBytes: 1024 * 1024, + ), +); + +const List _topLevelKeys = [ + 'reportVersion', + 'assets', + 'encodings', + 'invocations', + 'warnings', + 'toolchain', + 'sourceMarkup', +]; + +// --- Value types (TS interfaces -> immutable Dart classes). --- + +class CompileBundleReportAsset { + const CompileBundleReportAsset({ + required this.codec, + required this.path, + required this.bytes, + required this.sha256, + required this.codecString, + required this.type, + required this.integrity, + }); + + final VideoCodec codec; + + /// `${codec}.avl`. + final String path; + final int bytes; + final String sha256; + final String codecString; + + /// `application/vnd.aval; codecs="${codecString}"`. + final String type; + + /// `sha256-${base64}`. + final String integrity; +} + +class CompileBundleReportRendition { + const CompileBundleReportRendition({ + required this.id, + required this.width, + required this.height, + required this.crf, + }); + + final String id; + final int width; + final int height; + final int crf; +} + +/// TS union `CompileBundleReportEncoding`. The internal +/// `CompileBundleReportEncodingBase` interface is folded into this sealed base. +sealed class CompileBundleReportEncoding { + const CompileBundleReportEncoding({ + required this.codec, + required this.renditions, + }); + + final VideoCodec codec; + final List renditions; +} + +class CompileBundleReportH264Encoding extends CompileBundleReportEncoding { + const CompileBundleReportH264Encoding({ + required this.preset, + required super.renditions, + }) : super(codec: 'h264'); + + final String preset; +} + +class CompileBundleReportH265Encoding extends CompileBundleReportEncoding { + const CompileBundleReportH265Encoding({ + required this.preset, + required this.threads, + required super.renditions, + }) : super(codec: 'h265'); + + final String preset; + final int threads; +} + +class CompileBundleReportVp9Encoding extends CompileBundleReportEncoding { + const CompileBundleReportVp9Encoding({ + required this.deadline, + required this.cpuUsed, + required this.threads, + required super.renditions, + }) : super(codec: 'vp9'); + + final String deadline; + final int cpuUsed; + final int threads; +} + +/// The inline `{ columns, rows }` tiles object of an AV1 encoding. +class CompileBundleReportAv1Tiles { + const CompileBundleReportAv1Tiles({ + required this.columns, + required this.rows, + }); + + final int columns; + final int rows; +} + +class CompileBundleReportAv1Encoding extends CompileBundleReportEncoding { + const CompileBundleReportAv1Encoding({ + required this.bitDepth, + required this.cpuUsed, + required this.tiles, + required this.rowMt, + required this.threads, + required super.renditions, + }) : super(codec: 'av1'); + + /// `8 | 10`. + final VideoBitDepth bitDepth; + final int cpuUsed; + final CompileBundleReportAv1Tiles tiles; + final bool rowMt; + final int threads; +} + +class CompileBundleReportInvocation { + const CompileBundleReportInvocation({ + required this.operation, + required this.tool, + required this.arguments, + }); + + final String operation; + + /// `"ffmpeg" | "ffprobe"`. + final String tool; + final List arguments; +} + +class CompileBundleReportExecutableIdentity { + const CompileBundleReportExecutableIdentity({ + required this.device, + required this.inode, + required this.size, + required this.mtimeNanoseconds, + required this.ctimeNanoseconds, + }); + + final String device; + final String inode; + final int size; + final String mtimeNanoseconds; + final String ctimeNanoseconds; +} + +class CompileBundleReportTool { + const CompileBundleReportTool({ + required this.executableSha256, + required this.executableIdentity, + required this.version, + required this.versionOutputSha256, + }); + + final String executableSha256; + final CompileBundleReportExecutableIdentity executableIdentity; + final String version; + final String versionOutputSha256; +} + +/// `toolchain.ffmpeg`: a [CompileBundleReportTool] with three extra digests +/// (the TS intersection type). +class CompileBundleReportFfmpegTool extends CompileBundleReportTool { + const CompileBundleReportFfmpegTool({ + required super.executableSha256, + required super.executableIdentity, + required super.version, + required super.versionOutputSha256, + required this.configurationSha256, + required this.encodersOutputSha256, + required this.calibrationSha256, + }); + + final String configurationSha256; + final String encodersOutputSha256; + final String calibrationSha256; +} + +class CompileBundleReportToolchain { + const CompileBundleReportToolchain({ + required this.ffmpeg, + required this.ffprobe, + }); + + final CompileBundleReportFfmpegTool ffmpeg; + final CompileBundleReportTool ffprobe; + + /// Always `"derived"`. + String get aggregateMemoryLimit => 'derived'; +} + +class ParsedCompileBundleReport { + const ParsedCompileBundleReport({ + required this.assets, + required this.encodings, + required this.invocations, + required this.warnings, + required this.toolchain, + required this.sourceMarkup, + }); + + /// Always `"1.0"`. + String get reportVersion => '1.0'; + final List assets; + final List encodings; + final List invocations; + final List warnings; + final CompileBundleReportToolchain toolchain; + final String sourceMarkup; +} + +/// Validate, detach, and recursively rebuild one compiler-published build.json. +ParsedCompileBundleReport parseCompileBundleReport(Object? value) { + final input = _record(value, 'report'); + _exactKeys(input, _topLevelKeys, 'report'); + if (input['reportVersion'] != '1.0') { + _invalid('report.reportVersion', 'must be 1.0'); + } + + final encodings = _cloneEncodings(input['encodings']); + final assets = _cloneAssets(input['assets'], encodings); + final invocations = _cloneInvocations(input['invocations']); + final warningInputs = _boundedArray( + input['warnings'], + 'report.warnings', + 0, + compileBundleReportLimits.maxWarnings, + ); + final warnings = []; + for (var index = 0; index < warningInputs.length; index += 1) { + warnings.add(_pathFreeText( + warningInputs[index], + 'report.warnings[$index]', + compileBundleReportLimits.maxFreeTextCodeUnits, + )); + } + final toolchain = _cloneToolchain(input['toolchain']); + final sourceMarkup = createCompileBundleSourceMarkup(assets); + if (input['sourceMarkup'] != sourceMarkup) { + _invalid('report.sourceMarkup', 'must match the ordered asset metadata'); + } + + final report = ParsedCompileBundleReport( + assets: assets, + encodings: encodings, + invocations: invocations, + warnings: List.unmodifiable(warnings), + toolchain: toolchain, + sourceMarkup: sourceMarkup, + ); + try { + serializeCanonicalJsonWithLimits( + _reportToCanonicalValue(report), + compileBundleReportLimits.serialization, + ); + } catch (_) { + _invalid('report', 'exceeds canonical serialization limits'); + } + return report; +} + +List _cloneAssets( + Object? value, + List encodings, +) { + final inputs = _boundedArray( + value, + 'report.assets', + 1, + compileBundleReportLimits.maxAssets, + ); + if (inputs.length != encodings.length) { + _invalid('report.assets', 'must match the encoding count'); + } + final seen = {}; + final result = []; + for (var index = 0; index < inputs.length; index += 1) { + final path = 'report.assets[$index]'; + final input = _record(inputs[index], path); + _exactKeys( + input, + const [ + 'codec', + 'path', + 'bytes', + 'sha256', + 'codecString', + 'type', + 'integrity', + ], + path, + ); + final codec = _codecValue(input['codec'], '$path.codec'); + if (seen.contains(codec)) _invalid('$path.codec', 'must be unique'); + seen.add(codec); + final encoding = index < encodings.length ? encodings[index] : null; + if (encoding == null || codec != encoding.codec) { + _invalid('$path.codec', 'must match the encoding in the same position'); + } + final assetPath = '$codec.avl'; + if (input['path'] != assetPath) _invalid('$path.path', 'must be $assetPath'); + final bytes = _integer(input['bytes'], '$path.bytes', 1, maxSafeInteger); + final sha256Value = _sha256(input['sha256'], '$path.sha256'); + final bitDepth = + encoding is CompileBundleReportAv1Encoding ? encoding.bitDepth : 8; + final codecStringValue = input['codecString']; + if (!isVideoCodecString(codecStringValue, codec, bitDepth)) { + _invalid('$path.codecString', 'is not a supported codec string'); + } + final codecString = codecStringValue as String; + final type = 'application/vnd.aval; codecs="$codecString"'; + if (input['type'] != type) _invalid('$path.type', 'must match codecString'); + final integrity = _stringPattern( + input['integrity'], _integrity, _integrityText, '$path.integrity'); + if (integrity != _integrityForSha256(sha256Value)) { + _invalid('$path.integrity', 'must encode the declared sha256 digest'); + } + result.add(CompileBundleReportAsset( + codec: codec, + path: assetPath, + bytes: bytes, + sha256: sha256Value, + codecString: codecString, + type: type, + integrity: integrity, + )); + } + return List.unmodifiable(result); +} + +List _cloneEncodings(Object? value) { + final inputs = _boundedArray( + value, + 'report.encodings', + 1, + compileBundleReportLimits.maxAssets, + ); + final seen = {}; + final result = []; + for (var index = 0; index < inputs.length; index += 1) { + final path = 'report.encodings[$index]'; + final input = _record(inputs[index], path); + final codec = _codecValue(input['codec'], '$path.codec'); + if (seen.contains(codec)) _invalid('$path.codec', 'must be unique'); + seen.add(codec); + final renditions = _cloneRenditions(input['renditions'], path, codec); + switch (codec) { + case 'h264': + _exactKeys(input, const ['codec', 'preset', 'renditions'], path); + result.add(CompileBundleReportH264Encoding( + preset: + _oneOf(input['preset'], compileBundleH264Presets, '$path.preset'), + renditions: renditions, + )); + case 'h265': + _exactKeys( + input, const ['codec', 'preset', 'threads', 'renditions'], path); + result.add(CompileBundleReportH265Encoding( + preset: + _oneOf(input['preset'], compileBundleH265Presets, '$path.preset'), + threads: _integer(input['threads'], '$path.threads', 1, 64), + renditions: renditions, + )); + case 'vp9': + _exactKeys( + input, + const ['codec', 'deadline', 'cpuUsed', 'threads', 'renditions'], + path, + ); + result.add(CompileBundleReportVp9Encoding( + deadline: _oneOf( + input['deadline'], compileBundleVp9Deadlines, '$path.deadline'), + cpuUsed: _integer(input['cpuUsed'], '$path.cpuUsed', -8, 8), + threads: _integer(input['threads'], '$path.threads', 1, 64), + renditions: renditions, + )); + case 'av1': + _exactKeys( + input, + const [ + 'codec', + 'bitDepth', + 'cpuUsed', + 'tiles', + 'rowMt', + 'threads', + 'renditions', + ], + path, + ); + final tiles = _record(input['tiles'], '$path.tiles'); + _exactKeys(tiles, const ['columns', 'rows'], '$path.tiles'); + final columns = _powerOfTwo(tiles['columns'], '$path.tiles.columns'); + final rows = _powerOfTwo(tiles['rows'], '$path.tiles.rows'); + if (columns * rows > 64) { + _invalid('$path.tiles', 'product must be at most 64'); + } + final bitDepthValue = input['bitDepth']; + if (bitDepthValue != 8 && bitDepthValue != 10) { + _invalid('$path.bitDepth', 'must be 8 or 10'); + } + final rowMtValue = input['rowMt']; + if (rowMtValue is! bool) _invalid('$path.rowMt', 'must be a boolean'); + result.add(CompileBundleReportAv1Encoding( + bitDepth: bitDepthValue == 10 ? 10 : 8, + cpuUsed: _integer(input['cpuUsed'], '$path.cpuUsed', 0, 8), + tiles: CompileBundleReportAv1Tiles(columns: columns, rows: rows), + rowMt: rowMtValue, + threads: _integer(input['threads'], '$path.threads', 1, 64), + renditions: renditions, + )); + default: + // `_codecValue` guarantees one of the four codecs above. + _invalid('$path.codec', 'must be h264, h265, vp9, or av1'); + } + } + return List.unmodifiable(result); +} + +List _cloneRenditions( + Object? value, + String encodingPath, + VideoCodec codec, +) { + final inputs = _boundedArray(value, '$encodingPath.renditions', 1, 4); + final seen = {}; + final result = []; + for (var index = 0; index < inputs.length; index += 1) { + final path = '$encodingPath.renditions[$index]'; + final input = _record(inputs[index], path); + _exactKeys(input, const ['id', 'width', 'height', 'crf'], path); + final id = _stringPattern( + input['id'], identifierPattern, _identifierText, '$path.id'); + if (seen.contains(id)) _invalid('$path.id', 'must be unique'); + seen.add(id); + result.add(CompileBundleReportRendition( + id: id, + width: _integer(input['width'], '$path.width', 1, 0xffffffff), + height: _integer(input['height'], '$path.height', 1, 0xffffffff), + crf: _integer( + input['crf'], + '$path.crf', + 0, + codec == 'vp9' || codec == 'av1' ? 63 : 51, + ), + )); + } + return List.unmodifiable(result); +} + +List _cloneInvocations(Object? value) { + final inputs = _boundedArray( + value, + 'report.invocations', + 0, + compileBundleReportLimits.maxInvocations, + ); + final result = []; + for (var index = 0; index < inputs.length; index += 1) { + final path = 'report.invocations[$index]'; + final input = _record(inputs[index], path); + _exactKeys(input, const ['operation', 'tool', 'arguments'], path); + final argumentInputs = _boundedArray( + input['arguments'], + '$path.arguments', + 0, + compileBundleReportLimits.maxInvocationArguments, + ); + final arguments = []; + for (var argumentIndex = 0; + argumentIndex < argumentInputs.length; + argumentIndex += 1) { + arguments.add(_pathFreeText( + argumentInputs[argumentIndex], + '$path.arguments[$argumentIndex]', + compileBundleReportLimits.maxFreeTextCodeUnits, + true, + )); + } + result.add(CompileBundleReportInvocation( + operation: _pathFreeText( + input['operation'], + '$path.operation', + compileBundleReportLimits.maxOperationCodeUnits, + ), + tool: _oneOf(input['tool'], const ['ffmpeg', 'ffprobe'], '$path.tool'), + arguments: List.unmodifiable(arguments), + )); + } + return List.unmodifiable(result); +} + +CompileBundleReportToolchain _cloneToolchain(Object? value) { + const path = 'report.toolchain'; + final input = _record(value, path); + _exactKeys(input, const ['ffmpeg', 'ffprobe', 'aggregateMemoryLimit'], path); + if (input['aggregateMemoryLimit'] != 'derived') { + _invalid('$path.aggregateMemoryLimit', 'must be derived'); + } + return CompileBundleReportToolchain( + ffmpeg: _cloneFfmpegTool(input['ffmpeg'], '$path.ffmpeg'), + ffprobe: _cloneFfprobeTool(input['ffprobe'], '$path.ffprobe'), + ); +} + +CompileBundleReportFfmpegTool _cloneFfmpegTool(Object? value, String path) { + final input = _record(value, path); + _exactKeys( + input, + const [ + 'executableSha256', + 'executableIdentity', + 'version', + 'versionOutputSha256', + 'configurationSha256', + 'encodersOutputSha256', + 'calibrationSha256', + ], + path, + ); + final base = _cloneToolFields(input, path); + return CompileBundleReportFfmpegTool( + executableSha256: base.executableSha256, + executableIdentity: base.executableIdentity, + version: base.version, + versionOutputSha256: base.versionOutputSha256, + configurationSha256: + _sha256(input['configurationSha256'], '$path.configurationSha256'), + encodersOutputSha256: + _sha256(input['encodersOutputSha256'], '$path.encodersOutputSha256'), + calibrationSha256: + _sha256(input['calibrationSha256'], '$path.calibrationSha256'), + ); +} + +CompileBundleReportTool _cloneFfprobeTool(Object? value, String path) { + final input = _record(value, path); + _exactKeys( + input, + const [ + 'executableSha256', + 'executableIdentity', + 'version', + 'versionOutputSha256', + ], + path, + ); + return _cloneToolFields(input, path); +} + +CompileBundleReportTool _cloneToolFields( + Map input, + String path, +) { + return CompileBundleReportTool( + executableSha256: + _sha256(input['executableSha256'], '$path.executableSha256'), + executableIdentity: _cloneExecutableIdentity( + input['executableIdentity'], + '$path.executableIdentity', + ), + version: _pathFreeText( + input['version'], + '$path.version', + compileBundleReportLimits.maxFreeTextCodeUnits, + ), + versionOutputSha256: + _sha256(input['versionOutputSha256'], '$path.versionOutputSha256'), + ); +} + +CompileBundleReportExecutableIdentity _cloneExecutableIdentity( + Object? value, + String path, +) { + final input = _record(value, path); + _exactKeys( + input, + const ['device', 'inode', 'size', 'mtimeNanoseconds', 'ctimeNanoseconds'], + path, + ); + return CompileBundleReportExecutableIdentity( + device: _decimalTextValue(input['device'], '$path.device'), + inode: _decimalTextValue(input['inode'], '$path.inode'), + size: _integer(input['size'], '$path.size', 0, maxSafeInteger), + mtimeNanoseconds: + _decimalTextValue(input['mtimeNanoseconds'], '$path.mtimeNanoseconds'), + ctimeNanoseconds: + _decimalTextValue(input['ctimeNanoseconds'], '$path.ctimeNanoseconds'), + ); +} + +/// Ordered `` markup derived from the validated asset metadata. +String createCompileBundleSourceMarkup( + List assets, +) { + return assets + .map((asset) => + "") + .join('\n'); +} + +String _integrityForSha256(String value) { + final result = StringBuffer(); + for (var offset = 0; offset < value.length; offset += 6) { + final num byteCount = + 3 < (value.length - offset) / 2 ? 3 : (value.length - offset) / 2; + final first = int.parse(value.substring(offset, offset + 2), radix: 16); + final second = byteCount > 1 + ? int.parse(value.substring(offset + 2, offset + 4), radix: 16) + : 0; + final third = byteCount > 2 + ? int.parse(value.substring(offset + 4, offset + 6), radix: 16) + : 0; + final group = (first << 16) | (second << 8) | third; + result.write(_base64Alphabet[(group >>> 18) & 0x3f]); + result.write(_base64Alphabet[(group >>> 12) & 0x3f]); + result.write(byteCount > 1 ? _base64Alphabet[(group >>> 6) & 0x3f] : '='); + result.write(byteCount > 2 ? _base64Alphabet[group & 0x3f] : '='); + } + return 'sha256-$result'; +} + +VideoCodec _codecValue(Object? value, String path) { + if (value is! String || !videoCodecs.contains(value)) { + _invalid(path, 'must be h264, h265, vp9, or av1'); + } + return value; +} + +String _sha256(Object? value, String path) => + _stringPattern(value, sha256HexPattern, _sha256Text, path); + +String _decimalTextValue(Object? value, String path) => + _stringPattern(value, _decimalText, _decimalTextText, path); + +int _powerOfTwo(Object? value, String path) { + final result = _integer(value, path, 1, 64); + if ((result & (result - 1)) != 0) _invalid(path, 'must be a power of two'); + return result; +} + +int _integer(Object? value, String path, int minimum, int maximum) { + if (value is! int || value < minimum || value > maximum) { + _invalid(path, 'must be an integer from $minimum to $maximum'); + } + return value; +} + +String _boundedString( + Object? value, + String path, + int maximum, [ + bool allowEmpty = false, +]) { + if (value is! String || + (!allowEmpty && value.isEmpty) || + value.length > maximum || + _controlCharacter.hasMatch(value)) { + _invalid( + path, + 'must be ${allowEmpty ? 'a' : 'a non-empty'} string of at most $maximum characters without control characters', + ); + } + return value; +} + +String _pathFreeText( + Object? value, + String path, + int maximum, [ + bool allowEmpty = false, +]) { + final result = _boundedString(value, path, maximum, allowEmpty); + if (_pathOrUrl.hasMatch(result)) { + _invalid(path, 'must not contain a local path or URL'); + } + return result; +} + +String _stringPattern( + Object? value, + RegExp pattern, + String patternText, + String path, +) { + if (value is! String || !pattern.hasMatch(value)) { + _invalid(path, 'must match $patternText'); + } + return value; +} + +String _oneOf(Object? value, List choices, String path) { + if (value is! String || !choices.contains(value)) { + _invalid(path, 'must be one of ${choices.join(', ')}'); + } + return value; +} + +List _boundedArray( + Object? value, + String path, + int minimum, + int maximum, +) { + final result = _denseArray(value, path); + if (result.length < minimum || result.length > maximum) { + _invalid(path, 'must contain $minimum through $maximum entries'); + } + return result; +} + +List _denseArray(Object? value, String path) { + // Dart lists are never sparse, so the TS `hasOwnProperty` per-index guard has + // no analogue and is intentionally omitted. + if (value is! List) _invalid(path, 'must be an array'); + return value; +} + +Map _record(Object? value, String path) { + if (value is! Map) _invalid(path, 'must be an object'); + return value; +} + +void _exactKeys( + Map value, + List keys, + String path, +) { + final expected = keys.toSet(); + for (final key in value.keys) { + if (key is! String || !expected.contains(key)) { + _invalid(path, 'contains an unknown field $key'); + } + } + for (final key in keys) { + if (!value.containsKey(key)) { + _invalid('$path.$key', 'is required'); + } + } +} + +// Plain JSON tree used only for the canonical-serialization size guard. The +// return value is discarded; only a thrown limit violation is meaningful. +Map _reportToCanonicalValue(ParsedCompileBundleReport report) { + return { + 'reportVersion': report.reportVersion, + 'assets': [ + for (final a in report.assets) + { + 'codec': a.codec, + 'path': a.path, + 'bytes': a.bytes, + 'sha256': a.sha256, + 'codecString': a.codecString, + 'type': a.type, + 'integrity': a.integrity, + }, + ], + 'encodings': [ + for (final e in report.encodings) _encodingToCanonicalValue(e), + ], + 'invocations': [ + for (final i in report.invocations) + { + 'operation': i.operation, + 'tool': i.tool, + 'arguments': [...i.arguments], + }, + ], + 'warnings': [...report.warnings], + 'toolchain': { + 'ffmpeg': _ffmpegToolToCanonicalValue(report.toolchain.ffmpeg), + 'ffprobe': _toolToCanonicalValue(report.toolchain.ffprobe), + 'aggregateMemoryLimit': report.toolchain.aggregateMemoryLimit, + }, + 'sourceMarkup': report.sourceMarkup, + }; +} + +Map _encodingToCanonicalValue( + CompileBundleReportEncoding encoding, +) { + final renditions = [ + for (final r in encoding.renditions) + {'id': r.id, 'width': r.width, 'height': r.height, 'crf': r.crf}, + ]; + switch (encoding) { + case CompileBundleReportH264Encoding(): + return { + 'codec': encoding.codec, + 'preset': encoding.preset, + 'renditions': renditions, + }; + case CompileBundleReportH265Encoding(): + return { + 'codec': encoding.codec, + 'preset': encoding.preset, + 'threads': encoding.threads, + 'renditions': renditions, + }; + case CompileBundleReportVp9Encoding(): + return { + 'codec': encoding.codec, + 'deadline': encoding.deadline, + 'cpuUsed': encoding.cpuUsed, + 'threads': encoding.threads, + 'renditions': renditions, + }; + case CompileBundleReportAv1Encoding(): + return { + 'codec': encoding.codec, + 'bitDepth': encoding.bitDepth, + 'cpuUsed': encoding.cpuUsed, + 'tiles': { + 'columns': encoding.tiles.columns, + 'rows': encoding.tiles.rows, + }, + 'rowMt': encoding.rowMt, + 'threads': encoding.threads, + 'renditions': renditions, + }; + } +} + +Map _toolToCanonicalValue(CompileBundleReportTool tool) => { + 'executableSha256': tool.executableSha256, + 'executableIdentity': { + 'device': tool.executableIdentity.device, + 'inode': tool.executableIdentity.inode, + 'size': tool.executableIdentity.size, + 'mtimeNanoseconds': tool.executableIdentity.mtimeNanoseconds, + 'ctimeNanoseconds': tool.executableIdentity.ctimeNanoseconds, + }, + 'version': tool.version, + 'versionOutputSha256': tool.versionOutputSha256, + }; + +Map _ffmpegToolToCanonicalValue( + CompileBundleReportFfmpegTool tool, +) => + { + ..._toolToCanonicalValue(tool), + 'configurationSha256': tool.configurationSha256, + 'encodersOutputSha256': tool.encodersOutputSha256, + 'calibrationSha256': tool.calibrationSha256, + }; + +Never _invalid(String path, String message) { + throw FormatException('compile bundle report: $path $message'); +} diff --git a/flutter/packages/aval_format/lib/src/constants.dart b/flutter/packages/aval_format/lib/src/constants.dart new file mode 100644 index 0000000..8b9642b --- /dev/null +++ b/flutter/packages/aval_format/lib/src/constants.dart @@ -0,0 +1,114 @@ +/// Wire constants and budget resolution for the version-1.0 AVAL format. +/// +/// Dart port of `packages/format/src/constants.ts`. +library; + +import 'errors.dart'; +import 'model.dart' show FormatBudgets, FormatOptions; + +const List formatMagic = [0x41, 0x56, 0x4c, 0x46, 0x0d, 0x0a, 0x1a, 0x0a]; +const List chunkIndexMagic = [0x41, 0x56, 0x4c, 0x49]; + +const int formatVersionMajor = 1; +const int formatVersionMinor = 0; +const int formatHeaderLength = 64; +const int formatAlignment = 8; +const int chunkIndexHeaderLength = 16; +const int chunkIndexRecordLength = 48; +const int _uint32Max = 0xffffffff; + +final RegExp identifierPattern = RegExp(r'^[a-z][a-z0-9._-]{0,63}$'); +final RegExp sha256HexPattern = RegExp(r'^[0-9a-f]{64}$'); + +const int _maxSafeInteger = 9007199254740991; + +final FormatBudgets formatDefaultBudgets = FormatBudgets( + maxFileBytes: _maxSafeInteger, + maxManifestBytes: 1024 * 1024, + maxIndexBytes: _maxSafeInteger, + maxChunkBytes: _uint32Max, + maxPngBytes: _maxSafeInteger, + maxJsonDepth: 64, + maxJsonNodes: 20000, + maxJsonStringBytes: 4096, + maxStates: 32, + maxEdges: 64, + maxUnits: 96, + maxRenditions: 4, + maxBindings: 32, + maxBlobRanges: 128, + maxTotalUnitFrames: _uint32Max, + maxChunkRecords: _uint32Max, + maxPortsPerBody: 16, + maxReversibleFrames: _uint32Max, +); + +const List _budgetKeys = [ + 'maxFileBytes', + 'maxManifestBytes', + 'maxIndexBytes', + 'maxChunkBytes', + 'maxPngBytes', + 'maxJsonDepth', + 'maxJsonNodes', + 'maxJsonStringBytes', + 'maxStates', + 'maxEdges', + 'maxUnits', + 'maxRenditions', + 'maxBindings', + 'maxBlobRanges', + 'maxTotalUnitFrames', + 'maxChunkRecords', + 'maxPortsPerBody', + 'maxReversibleFrames', +]; +final Set _budgetKeySet = _budgetKeys.toSet(); + +/// Resolves lower-only caller overrides into a fresh immutable budget set. +FormatBudgets resolveFormatBudgets([FormatOptions? options]) { + try { + if (options == null) { + return formatDefaultBudgets; + } + + final overrides = options.budgets; + if (overrides == null) { + return formatDefaultBudgets; + } + + for (final key in overrides.keys) { + if (!_budgetKeySet.contains(key)) { + throw FormatError( + FormatErrorCode.inputInvalid, + 'unknown format budget $key', + FormatErrorDetails(path: 'budgets.$key'), + ); + } + } + + final resolvedMap = formatDefaultBudgets.toMap(); + for (final key in _budgetKeys) { + if (!overrides.containsKey(key)) continue; + final override = overrides[key]; + final defaultValue = resolvedMap[key]!; + if (override == null || override < 0 || override > defaultValue) { + throw FormatError( + FormatErrorCode.inputInvalid, + '$key must be a nonnegative safe integer no greater than $defaultValue', + FormatErrorDetails(path: 'budgets.$key'), + ); + } + resolvedMap[key] = override; + } + + return FormatBudgets.fromMap(resolvedMap); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.inputInvalid, + 'format options could not be read', + ); + } +} diff --git a/flutter/packages/aval_format/lib/src/errors.dart b/flutter/packages/aval_format/lib/src/errors.dart new file mode 100644 index 0000000..f44469d --- /dev/null +++ b/flutter/packages/aval_format/lib/src/errors.dart @@ -0,0 +1,65 @@ +/// Stable rejection codes and the immutable error type surfaced by the +/// format package. Dart port of `packages/format/src/errors.ts`. +library; + +enum FormatErrorCode { + inputInvalid('INPUT_INVALID'), + budgetExceeded('BUDGET_EXCEEDED'), + integerUnsafe('INTEGER_UNSAFE'), + headerInvalid('HEADER_INVALID'), + versionUnsupported('VERSION_UNSUPPORTED'), + featureUnsupported('FEATURE_UNSUPPORTED'), + jsonInvalid('JSON_INVALID'), + jsonDuplicateKey('JSON_DUPLICATE_KEY'), + jsonDangerousKey('JSON_DANGEROUS_KEY'), + jsonNoncanonical('JSON_NONCANONICAL'), + manifestInvalid('MANIFEST_INVALID'), + graphInvalid('GRAPH_INVALID'), + indexInvalid('INDEX_INVALID'), + layoutInvalid('LAYOUT_INVALID'), + profileInvalid('PROFILE_INVALID'), + pngEnvelopeInvalid('PNG_ENVELOPE_INVALID'), + pngDeflateInvalid('PNG_DEFLATE_INVALID'), + pngScanlineInvalid('PNG_SCANLINE_INVALID'), + writerInvalid('WRITER_INVALID'), + writerNonconvergent('WRITER_NONCONVERGENT'); + + const FormatErrorCode(this.wireName); + + /// The exact TypeScript string literal for this code, e.g. `"INPUT_INVALID"`. + final String wireName; + + @override + String toString() => wireName; +} + +/// Optional structured details attached to a [FormatError]. +class FormatErrorDetails { + const FormatErrorDetails({this.path, this.offset}); + + final String? path; + final int? offset; +} + +/// A stable, immutable rejection surfaced by the format package. +/// +/// Mirrors the TypeScript `FormatError` class: `code` is always present, +/// `path` and `offset` are present only when supplied. +class FormatError implements Exception { + FormatError(this.code, this.message, [FormatErrorDetails? details]) + : path = details?.path, + offset = details?.offset; + + final FormatErrorCode code; + final String message; + final String? path; + final int? offset; + + /// Matches the TS `Error.name` override, kept for parity/debug output. + String get name => 'FormatError'; + + @override + String toString() => 'FormatError: $message'; +} + +bool isFormatError(Object? error) => error is FormatError; diff --git a/flutter/packages/aval_format/lib/src/graph_adapter.dart b/flutter/packages/aval_format/lib/src/graph_adapter.dart new file mode 100644 index 0000000..1ec7ebf --- /dev/null +++ b/flutter/packages/aval_format/lib/src/graph_adapter.dart @@ -0,0 +1,145 @@ +/// Maps a validated compiled manifest into the canonical motion graph. +/// +/// Dart port of `packages/format/src/graph-adapter.ts` (1.0). Depends on the +/// sibling `aval_graph` package for `validateMotionGraphDefinition`, which — +/// like the TypeScript original — accepts a genuinely untrusted, dynamically +/// shaped `Object?` value (a `Map`/`List` tree with the exact field names +/// `unitId`, `kind`, `frameCount`, `ports`, `portalFrames`, `entryFrame`, +/// `initialUnit`, `id`, `from`, `to`, `trigger`/`type`/`name`, `start`/`type`/ +/// `sourcePort`/`targetPort`/`maxWaitFrames`, `transition`/`kind`/`unitId`/ +/// `frameCount`/`direction`/`reverseOf`, `continuity`) rather than a typed +/// definition class, mirroring how `packages/graph/src/validate.ts` walks its +/// input as `unknown`. +library; + +import 'package:aval_graph/aval_graph.dart' + show MotionGraphValidationError, ValidatedMotionGraph, validateMotionGraphDefinition; + +import 'errors.dart'; +import 'model.dart'; + +/// Map a validated compiled manifest into the canonical motion graph. +ValidatedMotionGraph adaptManifestToMotionGraph(CompiledManifest manifest) { + try { + final unitsById = {for (final unit in manifest.units) unit.id: unit}; + final definition = { + 'initialState': manifest.initialState, + 'states': manifest.states.map((state) => _adaptState(state, unitsById)).toList(), + 'edges': manifest.edges.map((edge) => _adaptEdge(edge, unitsById)).toList(), + }; + return validateMotionGraphDefinition(definition); + } on MotionGraphValidationError catch (error) { + throw FormatError( + FormatErrorCode.graphInvalid, + 'compiled manifest does not define a valid motion graph: ${error.message}', + ); + } on FormatError catch (error) { + if (error.code == FormatErrorCode.graphInvalid) rethrow; + throw FormatError( + FormatErrorCode.graphInvalid, + 'compiled manifest does not define a valid motion graph: ${error.message}', + ); + } catch (error) { + throw FormatError( + FormatErrorCode.graphInvalid, + 'compiled manifest does not define a valid motion graph: $error', + ); + } +} + +Map _adaptState(State state, Map unitsById) { + final body = unitsById[state.bodyUnit]; + if (body is! BodyUnit) { + _graphInvalid('state ${_quote(state.id)} has no body unit'); + } + final graphBody = { + 'unitId': body.id, + 'kind': body.playback == 'loop' ? 'loop' : (body.frameCount == 1 ? 'held' : 'finite'), + 'frameCount': body.frameCount, + 'ports': body.ports + .map((port) => { + 'id': port.id, + 'entryFrame': 0, + 'portalFrames': [...port.portalFrames], + }) + .toList(), + }; + final base = {'id': state.id, 'body': graphBody}; + if (state.initialUnit == null) { + return base; + } + final initial = unitsById[state.initialUnit]; + if (initial is! OneShotUnit) { + _graphInvalid('state ${_quote(state.id)} has no one-shot initial unit'); + } + return { + ...base, + 'initialUnit': {'unitId': initial.id, 'frameCount': initial.frameCount}, + }; +} + +Map _adaptEdge(Edge edge, Map unitsById) { + final base = { + 'id': edge.id, + 'from': edge.from, + 'to': edge.to, + 'start': _adaptStart(edge.start), + 'continuity': edge.continuity, + }; + if (edge.trigger != null) { + base['trigger'] = _adaptTrigger(edge.trigger!); + } + if (edge is NonCutEdge && edge.transition != null) { + base['transition'] = _adaptTransition(edge.transition!, unitsById); + } + return base; +} + +Map _adaptTrigger(Trigger trigger) { + if (trigger is EventTrigger) { + return {'type': 'event', 'name': trigger.name}; + } + return {'type': 'completion'}; +} + +Map _adaptStart(Start start) { + if (start is PortalStart) { + return { + 'type': 'portal', + 'sourcePort': start.sourcePort, + 'targetPort': start.targetPort, + 'maxWaitFrames': start.maxWaitFrames, + }; + } + if (start is FinishStart) { + return {'type': 'finish', 'targetPort': start.targetPort, 'maxWaitFrames': start.maxWaitFrames}; + } + return {'type': 'cut', 'targetPort': start.targetPort, 'maxWaitFrames': 1}; +} + +Map _adaptTransition(Transition transition, Map unitsById) { + final unit = unitsById[transition.unit]; + if (transition is LockedTransition) { + if (unit is! BridgeUnit) { + _graphInvalid('locked transition has no bridge unit ${_quote(transition.unit)}'); + } + return {'kind': 'locked', 'unitId': unit.id, 'frameCount': unit.frameCount}; + } + final reversible = transition as ReversibleTransition; + if (unit is! ReversibleUnit) { + _graphInvalid('reversible transition has no reversible unit ${_quote(transition.unit)}'); + } + final base = { + 'kind': 'reversible', + 'unitId': unit.id, + 'frameCount': unit.frameCount, + 'direction': reversible.direction, + }; + return reversible.reverseOf == null ? base : {...base, 'reverseOf': reversible.reverseOf}; +} + +Never _graphInvalid(String message) { + throw FormatError(FormatErrorCode.graphInvalid, message); +} + +String _quote(String value) => '"$value"'; diff --git a/flutter/packages/aval_format/lib/src/h264/annex_b.dart b/flutter/packages/aval_format/lib/src/h264/annex_b.dart new file mode 100644 index 0000000..fbbef0c --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/annex_b.dart @@ -0,0 +1,234 @@ +/// Annex B NAL-unit tokenizer and EBSP-to-RBSP conversion. +/// +/// Dart port of `packages/format/src/h264/annex-b.ts`. +library; + +// ignore_for_file: constant_identifier_names + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import 'failure.dart'; + +const int H264_NAL_TYPE_NON_IDR = 1; +const int H264_NAL_TYPE_IDR = 5; +const int H264_NAL_TYPE_SEI = 6; +const int H264_NAL_TYPE_SPS = 7; +const int H264_NAL_TYPE_PPS = 8; +const int H264_NAL_TYPE_AUD = 9; + +const Set _allowedNalTypes = { + H264_NAL_TYPE_NON_IDR, + H264_NAL_TYPE_IDR, + H264_NAL_TYPE_SPS, + H264_NAL_TYPE_PPS, + H264_NAL_TYPE_AUD, +}; +const int _defaultMaxNalUnits = 5124; + +class AnnexBNalUnit { + const AnnexBNalUnit({ + required this.type, + required this.referenceIdc, + required this.offset, + required this.prefixLength, + required this.payload, + required this.rbsp, + }); + + final int type; + final int referenceIdc; + final int offset; + + /// Always `3` or `4`. + final int prefixLength; + final Uint8List payload; + final Uint8List rbsp; +} + +class _StartCode { + const _StartCode({required this.offset, required this.length}); + + final int offset; + + /// Always `3` or `4`. + final int length; +} + +List splitAnnexBAccessUnit( + Uint8List bytes, + String path, [ + int maximumNalUnits = _defaultMaxNalUnits, + bool allowEncoderSei = false, +]) { + // TS `annex-b.ts:39` also asserts `bytes instanceof Uint8Array`; Dart's + // static typing guarantees a `Uint8List`, so the guard is structurally met. + requireH264(bytes.length >= 5, path, 'Annex B access unit is too short'); + requireH264( + maximumNalUnits > 0 && maximumNalUnits <= maxSafeInteger, + path, + 'NAL-unit budget is invalid', + ); + + final starts = _findStartCodes(bytes, path, maximumNalUnits); + requireH264(starts.isNotEmpty, path, 'Annex B start code is missing', 0); + requireH264( + starts[0].offset == 0, + path, + 'bytes precede the first start code', + 0, + ); + + final units = []; + for (var index = 0; index < starts.length; index += 1) { + final start = starts[index]; + final payloadOffset = start.offset + start.length; + final payloadEnd = + index + 1 < starts.length ? starts[index + 1].offset : bytes.length; + requireH264( + payloadEnd > payloadOffset, + path, + 'empty NAL unit is forbidden', + start.offset, + ); + final payload = Uint8List.sublistView(bytes, payloadOffset, payloadEnd); + requireH264(payload.isNotEmpty, path, 'NAL header is missing', payloadOffset); + final header = payload[0]; + requireH264( + payload[payload.length - 1] != 0, + path, + 'NAL units may not contain trailing_zero_8bits', + payloadEnd - 1, + ); + requireH264( + (header & 0x80) == 0, + path, + 'forbidden_zero_bit must be zero', + payloadOffset, + ); + final type = header & 0x1f; + requireH264( + _allowedNalTypes.contains(type) || + (allowEncoderSei && type == H264_NAL_TYPE_SEI), + path, + 'NAL unit type $type is not permitted by the production H264 profile', + payloadOffset, + ); + final referenceIdc = (header >> 5) & 0x03; + if (type == H264_NAL_TYPE_AUD || type == H264_NAL_TYPE_SEI) { + requireH264( + referenceIdc == 0, + path, + 'AUD and SEI nal_ref_idc must be zero', + payloadOffset, + ); + } else if (type == H264_NAL_TYPE_SPS || + type == H264_NAL_TYPE_PPS || + type == H264_NAL_TYPE_IDR) { + requireH264( + referenceIdc != 0, + path, + 'parameter sets and IDR pictures must be reference NAL units', + payloadOffset, + ); + } + units.add( + AnnexBNalUnit( + type: type, + referenceIdc: referenceIdc, + offset: payloadOffset, + prefixLength: start.length, + payload: payload, + rbsp: removeEmulationPrevention( + Uint8List.sublistView(payload, 1), + path, + payloadOffset + 1, + ), + ), + ); + } + + return List.unmodifiable(units); +} + +List<_StartCode> _findStartCodes( + Uint8List bytes, + String path, + int maximumNalUnits, +) { + final starts = <_StartCode>[]; + var cursor = 0; + while (cursor < bytes.length) { + if (bytes[cursor] != 0) { + cursor += 1; + continue; + } + final runStart = cursor; + while (cursor < bytes.length && bytes[cursor] == 0) { + cursor += 1; + } + if (cursor >= bytes.length || bytes[cursor] != 1 || cursor - runStart < 2) { + continue; + } + final zeroCount = cursor - runStart; + if (zeroCount > 3) { + h264Invalid( + path, + 'start codes may contain only two or three zero bytes', + runStart, + ); + } + starts.add(_StartCode(offset: runStart, length: zeroCount + 1)); + requireH264( + starts.length <= maximumNalUnits, + path, + 'NAL-unit count exceeds the inspection budget', + runStart, + ); + cursor += 1; + } + return List.unmodifiable(starts); +} + +/// Converts EBSP to RBSP while rejecting non-canonical escape sequences. +Uint8List removeEmulationPrevention( + Uint8List ebsp, + String path, + int absoluteOffset, +) { + requireH264(ebsp.isNotEmpty, path, 'NAL RBSP is empty', absoluteOffset); + final rbsp = Uint8List(ebsp.length); + var outputLength = 0; + var zeroCount = 0; + + for (var index = 0; index < ebsp.length; index += 1) { + final byte = ebsp[index]; + + if (zeroCount == 2) { + if (byte == 0x03) { + final escapedIndex = index + 1; + final escaped = escapedIndex < ebsp.length ? ebsp[escapedIndex] : null; + requireH264( + escaped != null && escaped <= 0x03, + path, + 'emulation_prevention_three_byte is not followed by 0x00..0x03', + absoluteOffset + index, + ); + zeroCount = 0; + continue; + } + requireH264( + byte > 0x02, + path, + 'unescaped start-code emulation sequence in EBSP', + absoluteOffset + index, + ); + } + + rbsp[outputLength] = byte; + outputLength += 1; + zeroCount = byte == 0 ? zeroCount + 1 : 0; + } + + return Uint8List.sublistView(rbsp, 0, outputLength); +} diff --git a/flutter/packages/aval_format/lib/src/h264/bit_reader.dart b/flutter/packages/aval_format/lib/src/h264/bit_reader.dart new file mode 100644 index 0000000..33fbc3c --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/bit_reader.dart @@ -0,0 +1,118 @@ +/// Bounded MSB-first RBSP bit reader. +/// +/// Dart port of `packages/format/src/h264/bit-reader.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import 'failure.dart'; + +/// Bounded MSB-first RBSP reader. +class RbspBitReader { + RbspBitReader(this._bytes, this._path, this._absoluteOffset); + + final Uint8List _bytes; + final String _path; + final int _absoluteOffset; + int _bitOffset = 0; + + int get bitOffset => _bitOffset; + + int get bitsRemaining => _bytes.length * 8 - _bitOffset; + + bool readBit(String label) { + if (_bitOffset >= _bytes.length * 8) { + _fail('truncated $label'); + } + final byte = _bytes[_bitOffset ~/ 8]; + final value = (byte >> (7 - (_bitOffset % 8))) & 1; + _bitOffset += 1; + return value == 1; + } + + int readBits(int width, String label) { + if (width < 0 || width > 32) { + _fail('invalid bit width while reading $label'); + } + if (bitsRemaining < width) { + _fail('truncated $label'); + } + var result = 0; + for (var index = 0; index < width; index += 1) { + result = result * 2 + (readBit(label) ? 1 : 0); + } + return result; + } + + int readUnsignedExpGolomb(String label, [int maximum = 0xffffffff]) { + var leadingZeroBits = 0; + while (!readBit(label)) { + leadingZeroBits += 1; + if (leadingZeroBits > 31) { + _fail('$label Exp-Golomb value is too large'); + } + } + + final suffix = readBits(leadingZeroBits, label); + final value = (1 << leadingZeroBits) - 1 + suffix; + if (value > maxSafeInteger || value > maximum) { + _fail('$label exceeds $maximum'); + } + return value; + } + + int readSignedExpGolomb( + String label, [ + int minimum = -0x7fffffff, + int maximum = 0x7fffffff, + ]) { + final codeNumber = readUnsignedExpGolomb(label); + final magnitude = (codeNumber + 1) ~/ 2; + final value = codeNumber % 2 == 0 ? -magnitude : magnitude; + if (value < minimum || value > maximum) { + _fail('$label lies outside the supported range'); + } + return value; + } + + /// True when syntax data remains before the mandatory RBSP stop bit. + bool moreRbspData() { + if (bitsRemaining == 0) { + return false; + } + final first = _peekBit(_bitOffset); + if (!first) { + return true; + } + for (var bit = _bitOffset + 1; bit < _bytes.length * 8; bit += 1) { + if (_peekBit(bit)) { + return true; + } + } + return false; + } + + void readTrailingBits() { + if (!readBit('rbsp_stop_one_bit')) { + _fail('rbsp_stop_one_bit must be one'); + } + while (bitsRemaining > 0) { + if (readBit('rbsp_alignment_zero_bit')) { + _fail('RBSP alignment bits must be zero'); + } + } + } + + bool _peekBit(int bitOffset) { + final byteIndex = bitOffset ~/ 8; + if (byteIndex >= _bytes.length) { + return false; + } + return ((_bytes[byteIndex] >> (7 - (bitOffset % 8))) & 1) == 1; + } + + Never _fail(String message) { + h264Invalid(_path, message, _absoluteOffset + (_bitOffset ~/ 8)); + } +} diff --git a/flutter/packages/aval_format/lib/src/h264/codec.dart b/flutter/packages/aval_format/lib/src/h264/codec.dart new file mode 100644 index 0000000..a6c9cd3 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/codec.dart @@ -0,0 +1,117 @@ +/// High-profile H264 level table and codec-string identification. +/// +/// Dart port of `packages/format/src/h264/codec.ts`. +library; + +import 'dart:math' as math; + +import '../errors.dart'; + +/// One `level_idc`, matching the closed set carried by [_levelRows]. +/// +/// TS `H264LevelIdc` is a string-literal union of numeric `level_idc` values; +/// used only for equality, so it is a Dart `int` typedef per the port +/// conventions. +typedef H264LevelIdc = int; + +/// Codec identifier string, e.g. `"avc1.640020"` (High profile). +/// +/// TS `H264Codec` is a string-literal union used only for equality/membership. +typedef H264Codec = String; + +class H264LevelLimits { + const H264LevelLimits({ + required this.levelIdc, + required this.codec, + required this.maximumMacroblocksPerSecond, + required this.maximumMacroblocksPerFrame, + required this.maximumMacroblockDimension, + required this.maximumDpbMacroblocks, + required this.maximumBitrate, + required this.maximumCpbBits, + }); + + final H264LevelIdc levelIdc; + final H264Codec codec; + final int maximumMacroblocksPerSecond; + final int maximumMacroblocksPerFrame; + final int maximumMacroblockDimension; + final int maximumDpbMacroblocks; + final int maximumBitrate; + final int maximumCpbBits; +} + +// Columns: level_idc, codec, maxMb/s, maxMb/frame, maxDpbMb, maxBitrate, +// maxCpbBits. Mirrors `codec.ts` `LEVEL_ROWS`. +const List> _levelRows = [ + [10, 'avc1.64000A', 1485, 99, 396, 64000, 175000], + [11, 'avc1.64000B', 3000, 396, 900, 192000, 500000], + [12, 'avc1.64000C', 6000, 396, 2376, 384000, 1000000], + [13, 'avc1.64000D', 11880, 396, 2376, 768000, 2000000], + [20, 'avc1.640014', 11880, 396, 2376, 2000000, 2000000], + [21, 'avc1.640015', 19800, 792, 4752, 4000000, 4000000], + [22, 'avc1.640016', 20250, 1620, 8100, 4000000, 4000000], + [30, 'avc1.64001E', 40500, 1620, 8100, 10000000, 10000000], + [31, 'avc1.64001F', 108000, 3600, 18000, 14000000, 14000000], + [32, 'avc1.640020', 216000, 5120, 20480, 20000000, 20000000], + [40, 'avc1.640028', 245760, 8192, 32768, 20000000, 25000000], + [41, 'avc1.640029', 245760, 8192, 32768, 50000000, 62500000], + [42, 'avc1.64002A', 522240, 8704, 34816, 50000000, 62500000], + [50, 'avc1.640032', 589824, 22080, 110400, 135000000, 135000000], + [51, 'avc1.640033', 983040, 36864, 184320, 240000000, 240000000], + [52, 'avc1.640034', 2073600, 36864, 184320, 240000000, 240000000], + [60, 'avc1.64003C', 4177920, 139264, 696320, 240000000, 240000000], + [61, 'avc1.64003D', 8355840, 139264, 696320, 480000000, 480000000], + [62, 'avc1.64003E', 16711680, 139264, 696320, 800000000, 800000000], +]; + +// `Math.floor(Math.sqrt(row[3] * 8))` from `codec.ts:59`. +int _maximumMacroblockDimension(int macroblocksPerFrame) => + math.sqrt(macroblocksPerFrame * 8).floor(); + +final Map _levels = { + for (final row in _levelRows) + (row[0] as int): H264LevelLimits( + levelIdc: row[0] as int, + codec: row[1] as String, + maximumMacroblocksPerSecond: row[2] as int, + maximumMacroblocksPerFrame: row[3] as int, + maximumMacroblockDimension: _maximumMacroblockDimension(row[3] as int), + maximumDpbMacroblocks: row[4] as int, + maximumBitrate: row[5] as int, + maximumCpbBits: row[6] as int, + ), +}; + +final Map _codecs = { + for (final limits in _levels.values) limits.codec: limits, +}; + +bool isH264LevelIdc(int value) => _levels.containsKey(value); + +H264LevelLimits h264LevelLimits(int levelIdc) { + final limits = _levels[levelIdc]; + if (limits == null) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'H264 level_idc is unsupported', + ); + } + return limits; +} + +H264Codec h264CodecForLevel(int levelIdc) => h264LevelLimits(levelIdc).codec; + +H264LevelLimits parseH264Codec(Object? codec) { + final limits = codec is String ? _codecs[codec] : null; + if (limits == null) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'H264 codec must identify a supported High-profile level', + ); + } + return limits; +} + +bool isH264Codec(Object? codec) => + codec is String && _codecs.containsKey(codec); diff --git a/flutter/packages/aval_format/lib/src/h264/decoder_surface.dart b/flutter/packages/aval_format/lib/src/h264/decoder_surface.dart new file mode 100644 index 0000000..5d5f4ee --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/decoder_surface.dart @@ -0,0 +1,59 @@ +/// Browser-decoder coded-surface padding bounds for H264. +/// +/// Dart port of `packages/format/src/h264/decoder-surface.ts`. +library; + +import '../errors.dart'; + +/// Browser-owned decoded-frame allocation may extend the exact SPS coded +/// surface by two macroblocks. Chromium 140 has been observed to expose a +/// 16x16 SPS as a 32x34 coded frame while retaining the exact 16x16 visible +/// rectangle. Reserve two complete macroblocks per axis so those +/// implementation pixels remain bounded without becoming part of the +/// wire/profile geometry. +/// +/// Port of TS `H264_DECODER_SURFACE_PADDING` (`decoder-surface.ts:10`). +const int h264DecoderSurfacePadding = 32; + +const int _maxSafeInteger = 9007199254740991; + +/// Conservative browser-decoder coded-surface bound for one H264 dimension. +int maximumH264DecoderSurfaceDimension(int dimension) { + if (dimension < 1 || dimension > _maxSafeInteger) { + throw FormatError( + FormatErrorCode.inputInvalid, + 'H264 decoder surface dimension must be a positive safe integer', + ); + } + final aligned = dimension % 16 == 0 + ? dimension + : _checkedAdd(dimension, 16 - dimension % 16); + return _checkedAdd(aligned, h264DecoderSurfacePadding); +} + +/// Worst-case logical RGBA lease for a decoder surface, including padding. +int maximumH264DecodedRgbaBytes(int codedWidth, int codedHeight) { + final width = maximumH264DecoderSurfaceDimension(codedWidth); + final height = maximumH264DecoderSurfaceDimension(codedHeight); + return _checkedMultiply(_checkedMultiply(width, height), 4); +} + +int _checkedAdd(int left, int right) { + if (left > _maxSafeInteger - right) { + throw FormatError( + FormatErrorCode.inputInvalid, + 'H264 decoder surface size exceeds the safe-integer range', + ); + } + return left + right; +} + +int _checkedMultiply(int left, int right) { + if (left != 0 && right > (_maxSafeInteger / left).floor()) { + throw FormatError( + FormatErrorCode.inputInvalid, + 'H264 decoded byte size exceeds the safe-integer range', + ); + } + return left * right; +} diff --git a/flutter/packages/aval_format/lib/src/h264/encoder_preparation.dart b/flutter/packages/aval_format/lib/src/h264/encoder_preparation.dart new file mode 100644 index 0000000..de7111a --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/encoder_preparation.dart @@ -0,0 +1,208 @@ +/// Converts bounded raw FFmpeg Annex B output into the one canonical H264 +/// runtime form. +/// +/// SEI is the sole encoder-only NAL type tolerated, and it is removed before +/// either candidate or strict inspection. +/// +/// Dart port of `packages/format/src/h264/encoder-preparation.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import '../constants.dart' show formatDefaultBudgets, identifierPattern; +import '../errors.dart'; +import 'annex_b.dart'; +import 'failure.dart'; +import 'inspector.dart' show cloneH264Profile, inspectH264AnnexBRendition; +import 'types.dart'; + +const int _maxEncoderNalUnitsPerAccessUnit = 4; +const int _maxEncoderParameterSetNalUnits = 2; +const List _fourByteStartCode = [0, 0, 0, 1]; + +/// Converts bounded raw FFmpeg output into the one canonical H264 runtime form. +/// SEI is the sole encoder-only NAL type tolerated, and it is removed before +/// either candidate or strict inspection. +H264EncoderRenditionPreparation prepareH264EncoderRendition( + H264EncoderRenditionPreparationInput input, +) { + try { + final profile = cloneH264Profile(input.profile); + requireH264(input.units.isNotEmpty, 'units', 'at least one unit is required'); + requireH264( + input.units.length <= formatDefaultBudgets.maxUnits, + 'units', + 'unit count exceeds the format budget', + ); + + final normalizedUnits = []; + final unitIds = {}; + var totalRawBytes = 0; + var totalAccessUnits = 0; + for (var index = 0; index < input.units.length; index += 1) { + final unit = input.units[index]; + final path = 'units[$index]'; + requireH264( + identifierPattern.hasMatch(unit.id), + '$path.id', + 'unit id is invalid', + ); + requireH264(!unitIds.contains(unit.id), '$path.id', 'unit id is duplicated'); + unitIds.add(unit.id); + requireH264(unit.bytes.isNotEmpty, '$path.bytes', 'raw unit stream is empty'); + requireH264( + unit.expectedAccessUnitCount > 0 && + unit.expectedAccessUnitCount <= maxSafeInteger, + '$path.expectedAccessUnitCount', + 'expected access-unit count must be a positive safe integer', + ); + totalRawBytes += unit.bytes.length; + totalAccessUnits += unit.expectedAccessUnitCount; + requireH264( + totalRawBytes <= maxSafeInteger && + totalRawBytes <= formatDefaultBudgets.maxFileBytes, + '$path.bytes', + 'raw rendition bytes exceed the compiled-file budget', + ); + requireH264( + totalAccessUnits <= maxSafeInteger && + totalAccessUnits <= formatDefaultBudgets.maxTotalUnitFrames, + '$path.expectedAccessUnitCount', + 'total access-unit count exceeds the format budget', + ); + + normalizedUnits.add( + H264UnitInput( + id: unit.id, + accessUnits: _normalizeEncoderUnitStream( + unit.bytes, + unit.expectedAccessUnitCount, + '$path.bytes', + ), + ), + ); + } + final canonicalUnits = List.unmodifiable(normalizedUnits); + final inspection = inspectH264AnnexBRendition( + H264RenditionInspectionInput(profile: profile, units: canonicalUnits), + ); + return H264EncoderRenditionPreparation( + units: canonicalUnits, + inspection: inspection, + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'H264 encoder rendition could not be prepared', + ); + } +} + +List _normalizeEncoderUnitStream( + Uint8List bytes, + int expectedAccessUnitCount, + String path, +) { + final maximumNalUnits = expectedAccessUnitCount * + _maxEncoderNalUnitsPerAccessUnit + + _maxEncoderParameterSetNalUnits; + requireH264( + maximumNalUnits <= maxSafeInteger, + path, + 'derived encoder NAL-unit budget is not representable', + ); + final nals = splitAnnexBAccessUnit(bytes, path, maximumNalUnits, true); + requireH264( + nals.isNotEmpty && nals[0].type == H264_NAL_TYPE_AUD, + path, + 'raw encoder stream must begin with AUD', + ); + + final groups = >[]; + List? current; + for (final nal in nals) { + if (nal.type == H264_NAL_TYPE_AUD) { + if (current != null) { + groups.add(current); + } + current = [nal]; + } else { + requireH264(current != null, path, 'NAL unit appears before the first AUD'); + current!.add(nal); + } + } + if (current != null) { + groups.add(current); + } + requireH264( + groups.length == expectedAccessUnitCount, + path, + 'expected $expectedAccessUnitCount access units but found ${groups.length}', + ); + + return List.unmodifiable([ + for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) + _normalizeEncoderAccessUnit( + groups[groupIndex], + '$path.accessUnits[$groupIndex]', + ), + ]); +} + +H264AccessUnitInput _normalizeEncoderAccessUnit( + List group, + String path, +) { + final retained = + group.where((nal) => nal.type != H264_NAL_TYPE_SEI).toList(); + requireH264( + retained.isNotEmpty && retained[0].type == H264_NAL_TYPE_AUD, + path, + 'normalized access unit must begin with AUD', + ); + final vcl = retained + .where( + (nal) => + nal.type == H264_NAL_TYPE_IDR || nal.type == H264_NAL_TYPE_NON_IDR, + ) + .toList(); + requireH264(vcl.isNotEmpty, path, 'access unit contains no coded picture'); + + var length = 0; + for (final nal in retained) { + length += _fourByteStartCode.length + nal.payload.length; + requireH264( + length <= maxSafeInteger && length <= formatDefaultBudgets.maxChunkBytes, + path, + 'normalized access unit exceeds the sample budget', + ); + } + Uint8List normalized; + try { + normalized = Uint8List(length); + } catch (_) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'normalized H264 access-unit allocation of $length bytes failed', + FormatErrorDetails(path: path), + ); + } + var offset = 0; + for (final nal in retained) { + normalized.setRange( + offset, + offset + _fourByteStartCode.length, + _fourByteStartCode, + ); + offset += _fourByteStartCode.length; + normalized.setRange(offset, offset + nal.payload.length, nal.payload); + offset += nal.payload.length; + } + return H264AccessUnitInput( + key: vcl.any((nal) => nal.type == H264_NAL_TYPE_IDR), + bytes: normalized, + ); +} diff --git a/flutter/packages/aval_format/lib/src/h264/failure.dart b/flutter/packages/aval_format/lib/src/h264/failure.dart new file mode 100644 index 0000000..23a1024 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/failure.dart @@ -0,0 +1,27 @@ +/// Shared H264 subsystem failure helper. +/// +/// Dart port of `packages/format/src/h264/failure.ts`. +library; + +import '../errors.dart'; + +/// Port of `h264Invalid` (`failure.ts:3`). +Never h264Invalid(String path, String message, [int? offset]) { + throw FormatError( + FormatErrorCode.profileInvalid, + message, + FormatErrorDetails(path: path, offset: offset), + ); +} + +/// Port of `requireH264` (`failure.ts:13`). +void requireH264( + bool condition, + String path, + String message, [ + int? offset, +]) { + if (!condition) { + h264Invalid(path, message, offset); + } +} diff --git a/flutter/packages/aval_format/lib/src/h264/index.dart b/flutter/packages/aval_format/lib/src/h264/index.dart new file mode 100644 index 0000000..7ce9c39 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/index.dart @@ -0,0 +1,39 @@ +/// H.264 (High-profile) Annex B subsystem public surface. +/// +/// Dart port of `packages/format/src/h264/index.ts`. Mirrors its export list +/// exactly. +library; + +export 'inspector.dart' show inspectH264AnnexBRendition; +export 'codec.dart' + show + h264CodecForLevel, + h264LevelLimits, + isH264Codec, + isH264LevelIdc, + parseH264Codec, + H264Codec, + H264LevelIdc, + H264LevelLimits; +export 'decoder_surface.dart' + show + h264DecoderSurfacePadding, + maximumH264DecodedRgbaBytes, + maximumH264DecoderSurfaceDimension; +export 'encoder_preparation.dart' show prepareH264EncoderRendition; +export 'types.dart' + show + H264AccessUnitInput, + H264AccessUnitSummary, + H264ColorSummary, + H264Profile, + H264CropSummary, + H264EncoderRenditionPreparation, + H264EncoderRenditionPreparationInput, + H264EncoderUnitStreamInput, + H264FrameRate, + H264ParameterSetSummary, + H264RenditionInspection, + H264RenditionInspectionInput, + H264UnitInput, + H264UnitInspection; diff --git a/flutter/packages/aval_format/lib/src/h264/inspector.dart b/flutter/packages/aval_format/lib/src/h264/inspector.dart new file mode 100644 index 0000000..c1aa133 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/inspector.dart @@ -0,0 +1,859 @@ +/// Inspects every access unit in an independently decodable H264 rendition. +/// +/// This is intentionally a syntax/dependency verifier, not a decoder. It +/// accepts only the production High-profile subset and returns a deeply +/// immutable scalar summary; no caller-owned byte views escape. +/// +/// Dart port of `packages/format/src/h264/inspector.ts`. +library; + +import 'dart:math' as math; + +import '../checked_integer.dart' show maxSafeInteger; +import '../constants.dart' show formatDefaultBudgets, identifierPattern; +import '../errors.dart'; +import '../model.dart' show Rect; +import 'annex_b.dart'; +import 'bit_reader.dart'; +import 'codec.dart' show h264CodecForLevel, h264LevelLimits; +import 'failure.dart'; +import 'parameter_sets.dart'; +import 'slice_header.dart'; +import 'types.dart'; + +class H264ParameterSetState { + const H264ParameterSetState({required this.sps, required this.pps}); + + final ParsedSps sps; + final ParsedPps pps; +} + +/// Mutable picture-order tracking state threaded through one unit's frames. +class H264PictureOrderState { + H264PictureOrderState({ + this.previousReferenceFrameNum = 0, + this.previousReferenceFrameNumOffset = 0, + this.previousPocMsb = 0, + this.previousPocLsb = 0, + }); + + int previousReferenceFrameNum; + int previousReferenceFrameNumOffset; + int previousPocMsb; + int previousPocLsb; +} + +/// Internal per-picture draft before presentation order is derived. Mirrors +/// TS `H264AccessUnitDraft`. +class _H264AccessUnitDraft { + const _H264AccessUnitDraft({ + required this.decodeIndex, + required this.pictureOrderCount, + required this.key, + required this.idr, + required this.sliceType, + required this.sliceCount, + required this.nalUnitTypes, + }); + + final int decodeIndex; + final int pictureOrderCount; + final bool key; + final bool idr; + final H264SliceType sliceType; + final int sliceCount; + final List nalUnitTypes; +} + +class _H264AccessUnitStateResult { + const _H264AccessUnitStateResult({ + required this.summary, + required this.parameterSets, + }); + + final _H264AccessUnitDraft summary; + final H264ParameterSetState parameterSets; +} + +/// Inspects every access unit in an independently decodable rendition. +H264RenditionInspection inspectH264AnnexBRendition( + H264RenditionInspectionInput input, +) => + _inspectRendition(input); + +H264RenditionInspection _inspectRendition( + H264RenditionInspectionInput input, +) { + try { + final profile = cloneH264Profile(input.profile); + requireH264(input.units.isNotEmpty, 'units', 'at least one unit is required'); + requireH264( + input.units.length <= formatDefaultBudgets.maxUnits, + 'units', + 'unit count exceeds the format budget', + ); + + final seenUnitIds = {}; + H264ParameterSetState? stableParameterSets; + int? macroblocksPerFrame; + var totalFrames = 0; + final units = []; + + for (var unitIndex = 0; unitIndex < input.units.length; unitIndex += 1) { + final unit = input.units[unitIndex]; + final unitPath = 'units[$unitIndex]'; + requireH264( + identifierPattern.hasMatch(unit.id), + '$unitPath.id', + 'unit id is invalid', + ); + requireH264( + !seenUnitIds.contains(unit.id), + '$unitPath.id', + 'unit id is duplicated', + ); + seenUnitIds.add(unit.id); + requireH264( + unit.accessUnits.isNotEmpty, + '$unitPath.accessUnits', + 'unit must contain at least one access unit', + ); + totalFrames += unit.accessUnits.length; + requireH264( + totalFrames <= formatDefaultBudgets.maxTotalUnitFrames, + '$unitPath.accessUnits', + 'total frame count exceeds the format budget', + ); + + final orderState = _createH264PictureOrderState(); + final drafts = <_H264AccessUnitDraft>[]; + final decodedPictureOrderCounts = {}; + var activeParameterSets = stableParameterSets; + + for ( + var decodeIndex = 0; + decodeIndex < unit.accessUnits.length; + decodeIndex += 1 + ) { + final accessUnit = unit.accessUnits[decodeIndex]; + final accessUnitPath = '$unitPath.accessUnits[$decodeIndex]'; + validateH264AccessUnitInput(accessUnit, accessUnitPath); + final result = _inspectH264AccessUnitStatefully( + accessUnit, + decodeIndex, + accessUnitPath, + activeParameterSets, + stableParameterSets, + profile, + orderState, + macroblocksPerFrame, + ); + activeParameterSets = result.parameterSets; + if (stableParameterSets == null) { + stableParameterSets = result.parameterSets; + macroblocksPerFrame = validateH264SpsAgainstProfile( + stableParameterSets.sps, + profile, + '$accessUnitPath.sps', + ); + } + requireH264( + !decodedPictureOrderCounts.contains(result.summary.pictureOrderCount), + accessUnitPath, + 'unit contains duplicate picture-order counts', + ); + decodedPictureOrderCounts.add(result.summary.pictureOrderCount); + drafts.add(result.summary); + } + + final parameterSets = activeParameterSets; + if (parameterSets == null) { + h264Invalid(unitPath, 'unit has no parameter sets'); + } + final decodeToPresentation = _deriveH264PresentationOrder( + drafts, + parameterSets.sps.maxNumReorderFrames, + '$unitPath.accessUnits', + ); + final accessUnits = [ + for (final draft in drafts) + H264AccessUnitSummary( + decodeIndex: draft.decodeIndex, + presentationIndex: decodeToPresentation[draft.decodeIndex], + pictureOrderCount: draft.pictureOrderCount, + key: draft.key, + idr: draft.idr, + sliceType: draft.sliceType, + sliceCount: draft.sliceCount, + nalUnitTypes: draft.nalUnitTypes, + ), + ]; + units.add( + H264UnitInspection( + id: unit.id, + accessUnits: List.unmodifiable(accessUnits), + decodeToPresentation: decodeToPresentation, + ), + ); + } + + if (stableParameterSets == null || macroblocksPerFrame == null) { + h264Invalid('units', 'no H264 parameter sets were found'); + } + final parameterSet = createH264ParameterSetSummary(stableParameterSets.sps); + return H264RenditionInspection( + parameterSet: parameterSet, + macroblocksPerFrame: macroblocksPerFrame, + units: List.unmodifiable(units), + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.profileInvalid, 'H264 inspection failed'); + } +} + +H264Profile cloneH264Profile(H264Profile profile) { + _positiveInteger(profile.codedWidth, 'profile.codedWidth'); + _positiveInteger(profile.codedHeight, 'profile.codedHeight'); + final expectedVisibleRect = _cloneExpectedVisibleRect( + profile.expectedVisibleRect, + profile.codedWidth, + profile.codedHeight, + ); + _positiveInteger(profile.frameRate.numerator, 'profile.frameRate.numerator'); + _positiveInteger( + profile.frameRate.denominator, + 'profile.frameRate.denominator', + ); + requireH264( + profile.requireBt709LimitedRange == true, + 'profile.requireBt709LimitedRange', + 'the production H264 profile requires BT.709 limited range', + ); + return H264Profile( + codedWidth: profile.codedWidth, + codedHeight: profile.codedHeight, + expectedVisibleRect: expectedVisibleRect, + frameRate: H264FrameRate( + numerator: profile.frameRate.numerator, + denominator: profile.frameRate.denominator, + ), + ); +} + +Rect _cloneExpectedVisibleRect(Rect? value, int codedWidth, int codedHeight) { + if (value == null) { + return Rect(0, 0, codedWidth, codedHeight); + } + requireH264( + value.x == 0 && value.y == 0, + 'profile.expectedVisibleRect', + 'expected visible rectangle must begin at the coded origin', + ); + _positiveIntegerMax(value.width, 'profile.expectedVisibleRect[2]', codedWidth); + _positiveIntegerMax( + value.height, + 'profile.expectedVisibleRect[3]', + codedHeight, + ); + requireH264( + value.width % 2 == 0 && value.height % 2 == 0, + 'profile.expectedVisibleRect', + 'expected visible dimensions must be even for yuv420p', + ); + requireH264( + (codedWidth - value.width) % 2 == 0 && + (codedHeight - value.height) % 2 == 0, + 'profile.expectedVisibleRect', + 'expected visible crop must use 4:2:0 crop units', + ); + return Rect(0, 0, value.width, value.height); +} + +void _positiveInteger(int value, String path) { + requireH264( + value > 0 && value <= maxSafeInteger, + path, + 'must be a positive safe integer', + ); +} + +void _positiveIntegerMax(int value, String path, int maximum) { + requireH264( + value > 0 && value <= maximum, + path, + 'must be a positive safe integer no greater than $maximum', + ); +} + +void validateH264AccessUnitInput(H264AccessUnitInput accessUnit, String path) { + requireH264( + accessUnit.bytes.length <= formatDefaultBudgets.maxChunkBytes, + '$path.bytes', + 'access unit exceeds the sample budget', + ); +} + +_H264AccessUnitStateResult _inspectH264AccessUnitStatefully( + H264AccessUnitInput accessUnit, + int decodeIndex, + String path, + H264ParameterSetState? activeParameterSets, + H264ParameterSetState? stableParameterSets, + H264Profile profile, + H264PictureOrderState orderState, + int? knownMacroblocksPerFrame, +) { + final nals = splitAnnexBAccessUnit(accessUnit.bytes, '$path.bytes'); + requireH264( + nals.every((nal) => nal.prefixLength == 4), + '$path.bytes', + 'stored H264 access units must use canonical four-byte start codes', + ); + final nalTypes = List.unmodifiable(nals.map((nal) => nal.type)); + ParsedSps? parsedSps; + ParsedPps? parsedPps; + int? audPrimaryPicType; + final vcl = []; + var reachedVcl = false; + + for (var index = 0; index < nals.length; index += 1) { + final nal = nals[index]; + final nalPath = '$path.nals[$index]'; + switch (nal.type) { + case H264_NAL_TYPE_AUD: + requireH264( + index == 0 && audPrimaryPicType == null, + nalPath, + 'AUD must appear once, before every other NAL', + nal.offset, + ); + audPrimaryPicType = _parseAud(nal, nalPath); + break; + case H264_NAL_TYPE_SPS: + requireH264( + !reachedVcl && parsedSps == null && parsedPps == null, + nalPath, + 'SPS must appear once before PPS and VCL', + nal.offset, + ); + parsedSps = parseSps(nal, nalPath); + break; + case H264_NAL_TYPE_PPS: + requireH264( + !reachedVcl && parsedPps == null && parsedSps != null, + nalPath, + 'PPS must appear once after SPS and before VCL', + nal.offset, + ); + final currentSps = parsedSps!; + final parsedPpsValue = parsePps(nal, nalPath); + requireH264( + parsedPpsValue.spsId == currentSps.id, + nalPath, + 'PPS references an SPS outside this access unit', + nal.offset, + ); + parsedPps = parsedPpsValue; + break; + case H264_NAL_TYPE_IDR: + case H264_NAL_TYPE_NON_IDR: + reachedVcl = true; + vcl.add(nal); + break; + default: + h264Invalid(nalPath, 'unreachable NAL type', nal.offset); + } + } + requireH264( + vcl.isNotEmpty, + path, + 'access unit contains no primary coded picture', + ); + + final idr = vcl[0].type == H264_NAL_TYPE_IDR; + requireH264( + vcl.every((nal) => (nal.type == H264_NAL_TYPE_IDR) == idr), + path, + 'an access unit mixes IDR and non-IDR slices', + ); + requireH264( + accessUnit.key == idr, + '$path.key', + idr + ? 'IDR access unit is missing its key assertion' + : 'non-IDR access unit has a false key assertion', + ); + requireH264( + decodeIndex != 0 || idr, + path, + 'frame zero of every unit must be an IDR picture', + ); + requireH264( + (parsedSps == null) == (parsedPps == null), + path, + 'SPS and PPS must be carried together', + ); + requireH264( + !idr || (parsedSps != null && parsedPps != null), + path, + 'every key/IDR access unit must carry SPS and PPS', + ); + requireH264( + idr || (parsedSps == null && parsedPps == null), + path, + 'parameter sets are permitted only in key/IDR access units', + ); + + H264ParameterSetState? parameterSets = activeParameterSets; + if (parsedSps != null && parsedPps != null) { + if (stableParameterSets != null) { + _requireStableParameterSets( + parsedSps, + parsedPps, + stableParameterSets, + path, + ); + } + parameterSets = H264ParameterSetState(sps: parsedSps, pps: parsedPps); + } + if (parameterSets == null) { + h264Invalid(path, 'access unit has no usable SPS/PPS'); + } + + final macroblocksPerFrame = knownMacroblocksPerFrame ?? + validateH264SpsAgainstProfile( + parameterSets.sps, + profile, + '$path.sps', + ); + final slices = [ + for (var index = 0; index < vcl.length; index += 1) + parseSliceHeader( + vcl[index], + parameterSets.pps, + parameterSets.sps, + macroblocksPerFrame, + '$path.slices[$index]', + ), + ]; + final primary = slices[0]; + requireH264( + primary.firstMacroblock == 0, + '$path.slices[0]', + 'the first slice must begin at macroblock zero', + ); + var previousFirstMacroblock = -1; + for (var index = 0; index < slices.length; index += 1) { + final slice = slices[index]; + requireH264( + samePrimaryPicture(primary, slice), + '$path.slices[$index]', + 'access unit contains more than one primary coded picture', + ); + requireH264( + slice.firstMacroblock > previousFirstMacroblock, + '$path.slices[$index]', + 'slice macroblock starts must be strictly increasing', + ); + previousFirstMacroblock = slice.firstMacroblock; + } + final pictureOrderCount = _validatePictureSequence( + primary, + parameterSets.sps, + orderState, + path, + ); + + final summary = _H264AccessUnitDraft( + decodeIndex: decodeIndex, + pictureOrderCount: pictureOrderCount, + key: accessUnit.key, + idr: idr, + sliceType: primary.sliceType, + sliceCount: slices.length, + nalUnitTypes: nalTypes, + ); + _validateCanonicalH264Subset( + decodeIndex, + summary, + parameterSets, + audPrimaryPicType, + path, + ); + return _H264AccessUnitStateResult( + summary: summary, + parameterSets: parameterSets, + ); +} + +void _validateCanonicalH264Subset( + int decodeIndex, + _H264AccessUnitDraft summary, + H264ParameterSetState parameterSets, + int? audPrimaryPicType, + String path, +) { + final first = decodeIndex == 0; + final expectedNalTypes = first ? const [9, 7, 8, 5] : const [9, 1]; + requireH264( + summary.nalUnitTypes.length == expectedNalTypes.length && + _listEquals(summary.nalUnitTypes, expectedNalTypes), + path, + first + ? 'frame zero must contain exactly AUD/SPS/PPS/IDR' + : 'later frames must contain exactly AUD/non-IDR', + ); + requireH264( + summary.sliceCount == 1, + path, + 'the production H264 profile requires exactly one slice per access unit', + ); + requireH264( + first + ? summary.idr && summary.sliceType == 'I' && summary.key + : !summary.idr && + (summary.sliceType == 'P' || summary.sliceType == 'B') && + !summary.key, + path, + 'unit pictures must be one decode-zero IDR I followed by non-IDR P/B pictures', + ); + final expectedAudPrimaryPicType = summary.sliceType == 'I' + ? 0 + : summary.sliceType == 'P' + ? 1 + : 2; + requireH264( + audPrimaryPicType == expectedAudPrimaryPicType, + '$path.nals[0]', + 'AUD primary_pic_type does not match the coded picture', + ); + final sps = parameterSets.sps; + requireH264( + sps.squareSampleAspect, + '$path.sps', + 'the production H264 profile requires square sample aspect', + ); + requireH264( + sps.timing.fixedFrameRate, + '$path.sps', + 'the production H264 profile requires fixed_frame_rate_flag', + ); + requireH264( + !sps.hrdPresent, + '$path.sps', + 'the production H264 profile forbids HRD syntax', + ); +} + +bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var index = 0; index < a.length; index += 1) { + if (a[index] != b[index]) return false; + } + return true; +} + +int _parseAud(AnnexBNalUnit nal, String path) { + final reader = RbspBitReader(nal.rbsp, path, nal.offset + 1); + final primaryPicType = reader.readBits(3, 'primary_pic_type'); + requireH264( + primaryPicType == 0 || primaryPicType == 1 || primaryPicType == 2, + path, + 'AUD announces SP or SI picture types', + nal.offset + 1, + ); + reader.readTrailingBits(); + return primaryPicType; +} + +void _requireStableParameterSets( + ParsedSps sps, + ParsedPps pps, + H264ParameterSetState stable, + String path, +) { + requireH264( + sps.payloadSignature == stable.sps.payloadSignature, + '$path.sps', + 'SPS bytes changed within the rendition', + ); + requireH264( + pps.payloadSignature == stable.pps.payloadSignature, + '$path.pps', + 'PPS bytes changed within the rendition', + ); +} + +int validateH264SpsAgainstProfile( + ParsedSps sps, + H264Profile profile, + String path, +) { + requireH264( + sps.codedWidth == profile.codedWidth && + sps.codedHeight == profile.codedHeight, + path, + 'SPS coded dimensions ${sps.codedWidth}x${sps.codedHeight} do not match the rendition', + ); + final expectedCrop = profile.expectedVisibleRect ?? + Rect(0, 0, profile.codedWidth, profile.codedHeight); + requireH264( + sps.crop.left == expectedCrop.x && + sps.crop.top == expectedCrop.y && + sps.crop.right == + profile.codedWidth - expectedCrop.x - expectedCrop.width && + sps.crop.bottom == + profile.codedHeight - expectedCrop.y - expectedCrop.height && + sps.crop.visibleWidth == expectedCrop.width && + sps.crop.visibleHeight == expectedCrop.height, + path, + 'SPS crop does not match the expected visible rectangle', + ); + final macroblocksPerFrame = (sps.codedWidth ~/ 16) * (sps.codedHeight ~/ 16); + final level = h264LevelLimits(sps.levelIdc); + final widthInMacroblocks = sps.codedWidth ~/ 16; + final heightInMacroblocks = sps.codedHeight ~/ 16; + requireH264( + widthInMacroblocks <= level.maximumMacroblockDimension && + heightInMacroblocks <= level.maximumMacroblockDimension, + path, + 'SPS width or height exceeds its declared H264 level dimension limit', + ); + requireH264( + macroblocksPerFrame <= level.maximumMacroblocksPerFrame, + path, + 'SPS exceeds its declared H264 level macroblocks-per-frame limit', + ); + requireH264( + BigInt.from(macroblocksPerFrame) * + BigInt.from(profile.frameRate.numerator) <= + BigInt.from(level.maximumMacroblocksPerSecond) * + BigInt.from(profile.frameRate.denominator), + path, + 'rendition exceeds its declared H264 level macroblocks-per-second limit', + ); + requireH264( + BigInt.from(sps.timing.timeScale) * + BigInt.from(profile.frameRate.denominator) == + BigInt.from(2) * + BigInt.from(sps.timing.numUnitsInTick) * + BigInt.from(profile.frameRate.numerator), + path, + 'SPS VUI timing does not match the rendition frame rate', + ); + requireH264( + sps.timing.fixedFrameRate, + path, + 'fixed_frame_rate_flag must be one', + ); + final maximumDpbFramesFromLevel = + level.maximumDpbMacroblocks ~/ macroblocksPerFrame; + final maximumDpbFrames = + 16 < maximumDpbFramesFromLevel ? 16 : maximumDpbFramesFromLevel; + requireH264( + sps.maxDecFrameBuffering <= maximumDpbFrames, + path, + 'SPS max_dec_frame_buffering exceeds its declared H264 level', + ); + requireH264( + !sps.color.fullRange && + sps.color.colourPrimaries == 1 && + sps.color.transferCharacteristics == 1 && + sps.color.matrixCoefficients == 1, + path, + 'the production H264 profile requires BT.709 limited-range colour signalling', + ); + return macroblocksPerFrame; +} + +int _validatePictureSequence( + ParsedSliceHeader picture, + ParsedSps sps, + H264PictureOrderState state, + String path, +) { + final maximumFrameNum = 1 << sps.frameNumBits; + var frameNumOffset = 0; + if (picture.idr) { + requireH264(picture.frameNum == 0, path, 'IDR frame_num must be zero'); + state.previousReferenceFrameNum = 0; + state.previousReferenceFrameNumOffset = 0; + state.previousPocMsb = 0; + state.previousPocLsb = 0; + } else { + final expectedFrameNum = + (state.previousReferenceFrameNum + 1) % maximumFrameNum; + requireH264( + picture.frameNum == expectedFrameNum, + path, + 'frame_num does not identify the next short-term picture', + ); + frameNumOffset = state.previousReferenceFrameNumOffset + + (picture.frameNum < state.previousReferenceFrameNum + ? maximumFrameNum + : 0); + } + + final poc = _calculatePictureOrderCount(picture, sps, state, frameNumOffset); + requireH264( + (poc >= -maxSafeInteger && poc <= maxSafeInteger) && + (!picture.idr || poc == 0), + path, + 'picture order count is invalid', + ); + if (picture.referenceIdc != 0) { + state.previousReferenceFrameNum = picture.frameNum; + state.previousReferenceFrameNumOffset = frameNumOffset; + } + return poc; +} + +int _calculatePictureOrderCount( + ParsedSliceHeader picture, + ParsedSps sps, + H264PictureOrderState state, + int frameNumOffset, +) { + final syntax = sps.picOrderCount; + if (syntax is PicOrderCountType2) { + if (picture.idr) return 0; + final absoluteFrameNum = frameNumOffset + picture.frameNum; + return picture.referenceIdc == 0 + ? 2 * absoluteFrameNum - 1 + : 2 * absoluteFrameNum; + } + if (syntax is PicOrderCountType1) { + if (picture.idr) { + return picture.deltaPicOrderCnt0; + } + var absoluteFrameNum = frameNumOffset + picture.frameNum; + if (picture.referenceIdc == 0 && absoluteFrameNum > 0) { + absoluteFrameNum -= 1; + } + final cycleLength = syntax.offsetForRefFrame.length; + var expected = 0; + if (absoluteFrameNum > 0 && cycleLength > 0) { + final expectedDelta = syntax.offsetForRefFrame + .fold(0, (total, offset) => total + offset); + final cycleCount = (absoluteFrameNum - 1) ~/ cycleLength; + final frameInCycle = (absoluteFrameNum - 1) % cycleLength; + expected = cycleCount * expectedDelta; + for (var index = 0; index <= frameInCycle; index += 1) { + expected += syntax.offsetForRefFrame[index]; + } + } + if (picture.referenceIdc == 0) { + expected += syntax.offsetForNonRefPic; + } + final top = expected + picture.deltaPicOrderCnt0; + final bottom = + top + syntax.offsetForTopToBottomField + picture.deltaPicOrderCnt1; + return top < bottom ? top : bottom; + } + + syntax as PicOrderCountType0; + final lsb = picture.picOrderCntLsb; + if (lsb == null) { + h264Invalid('slice', 'pic_order_cnt_lsb is missing'); + } + final maximumLsb = 1 << syntax.lsbBits; + var msb = 0; + if (!picture.idr) { + if (lsb < state.previousPocLsb && + state.previousPocLsb - lsb >= maximumLsb / 2) { + msb = state.previousPocMsb + maximumLsb; + } else if (lsb > state.previousPocLsb && + lsb - state.previousPocLsb > maximumLsb / 2) { + msb = state.previousPocMsb - maximumLsb; + } else { + msb = state.previousPocMsb; + } + } + final top = msb + lsb; + final bottom = top + picture.deltaPicOrderCntBottom; + if (picture.referenceIdc != 0) { + state.previousPocMsb = msb; + state.previousPocLsb = lsb; + } + return top < bottom ? top : bottom; +} + +List _deriveH264PresentationOrder( + List<_H264AccessUnitDraft> pictures, + int maximumReorderFrames, + String path, +) { + requireH264(pictures.isNotEmpty, path, 'unit contains no decoded pictures'); + final sorted = [...pictures] + ..sort((left, right) => left.pictureOrderCount - right.pictureOrderCount); + requireH264( + sorted[0].decodeIndex == 0 && sorted[0].pictureOrderCount == 0, + path, + 'the unit IDR must be the first presentation picture', + ); + final decodeToPresentation = List.filled(pictures.length, null); + int? previousPictureOrderCount; + for ( + var presentationIndex = 0; + presentationIndex < sorted.length; + presentationIndex += 1 + ) { + final picture = sorted[presentationIndex]; + requireH264( + previousPictureOrderCount == null || + picture.pictureOrderCount > previousPictureOrderCount, + path, + 'unit picture-order counts must be unique', + ); + requireH264( + picture.decodeIndex >= 0 && + picture.decodeIndex < pictures.length && + decodeToPresentation[picture.decodeIndex] == null, + path, + 'unit decode index is duplicated or out of range', + ); + decodeToPresentation[picture.decodeIndex] = presentationIndex; + previousPictureOrderCount = picture.pictureOrderCount; + } + var requiredReorderFrames = 0; + for ( + var decodeIndex = 0; + decodeIndex < decodeToPresentation.length; + decodeIndex += 1 + ) { + final presentationIndex = decodeToPresentation[decodeIndex]; + requireH264(presentationIndex != null, path, 'decode order has a gap'); + requiredReorderFrames = math.max( + requiredReorderFrames, + decodeIndex - presentationIndex!, + ); + } + requireH264( + requiredReorderFrames <= maximumReorderFrames, + path, + 'derived presentation reordering exceeds the SPS declaration', + ); + return List.unmodifiable([for (final value in decodeToPresentation) value!]); +} + +H264ParameterSetSummary createH264ParameterSetSummary(ParsedSps sps) { + return H264ParameterSetSummary( + codec: h264CodecForLevel(sps.levelIdc), + levelIdc: sps.levelIdc, + codedWidth: sps.codedWidth, + codedHeight: sps.codedHeight, + crop: sps.crop, + maxNumRefFrames: sps.maxNumRefFrames, + maxNumReorderFrames: sps.maxNumReorderFrames, + maxDecFrameBuffering: sps.maxDecFrameBuffering, + hrdPresent: sps.hrdPresent, + fixedFrameRate: sps.timing.fixedFrameRate, + squareSampleAspect: sps.squareSampleAspect, + color: sps.color, + ); +} + +H264PictureOrderState _createH264PictureOrderState() => + H264PictureOrderState(); diff --git a/flutter/packages/aval_format/lib/src/h264/parameter_sets.dart b/flutter/packages/aval_format/lib/src/h264/parameter_sets.dart new file mode 100644 index 0000000..fb529e2 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/parameter_sets.dart @@ -0,0 +1,660 @@ +/// H.264 SPS/PPS syntax parsing for the production High-profile subset. +/// +/// Dart port of `packages/format/src/h264/parameter-sets.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import 'annex_b.dart' show AnnexBNalUnit; +import 'bit_reader.dart'; +import 'codec.dart' show H264LevelIdc, isH264LevelIdc; +import 'failure.dart'; +import 'types.dart' show H264ColorSummary, H264CropSummary; + +const int _highProfileIdc = 100; +final BigInt _maxHrdBits = BigInt.from(maxSafeInteger); + +/// `numUnitsInTick`/`timeScale`/`fixedFrameRate` VUI timing terms. +class H264SpsTiming { + const H264SpsTiming({ + required this.numUnitsInTick, + required this.timeScale, + required this.fixedFrameRate, + }); + + final int numUnitsInTick; + final int timeScale; + final bool fixedFrameRate; +} + +class ParsedSps { + const ParsedSps({ + required this.id, + required this.payloadSignature, + required this.levelIdc, + required this.frameNumBits, + required this.picOrderCount, + required this.maxNumRefFrames, + required this.codedWidth, + required this.codedHeight, + required this.crop, + required this.timing, + required this.maxNumReorderFrames, + required this.maxDecFrameBuffering, + required this.hrdPresent, + this.hrdMaximumBitrate, + this.hrdMaximumCpbBits, + required this.squareSampleAspect, + required this.color, + }); + + final int id; + + /// Exact, immutable payload identity without retaining caller byte views. + final String payloadSignature; + + /// Always `100` in the TS source (`profileIdc: 100`). + int get profileIdc => 100; + final H264LevelIdc levelIdc; + final int frameNumBits; + final PicOrderCountSyntax picOrderCount; + final int maxNumRefFrames; + final int codedWidth; + final int codedHeight; + final H264CropSummary crop; + final H264SpsTiming timing; + final int maxNumReorderFrames; + final int maxDecFrameBuffering; + final bool hrdPresent; + final int? hrdMaximumBitrate; + final int? hrdMaximumCpbBits; + final bool squareSampleAspect; + final H264ColorSummary color; +} + +/// TS discriminated union `PicOrderCountSyntax`. [type] is the discriminant. +sealed class PicOrderCountSyntax { + const PicOrderCountSyntax(this.type); + + /// `0 | 1 | 2`. + final int type; +} + +class PicOrderCountType0 extends PicOrderCountSyntax { + const PicOrderCountType0({required this.lsbBits}) : super(0); + + final int lsbBits; +} + +class PicOrderCountType1 extends PicOrderCountSyntax { + const PicOrderCountType1({ + required this.deltaPicOrderAlwaysZero, + required this.offsetForNonRefPic, + required this.offsetForTopToBottomField, + required this.offsetForRefFrame, + }) : super(1); + + final bool deltaPicOrderAlwaysZero; + final int offsetForNonRefPic; + final int offsetForTopToBottomField; + final List offsetForRefFrame; +} + +class PicOrderCountType2 extends PicOrderCountSyntax { + const PicOrderCountType2() : super(2); +} + +class ParsedPps { + const ParsedPps({ + required this.id, + required this.spsId, + required this.payloadSignature, + required this.entropyCoding, + required this.bottomFieldPicOrderInFramePresent, + required this.numRefIdxL0DefaultActiveMinus1, + required this.numRefIdxL1DefaultActiveMinus1, + required this.weightedPrediction, + required this.weightedBipredIdc, + required this.deblockingFilterControlPresent, + required this.picInitQpMinus26, + }); + + final int id; + final int spsId; + + /// Exact, immutable payload identity without retaining caller byte views. + final String payloadSignature; + final bool entropyCoding; + final bool bottomFieldPicOrderInFramePresent; + final int numRefIdxL0DefaultActiveMinus1; + final int numRefIdxL1DefaultActiveMinus1; + final bool weightedPrediction; + final int weightedBipredIdc; + final bool deblockingFilterControlPresent; + final int picInitQpMinus26; + + /// Always `true` in the TS source (`transform8x8Mode: true`). + bool get transform8x8Mode => true; +} + +class _HrdSummary { + const _HrdSummary({ + required this.maximumBitrate, + required this.maximumCpbBits, + }); + + final int maximumBitrate; + final int maximumCpbBits; +} + +class _VuiSummary { + const _VuiSummary({ + required this.timing, + required this.maxNumReorderFrames, + required this.maxDecFrameBuffering, + required this.hrdPresent, + this.hrdMaximumBitrate, + this.hrdMaximumCpbBits, + required this.squareSampleAspect, + required this.color, + }); + + final H264SpsTiming timing; + final int maxNumReorderFrames; + final int maxDecFrameBuffering; + final bool hrdPresent; + final int? hrdMaximumBitrate; + final int? hrdMaximumCpbBits; + final bool squareSampleAspect; + final H264ColorSummary color; +} + +ParsedSps parseSps(AnnexBNalUnit nal, String path) { + final reader = RbspBitReader(nal.rbsp, path, nal.offset + 1); + final profileIdc = reader.readBits(8, 'profile_idc'); + requireH264( + profileIdc == _highProfileIdc, + path, + 'profile_idc must be High (100)', + nal.offset + 1, + ); + + final compatibility = reader.readBits(8, 'constraint flags'); + requireH264( + compatibility == 0, + path, + 'High-profile constraint and reserved flags must be zero', + nal.offset + 2, + ); + final levelIdc = reader.readBits(8, 'level_idc'); + requireH264( + isH264LevelIdc(levelIdc), + path, + 'level_idc must identify a supported H264 level', + nal.offset + 3, + ); + final id = reader.readUnsignedExpGolomb('seq_parameter_set_id', 31); + final chromaFormatIdc = reader.readUnsignedExpGolomb('chroma_format_idc', 3); + requireH264( + chromaFormatIdc == 1, + path, + 'High-profile streams must use 4:2:0 chroma', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + requireH264( + reader.readUnsignedExpGolomb('bit_depth_luma_minus8', 6) == 0 && + reader.readUnsignedExpGolomb('bit_depth_chroma_minus8', 6) == 0, + path, + 'High-profile streams must use 8-bit luma and chroma', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + requireH264( + !reader.readBit('qpprime_y_zero_transform_bypass_flag'), + path, + 'lossless transform bypass is forbidden', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + if (reader.readBit('seq_scaling_matrix_present_flag')) { + _parseScalingMatrices(reader, 8); + } + final log2MaxFrameNumMinus4 = + reader.readUnsignedExpGolomb('log2_max_frame_num_minus4', 12); + final frameNumBits = log2MaxFrameNumMinus4 + 4; + final picOrderCount = _parsePicOrderCount(reader); + final maxNumRefFrames = + reader.readUnsignedExpGolomb('max_num_ref_frames', 16); + requireH264(maxNumRefFrames > 0, path, 'max_num_ref_frames must be positive'); + requireH264( + !reader.readBit('gaps_in_frame_num_value_allowed_flag'), + path, + 'frame_num gaps are forbidden', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + + final widthInMacroblocks = + reader.readUnsignedExpGolomb('pic_width_in_mbs_minus1', 8191) + 1; + final heightInMapUnits = + reader.readUnsignedExpGolomb('pic_height_in_map_units_minus1', 8191) + 1; + final frameMbsOnly = reader.readBit('frame_mbs_only_flag'); + requireH264( + frameMbsOnly, + path, + 'interlaced and field-coded pictures are forbidden', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + reader.readBit('direct_8x8_inference_flag'); + + final codedWidth = widthInMacroblocks * 16; + final codedHeight = heightInMapUnits * 16; + var cropLeftOffset = 0; + var cropRightOffset = 0; + var cropTopOffset = 0; + var cropBottomOffset = 0; + if (reader.readBit('frame_cropping_flag')) { + cropLeftOffset = reader.readUnsignedExpGolomb('frame_crop_left_offset'); + cropRightOffset = reader.readUnsignedExpGolomb('frame_crop_right_offset'); + cropTopOffset = reader.readUnsignedExpGolomb('frame_crop_top_offset'); + cropBottomOffset = + reader.readUnsignedExpGolomb('frame_crop_bottom_offset'); + } + + // Progressive High profile with chroma_format_idc 1 uses 2x2 crop units. + final left = cropLeftOffset * 2; + final right = cropRightOffset * 2; + final top = cropTopOffset * 2; + final bottom = cropBottomOffset * 2; + requireH264( + left + right < codedWidth && top + bottom < codedHeight, + path, + 'SPS crop removes the complete coded picture', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + final crop = H264CropSummary( + left: left, + right: right, + top: top, + bottom: bottom, + visibleWidth: codedWidth - left - right, + visibleHeight: codedHeight - top - bottom, + ); + + requireH264( + reader.readBit('vui_parameters_present_flag'), + path, + 'VUI parameters are required by the H264 profile', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + final vui = _parseVui(reader, maxNumRefFrames, path, nal.offset + 1); + reader.readTrailingBits(); + + return ParsedSps( + id: id, + payloadSignature: _createPayloadSignature(nal.payload), + levelIdc: levelIdc, + frameNumBits: frameNumBits, + picOrderCount: picOrderCount, + maxNumRefFrames: maxNumRefFrames, + codedWidth: codedWidth, + codedHeight: codedHeight, + crop: crop, + timing: vui.timing, + maxNumReorderFrames: vui.maxNumReorderFrames, + maxDecFrameBuffering: vui.maxDecFrameBuffering, + hrdPresent: vui.hrdPresent, + hrdMaximumBitrate: vui.hrdMaximumBitrate, + hrdMaximumCpbBits: vui.hrdMaximumCpbBits, + squareSampleAspect: vui.squareSampleAspect, + color: vui.color, + ); +} + +ParsedPps parsePps(AnnexBNalUnit nal, String path) { + final reader = RbspBitReader(nal.rbsp, path, nal.offset + 1); + final id = reader.readUnsignedExpGolomb('pic_parameter_set_id', 255); + final spsId = reader.readUnsignedExpGolomb('seq_parameter_set_id', 31); + final entropyCoding = reader.readBit('entropy_coding_mode_flag'); + final bottomFieldPicOrderInFramePresent = + reader.readBit('bottom_field_pic_order_in_frame_present_flag'); + requireH264( + !bottomFieldPicOrderInFramePresent, + path, + 'bottom-field picture order syntax is forbidden', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + requireH264( + reader.readUnsignedExpGolomb('num_slice_groups_minus1', 8) == 0, + path, + 'slice groups/FMO are forbidden', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + final numRefIdxL0DefaultActiveMinus1 = + reader.readUnsignedExpGolomb('num_ref_idx_l0_default_active_minus1', 31); + final numRefIdxL1DefaultActiveMinus1 = + reader.readUnsignedExpGolomb('num_ref_idx_l1_default_active_minus1', 31); + final weightedPrediction = reader.readBit('weighted_pred_flag'); + final weightedBipredIdc = reader.readBits(2, 'weighted_bipred_idc'); + requireH264( + weightedBipredIdc <= 2, + path, + 'weighted_bipred_idc is reserved', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + final picInitQpMinus26 = + reader.readSignedExpGolomb('pic_init_qp_minus26', -26, 25); + requireH264( + reader.readSignedExpGolomb('pic_init_qs_minus26', -26, 25) == 0, + path, + 'pic_init_qs_minus26 must match the frozen encoder profile', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + reader.readSignedExpGolomb('chroma_qp_index_offset', -12, 12); + final deblockingFilterControlPresent = + reader.readBit('deblocking_filter_control_present_flag'); + requireH264( + deblockingFilterControlPresent, + path, + 'deblocking filter control must be present', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + requireH264( + !reader.readBit('constrained_intra_pred_flag'), + path, + 'constrained intra prediction is outside the production profile', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + requireH264( + !reader.readBit('redundant_pic_cnt_present_flag'), + path, + 'redundant pictures are forbidden', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + requireH264( + reader.moreRbspData(), + path, + 'High-profile PPS extension is required', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + final transform8x8Mode = reader.readBit('transform_8x8_mode_flag'); + requireH264( + transform8x8Mode, + path, + 'the production H264 profile requires transform_8x8_mode_flag', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + if (reader.readBit('pic_scaling_matrix_present_flag')) { + _parseScalingMatrices(reader, 8); + } + reader.readSignedExpGolomb('second_chroma_qp_index_offset', -12, 12); + reader.readTrailingBits(); + + return ParsedPps( + id: id, + spsId: spsId, + payloadSignature: _createPayloadSignature(nal.payload), + entropyCoding: entropyCoding, + bottomFieldPicOrderInFramePresent: bottomFieldPicOrderInFramePresent, + numRefIdxL0DefaultActiveMinus1: numRefIdxL0DefaultActiveMinus1, + numRefIdxL1DefaultActiveMinus1: numRefIdxL1DefaultActiveMinus1, + weightedPrediction: weightedPrediction, + weightedBipredIdc: weightedBipredIdc, + deblockingFilterControlPresent: deblockingFilterControlPresent, + picInitQpMinus26: picInitQpMinus26, + ); +} + +String _createPayloadSignature(Uint8List bytes) { + final buffer = StringBuffer(); + for (final byte in bytes) { + buffer.write(byte.toRadixString(16).padLeft(2, '0')); + } + return buffer.toString(); +} + +void _parseScalingMatrices(RbspBitReader reader, int count) { + for (var index = 0; index < count; index += 1) { + if (reader.readBit('scaling_list_present_flag[$index]')) { + _parseScalingList(reader, index < 6 ? 16 : 64, index); + } + } +} + +void _parseScalingList(RbspBitReader reader, int size, int listIndex) { + var lastScale = 8; + var nextScale = 8; + for (var entry = 0; entry < size; entry += 1) { + if (nextScale != 0) { + final delta = reader.readSignedExpGolomb( + 'scaling_list[$listIndex][$entry]', + -128, + 127, + ); + nextScale = (lastScale + delta + 256) % 256; + } + lastScale = nextScale == 0 ? lastScale : nextScale; + } +} + +PicOrderCountSyntax _parsePicOrderCount(RbspBitReader reader) { + final type = reader.readUnsignedExpGolomb('pic_order_cnt_type', 2); + if (type == 0) { + final lsbBits = + reader.readUnsignedExpGolomb('log2_max_pic_order_cnt_lsb_minus4', 12) + + 4; + return PicOrderCountType0(lsbBits: lsbBits); + } + if (type == 2) { + return const PicOrderCountType2(); + } + + final deltaPicOrderAlwaysZero = + reader.readBit('delta_pic_order_always_zero_flag'); + final offsetForNonRefPic = + reader.readSignedExpGolomb('offset_for_non_ref_pic'); + final offsetForTopToBottomField = + reader.readSignedExpGolomb('offset_for_top_to_bottom_field'); + final cycleLength = reader.readUnsignedExpGolomb( + 'num_ref_frames_in_pic_order_cnt_cycle', + 255, + ); + final offsetForRefFrame = []; + for (var index = 0; index < cycleLength; index += 1) { + offsetForRefFrame + .add(reader.readSignedExpGolomb('offset_for_ref_frame[$index]')); + } + return PicOrderCountType1( + deltaPicOrderAlwaysZero: deltaPicOrderAlwaysZero, + offsetForNonRefPic: offsetForNonRefPic, + offsetForTopToBottomField: offsetForTopToBottomField, + offsetForRefFrame: List.unmodifiable(offsetForRefFrame), + ); +} + +_VuiSummary _parseVui( + RbspBitReader reader, + int maxNumRefFrames, + String path, + int absoluteOffset, +) { + var squareSampleAspect = true; + if (reader.readBit('aspect_ratio_info_present_flag')) { + final aspectRatioIdc = reader.readBits(8, 'aspect_ratio_idc'); + if (aspectRatioIdc == 255) { + final sarWidth = reader.readBits(16, 'sar_width'); + final sarHeight = reader.readBits(16, 'sar_height'); + requireH264( + sarWidth > 0 && sarHeight > 0, + path, + 'extended sample aspect ratio must be positive', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + squareSampleAspect = sarWidth == sarHeight; + } else { + requireH264( + aspectRatioIdc >= 1 && aspectRatioIdc <= 16, + path, + 'aspect_ratio_idc is reserved', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + squareSampleAspect = aspectRatioIdc == 1; + } + } + if (reader.readBit('overscan_info_present_flag')) { + reader.readBit('overscan_appropriate_flag'); + } + + var fullRange = false; + int? colourPrimaries; + int? transferCharacteristics; + int? matrixCoefficients; + if (reader.readBit('video_signal_type_present_flag')) { + reader.readBits(3, 'video_format'); + fullRange = reader.readBit('video_full_range_flag'); + if (reader.readBit('colour_description_present_flag')) { + colourPrimaries = reader.readBits(8, 'colour_primaries'); + transferCharacteristics = reader.readBits(8, 'transfer_characteristics'); + matrixCoefficients = reader.readBits(8, 'matrix_coefficients'); + } + } + if (reader.readBit('chroma_loc_info_present_flag')) { + reader.readUnsignedExpGolomb('chroma_sample_loc_type_top_field', 5); + reader.readUnsignedExpGolomb('chroma_sample_loc_type_bottom_field', 5); + } + + requireH264( + reader.readBit('timing_info_present_flag'), + path, + 'VUI fixed timing is required', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + final numUnitsInTick = reader.readBits(32, 'num_units_in_tick'); + final timeScale = reader.readBits(32, 'time_scale'); + requireH264( + numUnitsInTick > 0 && timeScale > 0, + path, + 'VUI timing terms must be positive', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + // libx264 may leave this advisory flag clear for a CFR elementary stream. + // The compiler proves CFR from source timestamps; the inspector still + // requires exact VUI timing terms and checks them against that frame clock. + final fixedFrameRate = reader.readBit('fixed_frame_rate_flag'); + + final nalHrd = reader.readBit('nal_hrd_parameters_present_flag') + ? _parseHrd(reader, path, absoluteOffset) + : null; + final vclHrd = reader.readBit('vcl_hrd_parameters_present_flag') + ? _parseHrd(reader, path, absoluteOffset) + : null; + if (nalHrd != null || vclHrd != null) { + reader.readBit('low_delay_hrd_flag'); + } + reader.readBit('pic_struct_present_flag'); + + requireH264( + reader.readBit('bitstream_restriction_flag'), + path, + 'VUI bitstream restrictions are required', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + reader.readBit('motion_vectors_over_pic_boundaries_flag'); + reader.readUnsignedExpGolomb('max_bytes_per_pic_denom', 16); + reader.readUnsignedExpGolomb('max_bits_per_mb_denom', 16); + reader.readUnsignedExpGolomb('log2_max_mv_length_horizontal', 32); + reader.readUnsignedExpGolomb('log2_max_mv_length_vertical', 32); + final maxNumReorderFrames = + reader.readUnsignedExpGolomb('max_num_reorder_frames', 16); + final maxDecFrameBuffering = + reader.readUnsignedExpGolomb('max_dec_frame_buffering', 16); + requireH264( + maxDecFrameBuffering >= maxNumRefFrames, + path, + 'max_dec_frame_buffering is smaller than max_num_ref_frames', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + requireH264( + maxNumReorderFrames <= maxDecFrameBuffering, + path, + 'max_num_reorder_frames exceeds max_dec_frame_buffering', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + + final maximumBitrate = _maxInt( + nalHrd?.maximumBitrate ?? 0, + vclHrd?.maximumBitrate ?? 0, + ); + final maximumCpbBits = _maxInt( + nalHrd?.maximumCpbBits ?? 0, + vclHrd?.maximumCpbBits ?? 0, + ); + return _VuiSummary( + timing: H264SpsTiming( + numUnitsInTick: numUnitsInTick, + timeScale: timeScale, + fixedFrameRate: fixedFrameRate, + ), + maxNumReorderFrames: maxNumReorderFrames, + maxDecFrameBuffering: maxDecFrameBuffering, + hrdPresent: nalHrd != null || vclHrd != null, + hrdMaximumBitrate: + (nalHrd == null && vclHrd == null) ? null : maximumBitrate, + hrdMaximumCpbBits: + (nalHrd == null && vclHrd == null) ? null : maximumCpbBits, + squareSampleAspect: squareSampleAspect, + color: H264ColorSummary( + fullRange: fullRange, + colourPrimaries: colourPrimaries, + transferCharacteristics: transferCharacteristics, + matrixCoefficients: matrixCoefficients, + ), + ); +} + +int _maxInt(int left, int right) => left > right ? left : right; + +_HrdSummary _parseHrd(RbspBitReader reader, String path, int absoluteOffset) { + final cpbCount = reader.readUnsignedExpGolomb('cpb_cnt_minus1', 31) + 1; + final bitRateScale = reader.readBits(4, 'bit_rate_scale'); + final cpbSizeScale = reader.readBits(4, 'cpb_size_scale'); + var maximumBitrate = BigInt.zero; + var maximumCpbBits = BigInt.zero; + for (var index = 0; index < cpbCount; index += 1) { + final bitRateValue = BigInt.from( + reader.readUnsignedExpGolomb('bit_rate_value_minus1[$index]'), + ) + + BigInt.one; + final cpbSizeValue = BigInt.from( + reader.readUnsignedExpGolomb('cpb_size_value_minus1[$index]'), + ) + + BigInt.one; + final bitrate = bitRateValue << (6 + bitRateScale); + final cpbBits = cpbSizeValue << (4 + cpbSizeScale); + requireH264( + bitrate <= _maxHrdBits, + path, + 'HRD bitrate exceeds the JavaScript safe-integer range', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + requireH264( + cpbBits <= _maxHrdBits, + path, + 'HRD CPB exceeds the JavaScript safe-integer range', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + if (bitrate > maximumBitrate) { + maximumBitrate = bitrate; + } + if (cpbBits > maximumCpbBits) { + maximumCpbBits = cpbBits; + } + reader.readBit('cbr_flag[$index]'); + } + reader.readBits(5, 'initial_cpb_removal_delay_length_minus1'); + reader.readBits(5, 'cpb_removal_delay_length_minus1'); + reader.readBits(5, 'dpb_output_delay_length_minus1'); + reader.readBits(5, 'time_offset_length'); + return _HrdSummary( + maximumBitrate: maximumBitrate.toInt(), + maximumCpbBits: maximumCpbBits.toInt(), + ); +} diff --git a/flutter/packages/aval_format/lib/src/h264/slice_header.dart b/flutter/packages/aval_format/lib/src/h264/slice_header.dart new file mode 100644 index 0000000..6589302 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/slice_header.dart @@ -0,0 +1,307 @@ +/// Slice header syntax parser for the production H264 High-profile subset. +/// +/// Dart port of `packages/format/src/h264/slice-header.ts`. +library; + +import 'annex_b.dart' show H264_NAL_TYPE_IDR, AnnexBNalUnit; +import 'bit_reader.dart'; +import 'failure.dart'; +import 'parameter_sets.dart'; +import 'types.dart' show H264SliceType; + +class ParsedSliceHeader { + const ParsedSliceHeader({ + required this.firstMacroblock, + required this.sliceType, + required this.ppsId, + required this.frameNum, + required this.referenceIdc, + required this.idr, + this.idrPicId, + this.picOrderCntLsb, + required this.deltaPicOrderCntBottom, + required this.deltaPicOrderCnt0, + required this.deltaPicOrderCnt1, + required this.sliceQpDelta, + }); + + final int firstMacroblock; + final H264SliceType sliceType; + final int ppsId; + final int frameNum; + final int referenceIdc; + final bool idr; + final int? idrPicId; + final int? picOrderCntLsb; + final int deltaPicOrderCntBottom; + final int deltaPicOrderCnt0; + final int deltaPicOrderCnt1; + final int sliceQpDelta; +} + +ParsedSliceHeader parseSliceHeader( + AnnexBNalUnit nal, + ParsedPps pps, + ParsedSps sps, + int macroblocksPerFrame, + String path, +) { + final reader = RbspBitReader(nal.rbsp, path, nal.offset + 1); + final firstMacroblock = reader.readUnsignedExpGolomb( + 'first_mb_in_slice', + macroblocksPerFrame - 1, + ); + final rawSliceType = reader.readUnsignedExpGolomb('slice_type', 9); + final normalizedSliceType = rawSliceType % 5; + requireH264( + normalizedSliceType == 0 || + normalizedSliceType == 1 || + normalizedSliceType == 2, + path, + 'only I, P, and B slices are permitted (SP/SI are forbidden)', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + final sliceType = normalizedSliceType == 2 + ? 'I' + : normalizedSliceType == 1 + ? 'B' + : 'P'; + final idr = nal.type == H264_NAL_TYPE_IDR; + requireH264( + !idr || sliceType == 'I', + path, + 'an IDR picture must contain only I slices', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + + final ppsId = reader.readUnsignedExpGolomb('pic_parameter_set_id', 255); + requireH264( + ppsId == pps.id, + path, + 'slice references an unexpected PPS', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + final frameNum = reader.readBits(sps.frameNumBits, 'frame_num'); + final idrPicId = + idr ? reader.readUnsignedExpGolomb('idr_pic_id', 65535) : null; + + int? picOrderCntLsb; + var deltaPicOrderCntBottom = 0; + var deltaPicOrderCnt0 = 0; + var deltaPicOrderCnt1 = 0; + final poc = sps.picOrderCount; + if (poc is PicOrderCountType0) { + picOrderCntLsb = reader.readBits(poc.lsbBits, 'pic_order_cnt_lsb'); + if (pps.bottomFieldPicOrderInFramePresent) { + deltaPicOrderCntBottom = + reader.readSignedExpGolomb('delta_pic_order_cnt_bottom'); + } + } else if (poc is PicOrderCountType1 && !poc.deltaPicOrderAlwaysZero) { + deltaPicOrderCnt0 = reader.readSignedExpGolomb('delta_pic_order_cnt[0]'); + if (pps.bottomFieldPicOrderInFramePresent) { + deltaPicOrderCnt1 = reader.readSignedExpGolomb('delta_pic_order_cnt[1]'); + } + } + + var numRefIdxL0ActiveMinus1 = pps.numRefIdxL0DefaultActiveMinus1; + var numRefIdxL1ActiveMinus1 = pps.numRefIdxL1DefaultActiveMinus1; + if (sliceType == 'B') { + reader.readBit('direct_spatial_mv_pred_flag'); + } + if (sliceType == 'P' || sliceType == 'B') { + if (reader.readBit('num_ref_idx_active_override_flag')) { + numRefIdxL0ActiveMinus1 = + reader.readUnsignedExpGolomb('num_ref_idx_l0_active_minus1', 31); + if (sliceType == 'B') { + numRefIdxL1ActiveMinus1 = + reader.readUnsignedExpGolomb('num_ref_idx_l1_active_minus1', 31); + } + } + _parseReferenceListModifications(reader, sliceType); + } + + if ((pps.weightedPrediction && sliceType == 'P') || + (pps.weightedBipredIdc == 1 && sliceType == 'B')) { + _parsePredictionWeights( + reader, + numRefIdxL0ActiveMinus1, + sliceType == 'B' ? numRefIdxL1ActiveMinus1 : null, + ); + } + + if (idr) { + reader.readBit('no_output_of_prior_pics_flag'); + requireH264( + !reader.readBit('long_term_reference_flag'), + path, + 'long-term IDR references are forbidden', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + } else if (nal.referenceIdc != 0) { + _parseReferencePictureMarking(reader, path, nal.offset + 1); + } + + if (pps.entropyCoding && sliceType != 'I') { + reader.readUnsignedExpGolomb('cabac_init_idc', 2); + } + + final sliceQpDelta = reader.readSignedExpGolomb('slice_qp_delta', -87, 77); + final finalQp = 26 + pps.picInitQpMinus26 + sliceQpDelta; + requireH264( + finalQp >= 0 && finalQp <= 51, + path, + 'final slice QP is outside the 8-bit H264 range', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + if (pps.deblockingFilterControlPresent) { + final disableDeblockingFilterIdc = + reader.readUnsignedExpGolomb('disable_deblocking_filter_idc', 2); + if (disableDeblockingFilterIdc != 1) { + reader.readSignedExpGolomb('slice_alpha_c0_offset_div2', -6, 6); + reader.readSignedExpGolomb('slice_beta_offset_div2', -6, 6); + } + } + requireH264( + reader.bitsRemaining > 0, + path, + 'slice_data and RBSP trailing bits are missing', + nal.offset + 1 + (reader.bitOffset ~/ 8), + ); + + return ParsedSliceHeader( + firstMacroblock: firstMacroblock, + sliceType: sliceType, + ppsId: ppsId, + frameNum: frameNum, + referenceIdc: nal.referenceIdc, + idr: idr, + idrPicId: idrPicId, + picOrderCntLsb: picOrderCntLsb, + deltaPicOrderCntBottom: deltaPicOrderCntBottom, + deltaPicOrderCnt0: deltaPicOrderCnt0, + deltaPicOrderCnt1: deltaPicOrderCnt1, + sliceQpDelta: sliceQpDelta, + ); +} + +void _parseReferencePictureMarking( + RbspBitReader reader, + String path, + int absoluteOffset, +) { + if (!reader.readBit('adaptive_ref_pic_marking_mode_flag')) return; + for (var index = 0; index < 64; index += 1) { + final operation = reader.readUnsignedExpGolomb( + 'memory_management_control_operation[$index]', + 6, + ); + if (operation == 0) return; + requireH264( + operation == 1, + path, + 'only short-term reference release is permitted', + absoluteOffset + (reader.bitOffset ~/ 8), + ); + reader.readUnsignedExpGolomb( + 'difference_of_pic_nums_minus1[$index]', + 65535, + ); + } + requireH264( + false, + path, + 'reference-picture marking exceeds the syntax budget', + absoluteOffset + (reader.bitOffset ~/ 8), + ); +} + +void _parseReferenceListModifications( + RbspBitReader reader, + H264SliceType sliceType, +) { + _parseReferenceList(reader, 'l0'); + if (sliceType == 'B') { + _parseReferenceList(reader, 'l1'); + } +} + +void _parseReferenceList(RbspBitReader reader, String list) { + if (!reader.readBit('ref_pic_list_modification_flag_$list')) return; + for (var index = 0; index < 64; index += 1) { + final operation = reader.readUnsignedExpGolomb( + 'modification_of_pic_nums_idc_$list[$index]', + 3, + ); + if (operation == 3) return; + if (operation == 0 || operation == 1) { + reader.readUnsignedExpGolomb( + 'abs_diff_pic_num_minus1_$list[$index]', + 65535, + ); + } else { + requireH264( + false, + 'slice', + 'long-term reference-list entries are forbidden in independent units', + ); + } + } + requireH264( + false, + 'slice', + 'reference-list modification exceeds the syntax budget', + ); +} + +void _parsePredictionWeights( + RbspBitReader reader, + int list0Minus1, + int? list1Minus1, +) { + reader.readUnsignedExpGolomb('luma_log2_weight_denom', 7); + reader.readUnsignedExpGolomb('chroma_log2_weight_denom', 7); + _parsePredictionWeightList(reader, 'l0', list0Minus1 + 1); + if (list1Minus1 != null) { + _parsePredictionWeightList(reader, 'l1', list1Minus1 + 1); + } +} + +void _parsePredictionWeightList( + RbspBitReader reader, + String list, + int count, +) { + for (var index = 0; index < count; index += 1) { + if (reader.readBit('luma_weight_${list}_flag[$index]')) { + reader.readSignedExpGolomb('luma_weight_$list[$index]', -128, 127); + reader.readSignedExpGolomb('luma_offset_$list[$index]', -128, 127); + } + if (reader.readBit('chroma_weight_${list}_flag[$index]')) { + for (var component = 0; component < 2; component += 1) { + reader.readSignedExpGolomb( + 'chroma_weight_$list[$index][$component]', + -128, + 127, + ); + reader.readSignedExpGolomb( + 'chroma_offset_$list[$index][$component]', + -128, + 127, + ); + } + } + } +} + +bool samePrimaryPicture(ParsedSliceHeader left, ParsedSliceHeader right) { + return left.sliceType == right.sliceType && + left.ppsId == right.ppsId && + left.frameNum == right.frameNum && + left.referenceIdc == right.referenceIdc && + left.idr == right.idr && + left.idrPicId == right.idrPicId && + left.picOrderCntLsb == right.picOrderCntLsb && + left.deltaPicOrderCntBottom == right.deltaPicOrderCntBottom && + left.deltaPicOrderCnt0 == right.deltaPicOrderCnt0 && + left.deltaPicOrderCnt1 == right.deltaPicOrderCnt1; +} diff --git a/flutter/packages/aval_format/lib/src/h264/types.dart b/flutter/packages/aval_format/lib/src/h264/types.dart new file mode 100644 index 0000000..16889b6 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h264/types.dart @@ -0,0 +1,216 @@ +/// Shared H264 input/inspection value types. +/// +/// Dart port of `packages/format/src/h264/types.ts`. +library; + +import 'dart:typed_data'; + +import '../model.dart' show Rect; +import 'codec.dart' show H264Codec, H264LevelIdc; + +/// A single Annex B access unit and its container key assertion. +class H264AccessUnitInput { + const H264AccessUnitInput({required this.bytes, required this.key}); + + final Uint8List bytes; + final bool key; +} + +/// An independently decodable unit. Frame zero must be a closed-GOP IDR. +class H264UnitInput { + const H264UnitInput({required this.id, required this.accessUnits}); + + final String id; + final List accessUnits; +} + +class H264FrameRate { + const H264FrameRate({required this.numerator, required this.denominator}); + + final int numerator; + final int denominator; +} + +/// Non-bitstream facts that the compiler requires the High-profile stream to +/// match. +class H264Profile { + const H264Profile({ + required this.codedWidth, + required this.codedHeight, + this.expectedVisibleRect, + required this.frameRate, + }); + + final int codedWidth; + final int codedHeight; + + /// TS `expectedVisibleRect?: readonly [0, 0, number, number]`. + final Rect? expectedVisibleRect; + final H264FrameRate frameRate; + + /// Always `true` in the TS source (`requireBt709LimitedRange: true`). + bool get requireBt709LimitedRange => true; +} + +class H264RenditionInspectionInput { + const H264RenditionInspectionInput({ + required this.profile, + required this.units, + }); + + final H264Profile profile; + final List units; +} + +class H264CropSummary { + const H264CropSummary({ + required this.left, + required this.right, + required this.top, + required this.bottom, + required this.visibleWidth, + required this.visibleHeight, + }); + + final int left; + final int right; + final int top; + final int bottom; + final int visibleWidth; + final int visibleHeight; +} + +class H264ColorSummary { + const H264ColorSummary({ + required this.fullRange, + this.colourPrimaries, + this.transferCharacteristics, + this.matrixCoefficients, + }); + + final bool fullRange; + final int? colourPrimaries; + final int? transferCharacteristics; + final int? matrixCoefficients; +} + +class H264ParameterSetSummary { + const H264ParameterSetSummary({ + required this.codec, + required this.levelIdc, + required this.codedWidth, + required this.codedHeight, + required this.crop, + required this.maxNumRefFrames, + required this.maxNumReorderFrames, + required this.maxDecFrameBuffering, + required this.hrdPresent, + required this.fixedFrameRate, + required this.squareSampleAspect, + required this.color, + }); + + /// Always `100` in the TS source (`profileIdc: 100`). + int get profileIdc => 100; + final H264Codec codec; + final H264LevelIdc levelIdc; + final int codedWidth; + final int codedHeight; + final H264CropSummary crop; + + /// Always `8` in the TS source (`bitDepth: 8`). + int get bitDepth => 8; + + /// Always `"4:2:0"` in the TS source (`chromaFormat: "4:2:0"`). + String get chromaFormat => '4:2:0'; + final int maxNumRefFrames; + final int maxNumReorderFrames; + final int maxDecFrameBuffering; + final bool hrdPresent; + final bool fixedFrameRate; + final bool squareSampleAspect; + final H264ColorSummary color; +} + +/// `"I" | "P" | "B"`. +typedef H264SliceType = String; + +class H264AccessUnitSummary { + const H264AccessUnitSummary({ + required this.decodeIndex, + required this.presentationIndex, + required this.pictureOrderCount, + required this.key, + required this.idr, + required this.sliceType, + required this.sliceCount, + required this.nalUnitTypes, + }); + + final int decodeIndex; + final int presentationIndex; + final int pictureOrderCount; + final bool key; + final bool idr; + final H264SliceType sliceType; + final int sliceCount; + final List nalUnitTypes; +} + +class H264UnitInspection { + const H264UnitInspection({ + required this.id, + required this.accessUnits, + required this.decodeToPresentation, + }); + + final String id; + final List accessUnits; + final List decodeToPresentation; +} + +class H264RenditionInspection { + const H264RenditionInspection({ + required this.parameterSet, + required this.macroblocksPerFrame, + required this.units, + }); + + final H264ParameterSetSummary parameterSet; + final int macroblocksPerFrame; + final List units; +} + +/// One raw FFmpeg Annex B stream for an independently encoded unit. +class H264EncoderUnitStreamInput { + const H264EncoderUnitStreamInput({ + required this.id, + required this.bytes, + required this.expectedAccessUnitCount, + }); + + final String id; + final Uint8List bytes; + final int expectedAccessUnitCount; +} + +class H264EncoderRenditionPreparationInput { + const H264EncoderRenditionPreparationInput({ + required this.profile, + required this.units, + }); + + final H264Profile profile; + final List units; +} + +/// Canonical E0 access units detached from all caller-owned raw streams. +class H264EncoderRenditionPreparation { + const H264EncoderRenditionPreparation({ + required this.units, + required this.inspection, + }); + + final List units; + final H264RenditionInspection inspection; +} diff --git a/flutter/packages/aval_format/lib/src/h265/annex_b.dart b/flutter/packages/aval_format/lib/src/h265/annex_b.dart new file mode 100644 index 0000000..fa98e16 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/annex_b.dart @@ -0,0 +1,317 @@ +/// HEVC Annex-B NAL-unit tokenizer and EBSP-to-RBSP conversion. +/// +/// Dart port of `packages/format/src/h265/annex-b.ts`. +library; + +// ignore_for_file: constant_identifier_names + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import 'failure.dart'; + +const int H265_NAL_TRAIL_N = 0; +const int H265_NAL_TRAIL_R = 1; +const int H265_NAL_BLA_W_LP = 16; +const int H265_NAL_BLA_W_RADL = 17; +const int H265_NAL_BLA_N_LP = 18; +const int H265_NAL_IDR_W_RADL = 19; +const int H265_NAL_IDR_N_LP = 20; +const int H265_NAL_CRA_NUT = 21; +const int H265_NAL_VPS = 32; +const int H265_NAL_SPS = 33; +const int H265_NAL_PPS = 34; +const int H265_NAL_AUD = 35; +const int H265_NAL_EOS = 36; +const int H265_NAL_EOB = 37; +const int H265_NAL_FILLER = 38; +const int H265_NAL_PREFIX_SEI = 39; +const int H265_NAL_SUFFIX_SEI = 40; + +const int H265_MAX_ACCESS_UNIT_BYTES = 64 * 1024 * 1024; +const int H265_MAX_NAL_UNITS = 4096; +const int _maxParameterSetBytes = 1024 * 1024; + +/// Options controlling [splitH265AnnexBAccessUnit]. +/// +/// Port of `H265AnnexBOptions` (`src/h265/annex-b.ts:25`). +class H265AnnexBOptions { + const H265AnnexBOptions({ + this.maximumBytes, + this.maximumNalUnits, + this.allowEncoderMetadata, + }); + + final int? maximumBytes; + final int? maximumNalUnits; + final bool? allowEncoderMetadata; +} + +/// A single parsed HEVC Annex-B NAL unit. +/// +/// Port of `H265AnnexBNalUnit` (`src/h265/annex-b.ts:31`). +class H265AnnexBNalUnit { + const H265AnnexBNalUnit({ + required this.type, + required this.layerId, + required this.temporalId, + required this.offset, + required this.prefixLength, + required this.payload, + required this.rbsp, + }); + + final int type; + + /// Always `0` (multilayer HEVC is unsupported). + final int layerId; + final int temporalId; + final int offset; + + /// Always `3` or `4`. + final int prefixLength; + final Uint8List payload; + final Uint8List rbsp; +} + +class _StartCode { + const _StartCode({required this.offset, required this.length}); + + final int offset; + + /// Always `3` or `4`. + final int length; +} + +/// Splits one canonical Annex-B access unit without retaining hidden copies. +/// +/// Port of `splitH265AnnexBAccessUnit` (`src/h265/annex-b.ts:47`). +List splitH265AnnexBAccessUnit( + Uint8List bytes, [ + String path = 'accessUnit', + H265AnnexBOptions options = const H265AnnexBOptions(), +]) { + final maximumBytes = options.maximumBytes ?? H265_MAX_ACCESS_UNIT_BYTES; + final maximumNalUnits = options.maximumNalUnits ?? H265_MAX_NAL_UNITS; + requireH265( + maximumBytes <= maxSafeInteger && maximumBytes > 0, + path, + 'access-unit byte budget is invalid', + ); + requireH265( + maximumNalUnits <= maxSafeInteger && maximumNalUnits > 0, + path, + 'NAL-unit count budget is invalid', + ); + requireH265(bytes.length >= 6, path, 'Annex-B access unit is too short'); + requireH265( + bytes.length <= maximumBytes, + path, + 'Annex-B access unit exceeds the byte budget', + ); + + final starts = _findStartCodes(bytes, path, maximumNalUnits); + requireH265(starts.isNotEmpty, path, 'Annex-B start code is missing', 0); + requireH265( + starts[0].offset == 0, + path, + 'bytes precede the first start code', + 0, + ); + + final units = []; + for (var index = 0; index < starts.length; index += 1) { + final start = starts[index]; + final payloadOffset = start.offset + start.length; + final payloadEnd = + index + 1 < starts.length ? starts[index + 1].offset : bytes.length; + requireH265( + payloadEnd >= payloadOffset + 3, + path, + 'empty or truncated HEVC NAL unit', + start.offset, + ); + final payload = Uint8List.sublistView(bytes, payloadOffset, payloadEnd); + // TS reads payload[0]/[1]; the length >= 3 guarantee above keeps them + // in range, matching the `first !== undefined && second !== undefined` + // check (`src/h265/annex-b.ts:88`). + final first = payload[0]; + final second = payload[1]; + requireH265( + payload[payload.length - 1] != 0, + path, + 'NAL units may not contain trailing_zero_8bits', + payloadEnd - 1, + ); + requireH265( + (first & 0x80) == 0, + path, + 'forbidden_zero_bit must be zero', + payloadOffset, + ); + final type = (first >> 1) & 0x3f; + final layerId = ((first & 1) << 5) | (second >> 3); + final temporalIdPlusOne = second & 0x07; + requireH265( + layerId == 0, + path, + 'multilayer HEVC is unsupported', + payloadOffset, + ); + requireH265( + temporalIdPlusOne != 0, + path, + 'nuh_temporal_id_plus1 must not be zero', + payloadOffset + 1, + ); + requireH265( + _isPermittedH265NalType(type, options.allowEncoderMetadata == true), + path, + 'NAL unit type $type is outside the production HEVC profile', + payloadOffset, + ); + if (type >= H265_NAL_VPS) { + requireH265( + temporalIdPlusOne == 1, + path, + 'non-VCL NAL units must use temporal_id zero', + payloadOffset + 1, + ); + } + if (type == H265_NAL_VPS || type == H265_NAL_SPS || type == H265_NAL_PPS) { + requireH265( + payload.length <= _maxParameterSetBytes, + path, + 'HEVC parameter set exceeds the syntax budget', + payloadOffset, + ); + } + units.add( + H265AnnexBNalUnit( + type: type, + layerId: 0, + temporalId: temporalIdPlusOne - 1, + offset: payloadOffset, + prefixLength: start.length, + payload: payload, + rbsp: removeH265EmulationPrevention( + Uint8List.sublistView(payload, 2), + path, + payloadOffset + 2, + ), + ), + ); + } + return List.unmodifiable(units); +} + +/// Port of `isH265VclNalType` (`src/h265/annex-b.ts:155`). +bool isH265VclNalType(int type) { + return (type >= 0 && type <= 9) || (type >= 16 && type <= 21); +} + +/// Port of `isH265RandomAccessNalType` (`src/h265/annex-b.ts:159`). +bool isH265RandomAccessNalType(int type) { + return type >= H265_NAL_BLA_W_LP && type <= H265_NAL_CRA_NUT; +} + +/// Port of `isH265IdrNalType` (`src/h265/annex-b.ts:163`). +bool isH265IdrNalType(int type) { + return type == H265_NAL_IDR_W_RADL || type == H265_NAL_IDR_N_LP; +} + +bool _isPermittedH265NalType(int type, bool allowMetadata) { + if (isH265VclNalType(type)) return true; + if (type == H265_NAL_VPS || + type == H265_NAL_SPS || + type == H265_NAL_PPS || + type == H265_NAL_AUD) { + return true; + } + return allowMetadata && + (type == H265_NAL_EOS || + type == H265_NAL_EOB || + type == H265_NAL_FILLER || + type == H265_NAL_PREFIX_SEI || + type == H265_NAL_SUFFIX_SEI); +} + +List<_StartCode> _findStartCodes( + Uint8List bytes, + String path, + int maximumNalUnits, +) { + final starts = <_StartCode>[]; + var cursor = 0; + while (cursor < bytes.length) { + if (bytes[cursor] != 0) { + cursor += 1; + continue; + } + final runStart = cursor; + while (cursor < bytes.length && bytes[cursor] == 0) { + cursor += 1; + } + if (cursor >= bytes.length || bytes[cursor] != 1 || cursor - runStart < 2) { + continue; + } + final zeroCount = cursor - runStart; + requireH265( + zeroCount == 2 || zeroCount == 3, + path, + 'start codes may contain only two or three zero bytes', + runStart, + ); + starts.add(_StartCode(offset: runStart, length: zeroCount + 1)); + requireH265( + starts.length <= maximumNalUnits, + path, + 'NAL-unit count exceeds the inspection budget', + runStart, + ); + cursor += 1; + } + return List.unmodifiable(starts); +} + +/// Removes emulation-prevention bytes and rejects non-canonical EBSP. +/// +/// Port of `removeH265EmulationPrevention` (`src/h265/annex-b.ts:224`). +Uint8List removeH265EmulationPrevention( + Uint8List ebsp, + String path, + int absoluteOffset, +) { + requireH265(ebsp.isNotEmpty, path, 'NAL RBSP is empty', absoluteOffset); + final rbsp = Uint8List(ebsp.length); + var outputLength = 0; + var zeroCount = 0; + for (var index = 0; index < ebsp.length; index += 1) { + final byte = ebsp[index]; + if (zeroCount == 2) { + if (byte == 0x03) { + final escapedIndex = index + 1; + final escaped = escapedIndex < ebsp.length ? ebsp[escapedIndex] : null; + requireH265( + escaped != null && escaped <= 0x03, + path, + 'emulation_prevention_three_byte is not followed by 0x00..0x03', + absoluteOffset + index, + ); + zeroCount = 0; + continue; + } + requireH265( + byte > 0x02, + path, + 'unescaped start-code emulation sequence in EBSP', + absoluteOffset + index, + ); + } + rbsp[outputLength] = byte; + outputLength += 1; + zeroCount = byte == 0 ? zeroCount + 1 : 0; + } + // TS `rbsp.slice(0, outputLength)` returns a fresh copy. + return Uint8List.fromList(Uint8List.sublistView(rbsp, 0, outputLength)); +} diff --git a/flutter/packages/aval_format/lib/src/h265/bit_reader.dart b/flutter/packages/aval_format/lib/src/h265/bit_reader.dart new file mode 100644 index 0000000..670f0b4 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/bit_reader.dart @@ -0,0 +1,127 @@ +/// Bounded, MSB-first HEVC RBSP bit reader. +/// +/// Dart port of `packages/format/src/h265/bit-reader.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import 'failure.dart'; + +/// Bounded, MSB-first HEVC RBSP reader. +class H265RbspBitReader { + H265RbspBitReader(this._bytes, this._path, this._absoluteOffset); + + final Uint8List _bytes; + final String _path; + final int _absoluteOffset; + int _bitOffset = 0; + + int get bitOffset => _bitOffset; + + int get bitsRemaining => _bytes.length * 8 - _bitOffset; + + bool readBit(String label) { + if (_bitOffset >= _bytes.length * 8) { + _fail('truncated $label'); + } + final byte = _bytes[_bitOffset ~/ 8]; + final value = (byte >> (7 - (_bitOffset % 8))) & 1; + _bitOffset += 1; + return value == 1; + } + + int readBits(int width, String label) { + if (width < 0 || width > 32) { + _fail('invalid bit width while reading $label'); + } + if (bitsRemaining < width) { + _fail('truncated $label'); + } + var result = 0; + for (var index = 0; index < width; index += 1) { + result = result * 2 + (readBit(label) ? 1 : 0); + } + return result; + } + + void skipBits(int width, String label) { + // TS uses `!Number.isSafeInteger(width)`; Dart ints are always integral, so + // the corresponding guard is `width > maxSafeInteger` + // (`src/h265/bit-reader.ts:52`). + if (width < 0 || width > maxSafeInteger || bitsRemaining < width) { + _fail('truncated $label'); + } + _bitOffset += width; + } + + int readUnsignedExpGolomb(String label, [int maximum = 0xffffffff]) { + var leadingZeroBits = 0; + while (!readBit(label)) { + leadingZeroBits += 1; + if (leadingZeroBits > 31) { + _fail('$label Exp-Golomb value is too large'); + } + } + final suffix = readBits(leadingZeroBits, label); + final value = (1 << leadingZeroBits) - 1 + suffix; + if (value > maxSafeInteger || value > maximum) { + _fail('$label exceeds $maximum'); + } + return value; + } + + int readSignedExpGolomb( + String label, [ + int minimum = -0x7fffffff, + int maximum = 0x7fffffff, + ]) { + final codeNumber = readUnsignedExpGolomb(label); + // Math.ceil(codeNumber / 2) for non-negative codeNumber. + final magnitude = (codeNumber + 1) ~/ 2; + final value = codeNumber % 2 == 0 ? -magnitude : magnitude; + if (value < minimum || value > maximum) { + _fail('$label lies outside the supported range'); + } + return value; + } + + /// True when syntax data remains before rbsp_trailing_bits. + bool moreRbspData() { + if (bitsRemaining == 0) { + return false; + } + if (!_peekBit(_bitOffset)) { + return true; + } + for (var bit = _bitOffset + 1; bit < _bytes.length * 8; bit += 1) { + if (_peekBit(bit)) { + return true; + } + } + return false; + } + + void readTrailingBits() { + if (!readBit('rbsp_stop_one_bit')) { + _fail('rbsp_stop_one_bit must be one'); + } + while (bitsRemaining > 0) { + if (readBit('rbsp_alignment_zero_bit')) { + _fail('RBSP alignment bits must be zero'); + } + } + } + + bool _peekBit(int bitOffset) { + final byteIndex = bitOffset ~/ 8; + if (byteIndex >= _bytes.length) { + return false; + } + return ((_bytes[byteIndex] >> (7 - (bitOffset % 8))) & 1) == 1; + } + + Never _fail(String message) { + h265Invalid(_path, message, _absoluteOffset + (_bitOffset ~/ 8)); + } +} diff --git a/flutter/packages/aval_format/lib/src/h265/canonicalize.dart b/flutter/packages/aval_format/lib/src/h265/canonicalize.dart new file mode 100644 index 0000000..9eed066 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/canonicalize.dart @@ -0,0 +1,173 @@ +/// Canonicalizes HEVC Annex-B access units to four-byte start codes and +/// derives access-unit inputs from an AUD-delimited encoder stream. +/// +/// Dart port of `packages/format/src/h265/canonicalize.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import '../errors.dart'; +import 'annex_b.dart' + show + H265_MAX_ACCESS_UNIT_BYTES, + H265_NAL_AUD, + H265_NAL_EOB, + H265_NAL_EOS, + H265_NAL_FILLER, + H265_NAL_PREFIX_SEI, + H265_NAL_SUFFIX_SEI, + H265AnnexBNalUnit, + H265AnnexBOptions, + isH265RandomAccessNalType, + isH265VclNalType, + splitH265AnnexBAccessUnit; +import 'failure.dart'; +import 'types.dart' show H265AccessUnitInput; + +const List _fourByteStartCode = [0, 0, 0, 1]; + +/// Removes encoder metadata and normalizes every retained NAL to a four-byte +/// Annex-B start code. No caller-owned byte view is retained. +/// +/// Port of `canonicalizeH265AccessUnit` (`src/h265/canonicalize.ts:24`). +Uint8List canonicalizeH265AccessUnit( + Uint8List bytes, [ + String path = 'accessUnit', +]) { + final nals = splitH265AnnexBAccessUnit( + bytes, + path, + const H265AnnexBOptions(allowEncoderMetadata: true), + ); + return _canonicalizeNals(nals, path); +} + +/// Splits an AUD-delimited raw libx265 stream into canonical access units. +/// +/// Port of `canonicalizeH265EncoderUnitStream` +/// (`src/h265/canonicalize.ts:35`). +List canonicalizeH265EncoderUnitStream( + Uint8List bytes, + int expectedAccessUnitCount, [ + String path = 'encoderUnit', +]) { + requireH265( + expectedAccessUnitCount <= maxSafeInteger && expectedAccessUnitCount > 0, + path, + 'expected access-unit count must be a positive safe integer', + ); + final maximumNalUnits = expectedAccessUnitCount * 8 + 8; + requireH265( + maximumNalUnits <= maxSafeInteger, + path, + 'derived NAL-unit budget is not representable', + ); + final nals = splitH265AnnexBAccessUnit( + bytes, + path, + H265AnnexBOptions( + maximumBytes: H265_MAX_ACCESS_UNIT_BYTES, + maximumNalUnits: maximumNalUnits, + allowEncoderMetadata: true, + ), + ); + requireH265( + nals.isNotEmpty && nals[0].type == H265_NAL_AUD, + path, + 'raw HEVC encoder stream must begin with AUD', + ); + final groups = >[]; + List? current; + for (final nal in nals) { + if (nal.type == H265_NAL_AUD) { + if (current != null) groups.add(current); + current = [nal]; + } else { + requireH265( + current != null, + path, + 'NAL unit appears before the first AUD', + ); + current!.add(nal); + } + } + if (current != null) groups.add(current); + requireH265( + groups.length == expectedAccessUnitCount, + path, + 'expected $expectedAccessUnitCount access units but found ${groups.length}', + ); + final result = []; + for (var index = 0; index < groups.length; index += 1) { + final group = groups[index]; + final accessUnitPath = '$path.accessUnits[$index]'; + final accessUnitBytes = _canonicalizeNals(group, accessUnitPath); + final vcl = group.where((nal) => isH265VclNalType(nal.type)).toList(); + requireH265( + vcl.isNotEmpty, + accessUnitPath, + 'access unit contains no coded picture', + ); + result.add( + H265AccessUnitInput( + bytes: accessUnitBytes, + key: vcl.any((nal) => isH265RandomAccessNalType(nal.type)), + ), + ); + } + return List.unmodifiable(result); +} + +Uint8List _canonicalizeNals( + List nals, + String path, +) { + final retained = nals.where((nal) => !_isMetadataNal(nal.type)).toList(); + requireH265(retained.isNotEmpty, path, 'canonical access unit is empty'); + requireH265( + retained[0].type == H265_NAL_AUD, + path, + 'canonical access unit must begin with AUD', + ); + requireH265( + retained.any((nal) => isH265VclNalType(nal.type)), + path, + 'canonical access unit contains no coded picture', + ); + var length = 0; + for (final nal in retained) { + length += _fourByteStartCode.length + nal.payload.length; + requireH265( + length <= maxSafeInteger && length <= H265_MAX_ACCESS_UNIT_BYTES, + path, + 'canonical HEVC access unit exceeds the byte budget', + ); + } + Uint8List output; + try { + output = Uint8List(length); + } catch (_) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'HEVC canonicalization allocation of $length bytes failed', + FormatErrorDetails(path: path), + ); + } + var offset = 0; + for (final nal in retained) { + output.setAll(offset, _fourByteStartCode); + offset += _fourByteStartCode.length; + output.setAll(offset, nal.payload); + offset += nal.payload.length; + } + return output; +} + +bool _isMetadataNal(int type) { + return type == H265_NAL_PREFIX_SEI || + type == H265_NAL_SUFFIX_SEI || + type == H265_NAL_FILLER || + type == H265_NAL_EOS || + type == H265_NAL_EOB; +} diff --git a/flutter/packages/aval_format/lib/src/h265/codec.dart b/flutter/packages/aval_format/lib/src/h265/codec.dart new file mode 100644 index 0000000..b45ba5e --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/codec.dart @@ -0,0 +1,92 @@ +/// HEVC codec-string parsing/derivation and WebCodecs decoder config. +/// +/// Dart port of `packages/format/src/h265/codec.ts`. +library; + +import 'failure.dart'; +import 'parameter_sets.dart' show H265ProfileTierLevel, ParsedH265Sps; +import 'types.dart' show H265DecoderColorSpace, H265VideoDecoderConfig; + +const List _profileSpacePrefix = ['', 'A', 'B', 'C']; +final RegExp _h265MainCodec = RegExp( + r'^hvc1\.1\.(0|[1-9A-F][0-9A-F]*)\.[LH](0|[1-9][0-9]*)\.((?:[0-9A-F]{2}\.){0,5}(?!00)[0-9A-F]{2})$', + unicode: true, +); + +/// Port of `ParsedH265Codec` (`src/h265/codec.ts:9`). +class ParsedH265Codec { + const ParsedH265Codec({required this.codec}); + + final String codec; + + /// Always `8`. + final int bitDepth = 8; +} + +/// Parse the canonical Main/8-bit HEVC profile accepted by AVAL inspection. +/// +/// Port of `parseH265Codec` (`src/h265/codec.ts:15`). Not part of the module's +/// public barrel; retained for file-level parity with the TS source. +ParsedH265Codec? parseH265Codec(Object? value) { + if (value is! String) return null; + final match = _h265MainCodec.firstMatch(value); + if (match == null) return null; + final compatibilityFlags = int.parse(match.group(1)!, radix: 16); + final levelIdc = int.parse(match.group(2)!); + final firstConstraintByte = + int.parse(match.group(3)!.substring(0, 2), radix: 16); + if (compatibilityFlags > 0xffffffff || + (compatibilityFlags & 0x02) == 0 || + levelIdc < 1 || + levelIdc > 255 || + (firstConstraintByte & 0x80) == 0 || + (firstConstraintByte & 0x40) != 0 || + (firstConstraintByte & 0x10) == 0) { + return null; + } + return ParsedH265Codec(codec: value); +} + +/// Derives the RFC 6381/ISO BMFF HEVC identifier used by WebCodecs. +/// +/// Port of `h265CodecString` (`src/h265/codec.ts:39`). +String h265CodecString(H265ProfileTierLevel profileTierLevel) { + final prefix = (profileTierLevel.profileSpace >= 0 && + profileTierLevel.profileSpace < _profileSpacePrefix.length) + ? _profileSpacePrefix[profileTierLevel.profileSpace] + : null; + requireH265(prefix != null, 'profileTierLevel', 'invalid profile space'); + final compatibility = profileTierLevel.profileCompatibilityFlags + .toRadixString(16) + .toUpperCase(); + final constraints = List.from(profileTierLevel.constraintIndicatorFlags); + while (constraints.isNotEmpty && constraints.last == 0) { + constraints.removeLast(); + } + final suffix = constraints + .map((byte) => byte.toRadixString(16).toUpperCase().padLeft(2, '0')) + .join('.'); + return 'hvc1.$prefix${profileTierLevel.profileIdc}.$compatibility.' + '${profileTierLevel.tierFlag ? 'H' : 'L'}${profileTierLevel.levelIdc}' + '${suffix.isEmpty ? '' : '.$suffix'}'; +} + +/// Port of `createH265VideoDecoderConfig` (`src/h265/codec.ts:55`). +H265VideoDecoderConfig createH265VideoDecoderConfig(ParsedH265Sps sps) { + requireH265( + sps.color.fullRange == false && + sps.color.colourPrimaries == 1 && + sps.color.transferCharacteristics == 1 && + sps.color.matrixCoefficients == 1, + 'sps.vui', + 'HEVC decoder configuration requires BT.709 limited-range signalling', + ); + return H265VideoDecoderConfig( + codec: h265CodecString(sps.profileTierLevel), + codedWidth: sps.codedWidth, + codedHeight: sps.codedHeight, + displayAspectWidth: sps.crop.visibleWidth, + displayAspectHeight: sps.crop.visibleHeight, + colorSpace: const H265DecoderColorSpace(), + ); +} diff --git a/flutter/packages/aval_format/lib/src/h265/failure.dart b/flutter/packages/aval_format/lib/src/h265/failure.dart new file mode 100644 index 0000000..54c86f8 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/failure.dart @@ -0,0 +1,31 @@ +/// Shared HEVC (H.265) subsystem failure helper. +/// +/// Dart port of `packages/format/src/h265/failure.ts`. +library; + +import '../errors.dart'; + +/// Throws a `PROFILE_INVALID` [FormatError] for the HEVC subsystem. +/// +/// Port of `h265Invalid` (`src/h265/failure.ts:3`). +Never h265Invalid(String path, String message, [int? offset]) { + throw FormatError( + FormatErrorCode.profileInvalid, + message, + FormatErrorDetails(path: path, offset: offset), + ); +} + +/// Asserts [condition], otherwise fails via [h265Invalid]. +/// +/// Port of `requireH265` (`src/h265/failure.ts:14`). +void requireH265( + bool condition, + String path, + String message, [ + int? offset, +]) { + if (!condition) { + h265Invalid(path, message, offset); + } +} diff --git a/flutter/packages/aval_format/lib/src/h265/index.dart b/flutter/packages/aval_format/lib/src/h265/index.dart new file mode 100644 index 0000000..e7388dd --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/index.dart @@ -0,0 +1,70 @@ +/// HEVC (H.265) Annex-B subsystem public surface. +/// +/// Dart port of `packages/format/src/h265/index.ts`. Mirrors its export list +/// exactly. +library; + +export 'annex_b.dart' + show + H265_MAX_ACCESS_UNIT_BYTES, + H265_MAX_NAL_UNITS, + H265_NAL_AUD, + H265_NAL_BLA_N_LP, + H265_NAL_BLA_W_LP, + H265_NAL_BLA_W_RADL, + H265_NAL_CRA_NUT, + H265_NAL_IDR_N_LP, + H265_NAL_IDR_W_RADL, + H265_NAL_PPS, + H265_NAL_PREFIX_SEI, + H265_NAL_SPS, + H265_NAL_SUFFIX_SEI, + H265_NAL_VPS, + isH265IdrNalType, + isH265RandomAccessNalType, + isH265VclNalType, + removeH265EmulationPrevention, + splitH265AnnexBAccessUnit, + H265AnnexBNalUnit, + H265AnnexBOptions; +export 'canonicalize.dart' + show canonicalizeH265AccessUnit, canonicalizeH265EncoderUnitStream; +export 'bit_reader.dart' show H265RbspBitReader; +export 'codec.dart' show createH265VideoDecoderConfig, h265CodecString; +export 'inspector.dart' show inspectH265AnnexBRendition; +export 'parameter_sets.dart' + show + parseH265Pps, + parseH265ShortTermReferencePictureSet, + parseH265Sps, + parseH265Vps, + sameH265ProfileTierLevel, + H265ProfileTierLevel, + H265ShortTermReferencePicture, + H265ShortTermReferencePictureSet, + ParsedH265Pps, + ParsedH265Sps, + ParsedH265Vps; +export 'presentation_order.dart' + show + createH265PictureOrderState, + deriveH265PictureOrderCount, + deriveH265PresentationOrder, + H265DecodedPictureOrder, + H265PictureOrderState; +export 'slice_header.dart' show parseH265SliceHeader, ParsedH265SliceHeader; +export 'types.dart' + show + H265AccessUnitInput, + H265AccessUnitSummary, + H265ColorSummary, + H265CropSummary, + H265FrameRate, + H265MainProfile, + H265ParameterSetSummary, + H265RandomAccessKind, + H265RenditionInspection, + H265RenditionInspectionInput, + H265UnitInput, + H265UnitInspection, + H265VideoDecoderConfig; diff --git a/flutter/packages/aval_format/lib/src/h265/inspector.dart b/flutter/packages/aval_format/lib/src/h265/inspector.dart new file mode 100644 index 0000000..25817c5 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/inspector.dart @@ -0,0 +1,559 @@ +/// Strict HEVC rendition inspector: proves each graph unit is independently +/// decodable and derives decode/presentation order. +/// +/// Dart port of `packages/format/src/h265/inspector.ts`. +library; + +import '../checked_integer.dart' show maxSafeInteger; +import '../errors.dart'; +import 'annex_b.dart' + show + H265_MAX_ACCESS_UNIT_BYTES, + H265_NAL_AUD, + H265_NAL_PPS, + H265_NAL_SPS, + H265_NAL_VPS, + H265AnnexBNalUnit, + isH265RandomAccessNalType, + isH265VclNalType, + splitH265AnnexBAccessUnit; +import 'bit_reader.dart'; +import 'codec.dart' show createH265VideoDecoderConfig, h265CodecString; +import 'failure.dart'; +import 'parameter_sets.dart' + show + ParsedH265Pps, + ParsedH265Sps, + ParsedH265Vps, + parseH265Pps, + parseH265Sps, + parseH265Vps, + sameH265ProfileTierLevel; +import 'presentation_order.dart' + show + H265DecodedPictureOrder, + createH265PictureOrderState, + deriveH265PictureOrderCount, + deriveH265PresentationOrder; +import 'slice_header.dart' show ParsedH265SliceHeader, parseH265SliceHeader; +import 'types.dart' + show + H265AccessUnitInput, + H265AccessUnitSummary, + H265MainProfile, + H265ParameterSetSummary, + H265RandomAccessKind, + H265RenditionInspection, + H265RenditionInspectionInput, + H265UnitInspection; + +final RegExp _identifierPattern = RegExp(r'^[a-z][a-z0-9._-]{0,63}$'); +const int _maxUnits = 96; +const int _maxTotalAccessUnits = 1000000; + +class _H265ParameterSetState { + const _H265ParameterSetState({ + required this.vps, + required this.sps, + required this.pps, + }); + + final ParsedH265Vps vps; + final ParsedH265Sps sps; + final ParsedH265Pps pps; +} + +class _DraftSummary { + const _DraftSummary({ + required this.decodeIndex, + required this.pictureOrderCount, + required this.key, + required this.randomAccess, + required this.sliceType, + required this.temporalId, + required this.referencedPictureOrderCounts, + required this.nalUnitTypes, + }); + + final int decodeIndex; + final int pictureOrderCount; + final bool key; + final H265RandomAccessKind? randomAccess; + final String sliceType; + final int temporalId; + final List referencedPictureOrderCounts; + final List nalUnitTypes; +} + +class _InspectedAccessUnit { + const _InspectedAccessUnit({ + required this.parameterSets, + required this.vcl, + required this.slice, + }); + + final _H265ParameterSetState parameterSets; + final H265AnnexBNalUnit vcl; + final ParsedH265SliceHeader slice; +} + +/// Inspects canonical HEVC access units and proves each graph unit is closed. +/// +/// Port of `inspectH265AnnexBRendition` (`src/h265/inspector.ts:63`). +H265RenditionInspection inspectH265AnnexBRendition( + H265RenditionInspectionInput input, +) { + try { + final profile = _cloneH265Profile(input.profile); + requireH265( + input.units.isNotEmpty, + 'units', + 'at least one unit is required', + ); + requireH265( + input.units.length <= _maxUnits, + 'units', + 'unit count exceeds the HEVC inspection budget', + ); + final ids = {}; + _H265ParameterSetState? stableParameterSets; + var totalAccessUnits = 0; + final units = []; + + for (var unitIndex = 0; unitIndex < input.units.length; unitIndex += 1) { + final unit = input.units[unitIndex]; + final unitPath = 'units[$unitIndex]'; + requireH265( + _identifierPattern.hasMatch(unit.id), + '$unitPath.id', + 'unit id is invalid', + ); + requireH265( + !ids.contains(unit.id), + '$unitPath.id', + 'unit id is duplicated', + ); + ids.add(unit.id); + requireH265( + unit.accessUnits.isNotEmpty, + '$unitPath.accessUnits', + 'unit must contain at least one access unit', + ); + totalAccessUnits += unit.accessUnits.length; + requireH265( + totalAccessUnits <= maxSafeInteger && + totalAccessUnits <= _maxTotalAccessUnits, + '$unitPath.accessUnits', + 'total access-unit count exceeds the HEVC inspection budget', + ); + + final orderState = createH265PictureOrderState(); + final decodedPocs = {}; + final drafts = <_DraftSummary>[]; + _H265ParameterSetState? activeParameterSets; + for (var decodeIndex = 0; + decodeIndex < unit.accessUnits.length; + decodeIndex += 1) { + final accessUnit = unit.accessUnits[decodeIndex]; + final path = '$unitPath.accessUnits[$decodeIndex]'; + _validateAccessUnitInput(accessUnit, path); + final nals = splitH265AnnexBAccessUnit(accessUnit.bytes, '$path.bytes'); + requireH265( + nals.every((nal) => nal.prefixLength == 4), + '$path.bytes', + 'stored HEVC access units must use canonical four-byte start codes', + ); + final inspected = _inspectAccessUnitStructure( + nals, + accessUnit, + decodeIndex, + path, + activeParameterSets, + stableParameterSets, + ); + activeParameterSets = inspected.parameterSets; + if (stableParameterSets == null) { + stableParameterSets = inspected.parameterSets; + _validateParameterSetsAgainstProfile( + stableParameterSets, + profile, + path, + ); + } + final sps = inspected.parameterSets.sps; + final pictureOrderCount = deriveH265PictureOrderCount( + inspected.vcl.type, + inspected.vcl.temporalId, + inspected.slice.pictureOrderCountLsb, + sps.log2MaxPictureOrderCountLsb, + orderState, + ); + final references = List.unmodifiable( + inspected.slice.referencePictureSet.pictures + .map((picture) => pictureOrderCount + picture.deltaPoc), + ); + requireH265( + references.every((reference) => decodedPocs.contains(reference)), + path, + 'slice references a picture outside this independently decoded unit', + ); + requireH265( + !decodedPocs.contains(pictureOrderCount), + path, + 'unit contains duplicate picture-order counts', + ); + decodedPocs.add(pictureOrderCount); + drafts.add( + _DraftSummary( + decodeIndex: decodeIndex, + pictureOrderCount: pictureOrderCount, + key: accessUnit.key, + randomAccess: inspected.slice.randomAccess, + sliceType: inspected.slice.sliceType, + temporalId: inspected.vcl.temporalId, + referencedPictureOrderCounts: references, + nalUnitTypes: List.unmodifiable(nals.map((nal) => nal.type)), + ), + ); + } + final parameterSets = activeParameterSets; + if (parameterSets == null) { + h265Invalid(unitPath, 'unit has no parameter sets'); + } + final decodeToPresentation = deriveH265PresentationOrder( + drafts + .map((draft) => H265DecodedPictureOrder( + decodeIndex: draft.decodeIndex, + pictureOrderCount: draft.pictureOrderCount, + )) + .toList(), + parameterSets.sps.maxNumReorderPics, + '$unitPath.accessUnits', + ); + final accessUnits = List.unmodifiable( + drafts.map((draft) { + final presentationIndex = + draft.decodeIndex < decodeToPresentation.length + ? decodeToPresentation[draft.decodeIndex] + : null; + if (presentationIndex == null) { + h265Invalid(unitPath, 'presentation order is incomplete'); + } + return H265AccessUnitSummary( + decodeIndex: draft.decodeIndex, + presentationIndex: presentationIndex, + pictureOrderCount: draft.pictureOrderCount, + key: draft.key, + randomAccess: draft.randomAccess, + sliceType: draft.sliceType, + temporalId: draft.temporalId, + referencedPictureOrderCounts: draft.referencedPictureOrderCounts, + nalUnitTypes: draft.nalUnitTypes, + ); + }), + ); + units.add( + H265UnitInspection( + id: unit.id, + accessUnits: accessUnits, + decodeToPresentation: decodeToPresentation, + ), + ); + } + + if (stableParameterSets == null) { + h265Invalid('units', 'no HEVC parameter sets found'); + } + final parameterSet = _createParameterSetSummary(stableParameterSets.sps); + return H265RenditionInspection( + parameterSet: parameterSet, + decoderConfig: createH265VideoDecoderConfig(stableParameterSets.sps), + units: List.unmodifiable(units), + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.profileInvalid, 'HEVC inspection failed'); + } +} + +_InspectedAccessUnit _inspectAccessUnitStructure( + List nals, + H265AccessUnitInput input, + int decodeIndex, + String path, + _H265ParameterSetState? activeParameterSets, + _H265ParameterSetState? stableParameterSets, +) { + requireH265( + nals.isNotEmpty && nals[0].type == H265_NAL_AUD, + path, + 'access unit must begin with AUD', + ); + requireH265( + nals.where((nal) => nal.type == H265_NAL_AUD).length == 1, + path, + 'access unit must contain exactly one AUD', + ); + final vcl = nals.where((nal) => isH265VclNalType(nal.type)).toList(); + requireH265( + vcl.length == 1, + path, + 'the production HEVC profile requires one VCL NAL per access unit', + ); + final picture = vcl[0]; + final randomAccess = isH265RandomAccessNalType(picture.type); + requireH265( + input.key == randomAccess, + '$path.key', + randomAccess + ? 'random-access picture is missing its key assertion' + : 'non-random-access picture has a key assertion', + ); + requireH265( + decodeIndex == 0 ? randomAccess : !randomAccess, + path, + decodeIndex == 0 + ? 'every unit must begin with a random-access picture' + : 'random-access pictures are permitted only at unit start', + ); + + _H265ParameterSetState? parameterSets = activeParameterSets; + if (decodeIndex == 0) { + requireH265( + nals.length == 5 && + nals[1].type == H265_NAL_VPS && + nals[2].type == H265_NAL_SPS && + nals[3].type == H265_NAL_PPS && + identical(nals[4], picture), + path, + 'unit start must contain exactly AUD/VPS/SPS/PPS/VCL', + ); + final vpsNal = nals[1]; + final spsNal = nals[2]; + final ppsNal = nals[3]; + final vps = parseH265Vps(vpsNal, '$path.vps'); + final sps = parseH265Sps(spsNal, '$path.sps'); + final pps = parseH265Pps(ppsNal, '$path.pps'); + requireH265( + sps.videoParameterSetId == vps.id, + path, + 'SPS references an unexpected VPS', + ); + requireH265(pps.spsId == sps.id, path, 'PPS references an unexpected SPS'); + requireH265( + sameH265ProfileTierLevel(vps.profileTierLevel, sps.profileTierLevel), + path, + 'VPS and SPS profile-tier-level declarations differ', + ); + if (stableParameterSets != null) { + requireH265( + vps.payloadSignature == stableParameterSets.vps.payloadSignature && + sps.payloadSignature == stableParameterSets.sps.payloadSignature && + pps.payloadSignature == stableParameterSets.pps.payloadSignature, + path, + 'HEVC parameter-set bytes changed within the rendition', + ); + } + parameterSets = _H265ParameterSetState(vps: vps, sps: sps, pps: pps); + } else { + requireH265( + nals.length == 2 && identical(nals[1], picture), + path, + 'later access units must contain exactly AUD/VCL', + ); + } + if (parameterSets == null) { + h265Invalid(path, 'access unit has no parameter sets'); + } + final audPictureType = _parseAud(nals[0], '$path.aud'); + final slice = parseH265SliceHeader( + picture, + parameterSets.pps, + parameterSets.sps, + '$path.slice', + ); + requireH265( + (slice.sliceType == 'I' && audPictureType >= 0) || + (slice.sliceType == 'P' && audPictureType >= 1) || + (slice.sliceType == 'B' && audPictureType == 2), + '$path.aud', + 'AUD pic_type does not permit the coded slice type', + ); + return _InspectedAccessUnit( + parameterSets: parameterSets, + vcl: picture, + slice: slice, + ); +} + +void _validateParameterSetsAgainstProfile( + _H265ParameterSetState state, + H265MainProfile profile, + String path, +) { + final sps = state.sps; + final ptl = sps.profileTierLevel; + requireH265( + ptl.profileSpace == 0 && + ptl.profileIdc == 1 && + (ptl.profileCompatibilityFlags & 0x02) != 0, + '$path.sps', + 'the production HEVC profile requires Main profile compatibility', + ); + final firstConstraintByte = ptl.constraintIndicatorFlags.isNotEmpty + ? ptl.constraintIndicatorFlags[0] + : 0; + requireH265( + (firstConstraintByte & 0x80) != 0 && + (firstConstraintByte & 0x40) == 0 && + (firstConstraintByte & 0x10) != 0, + '$path.sps', + 'HEVC must signal progressive, frame-only source constraints', + ); + requireH265( + sps.codedWidth == profile.codedWidth && + sps.codedHeight == profile.codedHeight, + '$path.sps', + 'SPS coded dimensions do not match the rendition profile', + ); + final expected = profile.expectedVisibleRect ?? + [0, 0, profile.codedWidth, profile.codedHeight]; + requireH265( + sps.crop.left == expected[0] && + sps.crop.top == expected[1] && + sps.crop.visibleWidth == expected[2] && + sps.crop.visibleHeight == expected[3] && + sps.crop.right == profile.codedWidth - expected[2] && + sps.crop.bottom == profile.codedHeight - expected[3], + '$path.sps', + 'SPS conformance crop does not match the rendition profile', + ); + requireH265( + sps.squareSampleAspect, + '$path.sps', + 'square sample aspect is required', + ); + requireH265( + !sps.defaultDisplayWindowPresent, + '$path.sps', + 'default-display-window cropping is forbidden', + ); + requireH265(sps.timing != null, '$path.sps', 'SPS VUI timing is required'); + requireH265( + BigInt.from(sps.timing!.timeScale) * + BigInt.from(profile.frameRate.denominator) == + BigInt.from(sps.timing!.numUnitsInTick) * + BigInt.from(profile.frameRate.numerator), + '$path.sps', + 'SPS VUI timing does not match the rendition frame rate', + ); + requireH265( + !sps.color.fullRange && + sps.color.colourPrimaries == 1 && + sps.color.transferCharacteristics == 1 && + sps.color.matrixCoefficients == 1, + '$path.sps', + 'the production HEVC profile requires BT.709 limited-range colour signalling', + ); + requireH265( + !sps.longTermReferencePicturesPresent, + '$path.sps', + 'long-term HEVC references are outside the production profile', + ); +} + +H265MainProfile _cloneH265Profile(H265MainProfile profile) { + _positiveInteger(profile.codedWidth, 'profile.codedWidth'); + _positiveInteger(profile.codedHeight, 'profile.codedHeight'); + requireH265( + profile.codedWidth % 2 == 0 && profile.codedHeight % 2 == 0, + 'profile', + '4:2:0 HEVC coded dimensions must be even', + ); + _positiveInteger(profile.frameRate.numerator, 'profile.frameRate.numerator'); + _positiveInteger( + profile.frameRate.denominator, + 'profile.frameRate.denominator', + ); + requireH265( + profile.requireBt709LimitedRange == true, + 'profile.requireBt709LimitedRange', + 'the production HEVC profile requires BT.709 limited range', + ); + final expectedVisibleRect = profile.expectedVisibleRect == null + ? null + : _cloneVisibleRect( + profile.expectedVisibleRect!, + profile.codedWidth, + profile.codedHeight, + ); + return H265MainProfile( + codedWidth: profile.codedWidth, + codedHeight: profile.codedHeight, + expectedVisibleRect: expectedVisibleRect, + frameRate: profile.frameRate, + requireBt709LimitedRange: true, + ); +} + +List _cloneVisibleRect( + List value, + int codedWidth, + int codedHeight, +) { + requireH265( + value.length == 4 && value[0] == 0 && value[1] == 0, + 'profile.expectedVisibleRect', + 'expected visible rectangle must begin at the coded origin', + ); + _positiveInteger(value[2], 'profile.expectedVisibleRect[2]', codedWidth); + _positiveInteger(value[3], 'profile.expectedVisibleRect[3]', codedHeight); + requireH265( + value[2] % 2 == 0 && value[3] % 2 == 0, + 'profile.expectedVisibleRect', + '4:2:0 visible dimensions must be even', + ); + return List.unmodifiable([0, 0, value[2], value[3]]); +} + +void _validateAccessUnitInput(H265AccessUnitInput input, String path) { + requireH265( + input.bytes.length <= H265_MAX_ACCESS_UNIT_BYTES, + '$path.bytes', + 'access unit exceeds the HEVC byte budget', + ); +} + +int _parseAud(H265AnnexBNalUnit nal, String path) { + final reader = H265RbspBitReader(nal.rbsp, path, nal.offset + 2); + final pictureType = reader.readBits(3, 'pic_type'); + requireH265(pictureType <= 2, path, 'AUD pic_type is reserved'); + reader.readTrailingBits(); + return pictureType; +} + +H265ParameterSetSummary _createParameterSetSummary(ParsedH265Sps sps) { + return H265ParameterSetSummary( + profileTierLevel: sps.profileTierLevel, + codec: h265CodecString(sps.profileTierLevel), + codedWidth: sps.codedWidth, + codedHeight: sps.codedHeight, + crop: sps.crop, + maxNumReorderPics: sps.maxNumReorderPics, + maxDecPicBuffering: sps.maxDecPicBuffering, + color: sps.color, + ); +} + +void _positiveInteger(int value, String path, [int? maximum]) { + requireH265( + value > 0 && + value <= maxSafeInteger && + (maximum == null || value <= maximum), + path, + maximum == null + ? 'must be a positive safe integer' + : 'must be a positive safe integer no greater than $maximum', + ); +} diff --git a/flutter/packages/aval_format/lib/src/h265/parameter_sets.dart b/flutter/packages/aval_format/lib/src/h265/parameter_sets.dart new file mode 100644 index 0000000..9244e09 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/parameter_sets.dart @@ -0,0 +1,900 @@ +/// HEVC VPS/SPS/PPS and short-term reference-picture-set parsing. +/// +/// Dart port of `packages/format/src/h265/parameter-sets.ts`. +library; + +import 'dart:typed_data'; + +import 'annex_b.dart' show H265AnnexBNalUnit; +import 'bit_reader.dart'; +import 'failure.dart'; +import 'types.dart' show H265ColorSummary, H265CropSummary; + +const int _maxShortTermReferencePictures = 64; + +/// Port of `H265ProfileTierLevel` (`src/h265/parameter-sets.ts:8`). +class H265ProfileTierLevel { + const H265ProfileTierLevel({ + required this.profileSpace, + required this.tierFlag, + required this.profileIdc, + required this.profileCompatibilityFlags, + required this.constraintIndicatorFlags, + required this.levelIdc, + }); + + /// One of `0`, `1`, `2`, `3`. + final int profileSpace; + final bool tierFlag; + final int profileIdc; + + /// Compatibility flags numbered as in the codec-string registration. + final int profileCompatibilityFlags; + final List constraintIndicatorFlags; + final int levelIdc; +} + +/// Port of `H265ShortTermReferencePicture` (`src/h265/parameter-sets.ts:18`). +class H265ShortTermReferencePicture { + const H265ShortTermReferencePicture({ + required this.deltaPoc, + required this.usedByCurrentPicture, + }); + + final int deltaPoc; + final bool usedByCurrentPicture; +} + +/// Port of `H265ShortTermReferencePictureSet` +/// (`src/h265/parameter-sets.ts:23`). +class H265ShortTermReferencePictureSet { + const H265ShortTermReferencePictureSet({required this.pictures}); + + /// Negative deltas in closest-to-farthest order, then positive deltas. + final List pictures; +} + +/// Port of `ParsedH265Vps` (`src/h265/parameter-sets.ts:28`). +class ParsedH265Vps { + const ParsedH265Vps({ + required this.id, + required this.profileTierLevel, + required this.payloadSignature, + }); + + final int id; + + /// Always `1`. + final int maxSubLayers = 1; + final H265ProfileTierLevel profileTierLevel; + final String payloadSignature; +} + +/// The VUI timing block of an [ParsedH265Sps]. +/// +/// TS anonymous `{ numUnitsInTick, timeScale } | undefined` +/// (`src/h265/parameter-sets.ts:57`). +class H265SpsTiming { + const H265SpsTiming({required this.numUnitsInTick, required this.timeScale}); + + final int numUnitsInTick; + final int timeScale; +} + +/// Port of `ParsedH265Sps` (`src/h265/parameter-sets.ts:35`). +class ParsedH265Sps { + const ParsedH265Sps({ + required this.id, + required this.videoParameterSetId, + required this.profileTierLevel, + required this.codedWidth, + required this.codedHeight, + required this.crop, + required this.log2MaxPictureOrderCountLsb, + required this.maxDecPicBuffering, + required this.maxNumReorderPics, + required this.log2CtbSize, + required this.shortTermReferencePictureSets, + required this.longTermReferencePicturesPresent, + required this.temporalMvpEnabled, + required this.squareSampleAspect, + required this.defaultDisplayWindowPresent, + required this.timing, + required this.color, + required this.payloadSignature, + }); + + final int id; + final int videoParameterSetId; + + /// Always `1`. + final int maxSubLayers = 1; + + /// Always `true`. + final bool temporalIdNesting = true; + final H265ProfileTierLevel profileTierLevel; + + /// Always `1` (4:2:0). + final int chromaFormatIdc = 1; + + /// Always `false`. + final bool separateColourPlane = false; + final int codedWidth; + final int codedHeight; + final H265CropSummary crop; + + /// Always `8`. + final int bitDepthLuma = 8; + + /// Always `8`. + final int bitDepthChroma = 8; + final int log2MaxPictureOrderCountLsb; + final int maxDecPicBuffering; + final int maxNumReorderPics; + final int log2CtbSize; + final List shortTermReferencePictureSets; + final bool longTermReferencePicturesPresent; + final bool temporalMvpEnabled; + final bool squareSampleAspect; + final bool defaultDisplayWindowPresent; + final H265SpsTiming? timing; + final H265ColorSummary color; + final String payloadSignature; +} + +/// Port of `ParsedH265Pps` (`src/h265/parameter-sets.ts:65`). +class ParsedH265Pps { + const ParsedH265Pps({ + required this.id, + required this.spsId, + required this.dependentSliceSegmentsEnabled, + required this.outputFlagPresent, + required this.numExtraSliceHeaderBits, + required this.tilesEnabled, + required this.entropyCodingSyncEnabled, + required this.payloadSignature, + }); + + final int id; + final int spsId; + final bool dependentSliceSegmentsEnabled; + final bool outputFlagPresent; + final int numExtraSliceHeaderBits; + final bool tilesEnabled; + final bool entropyCodingSyncEnabled; + final String payloadSignature; +} + +class _VuiInfo { + const _VuiInfo({ + required this.squareSampleAspect, + required this.defaultDisplayWindowPresent, + required this.timing, + required this.color, + }); + + final bool squareSampleAspect; + final bool defaultDisplayWindowPresent; + final H265SpsTiming? timing; + final H265ColorSummary color; +} + +/// Port of `parseH265Vps` (`src/h265/parameter-sets.ts:76`). +ParsedH265Vps parseH265Vps(H265AnnexBNalUnit nal, String path) { + final reader = _readerFor(nal, path); + final id = reader.readBits(4, 'vps_video_parameter_set_id'); + requireH265( + reader.readBit('vps_base_layer_internal_flag'), + path, + 'VPS base layer must be internal', + ); + requireH265( + reader.readBit('vps_base_layer_available_flag'), + path, + 'VPS base layer must be available', + ); + requireH265( + reader.readBits(6, 'vps_max_layers_minus1') == 0, + path, + 'multilayer HEVC is unsupported', + ); + final maxSubLayersMinusOne = reader.readBits(3, 'vps_max_sub_layers_minus1'); + requireH265( + maxSubLayersMinusOne == 0, + path, + 'temporal sublayers are outside the initial HEVC profile', + ); + requireH265( + reader.readBit('vps_temporal_id_nesting_flag'), + path, + 'VPS temporal_id_nesting_flag must be one', + ); + requireH265( + reader.readBits(16, 'vps_reserved_0xffff_16bits') == 0xffff, + path, + 'VPS reserved bits are invalid', + ); + final profileTierLevel = + _parseProfileTierLevel(reader, maxSubLayersMinusOne, path); + reader.readBit('vps_sub_layer_ordering_info_present_flag'); + final maxDecPicBufferingMinusOne = reader.readUnsignedExpGolomb( + 'vps_max_dec_pic_buffering_minus1', + 15, + ); + final maxNumReorderPics = reader.readUnsignedExpGolomb( + 'vps_max_num_reorder_pics', + 15, + ); + requireH265( + maxNumReorderPics <= maxDecPicBufferingMinusOne, + path, + 'VPS reorder depth exceeds its decoded-picture buffer', + ); + reader.readUnsignedExpGolomb('vps_max_latency_increase_plus1'); + requireH265( + reader.readBits(6, 'vps_max_layer_id') == 0, + path, + 'VPS maximum layer id must be zero', + ); + requireH265( + reader.readUnsignedExpGolomb('vps_num_layer_sets_minus1', 1023) == 0, + path, + 'VPS layer sets are unsupported', + ); + if (reader.readBit('vps_timing_info_present_flag')) { + final numUnitsInTick = reader.readBits(32, 'vps_num_units_in_tick'); + final timeScale = reader.readBits(32, 'vps_time_scale'); + requireH265( + numUnitsInTick > 0 && timeScale > 0, + path, + 'VPS timing values must be positive', + ); + if (reader.readBit('vps_poc_proportional_to_timing_flag')) { + reader.readUnsignedExpGolomb('vps_num_ticks_poc_diff_one_minus1'); + } + requireH265( + reader.readUnsignedExpGolomb('vps_num_hrd_parameters', 1024) == 0, + path, + 'VPS HRD parameter sets are outside the production profile', + ); + } + requireH265( + !reader.readBit('vps_extension_flag'), + path, + 'VPS extensions are outside the production profile', + ); + reader.readTrailingBits(); + return ParsedH265Vps( + id: id, + profileTierLevel: profileTierLevel, + payloadSignature: _h265PayloadSignature(nal.payload), + ); +} + +/// Port of `parseH265Sps` (`src/h265/parameter-sets.ts:167`). +ParsedH265Sps parseH265Sps(H265AnnexBNalUnit nal, String path) { + final reader = _readerFor(nal, path); + final videoParameterSetId = reader.readBits(4, 'sps_video_parameter_set_id'); + final maxSubLayersMinusOne = reader.readBits(3, 'sps_max_sub_layers_minus1'); + requireH265( + maxSubLayersMinusOne == 0, + path, + 'temporal sublayers are outside the initial HEVC profile', + ); + requireH265( + reader.readBit('sps_temporal_id_nesting_flag'), + path, + 'SPS temporal_id_nesting_flag must be one', + ); + final profileTierLevel = + _parseProfileTierLevel(reader, maxSubLayersMinusOne, path); + final id = reader.readUnsignedExpGolomb('sps_seq_parameter_set_id', 15); + final chromaFormatIdc = reader.readUnsignedExpGolomb('chroma_format_idc', 3); + requireH265( + chromaFormatIdc == 1, + path, + 'the production HEVC profile requires 4:2:0 chroma', + ); + final codedWidth = reader.readUnsignedExpGolomb( + 'pic_width_in_luma_samples', + 1048576, + ); + final codedHeight = reader.readUnsignedExpGolomb( + 'pic_height_in_luma_samples', + 1048576, + ); + requireH265( + codedWidth > 0 && codedHeight > 0, + path, + 'SPS coded dimensions must be positive', + ); + var cropLeft = 0; + var cropRight = 0; + var cropTop = 0; + var cropBottom = 0; + if (reader.readBit('conformance_window_flag')) { + cropLeft = reader.readUnsignedExpGolomb('conf_win_left_offset') * 2; + cropRight = reader.readUnsignedExpGolomb('conf_win_right_offset') * 2; + cropTop = reader.readUnsignedExpGolomb('conf_win_top_offset') * 2; + cropBottom = reader.readUnsignedExpGolomb('conf_win_bottom_offset') * 2; + } + requireH265( + cropLeft + cropRight < codedWidth && cropTop + cropBottom < codedHeight, + path, + 'SPS conformance crop removes the complete picture', + ); + final crop = H265CropSummary( + left: cropLeft, + right: cropRight, + top: cropTop, + bottom: cropBottom, + visibleWidth: codedWidth - cropLeft - cropRight, + visibleHeight: codedHeight - cropTop - cropBottom, + ); + requireH265( + reader.readUnsignedExpGolomb('bit_depth_luma_minus8', 8) == 0 && + reader.readUnsignedExpGolomb('bit_depth_chroma_minus8', 8) == 0, + path, + 'the production HEVC profile requires 8-bit luma and chroma', + ); + final log2MaxPictureOrderCountLsb = + reader.readUnsignedExpGolomb('log2_max_pic_order_cnt_lsb_minus4', 12) + 4; + final orderingInfoPresent = + reader.readBit('sps_sub_layer_ordering_info_present_flag'); + final firstOrderingLayer = orderingInfoPresent ? 0 : maxSubLayersMinusOne; + var maxDecPicBuffering = 0; + var maxNumReorderPics = 0; + for (var layer = firstOrderingLayer; + layer <= maxSubLayersMinusOne; + layer += 1) { + maxDecPicBuffering = reader.readUnsignedExpGolomb( + 'sps_max_dec_pic_buffering_minus1[$layer]', + 15, + ) + + 1; + maxNumReorderPics = reader.readUnsignedExpGolomb( + 'sps_max_num_reorder_pics[$layer]', + 15, + ); + requireH265( + maxNumReorderPics < maxDecPicBuffering, + path, + 'SPS reorder depth must fit its decoded-picture buffer', + ); + reader.readUnsignedExpGolomb('sps_max_latency_increase_plus1[$layer]'); + } + final log2MinLumaCodingBlockSize = + reader.readUnsignedExpGolomb('log2_min_luma_coding_block_size_minus3', 3) + + 3; + final log2DiffMaxMinLumaCodingBlockSize = reader.readUnsignedExpGolomb( + 'log2_diff_max_min_luma_coding_block_size', + 6, + ); + final log2CtbSize = + log2MinLumaCodingBlockSize + log2DiffMaxMinLumaCodingBlockSize; + requireH265(log2CtbSize <= 6, path, 'SPS CTB size exceeds 64 luma samples'); + reader.readUnsignedExpGolomb('log2_min_luma_transform_block_size_minus2', 3); + reader.readUnsignedExpGolomb('log2_diff_max_min_luma_transform_block_size', 3); + reader.readUnsignedExpGolomb('max_transform_hierarchy_depth_inter', 6); + reader.readUnsignedExpGolomb('max_transform_hierarchy_depth_intra', 6); + if (reader.readBit('scaling_list_enabled_flag')) { + if (reader.readBit('sps_scaling_list_data_present_flag')) { + _skipScalingListData(reader); + } + } + reader.readBit('amp_enabled_flag'); + reader.readBit('sample_adaptive_offset_enabled_flag'); + if (reader.readBit('pcm_enabled_flag')) { + reader.readBits(4, 'pcm_sample_bit_depth_luma_minus1'); + reader.readBits(4, 'pcm_sample_bit_depth_chroma_minus1'); + reader.readUnsignedExpGolomb( + 'log2_min_pcm_luma_coding_block_size_minus3', + 3, + ); + reader.readUnsignedExpGolomb( + 'log2_diff_max_min_pcm_luma_coding_block_size', + 3, + ); + reader.readBit('pcm_loop_filter_disabled_flag'); + } + final numberOfShortTermSets = reader.readUnsignedExpGolomb( + 'num_short_term_ref_pic_sets', + _maxShortTermReferencePictures, + ); + final shortTermReferencePictureSets = []; + for (var index = 0; index < numberOfShortTermSets; index += 1) { + shortTermReferencePictureSets.add( + parseH265ShortTermReferencePictureSet( + reader, + index, + numberOfShortTermSets, + shortTermReferencePictureSets, + ), + ); + } + final longTermReferencePicturesPresent = + reader.readBit('long_term_ref_pics_present_flag'); + if (longTermReferencePicturesPresent) { + final count = reader.readUnsignedExpGolomb('num_long_term_ref_pics_sps', 32); + for (var index = 0; index < count; index += 1) { + reader.readBits( + log2MaxPictureOrderCountLsb, + 'lt_ref_pic_poc_lsb_sps[$index]', + ); + reader.readBit('used_by_curr_pic_lt_sps_flag[$index]'); + } + } + final temporalMvpEnabled = reader.readBit('sps_temporal_mvp_enabled_flag'); + reader.readBit('strong_intra_smoothing_enabled_flag'); + final vui = reader.readBit('vui_parameters_present_flag') + ? _parseVui(reader, maxSubLayersMinusOne, path) + : _defaultVui(); + if (reader.readBit('sps_extension_present_flag')) { + final extensionFlags = reader.readBits(8, 'SPS extension flags'); + requireH265( + extensionFlags == 0, + path, + 'SPS extensions are outside the production HEVC profile', + ); + } + reader.readTrailingBits(); + return ParsedH265Sps( + id: id, + videoParameterSetId: videoParameterSetId, + profileTierLevel: profileTierLevel, + codedWidth: codedWidth, + codedHeight: codedHeight, + crop: crop, + log2MaxPictureOrderCountLsb: log2MaxPictureOrderCountLsb, + maxDecPicBuffering: maxDecPicBuffering, + maxNumReorderPics: maxNumReorderPics, + log2CtbSize: log2CtbSize, + shortTermReferencePictureSets: + List.unmodifiable(shortTermReferencePictureSets), + longTermReferencePicturesPresent: longTermReferencePicturesPresent, + temporalMvpEnabled: temporalMvpEnabled, + squareSampleAspect: vui.squareSampleAspect, + defaultDisplayWindowPresent: vui.defaultDisplayWindowPresent, + timing: vui.timing, + color: vui.color, + payloadSignature: _h265PayloadSignature(nal.payload), + ); +} + +/// Port of `parseH265Pps` (`src/h265/parameter-sets.ts:358`). +ParsedH265Pps parseH265Pps(H265AnnexBNalUnit nal, String path) { + final reader = _readerFor(nal, path); + final id = reader.readUnsignedExpGolomb('pps_pic_parameter_set_id', 63); + final spsId = reader.readUnsignedExpGolomb('pps_seq_parameter_set_id', 15); + final dependentSliceSegmentsEnabled = + reader.readBit('dependent_slice_segments_enabled_flag'); + final outputFlagPresent = reader.readBit('output_flag_present_flag'); + final numExtraSliceHeaderBits = + reader.readBits(3, 'num_extra_slice_header_bits'); + reader.readBit('sign_data_hiding_enabled_flag'); + reader.readBit('cabac_init_present_flag'); + reader.readUnsignedExpGolomb('num_ref_idx_l0_default_active_minus1', 14); + reader.readUnsignedExpGolomb('num_ref_idx_l1_default_active_minus1', 14); + reader.readSignedExpGolomb('init_qp_minus26', -26, 25); + reader.readBit('constrained_intra_pred_flag'); + reader.readBit('transform_skip_enabled_flag'); + if (reader.readBit('cu_qp_delta_enabled_flag')) { + reader.readUnsignedExpGolomb('diff_cu_qp_delta_depth', 6); + } + reader.readSignedExpGolomb('pps_cb_qp_offset', -12, 12); + reader.readSignedExpGolomb('pps_cr_qp_offset', -12, 12); + reader.readBit('pps_slice_chroma_qp_offsets_present_flag'); + reader.readBit('weighted_pred_flag'); + reader.readBit('weighted_bipred_flag'); + reader.readBit('transquant_bypass_enabled_flag'); + final tilesEnabled = reader.readBit('tiles_enabled_flag'); + final entropyCodingSyncEnabled = + reader.readBit('entropy_coding_sync_enabled_flag'); + if (tilesEnabled) { + final columnsMinusOne = + reader.readUnsignedExpGolomb('num_tile_columns_minus1', 19); + final rowsMinusOne = + reader.readUnsignedExpGolomb('num_tile_rows_minus1', 21); + if (!reader.readBit('uniform_spacing_flag')) { + for (var column = 0; column < columnsMinusOne; column += 1) { + reader.readUnsignedExpGolomb('column_width_minus1[$column]'); + } + for (var row = 0; row < rowsMinusOne; row += 1) { + reader.readUnsignedExpGolomb('row_height_minus1[$row]'); + } + } + reader.readBit('loop_filter_across_tiles_enabled_flag'); + } + reader.readBit('pps_loop_filter_across_slices_enabled_flag'); + if (reader.readBit('deblocking_filter_control_present_flag')) { + reader.readBit('deblocking_filter_override_enabled_flag'); + final disabled = reader.readBit('pps_deblocking_filter_disabled_flag'); + if (!disabled) { + reader.readSignedExpGolomb('pps_beta_offset_div2', -6, 6); + reader.readSignedExpGolomb('pps_tc_offset_div2', -6, 6); + } + } + if (reader.readBit('pps_scaling_list_data_present_flag')) { + _skipScalingListData(reader); + } + reader.readBit('lists_modification_present_flag'); + reader.readUnsignedExpGolomb('log2_parallel_merge_level_minus2', 4); + reader.readBit('slice_segment_header_extension_present_flag'); + if (reader.readBit('pps_extension_present_flag')) { + final extensionFlags = reader.readBits(8, 'PPS extension flags'); + requireH265( + extensionFlags == 0, + path, + 'PPS extensions are outside the production HEVC profile', + ); + } + reader.readTrailingBits(); + return ParsedH265Pps( + id: id, + spsId: spsId, + dependentSliceSegmentsEnabled: dependentSliceSegmentsEnabled, + outputFlagPresent: outputFlagPresent, + numExtraSliceHeaderBits: numExtraSliceHeaderBits, + tilesEnabled: tilesEnabled, + entropyCodingSyncEnabled: entropyCodingSyncEnabled, + payloadSignature: _h265PayloadSignature(nal.payload), + ); +} + +/// Port of `parseH265ShortTermReferencePictureSet` +/// (`src/h265/parameter-sets.ts:436`). +H265ShortTermReferencePictureSet parseH265ShortTermReferencePictureSet( + H265RbspBitReader reader, + int setIndex, + int numberOfSpsSets, + List previousSets, +) { + requireH265( + setIndex >= 0 && setIndex <= numberOfSpsSets, + 'shortTermReferencePictureSet', + 'short-term reference-picture-set index is invalid', + ); + if (setIndex != 0 && reader.readBit('inter_ref_pic_set_prediction_flag')) { + final deltaIndexMinusOne = setIndex == numberOfSpsSets + ? reader.readUnsignedExpGolomb('delta_idx_minus1', setIndex - 1) + : 0; + final referenceIndex = setIndex - (deltaIndexMinusOne + 1); + final reference = + (referenceIndex >= 0 && referenceIndex < previousSets.length) + ? previousSets[referenceIndex] + : null; + requireH265( + reference != null, + 'shortTermReferencePictureSet', + 'predicted reference-picture set points outside the SPS', + ); + final deltaSign = reader.readBit('delta_rps_sign'); + final absoluteDelta = + reader.readUnsignedExpGolomb('abs_delta_rps_minus1', 32767) + 1; + final deltaRps = deltaSign ? -absoluteDelta : absoluteDelta; + final candidates = [ + for (final picture in reference!.pictures) picture.deltaPoc + deltaRps, + deltaRps, + ]; + final selected = []; + for (var index = 0; index < candidates.length; index += 1) { + final used = reader.readBit('used_by_curr_pic_flag[$index]'); + final retained = used || reader.readBit('use_delta_flag[$index]'); + if (retained) { + final deltaPoc = candidates[index]; + requireH265( + deltaPoc != 0, + 'shortTermReferencePictureSet', + 'predicted RPS contains the current picture', + ); + selected.add( + H265ShortTermReferencePicture( + deltaPoc: deltaPoc, + usedByCurrentPicture: used, + ), + ); + } + } + return _freezeReferencePictureSet(selected); + } + + final numberOfNegativePictures = reader.readUnsignedExpGolomb( + 'num_negative_pics', + _maxShortTermReferencePictures, + ); + final numberOfPositivePictures = reader.readUnsignedExpGolomb( + 'num_positive_pics', + _maxShortTermReferencePictures, + ); + requireH265( + numberOfNegativePictures + numberOfPositivePictures <= + _maxShortTermReferencePictures, + 'shortTermReferencePictureSet', + 'short-term reference-picture set exceeds the picture budget', + ); + final pictures = []; + var delta = 0; + for (var index = 0; index < numberOfNegativePictures; index += 1) { + delta -= + reader.readUnsignedExpGolomb('delta_poc_s0_minus1[$index]', 32767) + 1; + pictures.add( + H265ShortTermReferencePicture( + deltaPoc: delta, + usedByCurrentPicture: + reader.readBit('used_by_curr_pic_s0_flag[$index]'), + ), + ); + } + delta = 0; + for (var index = 0; index < numberOfPositivePictures; index += 1) { + delta += + reader.readUnsignedExpGolomb('delta_poc_s1_minus1[$index]', 32767) + 1; + pictures.add( + H265ShortTermReferencePicture( + deltaPoc: delta, + usedByCurrentPicture: + reader.readBit('used_by_curr_pic_s1_flag[$index]'), + ), + ); + } + return _freezeReferencePictureSet(pictures); +} + +/// Port of `sameH265ProfileTierLevel` (`src/h265/parameter-sets.ts:527`). +bool sameH265ProfileTierLevel( + H265ProfileTierLevel left, + H265ProfileTierLevel right, +) { + if (!(left.profileSpace == right.profileSpace && + left.tierFlag == right.tierFlag && + left.profileIdc == right.profileIdc && + left.profileCompatibilityFlags == right.profileCompatibilityFlags && + left.levelIdc == right.levelIdc && + left.constraintIndicatorFlags.length == + right.constraintIndicatorFlags.length)) { + return false; + } + for (var index = 0; + index < left.constraintIndicatorFlags.length; + index += 1) { + if (left.constraintIndicatorFlags[index] != + right.constraintIndicatorFlags[index]) { + return false; + } + } + return true; +} + +H265ProfileTierLevel _parseProfileTierLevel( + H265RbspBitReader reader, + int maxSubLayersMinusOne, + String path, +) { + final profileSpace = reader.readBits(2, 'general_profile_space'); + final tierFlag = reader.readBit('general_tier_flag'); + final profileIdc = reader.readBits(5, 'general_profile_idc'); + var profileCompatibilityFlags = 0; + for (var index = 0; index < 32; index += 1) { + if (reader.readBit('general_profile_compatibility_flag[$index]')) { + profileCompatibilityFlags += 1 << index; + } + } + final constraintIndicatorFlags = []; + for (var index = 0; index < 6; index += 1) { + constraintIndicatorFlags.add( + reader.readBits(8, 'general_constraint_indicator_flags[$index]'), + ); + } + final levelIdc = reader.readBits(8, 'general_level_idc'); + requireH265(levelIdc > 0, path, 'general_level_idc must be nonzero'); + + final subLayerProfilePresent = []; + final subLayerLevelPresent = []; + for (var layer = 0; layer < maxSubLayersMinusOne; layer += 1) { + subLayerProfilePresent.add( + reader.readBit('sub_layer_profile_present_flag[$layer]'), + ); + subLayerLevelPresent.add( + reader.readBit('sub_layer_level_present_flag[$layer]'), + ); + } + if (maxSubLayersMinusOne > 0) { + for (var layer = maxSubLayersMinusOne; layer < 8; layer += 1) { + requireH265( + reader.readBits(2, 'reserved_zero_2bits[$layer]') == 0, + path, + 'profile-tier-level reserved bits must be zero', + ); + } + } + for (var layer = 0; layer < maxSubLayersMinusOne; layer += 1) { + if (subLayerProfilePresent[layer]) { + reader.skipBits(88, 'sub_layer_profile_tier_level[$layer]'); + } + if (subLayerLevelPresent[layer]) { + reader.skipBits(8, 'sub_layer_level_idc[$layer]'); + } + } + return H265ProfileTierLevel( + profileSpace: profileSpace, + tierFlag: tierFlag, + profileIdc: profileIdc, + profileCompatibilityFlags: profileCompatibilityFlags, + constraintIndicatorFlags: List.unmodifiable(constraintIndicatorFlags), + levelIdc: levelIdc, + ); +} + +_VuiInfo _parseVui( + H265RbspBitReader reader, + int maxSubLayersMinusOne, + String path, +) { + var squareSampleAspect = true; + if (reader.readBit('aspect_ratio_info_present_flag')) { + final aspectRatioIdc = reader.readBits(8, 'aspect_ratio_idc'); + if (aspectRatioIdc == 255) { + final width = reader.readBits(16, 'sar_width'); + final height = reader.readBits(16, 'sar_height'); + requireH265( + width > 0 && height > 0, + path, + 'sample aspect ratio is invalid', + ); + squareSampleAspect = width == height; + } else { + squareSampleAspect = aspectRatioIdc == 1; + } + } + if (reader.readBit('overscan_info_present_flag')) { + reader.readBit('overscan_appropriate_flag'); + } + var fullRange = false; + int? colourPrimaries; + int? transferCharacteristics; + int? matrixCoefficients; + if (reader.readBit('video_signal_type_present_flag')) { + reader.readBits(3, 'video_format'); + fullRange = reader.readBit('video_full_range_flag'); + if (reader.readBit('colour_description_present_flag')) { + colourPrimaries = reader.readBits(8, 'colour_primaries'); + transferCharacteristics = reader.readBits(8, 'transfer_characteristics'); + matrixCoefficients = reader.readBits(8, 'matrix_coefficients'); + } + } + if (reader.readBit('chroma_loc_info_present_flag')) { + reader.readUnsignedExpGolomb('chroma_sample_loc_type_top_field', 5); + reader.readUnsignedExpGolomb('chroma_sample_loc_type_bottom_field', 5); + } + reader.readBit('neutral_chroma_indication_flag'); + requireH265( + !reader.readBit('field_seq_flag'), + path, + 'field sequences are unsupported', + ); + reader.readBit('frame_field_info_present_flag'); + final defaultDisplayWindowPresent = + reader.readBit('default_display_window_flag'); + if (defaultDisplayWindowPresent) { + reader.readUnsignedExpGolomb('def_disp_win_left_offset'); + reader.readUnsignedExpGolomb('def_disp_win_right_offset'); + reader.readUnsignedExpGolomb('def_disp_win_top_offset'); + reader.readUnsignedExpGolomb('def_disp_win_bottom_offset'); + } + H265SpsTiming? timing; + if (reader.readBit('vui_timing_info_present_flag')) { + final numUnitsInTick = reader.readBits(32, 'vui_num_units_in_tick'); + final timeScale = reader.readBits(32, 'vui_time_scale'); + requireH265( + numUnitsInTick > 0 && timeScale > 0, + path, + 'VUI timing values must be positive', + ); + timing = H265SpsTiming(numUnitsInTick: numUnitsInTick, timeScale: timeScale); + if (reader.readBit('vui_poc_proportional_to_timing_flag')) { + reader.readUnsignedExpGolomb('vui_num_ticks_poc_diff_one_minus1'); + } + requireH265( + !reader.readBit('vui_hrd_parameters_present_flag'), + path, + 'VUI HRD parameters are outside the production profile', + ); + } + if (reader.readBit('bitstream_restriction_flag')) { + reader.readBit('tiles_fixed_structure_flag'); + reader.readBit('motion_vectors_over_pic_boundaries_flag'); + reader.readBit('restricted_ref_pic_lists_flag'); + reader.readUnsignedExpGolomb('min_spatial_segmentation_idc', 4095); + reader.readUnsignedExpGolomb('max_bytes_per_pic_denom', 16); + reader.readUnsignedExpGolomb('max_bits_per_min_cu_denom', 16); + reader.readUnsignedExpGolomb('log2_max_mv_length_horizontal', 16); + reader.readUnsignedExpGolomb('log2_max_mv_length_vertical', 16); + } + final color = H265ColorSummary( + fullRange: fullRange, + colourPrimaries: colourPrimaries, + transferCharacteristics: transferCharacteristics, + matrixCoefficients: matrixCoefficients, + ); + return _VuiInfo( + squareSampleAspect: squareSampleAspect, + defaultDisplayWindowPresent: defaultDisplayWindowPresent, + timing: timing, + color: color, + ); +} + +_VuiInfo _defaultVui() { + return const _VuiInfo( + squareSampleAspect: true, + defaultDisplayWindowPresent: false, + timing: null, + color: H265ColorSummary(fullRange: false), + ); +} + +void _skipScalingListData(H265RbspBitReader reader) { + for (var sizeId = 0; sizeId < 4; sizeId += 1) { + final increment = sizeId == 3 ? 3 : 1; + for (var matrixId = 0; matrixId < 6; matrixId += increment) { + if (!reader.readBit('scaling_list_pred_mode_flag[$sizeId][$matrixId]')) { + reader.readUnsignedExpGolomb( + 'scaling_list_pred_matrix_id_delta[$sizeId][$matrixId]', + matrixId, + ); + continue; + } + final rawCount = 1 << (4 + sizeId * 2); + final coefficientCount = 64 < rawCount ? 64 : rawCount; + if (sizeId > 1) { + reader.readSignedExpGolomb( + 'scaling_list_dc_coef_minus8[$sizeId][$matrixId]', + -7, + 247, + ); + } + for (var coefficient = 0; + coefficient < coefficientCount; + coefficient += 1) { + reader.readSignedExpGolomb( + 'scaling_list_delta_coef[$sizeId][$matrixId][$coefficient]', + -128, + 127, + ); + } + } + } +} + +H265ShortTermReferencePictureSet _freezeReferencePictureSet( + List pictures, +) { + final sorted = List.from(pictures) + ..sort((left, right) { + if (left.deltaPoc < 0 && right.deltaPoc >= 0) return -1; + if (left.deltaPoc >= 0 && right.deltaPoc < 0) return 1; + return left.deltaPoc < 0 + ? right.deltaPoc - left.deltaPoc + : left.deltaPoc - right.deltaPoc; + }); + for (var index = 0; index < sorted.length; index += 1) { + requireH265( + index == 0 || sorted[index].deltaPoc != sorted[index - 1].deltaPoc, + 'shortTermReferencePictureSet', + 'short-term reference-picture set contains duplicate POC deltas', + ); + } + return H265ShortTermReferencePictureSet(pictures: List.unmodifiable(sorted)); +} + +H265RbspBitReader _readerFor(H265AnnexBNalUnit nal, String path) { + return H265RbspBitReader(nal.rbsp, path, nal.offset + 2); +} + +String _h265PayloadSignature(Uint8List payload) { + final buffer = StringBuffer(); + for (final byte in payload) { + buffer.write(byte.toRadixString(16).padLeft(2, '0')); + } + return buffer.toString(); +} diff --git a/flutter/packages/aval_format/lib/src/h265/presentation_order.dart b/flutter/packages/aval_format/lib/src/h265/presentation_order.dart new file mode 100644 index 0000000..f6fc326 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/presentation_order.dart @@ -0,0 +1,139 @@ +/// HEVC picture-order-count derivation and unit-local presentation ordering. +/// +/// Dart port of `packages/format/src/h265/presentation-order.ts`. +library; + +import 'annex_b.dart' show isH265IdrNalType, isH265RandomAccessNalType; +import 'failure.dart'; + +/// Mutable per-unit POC derivation state. +/// +/// Port of `H265PictureOrderState` (`src/h265/presentation-order.ts:7`). +class H265PictureOrderState { + H265PictureOrderState({ + required this.initialized, + required this.previousTid0PictureOrderCountLsb, + required this.previousTid0PictureOrderCountMsb, + }); + + bool initialized; + int previousTid0PictureOrderCountLsb; + int previousTid0PictureOrderCountMsb; +} + +/// Port of `H265DecodedPictureOrder` (`src/h265/presentation-order.ts:13`). +class H265DecodedPictureOrder { + const H265DecodedPictureOrder({ + required this.decodeIndex, + required this.pictureOrderCount, + }); + + final int decodeIndex; + final int pictureOrderCount; +} + +/// Port of `createH265PictureOrderState` +/// (`src/h265/presentation-order.ts:18`). +H265PictureOrderState createH265PictureOrderState() { + return H265PictureOrderState( + initialized: false, + previousTid0PictureOrderCountLsb: 0, + previousTid0PictureOrderCountMsb: 0, + ); +} + +/// Derives PicOrderCntVal with unit-local state (HEVC 8.3.1 subset). +/// +/// Port of `deriveH265PictureOrderCount` +/// (`src/h265/presentation-order.ts:27`). +int deriveH265PictureOrderCount( + int nalType, + int temporalId, + int pictureOrderCountLsb, + int log2MaxPictureOrderCountLsb, + H265PictureOrderState state, +) { + final maximum = 1 << log2MaxPictureOrderCountLsb; + requireH265( + pictureOrderCountLsb >= 0 && pictureOrderCountLsb < maximum, + 'pictureOrderCount', + 'slice picture-order-count LSB is out of range', + ); + if (isH265IdrNalType(nalType)) { + state.initialized = true; + state.previousTid0PictureOrderCountLsb = 0; + state.previousTid0PictureOrderCountMsb = 0; + return 0; + } + var msb = 0; + if (state.initialized && !isH265RandomAccessNalType(nalType)) { + final previousLsb = state.previousTid0PictureOrderCountLsb; + final previousMsb = state.previousTid0PictureOrderCountMsb; + if (pictureOrderCountLsb < previousLsb && + previousLsb - pictureOrderCountLsb >= maximum / 2) { + msb = previousMsb + maximum; + } else if (pictureOrderCountLsb > previousLsb && + pictureOrderCountLsb - previousLsb > maximum / 2) { + msb = previousMsb - maximum; + } else { + msb = previousMsb; + } + } + final pictureOrderCount = msb + pictureOrderCountLsb; + // RADL/RASL pictures (types 6..9) do not become prevTid0Pic. + if (temporalId == 0 && !(nalType >= 6 && nalType <= 9)) { + state.initialized = true; + state.previousTid0PictureOrderCountLsb = pictureOrderCountLsb; + state.previousTid0PictureOrderCountMsb = msb; + } + return pictureOrderCount; +} + +/// Maps decoder submission order to a contiguous unit-local display order. +/// +/// Port of `deriveH265PresentationOrder` +/// (`src/h265/presentation-order.ts:74`). +List deriveH265PresentationOrder( + List pictures, + int maximumReorderPictures, + String path, +) { + requireH265(pictures.isNotEmpty, path, 'unit contains no decoded pictures'); + final sorted = List.from(pictures) + ..sort((left, right) => left.pictureOrderCount - right.pictureOrderCount); + final first = sorted[0].pictureOrderCount; + final decodeToPresentation = List.filled(pictures.length, null); + for (var presentationIndex = 0; + presentationIndex < sorted.length; + presentationIndex += 1) { + final picture = sorted[presentationIndex]; + requireH265( + picture.pictureOrderCount == first + presentationIndex, + path, + 'unit picture-order counts must be unique and contiguous', + ); + requireH265( + picture.decodeIndex >= 0 && + picture.decodeIndex < pictures.length && + decodeToPresentation[picture.decodeIndex] == null, + path, + 'unit decode index is duplicated or out of range', + ); + decodeToPresentation[picture.decodeIndex] = presentationIndex; + } + var requiredReorder = 0; + for (var decodeIndex = 0; + decodeIndex < decodeToPresentation.length; + decodeIndex += 1) { + final presentationIndex = decodeToPresentation[decodeIndex]; + requireH265(presentationIndex != null, path, 'decode order has a gap'); + final delta = decodeIndex - presentationIndex!; + requiredReorder = requiredReorder > delta ? requiredReorder : delta; + } + requireH265( + requiredReorder <= maximumReorderPictures, + path, + 'derived presentation reordering exceeds the SPS declaration', + ); + return List.unmodifiable(decodeToPresentation.cast()); +} diff --git a/flutter/packages/aval_format/lib/src/h265/slice_header.dart b/flutter/packages/aval_format/lib/src/h265/slice_header.dart new file mode 100644 index 0000000..bb1772f --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/slice_header.dart @@ -0,0 +1,155 @@ +/// HEVC slice-segment header parsing (production profile subset). +/// +/// Dart port of `packages/format/src/h265/slice-header.ts`. +library; + +import 'annex_b.dart' + show + H265_NAL_BLA_N_LP, + H265_NAL_BLA_W_LP, + H265_NAL_BLA_W_RADL, + H265_NAL_CRA_NUT, + H265AnnexBNalUnit, + isH265IdrNalType, + isH265RandomAccessNalType; +import 'bit_reader.dart'; +import 'failure.dart'; +import 'parameter_sets.dart' + show + H265ShortTermReferencePictureSet, + ParsedH265Pps, + ParsedH265Sps, + parseH265ShortTermReferencePictureSet; +import 'types.dart' show H265RandomAccessKind; + +/// Port of `ParsedH265SliceHeader` (`src/h265/slice-header.ts:20`). +class ParsedH265SliceHeader { + const ParsedH265SliceHeader({ + required this.ppsId, + required this.sliceType, + required this.pictureOrderCountLsb, + required this.referencePictureSet, + required this.randomAccess, + required this.noOutputOfPriorPictures, + }); + + final int ppsId; + + /// One of `"I"`, `"P"`, `"B"`. + final String sliceType; + final int pictureOrderCountLsb; + final H265ShortTermReferencePictureSet referencePictureSet; + final H265RandomAccessKind? randomAccess; + final bool noOutputOfPriorPictures; +} + +/// Port of `parseH265SliceHeader` (`src/h265/slice-header.ts:29`). +ParsedH265SliceHeader parseH265SliceHeader( + H265AnnexBNalUnit nal, + ParsedH265Pps pps, + ParsedH265Sps sps, + String path, +) { + final reader = H265RbspBitReader(nal.rbsp, path, nal.offset + 2); + requireH265( + reader.readBit('first_slice_segment_in_pic_flag'), + path, + 'the production HEVC profile requires one slice segment per picture', + ); + final randomAccess = _randomAccessKind(nal.type); + final noOutputOfPriorPictures = randomAccess == null + ? false + : reader.readBit('no_output_of_prior_pics_flag'); + final ppsId = reader.readUnsignedExpGolomb('slice_pic_parameter_set_id', 63); + requireH265(ppsId == pps.id, path, 'slice references an unexpected PPS'); + for (var index = 0; index < pps.numExtraSliceHeaderBits; index += 1) { + reader.readBit('slice_reserved_flag[$index]'); + } + final rawSliceType = reader.readUnsignedExpGolomb('slice_type', 2); + final sliceType = rawSliceType == 2 + ? 'I' + : rawSliceType == 1 + ? 'P' + : 'B'; + requireH265( + randomAccess == null || sliceType == 'I', + path, + 'an HEVC random-access picture must be intra-coded', + ); + if (pps.outputFlagPresent) reader.readBit('pic_output_flag'); + + var pictureOrderCountLsb = 0; + H265ShortTermReferencePictureSet referencePictureSet = + const H265ShortTermReferencePictureSet(pictures: []); + if (!isH265IdrNalType(nal.type)) { + pictureOrderCountLsb = reader.readBits( + sps.log2MaxPictureOrderCountLsb, + 'slice_pic_order_cnt_lsb', + ); + final fromSps = reader.readBit('short_term_ref_pic_set_sps_flag'); + if (fromSps) { + requireH265( + sps.shortTermReferencePictureSets.isNotEmpty, + path, + 'slice selects an absent SPS reference-picture set', + ); + final indexWidth = _ceilLog2(sps.shortTermReferencePictureSets.length); + final index = indexWidth == 0 + ? 0 + : reader.readBits(indexWidth, 'short_term_ref_pic_set_idx'); + final selected = + index >= 0 && index < sps.shortTermReferencePictureSets.length + ? sps.shortTermReferencePictureSets[index] + : null; + requireH265(selected != null, path, 'slice RPS index is out of range'); + referencePictureSet = selected!; + } else { + referencePictureSet = parseH265ShortTermReferencePictureSet( + reader, + sps.shortTermReferencePictureSets.length, + sps.shortTermReferencePictureSets.length, + sps.shortTermReferencePictureSets.toList(), + ); + } + requireH265( + !sps.longTermReferencePicturesPresent, + path, + 'long-term references are outside the production HEVC profile', + ); + if (sps.temporalMvpEnabled) { + reader.readBit('slice_temporal_mvp_enabled_flag'); + } + } + requireH265( + reader.bitsRemaining >= 8, + path, + 'slice header or coded slice payload is truncated', + ); + return ParsedH265SliceHeader( + ppsId: ppsId, + sliceType: sliceType, + pictureOrderCountLsb: pictureOrderCountLsb, + referencePictureSet: referencePictureSet, + randomAccess: randomAccess, + noOutputOfPriorPictures: noOutputOfPriorPictures, + ); +} + +H265RandomAccessKind? _randomAccessKind(int type) { + if (!isH265RandomAccessNalType(type)) return null; + if (type == H265_NAL_BLA_W_LP || + type == H265_NAL_BLA_W_RADL || + type == H265_NAL_BLA_N_LP) { + return 'bla'; + } + if (type == H265_NAL_CRA_NUT) return 'cra'; + return 'idr'; +} + +/// Port of `ceilLog2` (`src/h265/slice-header.ts:123`). +/// +/// For `value > 1`, `Math.ceil(Math.log2(value))` equals `(value - 1)`'s bit +/// length; computed here with integer arithmetic to avoid float rounding. +int _ceilLog2(int value) { + return value <= 1 ? 0 : (value - 1).bitLength; +} diff --git a/flutter/packages/aval_format/lib/src/h265/types.dart b/flutter/packages/aval_format/lib/src/h265/types.dart new file mode 100644 index 0000000..42a3c3b --- /dev/null +++ b/flutter/packages/aval_format/lib/src/h265/types.dart @@ -0,0 +1,226 @@ +/// Public data types for the HEVC (H.265) inspection subsystem. +/// +/// Dart port of `packages/format/src/h265/types.ts`. +library; + +import 'dart:typed_data'; + +import 'parameter_sets.dart' show H265ProfileTierLevel; + +/// Port of `H265AccessUnitInput` (`src/h265/types.ts:3`). +class H265AccessUnitInput { + const H265AccessUnitInput({required this.bytes, required this.key}); + + final Uint8List bytes; + final bool key; +} + +/// Port of `H265UnitInput` (`src/h265/types.ts:8`). +class H265UnitInput { + const H265UnitInput({required this.id, required this.accessUnits}); + + final String id; + final List accessUnits; +} + +/// Port of `H265FrameRate` (`src/h265/types.ts:13`). +class H265FrameRate { + const H265FrameRate({required this.numerator, required this.denominator}); + + final int numerator; + final int denominator; +} + +/// Port of `H265MainProfile` (`src/h265/types.ts:18`). +/// +/// `expectedVisibleRect` mirrors the TS `readonly [0, 0, width, height]` +/// tuple as a 4-element `List`. +class H265MainProfile { + const H265MainProfile({ + required this.codedWidth, + required this.codedHeight, + this.expectedVisibleRect, + required this.frameRate, + required this.requireBt709LimitedRange, + }); + + final int codedWidth; + final int codedHeight; + final List? expectedVisibleRect; + final H265FrameRate frameRate; + final bool requireBt709LimitedRange; +} + +/// Port of `H265RenditionInspectionInput` (`src/h265/types.ts:31`). +class H265RenditionInspectionInput { + const H265RenditionInspectionInput({ + required this.profile, + required this.units, + }); + + final H265MainProfile profile; + final List units; +} + +/// Port of `H265ColorSummary` (`src/h265/types.ts:36`). +class H265ColorSummary { + const H265ColorSummary({ + required this.fullRange, + this.colourPrimaries, + this.transferCharacteristics, + this.matrixCoefficients, + }); + + final bool fullRange; + final int? colourPrimaries; + final int? transferCharacteristics; + final int? matrixCoefficients; +} + +/// Port of `H265CropSummary` (`src/h265/types.ts:43`). +class H265CropSummary { + const H265CropSummary({ + required this.left, + required this.right, + required this.top, + required this.bottom, + required this.visibleWidth, + required this.visibleHeight, + }); + + final int left; + final int right; + final int top; + final int bottom; + final int visibleWidth; + final int visibleHeight; +} + +/// Port of `H265ParameterSetSummary` (`src/h265/types.ts:52`). +class H265ParameterSetSummary { + const H265ParameterSetSummary({ + required this.profileTierLevel, + required this.codec, + required this.codedWidth, + required this.codedHeight, + required this.crop, + required this.maxNumReorderPics, + required this.maxDecPicBuffering, + required this.color, + }); + + final H265ProfileTierLevel profileTierLevel; + final String codec; + final int codedWidth; + final int codedHeight; + final H265CropSummary crop; + + /// Always `8`. + final int bitDepth = 8; + + /// Always `"4:2:0"`. + final String chromaFormat = '4:2:0'; + final int maxNumReorderPics; + final int maxDecPicBuffering; + final H265ColorSummary color; +} + +/// Random-access picture classification. TS string-literal union +/// `"bla" | "idr" | "cra"` (`src/h265/types.ts:65`). +typedef H265RandomAccessKind = String; + +/// Port of `H265AccessUnitSummary` (`src/h265/types.ts:67`). +class H265AccessUnitSummary { + const H265AccessUnitSummary({ + required this.decodeIndex, + required this.presentationIndex, + required this.pictureOrderCount, + required this.key, + required this.randomAccess, + required this.sliceType, + required this.temporalId, + required this.referencedPictureOrderCounts, + required this.nalUnitTypes, + }); + + final int decodeIndex; + final int presentationIndex; + final int pictureOrderCount; + final bool key; + + /// One of `"bla"`, `"idr"`, `"cra"`, or `null` (TS `undefined`). + final H265RandomAccessKind? randomAccess; + + /// One of `"I"`, `"P"`, `"B"`. + final String sliceType; + final int temporalId; + final List referencedPictureOrderCounts; + final List nalUnitTypes; +} + +/// Port of `H265UnitInspection` (`src/h265/types.ts:79`). +class H265UnitInspection { + const H265UnitInspection({ + required this.id, + required this.accessUnits, + required this.decodeToPresentation, + }); + + final String id; + final List accessUnits; + final List decodeToPresentation; +} + +/// Port of `H265RenditionInspection` (`src/h265/types.ts:85`). +class H265RenditionInspection { + const H265RenditionInspection({ + required this.parameterSet, + required this.decoderConfig, + required this.units, + }); + + final H265ParameterSetSummary parameterSet; + final H265VideoDecoderConfig decoderConfig; + final List units; +} + +/// The BT.709 color-space block of an [H265VideoDecoderConfig]. +/// +/// Nested object literal from `H265VideoDecoderConfig.colorSpace` +/// (`src/h265/types.ts:98`). All fields are fixed constants. +class H265DecoderColorSpace { + const H265DecoderColorSpace(); + + /// Always `"bt709"`. + final String primaries = 'bt709'; + + /// Always `"bt709"`. + final String transfer = 'bt709'; + + /// Always `"bt709"`. + final String matrix = 'bt709'; + + /// Always `false`. + final bool fullRange = false; +} + +/// Structural subset of `VideoDecoderConfig` used by the browser adapter. +/// +/// Port of `H265VideoDecoderConfig` (`src/h265/types.ts:92`). +class H265VideoDecoderConfig { + const H265VideoDecoderConfig({ + required this.codec, + required this.codedWidth, + required this.codedHeight, + required this.displayAspectWidth, + required this.displayAspectHeight, + required this.colorSpace, + }); + + final String codec; + final int codedWidth; + final int codedHeight; + final int displayAspectWidth; + final int displayAspectHeight; + final H265DecoderColorSpace colorSpace; +} diff --git a/flutter/packages/aval_format/lib/src/header.dart b/flutter/packages/aval_format/lib/src/header.dart new file mode 100644 index 0000000..22953e0 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/header.dart @@ -0,0 +1,269 @@ +/// Fixed 64-byte version-1.0 format header codec. +/// +/// Dart port of `packages/format/src/header.ts`. +library; + +import 'dart:typed_data'; + +import 'checked_integer.dart'; +import 'constants.dart'; +import 'errors.dart'; +import 'model.dart' show FormatHeader, FormatOptions; + +class _HeaderFields { + const _HeaderFields({ + required this.major, + required this.minor, + required this.headerLength, + required this.requiredFeatureFlags, + required this.declaredFileLength, + required this.manifestOffset, + required this.manifestLength, + required this.indexOffset, + required this.indexLength, + }); + + final int major; + final int minor; + final int headerLength; + final int requiredFeatureFlags; + final int declaredFileLength; + final int manifestOffset; + final int manifestLength; + final int indexOffset; + final int indexLength; +} + +Never _fail(String message, int offset) { + throw FormatError( + FormatErrorCode.headerInvalid, + message, + FormatErrorDetails(offset: offset), + ); +} + +void _assertMagic(Uint8List bytes) { + for (var index = 0; index < formatMagic.length; index += 1) { + if (bytes[index] != formatMagic[index]) { + _fail('format magic does not match AVLF 1.0', index); + } + } +} + +void _validateHeaderShape(_HeaderFields header, [FormatOptions? options]) { + final budgets = resolveFormatBudgets(options); + + if (header.major != formatVersionMajor || header.minor != formatVersionMinor) { + throw FormatError( + FormatErrorCode.versionUnsupported, + 'format version ${header.major}.${header.minor} is unsupported', + FormatErrorDetails(offset: header.major != formatVersionMajor ? 8 : 10), + ); + } + if (header.headerLength != formatHeaderLength) { + _fail('header length must be $formatHeaderLength', 12); + } + if (header.requiredFeatureFlags != 0) { + throw FormatError( + FormatErrorCode.featureUnsupported, + 'required feature flags are unsupported in format 1.0', + const FormatErrorDetails(offset: 16), + ); + } + if (header.manifestOffset != formatHeaderLength) { + _fail('manifest offset must be $formatHeaderLength', 32); + } + if (header.manifestLength == 0) { + _fail('manifest length must be positive', 40); + } + + int expectedIndexOffset; + try { + expectedIndexOffset = align8( + checkedAdd(formatHeaderLength, header.manifestLength, budgets.maxFileBytes, 'manifest end'), + budgets.maxFileBytes, + 'index offset', + ); + } on FormatError catch (error) { + throw FormatError(error.code, error.message, const FormatErrorDetails(offset: 40)); + } catch (_) { + _fail('manifest range is invalid', 40); + } + if (header.indexOffset != expectedIndexOffset) { + _fail('index offset must be $expectedIndexOffset', 48); + } + if (header.indexLength < chunkIndexHeaderLength || + (header.indexLength - chunkIndexHeaderLength) % chunkIndexRecordLength != 0) { + _fail('index length does not encode whole access-unit records', 56); + } + final chunkCount = + (header.indexLength - chunkIndexHeaderLength) ~/ chunkIndexRecordLength; + if (chunkCount > budgets.maxChunkRecords) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'chunk record count exceeds the active limit of ${budgets.maxChunkRecords}', + const FormatErrorDetails(offset: 56), + ); + } + + int frontIndexEnd; + try { + frontIndexEnd = + checkedAdd(header.indexOffset, header.indexLength, budgets.maxFileBytes, 'front index end'); + } on FormatError catch (error) { + throw FormatError(error.code, error.message, const FormatErrorDetails(offset: 56)); + } catch (_) { + _fail('front index range is invalid', 56); + } + if (frontIndexEnd > header.declaredFileLength) { + _fail('front index extends beyond the declared file length', 24); + } +} + +/// Decodes and validates the exact 64-byte version-1.0 header. +FormatHeader parseHeader(Uint8List bytes, [FormatOptions? options]) { + try { + requireByteRange( + bytes, + 0, + formatHeaderLength, + FormatErrorCode.headerInvalid, + 'format header', + ); + _assertMagic(bytes); + + final major = readUint16LE(bytes, 8, FormatErrorCode.headerInvalid, 'major version'); + final minor = readUint16LE(bytes, 10, FormatErrorCode.headerInvalid, 'minor version'); + final headerLength = readUint32LE(bytes, 12, FormatErrorCode.headerInvalid, 'header length'); + final requiredFeatureFlags = + readUint32LE(bytes, 16, FormatErrorCode.headerInvalid, 'required feature flags'); + final reserved = readUint32LE(bytes, 20, FormatErrorCode.headerInvalid, 'reserved field'); + if (reserved != 0) { + _fail('reserved header field must be zero', 20); + } + + final budgets = resolveFormatBudgets(options); + final declaredFileLength = readUint64LE( + bytes, + 24, + budgets.maxFileBytes, + FormatErrorCode.headerInvalid, + 'declared file length', + ); + final manifestOffset = readUint64LE( + bytes, + 32, + budgets.maxFileBytes, + FormatErrorCode.headerInvalid, + 'manifest offset', + ); + final manifestLength = readUint64LE( + bytes, + 40, + budgets.maxManifestBytes, + FormatErrorCode.headerInvalid, + 'manifest length', + ); + final indexOffset = readUint64LE( + bytes, + 48, + budgets.maxFileBytes, + FormatErrorCode.headerInvalid, + 'index offset', + ); + final indexLength = readUint64LE( + bytes, + 56, + budgets.maxIndexBytes, + FormatErrorCode.headerInvalid, + 'index length', + ); + + final fields = _HeaderFields( + major: major, + minor: minor, + headerLength: headerLength, + requiredFeatureFlags: requiredFeatureFlags, + declaredFileLength: declaredFileLength, + manifestOffset: manifestOffset, + manifestLength: manifestLength, + indexOffset: indexOffset, + indexLength: indexLength, + ); + _validateHeaderShape(fields, options); + return FormatHeader( + declaredFileLength: declaredFileLength, + manifestLength: manifestLength, + indexOffset: indexOffset, + indexLength: indexLength, + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.headerInvalid, 'format header could not be parsed'); + } +} + +/// Encodes one canonical version-1.0 header into a new 64-byte array. +Uint8List encodeHeader(FormatHeader header, [FormatOptions? options]) { + try { + _validateHeaderShape( + _HeaderFields( + major: header.major, + minor: header.minor, + headerLength: header.headerLength, + requiredFeatureFlags: header.requiredFeatureFlags, + declaredFileLength: header.declaredFileLength, + manifestOffset: header.manifestOffset, + manifestLength: header.manifestLength, + indexOffset: header.indexOffset, + indexLength: header.indexLength, + ), + options, + ); + final bytes = Uint8List(formatHeaderLength); + bytes.setRange(0, formatMagic.length, formatMagic); + writeUint16LE(bytes, 8, header.major, FormatErrorCode.headerInvalid, 'major version'); + writeUint16LE(bytes, 10, header.minor, FormatErrorCode.headerInvalid, 'minor version'); + writeUint32LE(bytes, 12, header.headerLength, FormatErrorCode.headerInvalid, 'header length'); + writeUint32LE( + bytes, + 16, + header.requiredFeatureFlags, + FormatErrorCode.headerInvalid, + 'required feature flags', + ); + writeUint32LE(bytes, 20, 0, FormatErrorCode.headerInvalid, 'reserved field'); + writeUint64LE( + bytes, + 24, + header.declaredFileLength, + FormatErrorCode.headerInvalid, + 'declared file length', + ); + writeUint64LE( + bytes, + 32, + header.manifestOffset, + FormatErrorCode.headerInvalid, + 'manifest offset', + ); + writeUint64LE( + bytes, + 40, + header.manifestLength, + FormatErrorCode.headerInvalid, + 'manifest length', + ); + writeUint64LE(bytes, 48, header.indexOffset, FormatErrorCode.headerInvalid, 'index offset'); + writeUint64LE(bytes, 56, header.indexLength, FormatErrorCode.headerInvalid, 'index length'); + return bytes; + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.headerInvalid, 'format header could not be encoded'); + } +} + +final int minimumCanonicalFileLength = formatHeaderLength + chunkIndexHeaderLength; +final int maximumDefaultFileLength = formatDefaultBudgets.maxFileBytes; diff --git a/flutter/packages/aval_format/lib/src/layout.dart b/flutter/packages/aval_format/lib/src/layout.dart new file mode 100644 index 0000000..9c14be4 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/layout.dart @@ -0,0 +1,324 @@ +/// Canonical version-1.0 asset byte-layout derivation. +/// +/// Dart port of `packages/format/src/layout.ts`. +library; + +import 'dart:typed_data'; + +import 'checked_integer.dart'; +import 'chunk_plan.dart'; +import 'constants.dart'; +import 'errors.dart'; +import 'model.dart'; + +class CanonicalAssetLayout { + const CanonicalAssetLayout({ + required this.frontIndexRange, + required this.unitBlobs, + required this.paddingRanges, + required this.fileRange, + }); + + final ByteRange frontIndexRange; + final List unitBlobs; + final List paddingRanges; + final ByteRange fileRange; +} + +/// Structural counterpart of the TS `ChunkPayloadShape` interface: the exact +/// per-chunk fields the layout planner needs. `EncodedChunkRecord` satisfies +/// this shape; the writer builds one per encoded payload. +class ChunkPayloadShape { + const ChunkPayloadShape({ + required this.byteLength, + required this.presentationTimestamp, + required this.duration, + required this.randomAccess, + required this.displayedFrameCount, + }); + + final int byteLength; + final int presentationTimestamp; + final int duration; + final bool randomAccess; + final int displayedFrameCount; +} + +class CanonicalAssetPlan extends CanonicalAssetLayout { + const CanonicalAssetPlan({ + required this.indexOffset, + required this.indexLength, + required this.records, + required super.frontIndexRange, + required super.unitBlobs, + required super.paddingRanges, + required super.fileRange, + }); + + final int indexOffset; + final int indexLength; + final List records; +} + +Never _fail(String message, [FormatErrorDetails? details]) { + throw FormatError(FormatErrorCode.layoutInvalid, message, details); +} + +ByteRange _freezeRange(int offset, int length) => + ByteRange(offset: offset, length: length); + +void _addPaddingRange(List ranges, int offset, int end) { + if (end > offset) ranges.add(_freezeRange(offset, end - offset)); +} + +/// Build the sole legal 1.0 file layout from bounded chunk descriptors. +CanonicalAssetPlan planCanonicalAssetLayout( + int manifestLength, + CompiledManifest manifest, + List chunks, [ + FormatOptions? options, +]) { + try { + final budgets = resolveFormatBudgets(options); + final chunkPlan = createCanonicalChunkPlan( + manifest.renditions, + manifest.units, + budgets.maxChunkRecords, + budgets.maxTotalUnitFrames, + ); + validateCanonicalChunkSpans(chunkPlan, manifest.units); + if (chunks.length != chunkPlan.recordCount) { + _fail( + 'encoded-chunk payload count must be ${chunkPlan.recordCount}, received ${chunks.length}'); + } + if (chunkPlan.spans.length > budgets.maxBlobRanges) { + throw FormatError(FormatErrorCode.budgetExceeded, + 'canonical blob range count exceeds the active budget'); + } + if (manifestLength > budgets.maxManifestBytes) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'manifest length exceeds the active limit of ${budgets.maxManifestBytes}', + ); + } + final manifestEnd = + checkedAdd(formatHeaderLength, manifestLength, budgets.maxFileBytes, 'manifest end'); + final indexOffset = + align8(manifestEnd, budgets.maxFileBytes, 'encoded-chunk index offset'); + final indexLength = checkedAdd( + chunkIndexHeaderLength, + checkedMultiply( + chunkPlan.recordCount, + chunkIndexRecordLength, + budgets.maxIndexBytes, + 'encoded-chunk records length', + ), + budgets.maxIndexBytes, + 'encoded-chunk index length', + ); + final frontIndexEnd = + checkedAdd(indexOffset, indexLength, budgets.maxFileBytes, 'front index end'); + + final paddingRanges = []; + _addPaddingRange(paddingRanges, manifestEnd, indexOffset); + final records = []; + final unitBlobs = []; + var cursor = frontIndexEnd; + for (final span in chunkPlan.spans) { + final aligned = align8(cursor, budgets.maxFileBytes, 'unit blob offset'); + _addPaddingRange(paddingRanges, cursor, aligned); + cursor = aligned; + final blobOffset = cursor; + final unit = + span.unitIndex < manifest.units.length ? manifest.units[span.unitIndex] : null; + final descriptor = + unit != null && span.renditionIndex < unit.chunks.length + ? unit.chunks[span.renditionIndex] + : null; + if (unit == null || descriptor == null) { + _fail('canonical unit chunk descriptor is missing'); + } + final spanEnd = checkedAdd( + span.chunkStart, + span.chunkCount, + chunkPlan.recordCount, + 'chunk span end', + ); + var displayedFrames = 0; + for (var ordinal = span.chunkStart; ordinal < spanEnd; ordinal += 1) { + final slot = chunkPlan.recordAt(ordinal); + final chunk = ordinal < chunks.length ? chunks[ordinal] : null; + if (chunk == null) _fail('canonical encoded-chunk payload is missing'); + if (chunk.byteLength < 1 || chunk.byteLength > maxSafeInteger) { + _fail('encoded-chunk byte length must be a positive safe integer'); + } + if (chunk.byteLength > budgets.maxChunkBytes) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'encoded-chunk byte length exceeds the active limit of ${budgets.maxChunkBytes}', + ); + } + if (slot.randomAccessRequired && !chunk.randomAccess) { + _fail('every unit must begin with a random-access chunk'); + } + if (chunk.presentationTimestamp < 0 || + chunk.presentationTimestamp > maxSafeInteger || + chunk.duration < 0 || + chunk.duration > maxSafeInteger || + chunk.displayedFrameCount < 0 || + chunk.displayedFrameCount > maxSafeInteger) { + _fail('encoded-chunk timeline fields must be nonnegative safe integers'); + } + if (chunk.displayedFrameCount > 0 && chunk.duration == 0) { + _fail('a displayed encoded chunk must have a positive duration'); + } + displayedFrames = checkedAdd( + displayedFrames, + chunk.displayedFrameCount, + budgets.maxTotalUnitFrames, + 'unit displayed frame count', + ); + records.add(EncodedChunkRecord( + byteOffset: cursor, + byteLength: chunk.byteLength, + presentationTimestamp: chunk.presentationTimestamp, + duration: chunk.duration, + randomAccess: chunk.randomAccess, + displayedFrameCount: chunk.displayedFrameCount, + )); + cursor = checkedAdd( + cursor, + chunk.byteLength, + budgets.maxFileBytes, + 'encoded-chunk payload end', + ); + } + if (displayedFrames != span.frameCount) { + _fail( + 'unit ${span.unitId} rendition ${span.renditionId} must display exactly ${span.frameCount} frames'); + } + unitBlobs.add(UnitBlobRange( + rendition: span.renditionId, + unit: span.unitId, + chunkStart: span.chunkStart, + chunkCount: span.chunkCount, + frameCount: span.frameCount, + sha256: descriptor.sha256, + offset: blobOffset, + length: cursor - blobOffset, + )); + } + if (cursor > manifest.limits.maxCompiledBytes) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'compiled file exceeds manifest limits.maxCompiledBytes', + const FormatErrorDetails(path: 'limits.maxCompiledBytes'), + ); + } + return CanonicalAssetPlan( + indexOffset: indexOffset, + indexLength: indexLength, + records: records, + frontIndexRange: _freezeRange(0, frontIndexEnd), + unitBlobs: unitBlobs, + paddingRanges: paddingRanges, + fileRange: _freezeRange(0, cursor), + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.layoutInvalid, 'canonical asset layout could not be planned'); + } +} + +/// Derive and validate the sole legal 1.0 byte layout. +CanonicalAssetLayout deriveCanonicalAssetLayout( + FormatHeader header, + CompiledManifest manifest, + List records, [ + FormatOptions? options, +]) { + try { + final plan = planCanonicalAssetLayout( + header.manifestLength, + manifest, + records + .map((r) => ChunkPayloadShape( + byteLength: r.byteLength, + presentationTimestamp: r.presentationTimestamp, + duration: r.duration, + randomAccess: r.randomAccess, + displayedFrameCount: r.displayedFrameCount, + )) + .toList(), + options, + ); + if (header.manifestOffset != formatHeaderLength) { + _fail('manifest offset is not canonical', + FormatErrorDetails(offset: header.manifestOffset)); + } + if (header.indexOffset != plan.indexOffset) { + _fail('encoded-chunk index offset is not canonical', + FormatErrorDetails(offset: header.indexOffset)); + } + if (header.indexLength != plan.indexLength) { + _fail('encoded-chunk index length is not canonical', + FormatErrorDetails(offset: header.indexOffset)); + } + if (header.declaredFileLength != plan.fileRange.length) { + _fail( + header.declaredFileLength > plan.fileRange.length + ? 'declared file contains trailing bytes' + : 'payload layout extends beyond the declared file', + FormatErrorDetails( + offset: header.declaredFileLength < plan.fileRange.length + ? header.declaredFileLength + : plan.fileRange.length), + ); + } + for (var index = 0; index < plan.records.length; index += 1) { + final actual = index < records.length ? records[index] : null; + final expected = plan.records[index]; + if (actual == null || + actual.byteOffset != expected.byteOffset || + actual.byteLength != expected.byteLength || + actual.presentationTimestamp != expected.presentationTimestamp || + actual.duration != expected.duration || + actual.randomAccess != expected.randomAccess || + actual.displayedFrameCount != expected.displayedFrameCount) { + _fail('encoded-chunk record is not canonical', + FormatErrorDetails(offset: actual?.byteOffset ?? header.indexOffset)); + } + } + return CanonicalAssetLayout( + frontIndexRange: plan.frontIndexRange, + unitBlobs: plan.unitBlobs, + paddingRanges: plan.paddingRanges, + fileRange: plan.fileRange, + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.layoutInvalid, 'asset layout could not be derived'); + } +} + +void validateZeroPadding(Uint8List bytes, List ranges) { + try { + for (final range in ranges) { + final end = + checkedAdd(range.offset, range.length, bytes.lengthInBytes, 'padding range end'); + for (var offset = range.offset; offset < end; offset += 1) { + if (bytes[offset] != 0) { + _fail('alignment padding must contain only zero bytes', + FormatErrorDetails(offset: offset)); + } + } + } + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.layoutInvalid, 'asset padding could not be validated'); + } +} diff --git a/flutter/packages/aval_format/lib/src/manifest_constraints.dart b/flutter/packages/aval_format/lib/src/manifest_constraints.dart new file mode 100644 index 0000000..c5f3131 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_constraints.dart @@ -0,0 +1,5 @@ +/// Dart port of `packages/format/src/manifest-constraints.ts`. +library; + +const int minRunwayFrames = 6; +const int maxRunwayFrames = 12; diff --git a/flutter/packages/aval_format/lib/src/manifest_graph_schema.dart b/flutter/packages/aval_format/lib/src/manifest_graph_schema.dart new file mode 100644 index 0000000..4c7f57b --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_graph_schema.dart @@ -0,0 +1,223 @@ +/// State, edge, binding, and readiness schema validation. +/// +/// Dart port of `packages/format/src/manifest-graph-schema.ts` (1.0). +library; + +import 'manifest_constraints.dart'; +import 'manifest_validation.dart'; +import 'model.dart'; + +const Set _bindingSources = { + 'activate', + 'engagement.off', + 'engagement.on', + 'focus.in', + 'focus.out', + 'hidden', + 'pointer.enter', + 'pointer.leave', + 'visible', +}; + +List cloneStates(Object? value, FormatBudgets budgets, String path) { + final inputs = boundedArray(value, path, 1, budgets.maxStates); + final states = [ + for (var index = 0; index < inputs.length; index += 1) + _cloneState(inputs[index], '$path[$index]'), + ]; + requireIdOrder(states, (s) => s.id, path); + return states; +} + +State _cloneState(Object? entry, String statePath) { + final input = record(entry, statePath); + exactKeys(input, ['id', 'bodyUnit'], statePath, ['initialUnit']); + final id = identifier(input['id'], '$statePath.id'); + final bodyUnit = identifier(input['bodyUnit'], '$statePath.bodyUnit'); + if (!owns(input, 'initialUnit')) { + return State(id: id, bodyUnit: bodyUnit); + } + return State( + id: id, + bodyUnit: bodyUnit, + initialUnit: identifier(input['initialUnit'], '$statePath.initialUnit'), + ); +} + +List cloneEdges(Object? value, FormatBudgets budgets, String path) { + final inputs = boundedArray(value, path, 0, budgets.maxEdges); + final edges = [ + for (var index = 0; index < inputs.length; index += 1) + _cloneEdge(inputs[index], '$path[$index]'), + ]; + requireIdOrder(edges, (e) => e.id, path); + return edges; +} + +Edge _cloneEdge(Object? value, String path) { + final input = record(value, path); + final startProbe = record(input['start'], '$path.start'); + final cut = startProbe['type'] == 'cut'; + exactKeys( + input, + cut + ? ['id', 'from', 'to', 'start', 'continuity', 'targetRunwayFrames'] + : ['id', 'from', 'to', 'start', 'continuity'], + path, + cut ? ['trigger'] : ['trigger', 'transition'], + ); + final id = identifier(input['id'], '$path.id'); + final from = identifier(input['from'], '$path.from'); + final to = identifier(input['to'], '$path.to'); + if (from == to) { + invalid('$path.to', 'must differ from from'); + } + final trigger = + owns(input, 'trigger') ? _cloneTrigger(input['trigger'], '$path.trigger') : null; + final start = _cloneStart(input['start'], '$path.start'); + + if (start.type == 'cut') { + literal(input['continuity'], 'cut', '$path.continuity'); + final targetRunwayFrames = integerInRange( + input['targetRunwayFrames'], + '$path.targetRunwayFrames', + minRunwayFrames, + maxRunwayFrames, + ); + return CutEdge( + id: id, + from: from, + to: to, + trigger: trigger, + start: start as CutStart, + targetRunwayFrames: targetRunwayFrames, + ); + } + + final continuity = + oneOf(input['continuity'], ['exact-authored', 'exact-reverse'], '$path.continuity'); + final transition = + owns(input, 'transition') ? _cloneTransition(input['transition'], '$path.transition') : null; + return NonCutEdge( + id: id, + from: from, + to: to, + trigger: trigger, + start: start, + continuity: continuity, + transition: transition, + ); +} + +Trigger _cloneTrigger(Object? value, String path) { + final input = record(value, path); + if (input['type'] == 'completion') { + exactKeys(input, ['type'], path); + return const CompletionTrigger(); + } + if (input['type'] == 'event') { + exactKeys(input, ['type', 'name'], path); + return EventTrigger(identifier(input['name'], '$path.name')); + } + invalid('$path.type', 'must be event or completion'); +} + +Start _cloneStart(Object? value, String path) { + final input = record(value, path); + if (input['type'] == 'portal') { + exactKeys(input, ['type', 'sourcePort', 'targetPort', 'maxWaitFrames'], path); + return PortalStart( + sourcePort: identifier(input['sourcePort'], '$path.sourcePort'), + targetPort: identifier(input['targetPort'], '$path.targetPort'), + maxWaitFrames: nonNegativeInteger(input['maxWaitFrames'], '$path.maxWaitFrames'), + ); + } + if (input['type'] == 'finish') { + exactKeys(input, ['type', 'targetPort', 'maxWaitFrames'], path); + return FinishStart( + targetPort: identifier(input['targetPort'], '$path.targetPort'), + maxWaitFrames: nonNegativeInteger(input['maxWaitFrames'], '$path.maxWaitFrames'), + ); + } + if (input['type'] == 'cut') { + exactKeys(input, ['type', 'targetPort', 'maxWaitFrames'], path); + literal(input['maxWaitFrames'], 1, '$path.maxWaitFrames'); + return CutStart(targetPort: identifier(input['targetPort'], '$path.targetPort')); + } + invalid('$path.type', 'must be portal, finish, or cut'); +} + +Transition _cloneTransition(Object? value, String path) { + final input = record(value, path); + if (input['kind'] == 'locked') { + exactKeys(input, ['kind', 'unit'], path); + return LockedTransition(unit: identifier(input['unit'], '$path.unit')); + } + if (input['kind'] == 'reversible') { + exactKeys(input, ['kind', 'unit', 'direction'], path, ['reverseOf']); + final unit = identifier(input['unit'], '$path.unit'); + final direction = oneOf(input['direction'], ['forward', 'reverse'], '$path.direction'); + if (!owns(input, 'reverseOf')) { + return ReversibleTransition(unit: unit, direction: direction); + } + return ReversibleTransition( + unit: unit, + direction: direction, + reverseOf: identifier(input['reverseOf'], '$path.reverseOf'), + ); + } + invalid('$path.kind', 'must be locked or reversible'); +} + +List cloneBindings(Object? value, FormatBudgets budgets, String path) { + final inputs = boundedArray(value, path, 0, budgets.maxBindings); + final bindings = [ + for (var index = 0; index < inputs.length; index += 1) + _cloneBinding(inputs[index], '$path[$index]'), + ]; + for (var index = 1; index < bindings.length; index += 1) { + final previous = bindings[index - 1]; + final current = bindings[index]; + final order = compareAscii(previous.source, current.source) != 0 + ? compareAscii(previous.source, current.source) + : compareAscii(previous.event, current.event); + if (order >= 0) { + invalid(path, 'must be sorted and unique by source then event'); + } + if (previous.source == current.source) { + invalid('$path[$index].source', 'duplicates a binding source'); + } + } + return bindings; +} + +Binding _cloneBinding(Object? entry, String bindingPath) { + final input = record(entry, bindingPath); + exactKeys(input, ['source', 'event'], bindingPath); + final source = input['source']; + if (source is! String || !_bindingSources.contains(source)) { + invalid('$bindingPath.source', 'is not a supported binding source'); + } + return Binding(source: source, event: identifier(input['event'], '$bindingPath.event')); +} + +Readiness cloneReadiness(Object? value, FormatBudgets budgets, String path) { + final input = record(value, path); + exactKeys(input, ['policy', 'bootstrapUnits', 'immediateEdges'], path); + literal(input['policy'], 'all-routes', '$path.policy'); + final bootstrapUnits = + _cloneIdArray(input['bootstrapUnits'], budgets.maxUnits, '$path.bootstrapUnits'); + final immediateEdges = + _cloneIdArray(input['immediateEdges'], budgets.maxEdges, '$path.immediateEdges'); + return Readiness(bootstrapUnits: bootstrapUnits, immediateEdges: immediateEdges); +} + +List _cloneIdArray(Object? value, int maximum, String path) { + final inputs = boundedArray(value, path, 0, maximum); + final ids = [ + for (var index = 0; index < inputs.length; index += 1) + identifier(inputs[index], '$path[$index]'), + ]; + requireStringOrder(ids, path); + return ids; +} diff --git a/flutter/packages/aval_format/lib/src/manifest_json.dart b/flutter/packages/aval_format/lib/src/manifest_json.dart new file mode 100644 index 0000000..d5349ad --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_json.dart @@ -0,0 +1,181 @@ +/// Serializes the typed [CompiledManifest] tree (and its nested types) into the +/// plain `Map`/`List` shape that +/// `canonical_json.dart`'s writer understands. +/// +/// This has no standalone TS equivalent: in TypeScript a `CompiledManifest` +/// value already *is* a plain JS object literal, so `serializeCanonicalJson` +/// walks it directly. Here `model.dart` uses real Dart classes, so this file is +/// the bridge between the typed model and the untyped canonical-JSON writer. +/// The wire keys emitted here are IDENTICAL to those read by the +/// `manifest_*schema.dart` validators, so `parse(serialize(x))` round-trips. +library; + +import 'model.dart'; + +Map compiledManifestToJson(CompiledManifest manifest) => { + 'formatVersion': manifest.formatVersion, + 'generator': manifest.generator, + 'codec': manifest.codec, + 'bitstream': manifest.bitstream, + 'layout': manifest.layout, + 'canvas': _canvasToJson(manifest.canvas), + 'frameRate': _rationalToJson(manifest.frameRate), + 'renditions': manifest.renditions.map(_renditionToJson).toList(), + 'units': manifest.units.map(_unitToJson).toList(), + 'initialState': manifest.initialState, + 'states': manifest.states.map(_stateToJson).toList(), + 'edges': manifest.edges.map(_edgeToJson).toList(), + 'bindings': manifest.bindings.map(_bindingToJson).toList(), + 'readiness': _readinessToJson(manifest.readiness), + 'limits': _limitsToJson(manifest.limits), + }; + +Map _canvasToJson(Canvas canvas) => { + 'width': canvas.width, + 'height': canvas.height, + 'fit': canvas.fit, + 'pixelAspect': canvas.pixelAspect, + 'colorSpace': canvas.colorSpace, + }; + +Map _rationalToJson(Rational rational) => + {'numerator': rational.numerator, 'denominator': rational.denominator}; + +Map _alphaLayoutToJson(AlphaLayout alphaLayout) { + if (alphaLayout is StackedAlphaLayout) { + return { + 'type': 'stacked', + 'colorRect': alphaLayout.colorRect.toList(), + 'alphaRect': alphaLayout.alphaRect.toList(), + }; + } + return {'type': 'opaque', 'colorRect': alphaLayout.colorRect.toList()}; +} + +Map _bitrateToJson(Bitrate bitrate) => + {'average': bitrate.average, 'peak': bitrate.peak}; + +Map _renditionToJson(ProductionRendition rendition) => { + 'id': rendition.id, + 'codec': rendition.codec, + 'bitDepth': rendition.bitDepth, + 'codedWidth': rendition.codedWidth, + 'codedHeight': rendition.codedHeight, + 'alphaLayout': _alphaLayoutToJson(rendition.alphaLayout), + 'bitrate': _bitrateToJson(rendition.bitrate), + }; + +Map _chunkSpanToJson(UnitChunkSpan chunk) => { + 'rendition': chunk.rendition, + 'chunkStart': chunk.chunkStart, + 'chunkCount': chunk.chunkCount, + 'frameCount': chunk.frameCount, + 'sha256': chunk.sha256, + }; + +Map _portToJson(Port port) => + {'id': port.id, 'entryFrame': port.entryFrame, 'portalFrames': port.portalFrames}; + +Map _residencyEndpointToJson(ResidencyEndpoint endpoint) => + {'state': endpoint.state, 'port': endpoint.port, 'frames': endpoint.frames}; + +Map _unitToJson(Unit unit) { + final base = { + 'id': unit.id, + 'kind': unit.kind, + 'frameCount': unit.frameCount, + 'chunks': unit.chunks.map(_chunkSpanToJson).toList(), + }; + if (unit is BodyUnit) { + return { + ...base, + 'playback': unit.playback, + 'ports': unit.ports.map(_portToJson).toList(), + }; + } + if (unit is ReversibleUnit) { + return { + ...base, + 'residency': { + 'endpoints': unit.residency.endpoints.map(_residencyEndpointToJson).toList(), + }, + }; + } + return base; +} + +Map _stateToJson(State state) { + final base = {'id': state.id, 'bodyUnit': state.bodyUnit}; + return state.initialUnit == null ? base : {...base, 'initialUnit': state.initialUnit}; +} + +Map _startToJson(Start start) { + if (start is PortalStart) { + return { + 'type': 'portal', + 'sourcePort': start.sourcePort, + 'targetPort': start.targetPort, + 'maxWaitFrames': start.maxWaitFrames, + }; + } + if (start is FinishStart) { + return {'type': 'finish', 'targetPort': start.targetPort, 'maxWaitFrames': start.maxWaitFrames}; + } + return {'type': 'cut', 'targetPort': start.targetPort, 'maxWaitFrames': 1}; +} + +Map _triggerToJson(Trigger trigger) { + if (trigger is EventTrigger) { + return {'type': 'event', 'name': trigger.name}; + } + return {'type': 'completion'}; +} + +Map _transitionToJson(Transition transition) { + if (transition is LockedTransition) { + return {'kind': 'locked', 'unit': transition.unit}; + } + final reversible = transition as ReversibleTransition; + final base = { + 'kind': 'reversible', + 'unit': reversible.unit, + 'direction': reversible.direction, + }; + return reversible.reverseOf == null ? base : {...base, 'reverseOf': reversible.reverseOf}; +} + +Map _edgeToJson(Edge edge) { + final base = { + 'id': edge.id, + 'from': edge.from, + 'to': edge.to, + 'start': _startToJson(edge.start), + 'continuity': edge.continuity, + }; + if (edge.trigger != null) { + base['trigger'] = _triggerToJson(edge.trigger!); + } + if (edge is CutEdge) { + base['targetRunwayFrames'] = edge.targetRunwayFrames; + } else if (edge is NonCutEdge && edge.transition != null) { + base['transition'] = _transitionToJson(edge.transition!); + } + return base; +} + +Map _bindingToJson(Binding binding) => + {'source': binding.source, 'event': binding.event}; + +Map _readinessToJson(Readiness readiness) => { + 'policy': readiness.policy, + 'bootstrapUnits': readiness.bootstrapUnits, + 'immediateEdges': readiness.immediateEdges, + }; + +Map _limitsToJson(DeclaredLimits limits) => { + 'maxCompiledBytes': limits.maxCompiledBytes, + 'maxRuntimeBytes': limits.maxRuntimeBytes, + 'decodedPixelBytes': limits.decodedPixelBytes, + 'persistentCacheBytes': limits.persistentCacheBytes, + 'runtimeWorkingSetBytes': limits.runtimeWorkingSetBytes, + }; diff --git a/flutter/packages/aval_format/lib/src/manifest_limits_schema.dart b/flutter/packages/aval_format/lib/src/manifest_limits_schema.dart new file mode 100644 index 0000000..e450ffb --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_limits_schema.dart @@ -0,0 +1,84 @@ +/// Declared runtime/compiled-byte limits schema validation. +/// +/// Dart port of `packages/format/src/manifest-limits-schema.ts` (1.0). The +/// minimum decoded-pixel budget is derived directly from each rendition's +/// coded surface (`codedWidth * codedHeight * 4`). +library; + +import 'checked_integer.dart' show checkedMultiply; +import 'errors.dart'; +import 'manifest_validation.dart'; +import 'model.dart'; + +const int _maxSafeInteger = 9007199254740991; + +DeclaredLimits cloneDeclaredLimits( + Object? value, + List renditions, + FormatBudgets budgets, + String path, +) { + final input = record(value, path); + exactKeys( + input, + [ + 'maxCompiledBytes', + 'maxRuntimeBytes', + 'decodedPixelBytes', + 'persistentCacheBytes', + 'runtimeWorkingSetBytes', + ], + path, + ); + final maxCompiledBytes = positiveInteger(input['maxCompiledBytes'], '$path.maxCompiledBytes'); + if (maxCompiledBytes > budgets.maxFileBytes) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'maxCompiledBytes exceeds the active limit of ${budgets.maxFileBytes}', + FormatErrorDetails(path: '$path.maxCompiledBytes'), + ); + } + final maxRuntimeBytes = positiveInteger(input['maxRuntimeBytes'], '$path.maxRuntimeBytes'); + final decodedPixelBytes = + integerInRange(input['decodedPixelBytes'], '$path.decodedPixelBytes', 0, maxRuntimeBytes); + final persistentCacheBytes = + integerInRange(input['persistentCacheBytes'], '$path.persistentCacheBytes', 0, maxRuntimeBytes); + final runtimeWorkingSetBytes = integerInRange( + input['runtimeWorkingSetBytes'], + '$path.runtimeWorkingSetBytes', + 0, + maxRuntimeBytes, + ); + if (runtimeWorkingSetBytes < decodedPixelBytes || runtimeWorkingSetBytes < persistentCacheBytes) { + invalid( + '$path.runtimeWorkingSetBytes', + 'must be at least decodedPixelBytes and persistentCacheBytes', + ); + } + var minimumDecodedBytes = 0; + for (var index = 0; index < renditions.length; index += 1) { + final rendition = renditions[index]; + final candidate = checkedMultiply( + checkedMultiply( + rendition.codedWidth, + rendition.codedHeight, + _maxSafeInteger, + 'renditions[$index] coded pixel count', + ), + 4, + _maxSafeInteger, + 'renditions[$index] decoded RGBA bytes', + ); + if (candidate > minimumDecodedBytes) minimumDecodedBytes = candidate; + } + if (decodedPixelBytes < minimumDecodedBytes) { + invalid('$path.decodedPixelBytes', 'must be at least $minimumDecodedBytes'); + } + return DeclaredLimits( + maxCompiledBytes: maxCompiledBytes, + maxRuntimeBytes: maxRuntimeBytes, + decodedPixelBytes: decodedPixelBytes, + persistentCacheBytes: persistentCacheBytes, + runtimeWorkingSetBytes: runtimeWorkingSetBytes, + ); +} diff --git a/flutter/packages/aval_format/lib/src/manifest_relations.dart b/flutter/packages/aval_format/lib/src/manifest_relations.dart new file mode 100644 index 0000000..3031144 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_relations.dart @@ -0,0 +1,309 @@ +/// Cross-referential manifest validation: state/edge/unit/binding relations. +/// +/// Dart port of `packages/format/src/manifest-relations.ts` (1.0). +library; + +import 'manifest_validation.dart'; +import 'model.dart'; + +const int _maxSafeInteger = 9007199254740991; + +class ManifestRelationInput { + const ManifestRelationInput({ + required this.initialState, + required this.renditions, + required this.units, + required this.states, + required this.edges, + required this.bindings, + required this.readiness, + }); + + final String initialState; + final List renditions; + final List units; + final List states; + final List edges; + final List bindings; + final Readiness readiness; +} + +void validateManifestRelations(ManifestRelationInput input) { + final unitsById = {for (final unit in input.units) unit.id: unit}; + final statesById = {for (final state in input.states) state.id: state}; + final edgesById = {for (final edge in input.edges) edge.id: edge}; + if (!statesById.containsKey(input.initialState)) { + invalid('initialState', 'does not reference a state'); + } + + final unitUseCount = {for (final unit in input.units) unit.id: 0}; + for (var index = 0; index < input.states.length; index += 1) { + final state = input.states[index]; + final path = 'states[$index]'; + final body = unitsById[state.bodyUnit]; + if (body is! BodyUnit) { + invalid('$path.bodyUnit', 'must reference a body unit'); + } + _incrementUse(unitUseCount, body.id); + if (state.initialUnit != null) { + if (state.id != input.initialState) { + invalid('$path.initialUnit', 'is allowed only on the initial state'); + } + final initial = unitsById[state.initialUnit]; + if (initial is! OneShotUnit) { + invalid('$path.initialUnit', 'must reference a one-shot unit'); + } + _incrementUse(unitUseCount, initial.id); + } + } + + final reversibleEdges = >{}; + final eventNames = {}; + for (var index = 0; index < input.edges.length; index += 1) { + final edge = input.edges[index]; + _validateEdgeReferences(edge, index, statesById, unitsById, unitUseCount); + final trigger = edge.trigger; + if (trigger is EventTrigger) { + eventNames.add(trigger.name); + } + final transition = edge is NonCutEdge ? edge.transition : null; + if (transition is ReversibleTransition) { + final group = reversibleEdges.putIfAbsent(transition.unit, () => []); + group.add((edge: edge, index: index)); + } + } + + _validateReversibleGroups(reversibleEdges, unitsById); + _validateUseCounts(input.units, unitUseCount); + for (var index = 0; index < input.bindings.length; index += 1) { + if (!eventNames.contains(input.bindings[index].event)) { + invalid('bindings[$index].event', 'is not used by an event-triggered edge'); + } + } + _validateReadiness(input.readiness, input.initialState, statesById, edgesById, unitsById); +} + +void validateBlobCount( + List units, + List renditions, + FormatBudgets budgets, +) { + _rejectBlobCount(units.length * renditions.length, budgets); +} + +void validateRawBlobCount(Object? units, int renditionCount, FormatBudgets budgets) { + if (units is! List) { + invalid('units', 'must be an array'); + } + _rejectBlobCount(units.length * renditionCount, budgets); +} + +void _rejectBlobCount(int count, FormatBudgets budgets) { + if (count > _maxSafeInteger || count > budgets.maxBlobRanges) { + invalid('manifest', 'declares $count blobs, exceeding ${budgets.maxBlobRanges}'); + } +} + +void _validateEdgeReferences( + Edge edge, + int index, + Map statesById, + Map unitsById, + Map unitUseCount, +) { + final path = 'edges[$index]'; + final source = statesById[edge.from]; + final target = statesById[edge.to]; + if (source == null) { + invalid('$path.from', 'does not reference a state'); + } + if (target == null) { + invalid('$path.to', 'does not reference a state'); + } + final sourceBody = unitsById[source.bodyUnit]; + final targetBody = unitsById[target.bodyUnit]; + if (sourceBody is! BodyUnit || targetBody is! BodyUnit) { + invalid(path, 'state body reference is invalid'); + } + if (!targetBody.ports.any((port) => port.id == edge.start.targetPort)) { + invalid('$path.start.targetPort', 'does not reference the target body'); + } + final start = edge.start; + if (start is PortalStart) { + final sourcePortId = start.sourcePort; + Port? sourcePort; + for (final port in sourceBody.ports) { + if (port.id == sourcePortId) { + sourcePort = port; + break; + } + } + if (sourcePort == null) { + invalid('$path.start.sourcePort', 'does not reference the source body'); + } + if (sourceBody.playback == 'finite' && + (sourcePort.portalFrames.isEmpty || + sourcePort.portalFrames.last != sourceBody.frameCount - 1)) { + invalid('$path.start.sourcePort', 'finite source port must include the held final frame'); + } + } else if (start is FinishStart && sourceBody.playback == 'loop') { + invalid('$path.start.type', 'finish cannot originate from a looping body'); + } + + final transition = edge is NonCutEdge ? edge.transition : null; + if (transition is LockedTransition) { + final unit = unitsById[transition.unit]; + if (unit is! BridgeUnit) { + invalid('$path.transition.unit', 'must reference a bridge unit'); + } + _incrementUse(unitUseCount, unit.id); + if (edge.continuity != 'exact-authored') { + invalid('$path.continuity', 'locked transitions require exact-authored'); + } + } else if (transition is ReversibleTransition) { + final unit = unitsById[transition.unit]; + if (unit is! ReversibleUnit) { + invalid('$path.transition.unit', 'must reference a reversible unit'); + } + _incrementUse(unitUseCount, unit.id); + } else if (edge.start.type != 'cut' && edge.continuity != 'exact-authored') { + invalid('$path.continuity', 'transitionless edges require exact-authored'); + } +} + +void _validateReversibleGroups( + Map> groups, + Map unitsById, +) { + for (final entry in groups.entries) { + final unitId = entry.key; + final group = entry.value; + if (group.length != 2) { + invalid('edges', 'reversible unit ${quote(unitId)} must have two inverse edges'); + } + final first = group[0]; + final second = group[1]; + ({Edge edge, int index})? primary; + ({Edge edge, int index})? inverse; + for (final candidate in [first, second]) { + final transition = + candidate.edge is NonCutEdge ? (candidate.edge as NonCutEdge).transition : null; + if (transition is ReversibleTransition && transition.direction == 'forward') { + primary = candidate; + } + if (transition is ReversibleTransition && transition.direction == 'reverse') { + inverse = candidate; + } + } + if (primary == null || inverse == null) { + invalid('edges', 'reversible unit ${quote(unitId)} needs forward and reverse edges'); + } + final primaryTransition = + primary.edge is NonCutEdge ? (primary.edge as NonCutEdge).transition : null; + final inverseTransition = + inverse.edge is NonCutEdge ? (inverse.edge as NonCutEdge).transition : null; + if (primaryTransition is! ReversibleTransition || inverseTransition is! ReversibleTransition) { + invalid('edges', 'reversible unit ${quote(unitId)} has invalid transitions'); + } + if (primaryTransition.reverseOf != null) { + invalid('edges[${primary.index}].transition.reverseOf', 'must be omitted on the primary edge'); + } + if (inverseTransition.reverseOf != primary.edge.id) { + invalid('edges[${inverse.index}].transition.reverseOf', 'must reference the primary edge'); + } + if (primary.edge.continuity != 'exact-authored' || inverse.edge.continuity != 'exact-reverse') { + invalid('edges', 'reversible pair continuity is invalid'); + } + if (primary.edge.from != inverse.edge.to || primary.edge.to != inverse.edge.from) { + invalid('edges', 'reversible pair must reverse its states'); + } + final unit = unitsById[unitId]; + if (unit is! ReversibleUnit) { + invalid('edges', 'reversible unit ${quote(unitId)} is missing'); + } + _validateResidencyForEdge(unit, primary.edge, primary.index); + _validateResidencyForEdge(unit, inverse.edge, inverse.index); + } +} + +void _validateResidencyForEdge(ReversibleUnit unit, Edge edge, int index) { + final path = 'edges[$index]'; + ResidencyEndpoint? source; + ResidencyEndpoint? target; + for (final endpoint in unit.residency.endpoints) { + if (endpoint.state == edge.from) source = endpoint; + if (endpoint.state == edge.to) target = endpoint; + } + if (source == null || target == null || source == target) { + invalid(path, 'must connect the reversible residency states'); + } + final start = edge.start; + if (start is PortalStart && start.sourcePort != source.port) { + invalid('$path.start.sourcePort', 'must match source residency endpoint'); + } + if (edge.start.targetPort != target.port) { + invalid('$path.start.targetPort', 'must match target residency endpoint'); + } +} + +void _validateUseCounts(List units, Map counts) { + for (final unit in units) { + final count = counts[unit.id] ?? 0; + final expected = unit is ReversibleUnit ? 2 : 1; + if (count != expected) { + invalid( + 'units', + '${unit.kind} unit ${quote(unit.id)} must be referenced exactly $expected time${expected == 1 ? '' : 's'}', + ); + } + } +} + +void _validateReadiness( + Readiness readiness, + String initialStateId, + Map statesById, + Map edgesById, + Map unitsById, +) { + final immediate = edgesById.values + .where((edge) => edge.from == initialStateId) + .map((edge) => edge.id) + .toList() + ..sort(compareAscii); + if (!_sameStrings(readiness.immediateEdges, immediate)) { + invalid('readiness.immediateEdges', 'must exactly list edges originating at initialState'); + } + final bootstrap = readiness.bootstrapUnits.toSet(); + for (var index = 0; index < readiness.bootstrapUnits.length; index += 1) { + if (!unitsById.containsKey(readiness.bootstrapUnits[index])) { + invalid('readiness.bootstrapUnits[$index]', 'does not reference a unit'); + } + } + final initial = statesById[initialStateId]!; + final required = {initial.bodyUnit}; + if (initial.initialUnit != null) required.add(initial.initialUnit!); + for (final edgeId in immediate) { + final edge = edgesById[edgeId]!; + required.add(statesById[edge.to]!.bodyUnit); + final transition = edge is NonCutEdge ? edge.transition : null; + if (transition != null) required.add(transition.unit); + } + for (final unitId in required) { + if (!bootstrap.contains(unitId)) { + invalid('readiness.bootstrapUnits', 'must include required unit ${quote(unitId)}'); + } + } +} + +bool _sameStrings(List a, List b) { + if (a.length != b.length) return false; + for (var index = 0; index < a.length; index += 1) { + if (a[index] != b[index]) return false; + } + return true; +} + +void _incrementUse(Map counts, String id) { + counts[id] = (counts[id] ?? 0) + 1; +} diff --git a/flutter/packages/aval_format/lib/src/manifest_rendition_schema.dart b/flutter/packages/aval_format/lib/src/manifest_rendition_schema.dart new file mode 100644 index 0000000..735f7a3 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_rendition_schema.dart @@ -0,0 +1,194 @@ +/// Canvas, frame-rate, and per-codec rendition schema validation. +/// +/// Dart port of `packages/format/src/manifest-rendition-schema.ts` (1.0). The +/// per-codec model validates the WebCodecs codec string against the asset's +/// codec family/bit depth via `video/codec_string.dart` and enforces the +/// shared opaque/packed-alpha pane geometry from `video/geometry.dart`. +library; + +import 'manifest_validation.dart'; +import 'model.dart'; +import 'video/codec_string.dart' show isVideoCodecString; +import 'video/geometry.dart' show packedAlphaGutter; + +const int _maxPixelAspectTerm = 10000; +const int _maxFrameRate = 60; +const int _maxFrameRateDenominator = 1001; +const int _dimensionMax = 0xffffffff; + +Canvas cloneCanvas(Object? value, String path) { + final input = record(value, path); + exactKeys(input, ['width', 'height', 'fit', 'pixelAspect', 'colorSpace'], path); + final width = positiveInteger(input['width'], '$path.width', _dimensionMax); + final height = positiveInteger(input['height'], '$path.height', _dimensionMax); + final fit = oneOf(input['fit'], ['contain', 'cover', 'fill', 'none'], '$path.fit'); + final pixelAspectInput = tuple(input['pixelAspect'], 2, '$path.pixelAspect'); + final pixelAspect = [ + positiveInteger(pixelAspectInput[0], '$path.pixelAspect[0]', _maxPixelAspectTerm), + positiveInteger(pixelAspectInput[1], '$path.pixelAspect[1]', _maxPixelAspectTerm), + ]; + literal(input['colorSpace'], 'srgb', '$path.colorSpace'); + return Canvas(width: width, height: height, fit: fit, pixelAspect: pixelAspect); +} + +Rational cloneFrameRate(Object? value, String path) { + final input = record(value, path); + exactKeys(input, ['numerator', 'denominator'], path); + final numerator = positiveInteger(input['numerator'], '$path.numerator'); + final denominator = + positiveInteger(input['denominator'], '$path.denominator', _maxFrameRateDenominator); + if (numerator > denominator * _maxFrameRate) { + invalid('$path.numerator', 'must not exceed $_maxFrameRate frames per second'); + } + return Rational(numerator: numerator, denominator: denominator); +} + +/// Preserve authored quality order while requiring unique rendition IDs. +List cloneRenditions( + Object? value, + Canvas canvas, + VideoCodec codecFamily, + VideoLayout layout, + FormatBudgets budgets, + String path, +) { + final inputs = boundedArray(value, path, 1, budgets.maxRenditions); + final seen = {}; + final renditions = []; + for (var index = 0; index < inputs.length; index += 1) { + final rendition = + _cloneRendition(inputs[index], canvas, codecFamily, layout, '$path[$index]'); + if (seen.contains(rendition.id)) { + invalid('$path[$index].id', 'duplicates an earlier rendition ID'); + } + seen.add(rendition.id); + renditions.add(rendition); + } + return renditions; +} + +ProductionRendition _cloneRendition( + Object? value, + Canvas canvas, + VideoCodec codecFamily, + VideoLayout layout, + String path, +) { + final input = record(value, path); + exactKeys( + input, + ['id', 'codec', 'bitDepth', 'codedWidth', 'codedHeight', 'alphaLayout', 'bitrate'], + path, + ); + final id = identifier(input['id'], '$path.id'); + final bitDepthValue = integerInRange(input['bitDepth'], '$path.bitDepth', 8, 10); + if (bitDepthValue != 8 && bitDepthValue != 10) { + invalid('$path.bitDepth', 'must be 8 or 10'); + } + final bitDepth = bitDepthValue; + if (codecFamily != 'av1' && bitDepth != 8) { + invalid('$path.bitDepth', '$codecFamily assets require 8-bit renditions'); + } + if (!isVideoCodecString(input['codec'], codecFamily, bitDepth)) { + invalid('$path.codec', 'must be a canonical $codecFamily codec string matching bit depth'); + } + final codedWidth = positiveInteger(input['codedWidth'], '$path.codedWidth', _dimensionMax); + final codedHeight = positiveInteger(input['codedHeight'], '$path.codedHeight', _dimensionMax); + if (codedWidth % 2 != 0 || codedHeight % 2 != 0) { + invalid(path, '4:2:0 coded dimensions must be even'); + } + final alphaLayout = _cloneAlphaLayout( + input['alphaLayout'], + layout, + canvas, + codedWidth, + codedHeight, + '$path.alphaLayout', + ); + final bitrate = _cloneBitrate(input['bitrate'], '$path.bitrate'); + return ProductionRendition( + id: id, + codec: input['codec'] as String, + bitDepth: bitDepth, + codedWidth: codedWidth, + codedHeight: codedHeight, + alphaLayout: alphaLayout, + bitrate: bitrate, + ); +} + +AlphaLayout _cloneAlphaLayout( + Object? value, + VideoLayout layout, + Canvas canvas, + int codedWidth, + int codedHeight, + String path, +) { + final input = record(value, path); + if (layout == 'opaque') { + exactKeys(input, ['type', 'colorRect'], path); + literal(input['type'], 'opaque', '$path.type'); + final colorRect = + _cloneVisibleColorRect(input['colorRect'], canvas, codedWidth, codedHeight, '$path.colorRect'); + return OpaqueAlphaLayout(colorRect: colorRect); + } + exactKeys(input, ['type', 'colorRect', 'alphaRect'], path); + literal(input['type'], 'stacked', '$path.type'); + final colorRect = + _cloneVisibleColorRect(input['colorRect'], canvas, codedWidth, codedHeight, '$path.colorRect'); + final alphaRect = _cloneRect(input['alphaRect'], codedWidth, codedHeight, '$path.alphaRect'); + final paneHeight = colorRect.height % 2 == 0 ? colorRect.height : colorRect.height + 1; + final expectedY = paneHeight + packedAlphaGutter; + if (alphaRect.x != 0 || + alphaRect.y != expectedY || + alphaRect.width != colorRect.width || + alphaRect.height != colorRect.height) { + invalid('$path.alphaRect', 'must be a second matching pane after the fixed eight-pixel gutter'); + } + return StackedAlphaLayout(colorRect: colorRect, alphaRect: alphaRect); +} + +Rect _cloneVisibleColorRect( + Object? value, + Canvas canvas, + int codedWidth, + int codedHeight, + String path, +) { + final rect = _cloneRect(value, codedWidth, codedHeight, path); + if (rect.x != 0 || rect.y != 0) { + invalid(path, 'visible color rectangle must begin at the decoded surface origin'); + } + if (rect.width > canvas.width || rect.height > canvas.height) { + invalid(path, 'visible color rectangle must fit the logical canvas'); + } + if (BigInt.from(rect.width) * BigInt.from(canvas.height) != + BigInt.from(rect.height) * BigInt.from(canvas.width)) { + invalid(path, 'visible color rectangle must retain the canvas aspect ratio'); + } + return rect; +} + +Bitrate _cloneBitrate(Object? value, String path) { + final input = record(value, path); + exactKeys(input, ['average', 'peak'], path); + final average = positiveInteger(input['average'], '$path.average'); + final peak = positiveInteger(input['peak'], '$path.peak'); + if (average > peak) { + invalid('$path.average', 'must not exceed peak bitrate'); + } + return Bitrate(average: average, peak: peak); +} + +Rect _cloneRect(Object? value, int surfaceWidth, int surfaceHeight, String path) { + final input = tuple(value, 4, path); + final x = nonNegativeInteger(input[0], '$path[0]'); + final y = nonNegativeInteger(input[1], '$path[1]'); + final width = positiveInteger(input[2], '$path[2]'); + final height = positiveInteger(input[3], '$path[3]'); + if (x > surfaceWidth - width || y > surfaceHeight - height) { + invalid(path, 'must lie inside the coded surface'); + } + return Rect(x, y, width, height); +} diff --git a/flutter/packages/aval_format/lib/src/manifest_schema.dart b/flutter/packages/aval_format/lib/src/manifest_schema.dart new file mode 100644 index 0000000..494d185 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_schema.dart @@ -0,0 +1,94 @@ +/// Version-1.0 manifest schema composition root. +/// +/// Dart port of `packages/format/src/manifest-schema.ts`. +library; + +import 'constants.dart' show resolveFormatBudgets; +import 'errors.dart'; +import 'manifest_graph_schema.dart'; +import 'manifest_limits_schema.dart'; +import 'manifest_relations.dart'; +import 'manifest_rendition_schema.dart'; +import 'manifest_unit_schema.dart'; +import 'manifest_validation.dart'; +import 'model.dart'; +import 'video/codec_string.dart' show videoBitstreamByCodec, videoCodecs; + +const List _topLevelKeys = [ + 'formatVersion', + 'generator', + 'codec', + 'bitstream', + 'layout', + 'canvas', + 'frameRate', + 'renditions', + 'units', + 'initialState', + 'states', + 'edges', + 'bindings', + 'readiness', + 'limits', +]; + +/// Validate, detach, and freeze the sole production manifest. +CompiledManifest validateCompiledManifest(Object? value, [FormatOptions? options]) { + try { + final budgets = resolveFormatBudgets(options); + final input = record(value, 'manifest'); + exactKeys(input, _topLevelKeys, 'manifest'); + literal(input['formatVersion'], '1.0', 'formatVersion'); + final generator = generatorString(input['generator'], 'generator'); + final codec = oneOf(input['codec'], videoCodecs, 'codec'); + final bitstream = oneOf(input['bitstream'], ['annex-b', 'frame', 'low-overhead'], 'bitstream'); + if (bitstream != videoBitstreamByCodec[codec]) { + invalid('bitstream', 'must be ${videoBitstreamByCodec[codec]} for $codec'); + } + final layout = oneOf(input['layout'], ['opaque', 'packed-alpha'], 'layout'); + final canvas = cloneCanvas(input['canvas'], 'canvas'); + final frameRate = cloneFrameRate(input['frameRate'], 'frameRate'); + final renditions = + cloneRenditions(input['renditions'], canvas, codec, layout, budgets, 'renditions'); + validateRawBlobCount(input['units'], renditions.length, budgets); + final units = cloneUnits(input['units'], renditions, budgets, 'units'); + final initialState = identifier(input['initialState'], 'initialState'); + final states = cloneStates(input['states'], budgets, 'states'); + final edges = cloneEdges(input['edges'], budgets, 'edges'); + final bindings = cloneBindings(input['bindings'], budgets, 'bindings'); + final readiness = cloneReadiness(input['readiness'], budgets, 'readiness'); + final limits = cloneDeclaredLimits(input['limits'], renditions, budgets, 'limits'); + + validateBlobCount(units, renditions, budgets); + validateManifestRelations(ManifestRelationInput( + initialState: initialState, + renditions: renditions, + units: units, + states: states, + edges: edges, + bindings: bindings, + readiness: readiness, + )); + + return CompiledManifest( + generator: generator, + codec: codec, + bitstream: bitstream, + layout: layout, + canvas: canvas, + frameRate: frameRate, + renditions: renditions, + units: units, + initialState: initialState, + states: states, + edges: edges, + bindings: bindings, + readiness: readiness, + limits: limits, + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.manifestInvalid, 'manifest validation failed'); + } +} diff --git a/flutter/packages/aval_format/lib/src/manifest_unit_schema.dart b/flutter/packages/aval_format/lib/src/manifest_unit_schema.dart new file mode 100644 index 0000000..ac84ef7 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_unit_schema.dart @@ -0,0 +1,174 @@ +/// Unit (body/bridge/reversible/one-shot) schema validation. +/// +/// Dart port of `packages/format/src/manifest-unit-schema.ts` (1.0). Per-unit +/// chunk spans are shape-validated here; their canonical decode-order ordinals +/// are enforced by `createCanonicalChunkPlan` (chunk_plan.dart). +library; + +import 'chunk_plan.dart' show createCanonicalChunkPlan; +import 'errors.dart'; +import 'manifest_constraints.dart'; +import 'manifest_validation.dart'; +import 'model.dart'; + +List cloneUnits( + Object? value, + List renditions, + FormatBudgets budgets, + String path, +) { + final inputs = boundedArray(value, path, 1, budgets.maxUnits); + final units = [ + for (var index = 0; index < inputs.length; index += 1) + _cloneUnit(inputs[index], renditions, budgets, '$path[$index]'), + ]; + requireIdOrder(units, (u) => u.id, path); + try { + createCanonicalChunkPlan( + renditions, + units, + budgets.maxChunkRecords, + budgets.maxTotalUnitFrames, + ); + } on FormatError catch (error) { + if (error.code == FormatErrorCode.budgetExceeded || + error.code == FormatErrorCode.integerUnsafe) { + rethrow; + } + invalid(error.path ?? path, error.message); + } catch (_) { + invalid(path, 'canonical chunk plan could not be derived'); + } + return units; +} + +Unit _cloneUnit( + Object? value, + List renditions, + FormatBudgets budgets, + String path, +) { + final input = record(value, path); + final kind = input['kind']; + if (kind == 'body') { + exactKeys(input, ['id', 'kind', 'playback', 'frameCount', 'ports', 'chunks'], path); + final id = identifier(input['id'], '$path.id'); + final playback = oneOf(input['playback'], ['loop', 'finite'], '$path.playback'); + final frameCount = positiveInteger(input['frameCount'], '$path.frameCount'); + if (playback == 'loop' && frameCount < 2) { + invalid('$path.frameCount', 'looping bodies require at least two frames'); + } + final ports = _clonePorts(input['ports'], frameCount, budgets.maxPortsPerBody, '$path.ports'); + final chunks = _cloneChunkSpans(input['chunks'], renditions, frameCount, '$path.chunks'); + return BodyUnit( + id: id, + playback: playback, + frameCount: frameCount, + ports: ports, + chunks: chunks, + ); + } + + if (kind == 'bridge' || kind == 'one-shot') { + exactKeys(input, ['id', 'kind', 'frameCount', 'chunks'], path); + final id = identifier(input['id'], '$path.id'); + final frameCount = positiveInteger(input['frameCount'], '$path.frameCount'); + final chunks = _cloneChunkSpans(input['chunks'], renditions, frameCount, '$path.chunks'); + return kind == 'bridge' + ? BridgeUnit(id: id, frameCount: frameCount, chunks: chunks) + : OneShotUnit(id: id, frameCount: frameCount, chunks: chunks); + } + + if (kind == 'reversible') { + exactKeys(input, ['id', 'kind', 'frameCount', 'residency', 'chunks'], path); + final id = identifier(input['id'], '$path.id'); + final frameCount = + positiveInteger(input['frameCount'], '$path.frameCount', budgets.maxReversibleFrames); + final residencyInput = record(input['residency'], '$path.residency'); + exactKeys(residencyInput, ['endpoints'], '$path.residency'); + final endpointsInput = tuple(residencyInput['endpoints'], 2, '$path.residency.endpoints'); + final first = _cloneResidencyEndpoint(endpointsInput[0], '$path.residency.endpoints[0]'); + final second = _cloneResidencyEndpoint(endpointsInput[1], '$path.residency.endpoints[1]'); + if (compareEndpoint(first, second) >= 0) { + invalid('$path.residency.endpoints', 'must be distinct and sorted by state then port'); + } + final residency = ReversibleResidency([first, second]); + final chunks = _cloneChunkSpans(input['chunks'], renditions, frameCount, '$path.chunks'); + return ReversibleUnit(id: id, frameCount: frameCount, residency: residency, chunks: chunks); + } + + invalid('$path.kind', 'must be body, bridge, reversible, or one-shot'); +} + +List _cloneChunkSpans( + Object? value, + List renditions, + int unitFrameCount, + String path, +) { + final inputs = tuple(value, renditions.length, path); + final spans = []; + for (var renditionIndex = 0; renditionIndex < inputs.length; renditionIndex += 1) { + final spanPath = '$path[$renditionIndex]'; + final input = record(inputs[renditionIndex], spanPath); + exactKeys(input, ['rendition', 'chunkStart', 'chunkCount', 'frameCount', 'sha256'], spanPath); + final rendition = identifier(input['rendition'], '$spanPath.rendition'); + final expected = renditionIndex < renditions.length ? renditions[renditionIndex].id : null; + if (rendition != expected) { + invalid('$spanPath.rendition', 'must be ${quote(expected ?? "")}'); + } + final chunkStart = nonNegativeInteger(input['chunkStart'], '$spanPath.chunkStart'); + final chunkCount = positiveInteger(input['chunkCount'], '$spanPath.chunkCount'); + final frameCount = positiveInteger(input['frameCount'], '$spanPath.frameCount'); + if (frameCount != unitFrameCount) { + invalid('$spanPath.frameCount', 'must equal the unit frameCount'); + } + spans.add(UnitChunkSpan( + rendition: rendition, + chunkStart: chunkStart, + chunkCount: chunkCount, + frameCount: frameCount, + sha256: digest(input['sha256'], '$spanPath.sha256'), + )); + } + return spans; +} + +List _clonePorts(Object? value, int frameCount, int maximum, String path) { + final inputs = boundedArray(value, path, 0, maximum); + final ports = [ + for (var index = 0; index < inputs.length; index += 1) + _clonePort(inputs[index], '$path[$index]', frameCount), + ]; + requireIdOrder(ports, (p) => p.id, path); + return ports; +} + +Port _clonePort(Object? entry, String portPath, int frameCount) { + final input = record(entry, portPath); + exactKeys(input, ['id', 'entryFrame', 'portalFrames'], portPath); + final id = identifier(input['id'], '$portPath.id'); + literal(input['entryFrame'], 0, '$portPath.entryFrame'); + final frameInputs = boundedArray(input['portalFrames'], '$portPath.portalFrames', 1, frameCount); + final portalFrames = [ + for (var frameIndex = 0; frameIndex < frameInputs.length; frameIndex += 1) + integerInRange( + frameInputs[frameIndex], + '$portPath.portalFrames[$frameIndex]', + 0, + frameCount - 1, + ), + ]; + requireNumberOrder(portalFrames, '$portPath.portalFrames'); + return Port(id: id, portalFrames: portalFrames); +} + +ResidencyEndpoint _cloneResidencyEndpoint(Object? value, String path) { + final input = record(value, path); + exactKeys(input, ['state', 'port', 'frames'], path); + return ResidencyEndpoint( + state: identifier(input['state'], '$path.state'), + port: identifier(input['port'], '$path.port'), + frames: integerInRange(input['frames'], '$path.frames', minRunwayFrames, maxRunwayFrames), + ); +} diff --git a/flutter/packages/aval_format/lib/src/manifest_validation.dart b/flutter/packages/aval_format/lib/src/manifest_validation.dart new file mode 100644 index 0000000..c93a0ae --- /dev/null +++ b/flutter/packages/aval_format/lib/src/manifest_validation.dart @@ -0,0 +1,195 @@ +/// Shared manifest schema validation primitives. +/// +/// Dart port of `packages/format/src/manifest-validation.ts`. Manifest input +/// values are represented as plain `Object?` / `Map` / +/// `List`, mirroring how the TS source treats `unknown` JSON input +/// before it is validated into the typed `model.dart` classes. +library; + +import 'constants.dart' show identifierPattern, sha256HexPattern; +import 'errors.dart'; +import 'model.dart' show ResidencyEndpoint; +import 'utf8.dart' show utf8ByteLength; + +Map record(Object? value, String path) { + if (value is! Map) { + invalid(path, 'must be an object'); + } + // TS `record` returns the object as-is and defers key inspection to + // `exactKeys`, whose `Reflect.ownKeys` loop rejects non-string (symbol) + // keys with `contains unknown field [symbol]` (manifest-validation.ts:71-77). + // Dart JSON maps are keyed by `String`; a hostile non-string key is rejected + // here with the identical message and path so the behavior is preserved. + final result = {}; + value.forEach((key, dynamic v) { + if (key is! String) { + invalid(path, 'contains unknown field [symbol]'); + } + result[key] = v; + }); + return result; +} + +List array(Object? value, String path) { + final result = _arrayValue(value, path); + _requireDenseArray(result, path); + return result; +} + +List _arrayValue(Object? value, String path) { + if (value is! List) { + invalid(path, 'must be an array'); + } + return value; +} + +void _requireDenseArray(List value, String path) { + // Dart Lists are always dense; this mirrors the TS sparse-array check as a + // no-op guard kept for structural parity with the source. +} + +List boundedArray( + Object? value, + String path, + int minimum, + int maximum, +) { + final result = _arrayValue(value, path); + if (result.length < minimum || result.length > maximum) { + invalid(path, 'must contain between $minimum and $maximum entries'); + } + _requireDenseArray(result, path); + return result; +} + +List tuple(Object? value, int length, String path) { + final result = _arrayValue(value, path); + if (result.length != length) { + invalid(path, 'must contain exactly $length entries'); + } + _requireDenseArray(result, path); + return result; +} + +void exactKeys( + Map value, + List required, [ + String path = '', + List optional = const [], +]) { + final allowed = {...required, ...optional}; + for (final key in value.keys) { + if (!allowed.contains(key)) { + invalid(path, 'contains unknown field ${quote(key)}'); + } + } + for (final key in required) { + if (!owns(value, key)) { + invalid('$path.$key', 'is required'); + } + } +} + +bool owns(Map value, String key) => value.containsKey(key); + +String generatorString(Object? value, String path) { + if (value is! String) { + invalid(path, 'must be a string'); + } + for (var index = 0; index < value.length; index += 1) { + if (value.codeUnitAt(index) <= 0x1f) { + invalid(path, 'must not contain C0 controls'); + } + } + final length = utf8ByteLength(value, (message, [offset]) { + invalid(path, 'contains a lone surrogate'); + }); + if (length < 1 || length > 128) { + invalid(path, 'must contain between 1 and 128 UTF-8 bytes'); + } + return value; +} + +String identifier(Object? value, String path) { + if (value is! String || !identifierPattern.hasMatch(value)) { + invalid(path, 'must match ${identifierPattern.pattern}'); + } + return value; +} + +String digest(Object? value, String path) { + if (value is! String || !sha256HexPattern.hasMatch(value)) { + invalid(path, 'must be a lowercase 64-character SHA-256 hexadecimal string'); + } + return value; +} + +int positiveInteger(Object? value, String path, [int maximum = _maxSafeInteger]) { + return integerInRange(value, path, 1, maximum); +} + +int nonNegativeInteger(Object? value, String path) { + return integerInRange(value, path, 0, _maxSafeInteger); +} + +const int _maxSafeInteger = 9007199254740991; + +int integerInRange(Object? value, String path, int minimum, int maximum) { + if (value is! int || value < minimum || value > maximum) { + invalid(path, 'must be a safe integer from $minimum to $maximum'); + } + return value; +} + +T literal(Object? value, T expected, String path) { + if (value != expected) { + invalid(path, 'must be ${quote(expected.toString())}'); + } + return expected; +} + +String oneOf(Object? value, List choices, String path) { + if (value is! String || !choices.contains(value)) { + invalid(path, 'must be one of ${choices.map(quote).join(', ')}'); + } + return value; +} + +/// Generic over any type with a string identifier, matching the TS +/// structural type `readonly { readonly id: string }[]`. +void requireIdOrder(List values, String Function(T) idOf, String path) { + requireStringOrder(values.map(idOf).toList(), path); +} + +void requireStringOrder(List values, String path) { + for (var index = 1; index < values.length; index += 1) { + if (compareAscii(values[index - 1], values[index]) >= 0) { + invalid(path, 'must be sorted by ID and contain no duplicates'); + } + } +} + +void requireNumberOrder(List values, String path) { + for (var index = 1; index < values.length; index += 1) { + if (values[index - 1] >= values[index]) { + invalid(path, 'must be numerically sorted and unique'); + } + } +} + +int compareEndpoint(ResidencyEndpoint a, ResidencyEndpoint b) { + final byState = compareAscii(a.state, b.state); + return byState != 0 ? byState : compareAscii(a.port, b.port); +} + +int compareAscii(String a, String b) => a == b ? 0 : (a.compareTo(b) < 0 ? -1 : 1); + +String quote(String value) => '"$value"'; + +Never invalid(String path, String message) { + throw FormatError( + FormatErrorCode.manifestInvalid, + '$path $message', + FormatErrorDetails(path: path), + ); +} diff --git a/flutter/packages/aval_format/lib/src/model.dart b/flutter/packages/aval_format/lib/src/model.dart new file mode 100644 index 0000000..ec09064 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/model.dart @@ -0,0 +1,827 @@ +/// Version-1.0 AVAL wire model: budgets, manifest types, and layout records. +/// +/// Dart port of `packages/format/src/model.ts`. TypeScript discriminated +/// unions become sealed class hierarchies; plain interfaces become immutable +/// classes. String-literal unions whose only runtime use is equality/ +/// membership testing performed elsewhere (in the `manifest_*schema.dart` +/// validators, mirroring `manifest-*.ts`) are kept as plain `String`/`typedef` +/// rather than Dart `enum`s, so the single set of legal literals lives in one +/// place exactly as it does in the TS source. +library; + +import 'dart:typed_data'; + +import 'package:aval_graph/aval_graph.dart' show ValidatedMotionGraph; + +typedef Id = String; +typedef Sha256Hex = String; + +/// `"h264" | "h265" | "vp9" | "av1"`. +typedef VideoCodec = String; + +/// `"annex-b" | "frame" | "low-overhead"`. +typedef VideoBitstream = String; + +/// `"opaque" | "packed-alpha"`. +typedef VideoLayout = String; + +/// `8 | 10`. +typedef VideoBitDepth = int; + +/// `readonly [x, y, width, height]` in the TS source. +class Rect { + const Rect(this.x, this.y, this.width, this.height); + + final int x; + final int y; + final int width; + final int height; + + List toList() => [x, y, width, height]; + + @override + bool operator ==(Object other) => + other is Rect && + other.x == x && + other.y == y && + other.width == width && + other.height == height; + + @override + int get hashCode => Object.hash(x, y, width, height); + + @override + String toString() => 'Rect($x, $y, $width, $height)'; +} + +class FormatBudgets { + const FormatBudgets({ + required this.maxFileBytes, + required this.maxManifestBytes, + required this.maxIndexBytes, + required this.maxChunkBytes, + required this.maxPngBytes, + required this.maxJsonDepth, + required this.maxJsonNodes, + required this.maxJsonStringBytes, + required this.maxStates, + required this.maxEdges, + required this.maxUnits, + required this.maxRenditions, + required this.maxBindings, + required this.maxBlobRanges, + required this.maxTotalUnitFrames, + required this.maxChunkRecords, + required this.maxPortsPerBody, + required this.maxReversibleFrames, + }); + + final int maxFileBytes; + final int maxManifestBytes; + final int maxIndexBytes; + final int maxChunkBytes; + final int maxPngBytes; + final int maxJsonDepth; + final int maxJsonNodes; + final int maxJsonStringBytes; + final int maxStates; + final int maxEdges; + final int maxUnits; + final int maxRenditions; + final int maxBindings; + final int maxBlobRanges; + final int maxTotalUnitFrames; + final int maxChunkRecords; + final int maxPortsPerBody; + final int maxReversibleFrames; + + /// Keys match the exact TS `keyof FormatBudgets` field names, used by + /// `resolveFormatBudgets` to walk override keys generically. + Map toMap() => { + 'maxFileBytes': maxFileBytes, + 'maxManifestBytes': maxManifestBytes, + 'maxIndexBytes': maxIndexBytes, + 'maxChunkBytes': maxChunkBytes, + 'maxPngBytes': maxPngBytes, + 'maxJsonDepth': maxJsonDepth, + 'maxJsonNodes': maxJsonNodes, + 'maxJsonStringBytes': maxJsonStringBytes, + 'maxStates': maxStates, + 'maxEdges': maxEdges, + 'maxUnits': maxUnits, + 'maxRenditions': maxRenditions, + 'maxBindings': maxBindings, + 'maxBlobRanges': maxBlobRanges, + 'maxTotalUnitFrames': maxTotalUnitFrames, + 'maxChunkRecords': maxChunkRecords, + 'maxPortsPerBody': maxPortsPerBody, + 'maxReversibleFrames': maxReversibleFrames, + }; + + factory FormatBudgets.fromMap(Map map) => FormatBudgets( + maxFileBytes: map['maxFileBytes']!, + maxManifestBytes: map['maxManifestBytes']!, + maxIndexBytes: map['maxIndexBytes']!, + maxChunkBytes: map['maxChunkBytes']!, + maxPngBytes: map['maxPngBytes']!, + maxJsonDepth: map['maxJsonDepth']!, + maxJsonNodes: map['maxJsonNodes']!, + maxJsonStringBytes: map['maxJsonStringBytes']!, + maxStates: map['maxStates']!, + maxEdges: map['maxEdges']!, + maxUnits: map['maxUnits']!, + maxRenditions: map['maxRenditions']!, + maxBindings: map['maxBindings']!, + maxBlobRanges: map['maxBlobRanges']!, + maxTotalUnitFrames: map['maxTotalUnitFrames']!, + maxChunkRecords: map['maxChunkRecords']!, + maxPortsPerBody: map['maxPortsPerBody']!, + maxReversibleFrames: map['maxReversibleFrames']!, + ); +} + +/// `{ budgets?: Partial }`. A `Partial` is +/// represented as a sparse `Map` keyed by the same field names as +/// [FormatBudgets.toMap], since the TS validator itself treats budgets +/// generically by key name (`Reflect.ownKeys`). +class FormatOptions { + const FormatOptions({this.budgets}); + + final Map? budgets; +} + +class Rational { + const Rational({required this.numerator, required this.denominator}); + + final int numerator; + final int denominator; + + @override + bool operator ==(Object other) => + other is Rational && + other.numerator == numerator && + other.denominator == denominator; + + @override + int get hashCode => Object.hash(numerator, denominator); +} + +class Canvas { + const Canvas({ + required this.width, + required this.height, + required this.fit, + required this.pixelAspect, + this.colorSpace = 'srgb', + }); + + final int width; + final int height; + + /// `"contain" | "cover" | "fill" | "none"`. + final String fit; + + /// `readonly [numerator, denominator]`. + final List pixelAspect; + + /// Always `"srgb"` (the only literal in the TS union). + final String colorSpace; +} + +class Bitrate { + const Bitrate({required this.average, required this.peak}); + + final int average; + final int peak; + + @override + bool operator ==(Object other) => + other is Bitrate && other.average == average && other.peak == peak; + + @override + int get hashCode => Object.hash(average, peak); +} + +/// TS discriminated union `AlphaLayout`. [type] is the discriminant +/// (`"opaque" | "stacked"`). +sealed class AlphaLayout { + const AlphaLayout({required this.type, required this.colorRect}); + + final String type; + final Rect colorRect; +} + +class OpaqueAlphaLayout extends AlphaLayout { + const OpaqueAlphaLayout({required super.colorRect}) : super(type: 'opaque'); +} + +class StackedAlphaLayout extends AlphaLayout { + const StackedAlphaLayout({required super.colorRect, required this.alphaRect}) + : super(type: 'stacked'); + + final Rect alphaRect; +} + +/// One quality rung in a single-codec asset. Array order is author preference. +class ProductionRendition { + const ProductionRendition({ + required this.id, + required this.codec, + required this.bitDepth, + required this.codedWidth, + required this.codedHeight, + required this.alphaLayout, + required this.bitrate, + }); + + final Id id; + final String codec; + final VideoBitDepth bitDepth; + final int codedWidth; + final int codedHeight; + final AlphaLayout alphaLayout; + final Bitrate bitrate; +} + +/// One unit/rendition blob in the global decode-order chunk array. +class UnitChunkSpan { + const UnitChunkSpan({ + required this.rendition, + required this.chunkStart, + required this.chunkCount, + required this.frameCount, + required this.sha256, + }); + + final Id rendition; + final int chunkStart; + final int chunkCount; + final int frameCount; + final Sha256Hex sha256; + + @override + bool operator ==(Object other) => + other is UnitChunkSpan && + other.rendition == rendition && + other.chunkStart == chunkStart && + other.chunkCount == chunkCount && + other.frameCount == frameCount && + other.sha256 == sha256; + + @override + int get hashCode => + Object.hash(rendition, chunkStart, chunkCount, frameCount, sha256); +} + +class Port { + const Port({required this.id, required this.portalFrames}); + + final Id id; + + /// Always `0` (TS `entryFrame: 0` literal type). + int get entryFrame => 0; + final List portalFrames; +} + +class ResidencyEndpoint { + const ResidencyEndpoint({ + required this.state, + required this.port, + required this.frames, + }); + + final Id state; + final Id port; + final int frames; +} + +class ReversibleResidency { + const ReversibleResidency(this.endpoints); + + /// Exactly two entries, matching the TS tuple + /// `readonly [ResidencyEndpoint, ResidencyEndpoint]`. + final List endpoints; +} + +/// TS discriminated union `Unit`. [kind] is the discriminant. +sealed class Unit { + const Unit({ + required this.id, + required this.kind, + required this.frameCount, + required this.chunks, + }); + + final Id id; + + /// `"body" | "bridge" | "reversible" | "one-shot"`. + final String kind; + final int frameCount; + final List chunks; +} + +class BodyUnit extends Unit { + const BodyUnit({ + required super.id, + required super.frameCount, + required super.chunks, + required this.playback, + required this.ports, + }) : super(kind: 'body'); + + /// `"loop" | "finite"`. + final String playback; + final List ports; +} + +class BridgeUnit extends Unit { + const BridgeUnit({ + required super.id, + required super.frameCount, + required super.chunks, + }) : super(kind: 'bridge'); +} + +class ReversibleUnit extends Unit { + const ReversibleUnit({ + required super.id, + required super.frameCount, + required super.chunks, + required this.residency, + }) : super(kind: 'reversible'); + + final ReversibleResidency residency; +} + +class OneShotUnit extends Unit { + const OneShotUnit({ + required super.id, + required super.frameCount, + required super.chunks, + }) : super(kind: 'one-shot'); +} + +class State { + const State({required this.id, required this.bodyUnit, this.initialUnit}); + + final Id id; + final Id bodyUnit; + final Id? initialUnit; +} + +/// TS discriminated union `Trigger`. [type] is the discriminant. +sealed class Trigger { + const Trigger(this.type); + + /// `"event" | "completion"`. + final String type; +} + +class EventTrigger extends Trigger { + const EventTrigger(this.name) : super('event'); + + final Id name; +} + +class CompletionTrigger extends Trigger { + const CompletionTrigger() : super('completion'); +} + +/// TS discriminated union `Start`. [type] is the discriminant. +sealed class Start { + const Start({ + required this.type, + required this.targetPort, + required this.maxWaitFrames, + }); + + /// `"portal" | "finish" | "cut"`. + final String type; + final Id targetPort; + final int maxWaitFrames; +} + +class PortalStart extends Start { + const PortalStart({ + required this.sourcePort, + required super.targetPort, + required super.maxWaitFrames, + }) : super(type: 'portal'); + + final Id sourcePort; +} + +class FinishStart extends Start { + const FinishStart({ + required super.targetPort, + required super.maxWaitFrames, + }) : super(type: 'finish'); +} + +class CutStart extends Start { + const CutStart({required super.targetPort}) + : super(type: 'cut', maxWaitFrames: 1); +} + +/// TS discriminated union `Transition`. [kind] is the discriminant. +sealed class Transition { + const Transition({required this.kind, required this.unit}); + + /// `"locked" | "reversible"`. + final String kind; + final Id unit; +} + +class LockedTransition extends Transition { + const LockedTransition({required super.unit}) : super(kind: 'locked'); +} + +class ReversibleTransition extends Transition { + const ReversibleTransition({ + required super.unit, + required this.direction, + this.reverseOf, + }) : super(kind: 'reversible'); + + /// `"forward" | "reverse"`. + final String direction; + final Id? reverseOf; +} + +/// TS discriminated union `Edge` (`NonCutEdge | CutEdge`). +/// [continuity] `== 'cut'` iff this is a [CutEdge]. +sealed class Edge { + const Edge({ + required this.id, + required this.from, + required this.to, + this.trigger, + required this.start, + required this.continuity, + }); + + final Id id; + final Id from; + final Id to; + final Trigger? trigger; + final Start start; + + /// `"exact-authored" | "exact-reverse" | "cut"`. + final String continuity; +} + +class NonCutEdge extends Edge { + const NonCutEdge({ + required super.id, + required super.from, + required super.to, + super.trigger, + required super.start, + required super.continuity, + this.transition, + }); + + final Transition? transition; +} + +class CutEdge extends Edge { + const CutEdge({ + required super.id, + required super.from, + required super.to, + super.trigger, + required CutStart start, + required this.targetRunwayFrames, + }) : super(start: start, continuity: 'cut'); + + final int targetRunwayFrames; +} + +/// `"activate" | "engagement.off" | "engagement.on" | "focus.in" | +/// "focus.out" | "hidden" | "pointer.enter" | "pointer.leave" | "visible"`. +typedef BindingSource = String; + +class Binding { + const Binding({required this.source, required this.event}); + + final BindingSource source; + final Id event; +} + +class Readiness { + const Readiness({ + required this.bootstrapUnits, + required this.immediateEdges, + }); + + /// Always `"all-routes"`. + String get policy => 'all-routes'; + final List bootstrapUnits; + final List immediateEdges; +} + +class DeclaredLimits { + const DeclaredLimits({ + required this.maxCompiledBytes, + required this.maxRuntimeBytes, + required this.decodedPixelBytes, + required this.persistentCacheBytes, + required this.runtimeWorkingSetBytes, + }); + + final int maxCompiledBytes; + final int maxRuntimeBytes; + final int decodedPixelBytes; + final int persistentCacheBytes; + final int runtimeWorkingSetBytes; +} + +class CompiledManifest { + const CompiledManifest({ + required this.generator, + required this.codec, + required this.bitstream, + required this.layout, + required this.canvas, + required this.frameRate, + required this.renditions, + required this.units, + required this.initialState, + required this.states, + required this.edges, + required this.bindings, + required this.readiness, + required this.limits, + }); + + /// Always `"1.0"`. + String get formatVersion => '1.0'; + final String generator; + final VideoCodec codec; + final VideoBitstream bitstream; + final VideoLayout layout; + final Canvas canvas; + final Rational frameRate; + final List renditions; + final List units; + final Id initialState; + final List states; + final List edges; + final List bindings; + final Readiness readiness; + final DeclaredLimits limits; +} + +class FormatHeader { + const FormatHeader({ + required this.declaredFileLength, + required this.manifestLength, + required this.indexOffset, + required this.indexLength, + }); + + /// Always `1`. + int get major => 1; + + /// Always `0`. + int get minor => 0; + + /// Always `64`. + int get headerLength => 64; + + /// Always `0`. + int get requiredFeatureFlags => 0; + final int declaredFileLength; + + /// Always `64`. + int get manifestOffset => 64; + final int manifestLength; + final int indexOffset; + final int indexLength; +} + +/// Fixed-width decode-order metadata for one elementary encoded chunk. +class EncodedChunkRecord { + const EncodedChunkRecord({ + required this.byteOffset, + required this.byteLength, + required this.presentationTimestamp, + required this.duration, + required this.randomAccess, + required this.displayedFrameCount, + }); + + final int byteOffset; + final int byteLength; + final int presentationTimestamp; + final int duration; + final bool randomAccess; + final int displayedFrameCount; + + @override + bool operator ==(Object other) => + other is EncodedChunkRecord && + other.byteOffset == byteOffset && + other.byteLength == byteLength && + other.presentationTimestamp == presentationTimestamp && + other.duration == duration && + other.randomAccess == randomAccess && + other.displayedFrameCount == displayedFrameCount; + + @override + int get hashCode => Object.hash(byteOffset, byteLength, presentationTimestamp, + duration, randomAccess, displayedFrameCount); +} + +class ByteRange { + const ByteRange({required this.offset, required this.length}); + + final int offset; + final int length; + + @override + bool operator ==(Object other) => + other is ByteRange && other.offset == offset && other.length == length; + + @override + int get hashCode => Object.hash(offset, length); +} + +class UnitBlobRange extends ByteRange { + const UnitBlobRange({ + required super.offset, + required super.length, + required this.rendition, + required this.unit, + required this.chunkStart, + required this.chunkCount, + required this.frameCount, + required this.sha256, + }); + + final Id rendition; + final Id unit; + final int chunkStart; + final int chunkCount; + final int frameCount; + final Sha256Hex sha256; +} + +class ParsedFrontIndex { + const ParsedFrontIndex({ + required this.header, + required this.manifest, + required this.graph, + required this.records, + required this.frontIndexRange, + required this.unitBlobs, + }); + + final FormatHeader header; + final CompiledManifest manifest; + final ValidatedMotionGraph graph; + final List records; + final ByteRange frontIndexRange; + final List unitBlobs; +} + +class ValidatedAssetLayout { + const ValidatedAssetLayout({ + required this.frontIndex, + required this.fileRange, + }); + + final ParsedFrontIndex frontIndex; + final ByteRange fileRange; +} + +class ChunkDigestInput { + const ChunkDigestInput({required this.rendition, required this.sha256}); + + final Id rendition; + final Sha256Hex sha256; +} + +/// TS `UnitInputOf`: the writer-facing counterpart of [Unit] with +/// `chunks: readonly ChunkDigestInput[]` instead of `UnitChunkSpan[]`. +sealed class UnitInput { + const UnitInput({ + required this.id, + required this.kind, + required this.frameCount, + required this.chunks, + }); + + final Id id; + final String kind; + final int frameCount; + final List chunks; +} + +class BodyUnitInput extends UnitInput { + const BodyUnitInput({ + required super.id, + required super.frameCount, + required super.chunks, + required this.playback, + required this.ports, + }) : super(kind: 'body'); + + final String playback; + final List ports; +} + +class BridgeUnitInput extends UnitInput { + const BridgeUnitInput({ + required super.id, + required super.frameCount, + required super.chunks, + }) : super(kind: 'bridge'); +} + +class ReversibleUnitInput extends UnitInput { + const ReversibleUnitInput({ + required super.id, + required super.frameCount, + required super.chunks, + required this.residency, + }) : super(kind: 'reversible'); + + final ReversibleResidency residency; +} + +class OneShotUnitInput extends UnitInput { + const OneShotUnitInput({ + required super.id, + required super.frameCount, + required super.chunks, + }) : super(kind: 'one-shot'); +} + +class CompiledManifestInput { + const CompiledManifestInput({ + required this.generator, + required this.codec, + required this.bitstream, + required this.layout, + required this.canvas, + required this.frameRate, + required this.renditions, + required this.units, + required this.initialState, + required this.states, + required this.edges, + required this.bindings, + required this.readiness, + required this.limits, + }); + + String get formatVersion => '1.0'; + final String generator; + final VideoCodec codec; + final VideoBitstream bitstream; + final VideoLayout layout; + final Canvas canvas; + final Rational frameRate; + final List renditions; + final List units; + final Id initialState; + final List states; + final List edges; + final List bindings; + final Readiness readiness; + final DeclaredLimits limits; +} + +/// Caller-owned payload plus timeline metadata, identified within one unit. +class EncodedChunkInput { + const EncodedChunkInput({ + required this.rendition, + required this.unit, + required this.decodeIndex, + required this.presentationTimestamp, + required this.duration, + required this.randomAccess, + required this.displayedFrameCount, + required this.bytes, + }); + + final Id rendition; + final Id unit; + final int decodeIndex; + final int presentationTimestamp; + final int duration; + final bool randomAccess; + final int displayedFrameCount; + final Uint8List bytes; +} + +class CanonicalAssetInput { + const CanonicalAssetInput({ + required this.manifest, + required this.chunks, + }); + + final CompiledManifestInput manifest; + final List chunks; +} diff --git a/flutter/packages/aval_format/lib/src/parser.dart b/flutter/packages/aval_format/lib/src/parser.dart new file mode 100644 index 0000000..e0799bb --- /dev/null +++ b/flutter/packages/aval_format/lib/src/parser.dart @@ -0,0 +1,258 @@ +/// Parses and completely validates version-1.0 aval assets. +/// +/// Dart port of `packages/format/src/parser.ts`. +library; + +import 'dart:typed_data'; + +import 'access_unit_index.dart' show parseEncodedChunkIndex; +import 'canonical_json.dart' show parseCanonicalJson, serializeCanonicalJson; +import 'checked_integer.dart' show checkedAdd, requireByteRange; +import 'graph_adapter.dart' show adaptManifestToMotionGraph; +import 'header.dart' show parseHeader; +import 'layout.dart' show deriveCanonicalAssetLayout, validateZeroPadding; +import 'manifest_json.dart' show compiledManifestToJson; +import 'manifest_schema.dart' show validateCompiledManifest; +import 'errors.dart'; +import 'model.dart'; + +const List _headerFields = [ + 'major', + 'minor', + 'headerLength', + 'requiredFeatureFlags', + 'declaredFileLength', + 'manifestOffset', + 'manifestLength', + 'indexOffset', + 'indexLength', +]; + +const List _recordFields = [ + 'byteOffset', + 'byteLength', + 'presentationTimestamp', + 'duration', + 'randomAccess', + 'displayedFrameCount', +]; + +Never _rethrowAtFileOffset(Object error, int baseOffset) { + if (error is FormatError) { + throw FormatError( + error.code, + error.message, + FormatErrorDetails( + path: error.path, + offset: error.offset == null ? null : baseOffset + error.offset!, + ), + ); + } + throw error; +} + +bool _bytesEqual(Uint8List left, Uint8List right) { + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) return false; + } + return true; +} + +Map _headerFieldMap(FormatHeader header) => { + 'major': header.major, + 'minor': header.minor, + 'headerLength': header.headerLength, + 'requiredFeatureFlags': header.requiredFeatureFlags, + 'declaredFileLength': header.declaredFileLength, + 'manifestOffset': header.manifestOffset, + 'manifestLength': header.manifestLength, + 'indexOffset': header.indexOffset, + 'indexLength': header.indexLength, + }; + +Map _recordFieldMap(EncodedChunkRecord record) => { + 'byteOffset': record.byteOffset, + 'byteLength': record.byteLength, + 'presentationTimestamp': record.presentationTimestamp, + 'duration': record.duration, + 'randomAccess': record.randomAccess, + 'displayedFrameCount': record.displayedFrameCount, + }; + +void _assertMatchingFrontIndex( + ParsedFrontIndex supplied, + ParsedFrontIndex reparsed, [ + FormatOptions? options, +]) { + final suppliedFields = _headerFieldMap(supplied.header); + final reparsedFields = _headerFieldMap(reparsed.header); + for (final field in _headerFields) { + if (suppliedFields[field] != reparsedFields[field]) { + throw FormatError( + FormatErrorCode.layoutInvalid, + 'supplied front index header field $field does not match the asset', + ); + } + } + + Uint8List suppliedManifestBytes; + Uint8List reparsedManifestBytes; + try { + final suppliedManifest = + validateCompiledManifest(compiledManifestToJson(supplied.manifest), options); + suppliedManifestBytes = + serializeCanonicalJson(compiledManifestToJson(suppliedManifest), options); + reparsedManifestBytes = + serializeCanonicalJson(compiledManifestToJson(reparsed.manifest), options); + } on FormatError { + throw FormatError( + FormatErrorCode.layoutInvalid, + 'supplied front index manifest is not the asset manifest', + ); + } + if (!_bytesEqual(suppliedManifestBytes, reparsedManifestBytes)) { + throw FormatError( + FormatErrorCode.layoutInvalid, + 'supplied front index manifest does not match the asset', + ); + } + + if (supplied.records.length != reparsed.records.length) { + throw FormatError( + FormatErrorCode.layoutInvalid, + 'supplied front index record count does not match the asset', + ); + } + for (var index = 0; index < reparsed.records.length; index += 1) { + final suppliedRecord = + index < supplied.records.length ? _recordFieldMap(supplied.records[index]) : null; + final reparsedRecord = _recordFieldMap(reparsed.records[index]); + if (suppliedRecord == null) { + throw FormatError( + FormatErrorCode.layoutInvalid, + 'supplied front index record set is incomplete', + ); + } + for (final field in _recordFields) { + if (suppliedRecord[field] != reparsedRecord[field]) { + throw FormatError( + FormatErrorCode.layoutInvalid, + 'supplied front index record $index field $field does not match the asset', + ); + } + } + } +} + +CompiledManifest _parseManifest(Uint8List bytes, FormatHeader header, [FormatOptions? options]) { + final end = requireByteRange( + bytes, + header.manifestOffset, + header.manifestLength, + FormatErrorCode.jsonInvalid, + 'manifest', + ); + Object? parsed; + try { + parsed = parseCanonicalJson(bytes.sublist(header.manifestOffset, end), options); + } catch (error) { + _rethrowAtFileOffset(error, header.manifestOffset); + } + return validateCompiledManifest(parsed, options); +} + +/// Parses exactly the bounded metadata prefix needed to route and range-load +/// an asset. Payload bytes, when present in the input view, are ignored. +ParsedFrontIndex parseFrontIndex(Uint8List bytesFromFileStart, [FormatOptions? options]) { + try { + final header = parseHeader(bytesFromFileStart, options); + final frontIndexEnd = checkedAdd( + header.indexOffset, + header.indexLength, + header.declaredFileLength, + 'front index end', + ); + if (bytesFromFileStart.length < frontIndexEnd) { + throw FormatError( + FormatErrorCode.indexInvalid, + 'front index is truncated', + FormatErrorDetails(offset: bytesFromFileStart.length), + ); + } + + final manifest = _parseManifest(bytesFromFileStart, header, options); + final manifestEnd = checkedAdd( + header.manifestOffset, + header.manifestLength, + header.indexOffset, + 'manifest end', + ); + validateZeroPadding(bytesFromFileStart, [ + ByteRange(offset: manifestEnd, length: header.indexOffset - manifestEnd), + ]); + + List records; + try { + records = parseEncodedChunkIndex( + Uint8List.sublistView(bytesFromFileStart, header.indexOffset, frontIndexEnd), + manifest, + options, + ); + } catch (error) { + _rethrowAtFileOffset(error, header.indexOffset); + } + + final graph = adaptManifestToMotionGraph(manifest); + final layout = deriveCanonicalAssetLayout(header, manifest, records, options); + return ParsedFrontIndex( + header: header, + manifest: manifest, + graph: graph, + records: records, + frontIndexRange: layout.frontIndexRange, + unitBlobs: layout.unitBlobs, + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.inputInvalid, 'front index could not be parsed'); + } +} + +/// Reparses and completely validates one exact, caller-owned asset byte array. +ValidatedAssetLayout validateCompleteAsset({ + required Uint8List bytes, + ParsedFrontIndex? frontIndex, + FormatOptions? options, +}) { + try { + final reparsed = parseFrontIndex(bytes, options); + if (bytes.length != reparsed.header.declaredFileLength) { + throw FormatError( + FormatErrorCode.layoutInvalid, + bytes.length < reparsed.header.declaredFileLength + ? 'asset bytes are truncated' + : 'asset contains bytes beyond the declared file length', + FormatErrorDetails( + offset: bytes.length < reparsed.header.declaredFileLength + ? bytes.length + : reparsed.header.declaredFileLength, + ), + ); + } + if (frontIndex != null) { + _assertMatchingFrontIndex(frontIndex, reparsed, options); + } + + final layout = deriveCanonicalAssetLayout( + reparsed.header, reparsed.manifest, reparsed.records, options); + validateZeroPadding(bytes, layout.paddingRanges); + + return ValidatedAssetLayout(frontIndex: reparsed, fileRange: layout.fileRange); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.inputInvalid, 'complete asset could not be validated'); + } +} diff --git a/flutter/packages/aval_format/lib/src/png/chunks.dart b/flutter/packages/aval_format/lib/src/png/chunks.dart new file mode 100644 index 0000000..693e03b --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/chunks.dart @@ -0,0 +1,238 @@ +/// Restricted PNG chunk-stream parser: signature, IHDR, optional sRGB, one +/// or more IDAT, and terminal IEND. +/// +/// Dart port of `packages/format/src/png/chunks.ts`. All PNG length/CRC +/// fields are big-endian (network byte order), unlike the little-endian +/// helpers in `checked_integer.dart`, so this file implements its own +/// big-endian uint32 reader matching the TS source exactly. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart'; +import '../errors.dart'; +import 'crc32.dart'; + +const List _pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const int _maxChunks = 256; + +class _IdatRange { + const _IdatRange(this.offset, this.length); + + final int offset; + final int length; +} + +class ParsedPngChunks { + const ParsedPngChunks({ + required this.width, + required this.height, + required this.zlibBytes, + required this.chunkCount, + }); + + final int width; + final int height; + final Uint8List zlibBytes; + final int chunkCount; +} + +ParsedPngChunks parseRestrictedPngChunks({ + required Uint8List png, + required int expectedWidth, + required int expectedHeight, + required int maximumPngBytes, +}) { + if (png.length > maximumPngBytes) { + throw FormatError( + FormatErrorCode.budgetExceeded, + 'PNG length exceeds the active limit of $maximumPngBytes', + ); + } + _requireRange(png, 0, _pngSignature.length, 'PNG signature'); + for (var index = 0; index < _pngSignature.length; index += 1) { + if (png[index] != _pngSignature[index]) { + _fail('PNG signature is invalid', index); + } + } + + var cursor = _pngSignature.length; + var chunkCount = 0; + var width = 0; + var height = 0; + var sawIhdr = false; + var sawSrgb = false; + var sawIdat = false; + var ended = false; + var idatBytes = 0; + final idatRanges = <_IdatRange>[]; + + while (cursor < png.length) { + chunkCount += 1; + if (chunkCount > _maxChunks) { + _fail('PNG must contain at most $_maxChunks chunks', cursor); + } + _requireRange(png, cursor, 8, 'PNG chunk header'); + final length = _readUint32Be(png, cursor); + final dataOffset = checkedAdd( + cursor, + 8, + maxSafeInteger, + 'PNG chunk data offset', + ); + final payloadAndCrcLength = checkedAdd( + length, + 4, + maxSafeInteger, + 'PNG chunk payload and CRC length', + ); + _requireRange( + png, + dataOffset, + payloadAndCrcLength, + 'PNG chunk payload and CRC', + ); + final dataEnd = checkedAdd(dataOffset, length, png.length, 'PNG chunk data end'); + final chunkEnd = checkedAdd(dataEnd, 4, png.length, 'PNG chunk end'); + final expectedCrc = _readUint32Be(png, dataEnd); + if (crc32(Uint8List.sublistView(png, cursor + 4, dataEnd)) != expectedCrc) { + _fail('PNG chunk CRC-32 is invalid', dataEnd); + } + final type = _readChunkType(png, cursor + 4); + + if (!sawIhdr) { + if (type != 'IHDR') _fail('first PNG chunk must be IHDR', cursor + 4); + if (length != 13) _fail('IHDR payload must contain 13 bytes', cursor); + width = _readUint32Be(png, dataOffset); + height = _readUint32Be(png, dataOffset + 4); + if (width == 0 || height == 0) { + _fail( + 'PNG dimensions must be positive', + width == 0 ? dataOffset : dataOffset + 4, + ); + } + if (width != expectedWidth || height != expectedHeight) { + _fail('PNG dimensions do not match the static descriptor', dataOffset); + } + if (png[dataOffset + 8] != 8) { + _fail('PNG bit depth must be 8', dataOffset + 8); + } + if (png[dataOffset + 9] != 6) { + _fail('PNG color type must be RGBA (6)', dataOffset + 9); + } + if (png[dataOffset + 10] != 0) { + _fail('PNG compression method must be zero', dataOffset + 10); + } + if (png[dataOffset + 11] != 0) { + _fail('PNG filter method must be zero', dataOffset + 11); + } + if (png[dataOffset + 12] != 0) { + _fail('PNG must be non-interlaced', dataOffset + 12); + } + sawIhdr = true; + } else if (type == 'sRGB') { + if (sawSrgb || sawIdat || chunkCount != 2) { + _fail('sRGB is allowed once immediately after IHDR', cursor + 4); + } + if (length != 1 || png[dataOffset] != 0) { + _fail( + 'sRGB must declare only perceptual rendering intent zero', + dataOffset, + ); + } + sawSrgb = true; + } else if (type == 'IDAT') { + if (ended) _fail('IDAT cannot follow IEND', cursor + 4); + sawIdat = true; + idatRanges.add(_IdatRange(dataOffset, length)); + idatBytes = checkedAdd( + idatBytes, + length, + maximumPngBytes, + 'combined PNG IDAT bytes', + ); + } else if (type == 'IEND') { + if (!sawIdat) _fail('IEND must follow one or more IDAT chunks', cursor + 4); + if (length != 0) _fail('IEND payload must be empty', cursor); + if (ended) _fail('PNG must contain exactly one IEND', cursor + 4); + ended = true; + if (chunkEnd != png.length) { + _fail('PNG contains bytes after terminal IEND', chunkEnd); + } + } else { + _fail('PNG contains a chunk outside the restricted profile', cursor + 4); + } + + cursor = chunkEnd; + if (ended) break; + } + + if (!ended) _fail('PNG is missing terminal IEND', cursor); + if (!sawIdat) _fail('PNG must contain at least one IDAT chunk', cursor); + + Uint8List zlibBytes; + try { + zlibBytes = Uint8List(idatBytes); + } catch (_) { + _fail('combined PNG IDAT allocation failed for $idatBytes bytes'); + } + var target = 0; + for (final range in idatRanges) { + final rangeEnd = checkedAdd( + range.offset, + range.length, + png.length, + 'PNG IDAT range end', + ); + zlibBytes.setRange( + target, + target + range.length, + Uint8List.sublistView(png, range.offset, rangeEnd), + ); + target = checkedAdd(target, range.length, idatBytes, 'PNG IDAT copy end'); + } + return ParsedPngChunks( + width: width, + height: height, + zlibBytes: zlibBytes, + chunkCount: chunkCount, + ); +} + +void _requireRange(Uint8List bytes, int offset, int length, String label) { + if (offset < 0 || length < 0 || offset > bytes.length - length) { + final clamped = offset < 0 + ? 0 + : (offset > bytes.length ? bytes.length : offset); + _fail('$label is truncated', clamped); + } +} + +int _readUint32Be(Uint8List bytes, int offset) { + _requireRange(bytes, offset, 4, 'PNG uint32'); + return bytes[offset] * 0x1000000 + + bytes[offset + 1] * 0x10000 + + bytes[offset + 2] * 0x100 + + bytes[offset + 3]; +} + +String _readChunkType(Uint8List bytes, int offset) { + _requireRange(bytes, offset, 4, 'PNG chunk type'); + final buffer = StringBuffer(); + for (var index = 0; index < 4; index += 1) { + final byte = bytes[offset + index]; + if (!((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a))) { + _fail('PNG chunk type must contain ASCII letters', offset + index); + } + buffer.writeCharCode(byte); + } + return buffer.toString(); +} + +Never _fail(String message, [int? offset]) { + throw FormatError( + FormatErrorCode.pngEnvelopeInvalid, + message, + offset == null ? null : FormatErrorDetails(offset: offset), + ); +} diff --git a/flutter/packages/aval_format/lib/src/png/crc32.dart b/flutter/packages/aval_format/lib/src/png/crc32.dart new file mode 100644 index 0000000..c2cd10a --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/crc32.dart @@ -0,0 +1,50 @@ +/// Unsigned CRC-32 (PNG/IEEE) and Adler-32 (RFC 1950) checksums used by the +/// restricted PNG profile. +/// +/// Dart port of `packages/format/src/png/crc32.ts`. The TS source guards both +/// functions with a runtime `instanceof Uint8Array` check because JavaScript +/// has no static types; Dart's `Uint8List` parameter type enforces this at +/// compile time instead, so that check is not reproduced here. +library; + +import 'dart:typed_data'; + +final Uint32List _crcTable = _buildCrcTable(); +const int _adlerModulus = 65521; + +/// Unsigned PNG/IEEE CRC-32 over one bounded byte view. +int crc32(Uint8List bytes) { + var crc = 0xffffffff; + for (var index = 0; index < bytes.length; index += 1) { + crc = (crc >>> 8) ^ _crcTable[(crc ^ bytes[index]) & 0xff]; + } + return (crc ^ 0xffffffff) & 0xffffffff; +} + +/// Unsigned RFC 1950 Adler-32 over one bounded byte view. +int adler32(Uint8List bytes) { + var a = 1; + var b = 0; + for (var offset = 0; offset < bytes.length; offset += 5552) { + final end = bytes.length < offset + 5552 ? bytes.length : offset + 5552; + for (var index = offset; index < end; index += 1) { + a += bytes[index]; + b += a; + } + a %= _adlerModulus; + b %= _adlerModulus; + } + return ((b << 16) | a) & 0xffffffff; +} + +Uint32List _buildCrcTable() { + final table = Uint32List(256); + for (var index = 0; index < table.length; index += 1) { + var value = index; + for (var bit = 0; bit < 8; bit += 1) { + value = (value & 1) == 0 ? value >>> 1 : 0xedb88320 ^ (value >>> 1); + } + table[index] = value & 0xffffffff; + } + return table; +} diff --git a/flutter/packages/aval_format/lib/src/png/decode.dart b/flutter/packages/aval_format/lib/src/png/decode.dart new file mode 100644 index 0000000..ce1c253 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/decode.dart @@ -0,0 +1,96 @@ +/// Top-level restricted PNG decode entry points. +/// +/// Dart port of `packages/format/src/png/decode.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart'; +import '../errors.dart'; +import 'crc32.dart' show adler32; +import 'deflate.dart'; +import 'profile.dart'; +import 'unfilter.dart'; + +class PngRgbaDecodeResult { + const PngRgbaDecodeResult({ + required this.width, + required this.height, + required this.rgba, + }); + + final int width; + final int height; + + /// Fresh caller-owned straight RGBA bytes. + final Uint8List rgba; +} + +/// Decode through the bounded platform-free RFC 1950/1951 implementation. +PngRgbaDecodeResult decodePngRgba(PngDecodePlan plan) { + try { + final zlib = readOwnedPngZlib(plan); + final deflateEnd = checkedAdd( + plan.deflateRange.offset, + plan.deflateRange.length, + zlib.length, + 'PNG DEFLATE range end', + ); + final filtered = inflateDeflate( + DeflateInflateInput( + deflate: Uint8List.sublistView( + zlib, + plan.deflateRange.offset, + deflateEnd, + ), + expectedOutputLength: plan.expectedFilteredBytes, + ), + ); + return decodePngRgbaFromInflated(plan, filtered); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.pngDeflateInvalid, 'PNG could not be decoded'); + } +} + +/// Validate already-inflated bytes before the later native adapter may use +/// them. +PngRgbaDecodeResult decodePngRgbaFromInflated( + PngDecodePlan plan, + Uint8List filtered, +) { + try { + // Also authenticates the plan brand without retaining or cloning its + // bytes. + readOwnedPngZlib(plan); + if (filtered.length != plan.expectedFilteredBytes) { + _fail('inflated PNG length does not match the decode plan'); + } + if (adler32(filtered) != plan.declaredAdler32) { + _fail('inflated PNG Adler-32 does not match the zlib trailer'); + } + final rgba = unfilterPngRgba( + PngUnfilterInput(filtered: filtered, layout: readOwnedPngLayout(plan)), + ); + if (rgba.length != plan.expectedRgbaBytes) { + _fail('decoded RGBA length does not match the decode plan'); + } + return PngRgbaDecodeResult( + width: plan.width, + height: plan.height, + rgba: rgba, + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.pngDeflateInvalid, + 'inflated PNG bytes could not be validated', + ); + } +} + +Never _fail(String message) { + throw FormatError(FormatErrorCode.pngDeflateInvalid, message); +} diff --git a/flutter/packages/aval_format/lib/src/png/deflate.dart b/flutter/packages/aval_format/lib/src/png/deflate.dart new file mode 100644 index 0000000..1ed6b05 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/deflate.dart @@ -0,0 +1,314 @@ +/// Bounded RFC 1951 DEFLATE inflater (stored, fixed, and dynamic blocks). +/// +/// Dart port of `packages/format/src/png/deflate.ts`. Preserves every +/// validation rule, error message, and the exact work-limit formula from the +/// TS source. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart'; +import '../errors.dart'; +import 'deflate_bit_reader.dart'; +import 'deflate_huffman.dart'; + +const int _maxDistance = 32 * 1024; + +const List _codeLengthOrder = [ + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15, +]; +const List _lengthBase = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, + 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, +]; +const List _lengthExtra = [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, + 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, +]; +const List _distanceBase = [ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, + 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, + 6145, 8193, 12289, 16385, 24577, +]; +const List _distanceExtra = [ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, + 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, +]; + +final List _fixedLiteralLengths = _createFixedLiteralLengths(); +final List _fixedDistanceLengths = List.filled(32, 5); +final DeflateHuffmanTable _fixedLiteralTable = DeflateHuffmanTable.build( + _fixedLiteralLengths, + 'fixed literal/length', +); +final DeflateHuffmanTable _fixedDistanceTable = DeflateHuffmanTable.build( + _fixedDistanceLengths, + 'fixed distance', +); + +class DeflateInflateInput { + const DeflateInflateInput({ + required this.deflate, + required this.expectedOutputLength, + }); + + final Uint8List deflate; + final int expectedOutputLength; +} + +int calculateDeflateWorkLimit( + int compressedBytes, + int expectedInflatedBytes, +) { + if (compressedBytes < 0 || expectedInflatedBytes < 0) { + deflateInvalid('DEFLATE work-limit inputs are outside the PNG profile'); + } + try { + final bytes = checkedAdd( + compressedBytes, + expectedInflatedBytes, + maxSafeInteger, + 'DEFLATE work bytes', + ); + return checkedAdd( + checkedMultiply(bytes, 32, maxSafeInteger, 'DEFLATE work'), + 4096, + maxSafeInteger, + 'DEFLATE work limit', + ); + } on FormatError catch (error) { + deflateInvalid(error.message); + } catch (_) { + deflateInvalid('DEFLATE work limit could not be calculated'); + } +} + +Uint8List inflateDeflate(DeflateInflateInput input) { + return inflateDeflateWithLimit( + input, + calculateDeflateWorkLimit( + input.deflate.length, + input.expectedOutputLength, + ), + ); +} + +/// Package-internal deterministic lower-limit hook used by hostile tests. +Uint8List inflateDeflateWithLimit(DeflateInflateInput input, int workLimit) { + try { + if (input.deflate.isEmpty) { + deflateInvalid('DEFLATE byte length is outside the PNG profile'); + } + if (input.expectedOutputLength < 0) { + deflateInvalid('DEFLATE output length is outside the PNG profile'); + } + final reader = DeflateBitReader(input.deflate, workLimit); + Uint8List output; + try { + output = Uint8List(input.expectedOutputLength); + } catch (_) { + throw FormatError( + FormatErrorCode.pngDeflateInvalid, + 'DEFLATE output allocation failed for ' + '${input.expectedOutputLength} bytes', + ); + } + var outputOffset = 0; + var finalBlock = false; + while (!finalBlock) { + finalBlock = reader.readBits(1, 'BFINAL') == 1; + final blockType = reader.readBits(2, 'BTYPE'); + if (blockType == 0) { + outputOffset = _inflateStoredBlock(reader, output, outputOffset); + } else if (blockType == 1) { + outputOffset = _inflateHuffmanBlock( + reader, + output, + outputOffset, + _fixedLiteralTable, + _fixedDistanceTable, + ); + } else if (blockType == 2) { + final tables = _readDynamicTables(reader); + outputOffset = _inflateHuffmanBlock( + reader, + output, + outputOffset, + tables.literal, + tables.distance, + ); + } else { + deflateInvalid('reserved DEFLATE block type is forbidden'); + } + } + reader.finish(); + if (outputOffset != output.length) { + deflateInvalid('DEFLATE output length does not match the PNG profile'); + } + return output; + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.pngDeflateInvalid, + 'DEFLATE stream could not be decoded', + ); + } +} + +int _inflateStoredBlock( + DeflateBitReader reader, + Uint8List output, + int outputOffset, +) { + reader.alignToByte('stored block'); + final length = reader.readBits(16, 'stored LEN'); + final complement = reader.readBits(16, 'stored NLEN'); + if (((length ^ 0xffff) & 0xffff) != complement) { + deflateInvalid('stored DEFLATE LEN/NLEN mismatch'); + } + if (length > output.length - outputOffset) { + deflateInvalid('stored DEFLATE block exceeds expected output'); + } + var offset = outputOffset; + for (var index = 0; index < length; index += 1) { + output[offset] = reader.readBits(8, 'stored byte'); + offset += 1; + reader.copiedOutputByte(); + } + return offset; +} + +int _inflateHuffmanBlock( + DeflateBitReader reader, + Uint8List output, + int initialOutputOffset, + DeflateHuffmanTable literalTable, + DeflateHuffmanTable? distanceTable, +) { + var outputOffset = initialOutputOffset; + while (true) { + final symbol = literalTable.decode(reader, 'literal/length symbol'); + if (symbol < 256) { + if (outputOffset >= output.length) { + deflateInvalid('literal exceeds expected DEFLATE output'); + } + output[outputOffset] = symbol; + outputOffset += 1; + reader.copiedOutputByte(); + continue; + } + if (symbol == 256) return outputOffset; + if (symbol < 257 || symbol > 285) { + deflateInvalid('reserved literal/length symbol is forbidden'); + } + final distance_ = distanceTable; + if (distance_ == null) { + deflateInvalid( + 'DEFLATE length symbol requires a nonempty distance tree', + ); + } + final lengthIndex = symbol - 257; + final length = _lengthBase[lengthIndex] + + reader.readBits(_lengthExtra[lengthIndex], 'length extra bits'); + final distanceSymbol = distance_.decode(reader, 'distance symbol'); + if (distanceSymbol > 29) { + deflateInvalid('reserved distance symbol is forbidden'); + } + final distance = _distanceBase[distanceSymbol] + + reader.readBits(_distanceExtra[distanceSymbol], 'distance extra bits'); + if (distance < 1 || distance > _maxDistance || distance > outputOffset) { + deflateInvalid('DEFLATE distance exceeds produced history'); + } + if (length > output.length - outputOffset) { + deflateInvalid('length/distance copy exceeds expected DEFLATE output'); + } + for (var index = 0; index < length; index += 1) { + output[outputOffset] = output[outputOffset - distance]; + outputOffset += 1; + reader.copiedOutputByte(); + } + } +} + +class _DynamicTables { + const _DynamicTables(this.literal, this.distance); + + final DeflateHuffmanTable literal; + final DeflateHuffmanTable? distance; +} + +_DynamicTables _readDynamicTables(DeflateBitReader reader) { + final literalCount = reader.readBits(5, 'HLIT') + 257; + final distanceCount = reader.readBits(5, 'HDIST') + 1; + final codeLengthCount = reader.readBits(4, 'HCLEN') + 4; + final codeLengthLengths = List.filled(19, 0); + for (var index = 0; index < codeLengthCount; index += 1) { + codeLengthLengths[_codeLengthOrder[index]] = reader.readBits( + 3, + 'code-length code length', + ); + } + final codeLengthTable = DeflateHuffmanTable.build( + codeLengthLengths, + 'code-length', + ); + final total = literalCount + distanceCount; + final lengths = []; + while (lengths.length < total) { + final symbol = codeLengthTable.decode(reader, 'code-length symbol'); + if (symbol <= 15) { + lengths.add(symbol); + continue; + } + int repeated; + int count; + if (symbol == 16) { + if (lengths.isEmpty) { + deflateInvalid('code-length repeat 16 has no previous value'); + } + repeated = lengths[lengths.length - 1]; + count = reader.readBits(2, 'repeat-16 count') + 3; + } else if (symbol == 17) { + repeated = 0; + count = reader.readBits(3, 'repeat-17 count') + 3; + } else if (symbol == 18) { + repeated = 0; + count = reader.readBits(7, 'repeat-18 count') + 11; + } else { + deflateInvalid('reserved code-length symbol is forbidden'); + } + if (count > total - lengths.length) { + deflateInvalid('code-length repeat exceeds the declared tables'); + } + for (var index = 0; index < count; index += 1) { + lengths.add(repeated); + } + } + final literalLengths = lengths.sublist(0, literalCount); + final distanceLengths = lengths.sublist(literalCount); + if (literalLengths[256] == 0) { + deflateInvalid('literal/length tree must contain end-of-block symbol 256'); + } + final d30 = distanceLengths.length > 30 ? distanceLengths[30] : 0; + final d31 = distanceLengths.length > 31 ? distanceLengths[31] : 0; + if (d30 != 0 || d31 != 0) { + deflateInvalid('dynamic tree declares a reserved distance symbol'); + } + final distance = distanceLengths.every((length) => length == 0) + ? null + : DeflateHuffmanTable.build(distanceLengths, 'distance'); + return _DynamicTables( + DeflateHuffmanTable.build(literalLengths, 'literal/length'), + distance, + ); +} + +List _createFixedLiteralLengths() { + return List.generate(288, (symbol) { + if (symbol <= 143) return 8; + if (symbol <= 255) return 9; + if (symbol <= 279) return 7; + return 8; + }); +} diff --git a/flutter/packages/aval_format/lib/src/png/deflate_bit_reader.dart b/flutter/packages/aval_format/lib/src/png/deflate_bit_reader.dart new file mode 100644 index 0000000..f17d1dc --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/deflate_bit_reader.dart @@ -0,0 +1,80 @@ +/// LSB-first bounded bit reader for RFC 1951 DEFLATE streams. +/// +/// Dart port of `packages/format/src/png/deflate-bit-reader.ts`. The TS +/// constructor's runtime `instanceof Uint8Array` check is dropped because +/// Dart's `Uint8List` parameter type enforces it at compile time; the +/// `Number.isSafeInteger` guard on `workLimit` is likewise unnecessary since +/// Dart's `int` has no fractional/NaN states, so only the `>= 1` bound is +/// kept. +library; + +import 'dart:typed_data'; + +import '../errors.dart'; + +class DeflateBitReader { + DeflateBitReader(this._bytes, this._workLimit) { + if (_workLimit < 1) { + _fail('DEFLATE work limit must be a positive safe integer'); + } + } + + final Uint8List _bytes; + final int _workLimit; + int _bitOffset = 0; + int _work = 0; + + int get work => _work; + + int readBits(int count, String label) { + if (count < 0 || count > 24) { + _fail('$label bit count is invalid'); + } + var value = 0; + for (var bit = 0; bit < count; bit += 1) { + if (_bitOffset >= _bytes.length * 8) { + _fail('$label is truncated', _bytes.length); + } + _charge(1); + final byte = _bytes[_bitOffset ~/ 8]; + value |= ((byte >> (_bitOffset & 7)) & 1) << bit; + _bitOffset += 1; + } + return value; + } + + void alignToByte(String label) { + final remainder = _bitOffset & 7; + if (remainder == 0) return; + final padding = readBits(8 - remainder, '$label padding'); + if (padding != 0) _fail('$label padding bits must be zero'); + } + + void finish() { + alignToByte('terminal DEFLATE'); + if (_bitOffset != _bytes.length * 8) { + _fail('DEFLATE contains trailing bytes', _bitOffset ~/ 8); + } + } + + void decodedSymbol() => _charge(1); + + void copiedOutputByte() => _charge(1); + + void _charge(int amount) { + if (_work > _workLimit - amount) { + _fail('DEFLATE work limit exceeded'); + } + _work += amount; + } +} + +Never deflateInvalid(String message, [int? offset]) => _fail(message, offset); + +Never _fail(String message, [int? offset]) { + throw FormatError( + FormatErrorCode.pngDeflateInvalid, + message, + offset == null ? null : FormatErrorDetails(offset: offset), + ); +} diff --git a/flutter/packages/aval_format/lib/src/png/deflate_huffman.dart b/flutter/packages/aval_format/lib/src/png/deflate_huffman.dart new file mode 100644 index 0000000..07315c8 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/deflate_huffman.dart @@ -0,0 +1,90 @@ +/// Canonical Huffman tree construction and decoding for RFC 1951 DEFLATE. +/// +/// Dart port of `packages/format/src/png/deflate-huffman.ts`. The TS +/// `Array.isArray`/`instanceof Uint8Array` runtime check on `lengths` is +/// dropped because Dart's `List` parameter type enforces it at compile +/// time. +library; + +import 'dart:typed_data'; + +import 'deflate_bit_reader.dart'; + +const int _maxCodeBits = 15; + +class DeflateHuffmanTable { + DeflateHuffmanTable._(this._codes, this._maximumBits); + + final List> _codes; + final int _maximumBits; + + static DeflateHuffmanTable build(List lengths, String label) { + final counts = Uint16List(_maxCodeBits + 1); + var symbols = 0; + var maximumBits = 0; + for (var symbol = 0; symbol < lengths.length; symbol += 1) { + final length = lengths[symbol]; + if (length < 0 || length > _maxCodeBits) { + deflateInvalid('$label contains an invalid code length'); + } + if (length != 0) { + counts[length] = counts[length] + 1; + symbols += 1; + if (length > maximumBits) maximumBits = length; + } + } + if (symbols == 0) deflateInvalid('$label Huffman tree is empty'); + + var remaining = 1; + for (var bits = 1; bits <= _maxCodeBits; bits += 1) { + remaining = remaining * 2 - counts[bits]; + if (remaining < 0) { + deflateInvalid('$label Huffman tree is oversubscribed'); + } + } + final permittedSingle = symbols == 1 && counts[1] == 1; + if (remaining != 0 && !permittedSingle) { + deflateInvalid('$label Huffman tree is incomplete'); + } + + final nextCodes = Uint16List(_maxCodeBits + 1); + var code = 0; + for (var bits = 1; bits <= _maxCodeBits; bits += 1) { + code = (code + counts[bits - 1]) << 1; + nextCodes[bits] = code; + } + final mutable = List>.generate( + maximumBits + 1, + (_) => {}, + ); + for (var symbol = 0; symbol < lengths.length; symbol += 1) { + final length = lengths[symbol]; + if (length == 0) continue; + final canonical = nextCodes[length]; + nextCodes[length] = canonical + 1; + mutable[length][_reverseBits(canonical, length)] = symbol; + } + return DeflateHuffmanTable._(mutable, maximumBits); + } + + int decode(DeflateBitReader reader, String label) { + var code = 0; + for (var length = 1; length <= _maximumBits; length += 1) { + code |= reader.readBits(1, label) << (length - 1); + final symbol = _codes[length][code]; + if (symbol != null) { + reader.decodedSymbol(); + return symbol; + } + } + deflateInvalid('$label does not match the Huffman tree'); + } +} + +int _reverseBits(int value, int width) { + var result = 0; + for (var index = 0; index < width; index += 1) { + result = (result << 1) | ((value >> index) & 1); + } + return result; +} diff --git a/flutter/packages/aval_format/lib/src/png/profile.dart b/flutter/packages/aval_format/lib/src/png/profile.dart new file mode 100644 index 0000000..960c756 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/profile.dart @@ -0,0 +1,138 @@ +/// Restricted PNG profile validation, producing an immutable, caller-opaque +/// decode plan. +/// +/// Dart port of `packages/format/src/png/profile.ts`. The TS source keeps +/// the detached zlib bytes and the checked layout out of the public +/// `PngDecodePlan` object shape via module-level `WeakMap`s, so a caller +/// cannot see or tamper with them while `decode.ts` (a trusted sibling +/// module) can still retrieve them by object identity. Dart's library-level +/// privacy gives the same guarantee directly: [PngDecodePlan] stores them in +/// private (`_`-prefixed) fields that only this file can read, and the +/// package-private accessors [readOwnedPngZlib]/[readOwnedPngLayout] below +/// (exported, but only meaningful to sibling `png/*.dart` files) hand them to +/// `decode.dart` without exposing them as public fields on the plan itself. +library; + +import 'dart:typed_data'; + +import '../constants.dart'; +import '../errors.dart'; +import '../model.dart' show ByteRange, FormatOptions; +import 'chunks.dart'; +import 'unfilter.dart'; +import 'zlib_envelope.dart'; + +const int _uint32Max = 0xffffffff; + +class PngProfileValidationInput { + const PngProfileValidationInput({ + required this.png, + required this.expectedWidth, + required this.expectedHeight, + this.options, + }); + + final Uint8List png; + final int expectedWidth; + final int expectedHeight; + final FormatOptions? options; +} + +class PngDecodePlan { + PngDecodePlan._({ + required this.width, + required this.height, + required this.byteRange, + required this.expectedFilteredBytes, + required this.expectedRgbaBytes, + required this.zlibByteLength, + required this.deflateRange, + required this.declaredAdler32, + required Uint8List zlibBytes, + required PngRgbaLayout layout, + }) : _zlibBytes = zlibBytes, + _layout = layout; + + final int width; + final int height; + final ByteRange byteRange; + final int expectedFilteredBytes; + final int expectedRgbaBytes; + final int zlibByteLength; + final ByteRange deflateRange; + final int declaredAdler32; + final Uint8List _zlibBytes; + final PngRgbaLayout _layout; + + /// Fresh caller-owned copy of the detached zlib member. + Uint8List copyZlibBytes() => _copyOwnedPngZlib(this); +} + +PngDecodePlan validatePngProfile(PngProfileValidationInput input) { + try { + final expectedWidth = _expectedDimension( + input.expectedWidth, + 'expected PNG width', + ); + final expectedHeight = _expectedDimension( + input.expectedHeight, + 'expected PNG height', + ); + final budgets = resolveFormatBudgets(input.options); + final chunks = parseRestrictedPngChunks( + png: input.png, + expectedWidth: expectedWidth, + expectedHeight: expectedHeight, + maximumPngBytes: budgets.maxPngBytes, + ); + final layout = derivePngRgbaLayout(expectedWidth, expectedHeight); + final zlib = validateZlibEnvelope(chunks.zlibBytes); + return PngDecodePlan._( + width: chunks.width, + height: chunks.height, + byteRange: ByteRange(offset: 0, length: input.png.length), + expectedFilteredBytes: layout.filteredBytes, + expectedRgbaBytes: layout.rgbaBytes, + zlibByteLength: chunks.zlibBytes.length, + deflateRange: zlib.deflateRange, + declaredAdler32: zlib.declaredAdler32, + zlibBytes: chunks.zlibBytes, + layout: layout, + ); + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.pngEnvelopeInvalid, + 'PNG profile could not be validated', + ); + } +} + +/// Package-internal zero-copy access to the detached zlib member. +Uint8List readOwnedPngZlib(PngDecodePlan plan) => plan._zlibBytes; + +Uint8List _copyOwnedPngZlib(PngDecodePlan plan) { + final bytes = plan._zlibBytes; + try { + return Uint8List.fromList(bytes); + } catch (_) { + throw FormatError( + FormatErrorCode.pngEnvelopeInvalid, + 'PNG zlib copy allocation failed for ${bytes.length} bytes', + ); + } +} + +/// Package-internal access to the checked layout associated with a plan. +PngRgbaLayout readOwnedPngLayout(PngDecodePlan plan) => plan._layout; + +int _expectedDimension(int value, String label) { + if (value < 1 || value > _uint32Max) { + throw FormatError( + FormatErrorCode.pngEnvelopeInvalid, + '$label must be from 1 through $_uint32Max', + ); + } + return value; +} diff --git a/flutter/packages/aval_format/lib/src/png/unfilter.dart b/flutter/packages/aval_format/lib/src/png/unfilter.dart new file mode 100644 index 0000000..e49e2ad --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/unfilter.dart @@ -0,0 +1,170 @@ +/// Noninterlaced 8-bit RGBA scanline unfiltering (PNG filter types 0-4). +/// +/// Dart port of `packages/format/src/png/unfilter.ts`. The TS source brands +/// a `PngRgbaLayout` via a module-level `WeakSet` so `unfilterPngRgba` can +/// reject a layout object that was not produced by `derivePngRgbaLayout`. +/// Dart achieves the same guarantee more directly: [PngRgbaLayout] has a +/// private constructor, so only this library can ever construct one, and +/// every [PngRgbaLayout] value that type-checks is therefore genuine — no +/// runtime brand check is needed. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart'; +import '../errors.dart'; + +const int _bytesPerPixel = 4; +const int _uint32Max = 0xffffffff; + +class PngRgbaLayout { + const PngRgbaLayout._({ + required this.width, + required this.height, + required this.rowBytes, + required this.filteredRowBytes, + required this.filteredBytes, + required this.rgbaBytes, + }); + + final int width; + final int height; + final int rowBytes; + final int filteredRowBytes; + final int filteredBytes; + final int rgbaBytes; +} + +/// Derive all noninterlaced 8-bit RGBA storage using checked arithmetic once. +PngRgbaLayout derivePngRgbaLayout(int widthValue, int heightValue) { + final width = _dimension(widthValue, 'PNG width'); + final height = _dimension(heightValue, 'PNG height'); + final rowBytes = checkedMultiply( + width, + _bytesPerPixel, + maxSafeInteger, + 'PNG row bytes', + ); + final filteredRowBytes = checkedAdd( + rowBytes, + 1, + maxSafeInteger, + 'PNG filtered row bytes', + ); + final filteredBytes = checkedMultiply( + height, + filteredRowBytes, + maxSafeInteger, + 'PNG filtered bytes', + ); + final rgbaBytes = checkedMultiply( + height, + rowBytes, + maxSafeInteger, + 'PNG RGBA bytes', + ); + return PngRgbaLayout._( + width: width, + height: height, + rowBytes: rowBytes, + filteredRowBytes: filteredRowBytes, + filteredBytes: filteredBytes, + rgbaBytes: rgbaBytes, + ); +} + +class PngUnfilterInput { + const PngUnfilterInput({required this.filtered, required this.layout}); + + final Uint8List filtered; + final PngRgbaLayout layout; +} + +/// Reconstruct exact noninterlaced 8-bit RGBA scanlines for filters 0-4. +Uint8List unfilterPngRgba(PngUnfilterInput input) { + try { + final layout = input.layout; + final height = layout.height; + final rowBytes = layout.rowBytes; + final filteredRowBytes = layout.filteredRowBytes; + final filteredBytes = layout.filteredBytes; + final rgbaBytes = layout.rgbaBytes; + if (input.filtered.length != filteredBytes) { + _fail('filtered PNG length does not match its dimensions'); + } + Uint8List rgba; + try { + rgba = Uint8List(rgbaBytes); + } catch (_) { + throw FormatError( + FormatErrorCode.pngScanlineInvalid, + 'PNG RGBA allocation failed for $rgbaBytes bytes', + ); + } + for (var row = 0; row < height; row += 1) { + final sourceRow = row * filteredRowBytes; + final targetRow = row * rowBytes; + final filter = input.filtered[sourceRow]; + if (filter > 4) { + _fail('PNG scanline filter must be from 0 through 4', sourceRow); + } + for (var column = 0; column < rowBytes; column += 1) { + final encoded = input.filtered[sourceRow + 1 + column]; + final left = column >= _bytesPerPixel + ? rgba[targetRow + column - _bytesPerPixel] + : 0; + final up = row > 0 ? rgba[targetRow - rowBytes + column] : 0; + final upperLeft = row > 0 && column >= _bytesPerPixel + ? rgba[targetRow - rowBytes + column - _bytesPerPixel] + : 0; + final int predictor; + if (filter == 0) { + predictor = 0; + } else if (filter == 1) { + predictor = left; + } else if (filter == 2) { + predictor = up; + } else if (filter == 3) { + predictor = (left + up) ~/ 2; + } else { + predictor = _paeth(left, up, upperLeft); + } + rgba[targetRow + column] = (encoded + predictor) & 0xff; + } + } + return rgba; + } on FormatError { + rethrow; + } catch (_) { + throw FormatError( + FormatErrorCode.pngScanlineInvalid, + 'PNG scanlines could not be reconstructed', + ); + } +} + +int _dimension(int value, String label) { + if (value < 1 || value > _uint32Max) { + _fail('$label must be from 1 through $_uint32Max'); + } + return value; +} + +int _paeth(int left, int up, int upperLeft) { + final prediction = left + up - upperLeft; + final leftDistance = (prediction - left).abs(); + final upDistance = (prediction - up).abs(); + final upperLeftDistance = (prediction - upperLeft).abs(); + if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) { + return left; + } + return upDistance <= upperLeftDistance ? up : upperLeft; +} + +Never _fail(String message, [int? offset]) { + throw FormatError( + FormatErrorCode.pngScanlineInvalid, + message, + offset == null ? null : FormatErrorDetails(offset: offset), + ); +} diff --git a/flutter/packages/aval_format/lib/src/png/zlib_envelope.dart b/flutter/packages/aval_format/lib/src/png/zlib_envelope.dart new file mode 100644 index 0000000..49b748d --- /dev/null +++ b/flutter/packages/aval_format/lib/src/png/zlib_envelope.dart @@ -0,0 +1,55 @@ +/// RFC 1950 zlib envelope (CMF/FLG header, dictionary flag, and Adler-32 +/// trailer) validation for the restricted PNG profile. +/// +/// Dart port of `packages/format/src/png/zlib-envelope.ts`. +library; + +import 'dart:typed_data'; + +import '../errors.dart'; +import '../model.dart' show ByteRange; + +class ZlibEnvelope { + const ZlibEnvelope({ + required this.deflateRange, + required this.declaredAdler32, + }); + + final ByteRange deflateRange; + final int declaredAdler32; +} + +ZlibEnvelope validateZlibEnvelope(Uint8List zlib) { + if (zlib.length < 7) { + _fail('zlib member is missing DEFLATE data or Adler-32 trailer'); + } + final cmf = zlib[0]; + final flg = zlib[1]; + if ((cmf & 0x0f) != 8) _fail('zlib compression method must be DEFLATE', 0); + if ((cmf >> 4) > 7) _fail('zlib window size exceeds 32 KiB', 0); + if (((cmf << 8) | flg) % 31 != 0) _fail('zlib FCHECK is invalid', 1); + if ((flg & 0x20) != 0) _fail('zlib preset dictionaries are forbidden', 1); + final deflateLength = zlib.length - 6; + if (deflateLength < 1) _fail('zlib member must contain a DEFLATE block', 2); + final trailerOffset = zlib.length - 4; + final declaredAdler32 = _readUint32Be(zlib, trailerOffset); + return ZlibEnvelope( + deflateRange: ByteRange(offset: 2, length: deflateLength), + declaredAdler32: declaredAdler32, + ); +} + +int _readUint32Be(Uint8List bytes, int offset) { + return bytes[offset] * 0x1000000 + + bytes[offset + 1] * 0x10000 + + bytes[offset + 2] * 0x100 + + bytes[offset + 3]; +} + +Never _fail(String message, [int? offset]) { + throw FormatError( + FormatErrorCode.pngEnvelopeInvalid, + message, + offset == null ? null : FormatErrorDetails(offset: offset), + ); +} diff --git a/flutter/packages/aval_format/lib/src/utf8.dart b/flutter/packages/aval_format/lib/src/utf8.dart new file mode 100644 index 0000000..05b992f --- /dev/null +++ b/flutter/packages/aval_format/lib/src/utf8.dart @@ -0,0 +1,175 @@ +/// Strict UTF-8 <-> UTF-16 codecs used by the canonical JSON layer. +/// +/// Dart port of `packages/format/src/utf8.ts`. Dart strings are UTF-16 code +/// unit sequences (same as JS), so surrogate-pair handling mirrors the TS +/// source exactly using `String.codeUnitAt`. +library; + +class UnicodeScalar { + const UnicodeScalar({required this.codePoint, required this.width}); + + final int codePoint; + + /// Bytes for UTF-8 input, UTF-16 code units for Dart strings. + final int width; +} + +/// Reports a decoding failure. Implementations never return normally. +typedef UnicodeFailure = Never Function(String message, [int? offset]); + +bool isHighSurrogate(int codeUnit) => codeUnit >= 0xd800 && codeUnit <= 0xdbff; + +bool isLowSurrogate(int codeUnit) => codeUnit >= 0xdc00 && codeUnit <= 0xdfff; + +int decodeSurrogatePair(int high, int low) => + 0x10000 + ((high - 0xd800) << 10) + (low - 0xdc00); + +/// Returns the number of bytes in the shortest UTF-8 encoding of a scalar. +int utf8ScalarWidth(int codePoint) { + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +/// Decodes one strictly well-formed Unicode scalar from UTF-8 bytes. +UnicodeScalar readUtf8Scalar( + List bytes, + int offset, + UnicodeFailure fail, +) { + if (offset >= bytes.length) { + fail('Truncated UTF-8 sequence', offset); + } + final first = bytes[offset]; + + if (first <= 0x7f) return UnicodeScalar(codePoint: first, width: 1); + + int width; + int minimum; + int codePoint; + + if (first >= 0xc2 && first <= 0xdf) { + width = 2; + minimum = 0x80; + codePoint = first & 0x1f; + } else if (first >= 0xe0 && first <= 0xef) { + width = 3; + minimum = 0x800; + codePoint = first & 0x0f; + } else if (first >= 0xf0 && first <= 0xf4) { + width = 4; + minimum = 0x10000; + codePoint = first & 0x07; + } else { + fail('Invalid UTF-8 leading byte', offset); + } + + if (offset + width > bytes.length) { + fail('Truncated UTF-8 sequence', offset); + } + + for (var index = 1; index < width; index += 1) { + if (offset + index >= bytes.length) { + fail('Invalid UTF-8 continuation byte', offset + index); + } + final continuation = bytes[offset + index]; + if ((continuation & 0xc0) != 0x80) { + fail('Invalid UTF-8 continuation byte', offset + index); + } + codePoint = (codePoint << 6) | (continuation & 0x3f); + } + + if (codePoint < minimum || + codePoint > 0x10ffff || + isHighSurrogate(codePoint) || + isLowSurrogate(codePoint)) { + fail('Invalid UTF-8 scalar value', offset); + } + + return UnicodeScalar(codePoint: codePoint, width: width); +} + +/// Reads one Unicode scalar from a Dart (UTF-16) string. +UnicodeScalar readStringScalar( + String value, + int offset, + UnicodeFailure fail, +) { + if (offset >= value.length) { + fail('Unexpected end of string', offset); + } + final first = value.codeUnitAt(offset); + if (!isHighSurrogate(first) && !isLowSurrogate(first)) { + return UnicodeScalar(codePoint: first, width: 1); + } + if (isLowSurrogate(first)) { + fail('String contains a lone low surrogate', offset); + } + + if (offset + 1 >= value.length) { + fail('String contains a lone high surrogate', offset); + } + final second = value.codeUnitAt(offset + 1); + if (!isLowSurrogate(second)) { + fail('String contains a lone high surrogate', offset); + } + return UnicodeScalar( + codePoint: decodeSurrogatePair(first, second), + width: 2, + ); +} + +/// Appends the shortest UTF-8 encoding of a Unicode scalar. +void pushUtf8Scalar(List target, int codePoint) { + if (codePoint <= 0x7f) { + target.add(codePoint); + } else if (codePoint <= 0x7ff) { + target.add(0xc0 | (codePoint >> 6)); + target.add(0x80 | (codePoint & 0x3f)); + } else if (codePoint <= 0xffff) { + target.add(0xe0 | (codePoint >> 12)); + target.add(0x80 | ((codePoint >> 6) & 0x3f)); + target.add(0x80 | (codePoint & 0x3f)); + } else { + target.add(0xf0 | (codePoint >> 18)); + target.add(0x80 | ((codePoint >> 12) & 0x3f)); + target.add(0x80 | ((codePoint >> 6) & 0x3f)); + target.add(0x80 | (codePoint & 0x3f)); + } +} + +/// Counts UTF-8 bytes while rejecting unpaired UTF-16 surrogates. +int utf8ByteLength(String value, UnicodeFailure fail) { + var length = 0; + var offset = 0; + while (offset < value.length) { + final scalar = readStringScalar(value, offset, fail); + length += utf8ScalarWidth(scalar.codePoint); + offset += scalar.width; + } + return length; +} + +/// Encodes a Dart string as strict UTF-8. +List encodeUtf8String(String value, UnicodeFailure fail) { + final bytes = []; + var offset = 0; + while (offset < value.length) { + final scalar = readStringScalar(value, offset, fail); + pushUtf8Scalar(bytes, scalar.codePoint); + offset += scalar.width; + } + return bytes; +} + +/// Compares byte strings using unsigned lexicographic order. +int compareBytes(List left, List right) { + final length = left.length < right.length ? left.length : right.length; + for (var index = 0; index < length; index += 1) { + final leftByte = left[index]; + final rightByte = right[index]; + if (leftByte != rightByte) return leftByte - rightByte; + } + return left.length - right.length; +} diff --git a/flutter/packages/aval_format/lib/src/video/codec_string.dart b/flutter/packages/aval_format/lib/src/video/codec_string.dart new file mode 100644 index 0000000..82bbb70 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/video/codec_string.dart @@ -0,0 +1,69 @@ +/// Canonical WebCodecs codec-string parsing shared across codecs. +/// +/// Dart port of `packages/format/src/video/codec-string.ts`. +library; + +import '../av1/codec.dart' show isAv1Codec; +import '../h264/codec.dart' show isH264Codec; +import '../h265/codec.dart' show parseH265Codec; +import '../model.dart' show VideoBitDepth, VideoBitstream, VideoCodec; +import '../vp9/codec.dart' show isVp9Codec; + +const List videoCodecs = ['h264', 'h265', 'vp9', 'av1']; + +const Map videoBitstreamByCodec = { + 'h264': 'annex-b', + 'h265': 'annex-b', + 'vp9': 'frame', + 'av1': 'low-overhead', +}; + +/// `{ family, bitDepth }` — the parsed family and declared bit depth of a +/// supported WebCodecs codec string. +class ParsedVideoCodecString { + const ParsedVideoCodecString({required this.family, required this.bitDepth}); + + /// `"h264" | "h265" | "vp9" | "av1"`. + final VideoCodec family; + final VideoBitDepth bitDepth; +} + +final RegExp _vp9Short = + RegExp(r'^vp09\.00\.(?:10|11|20|21|30|31|40|41|50|51|52|60|61|62)\.08$', unicode: true); +final RegExp _av1Short = + RegExp(r'^av01\.0\.(?:0[0-9]|[12][0-9]|3[01])[MH]\.(08|10)$', unicode: true); + +/// Parse one canonical WebCodecs codec string supported by the AVAL format. +ParsedVideoCodecString? parseVideoCodecString(String value) { + if (isH264Codec(value)) { + return const ParsedVideoCodecString(family: 'h264', bitDepth: 8); + } + + final h265 = parseH265Codec(value); + if (h265 != null) { + return ParsedVideoCodecString(family: 'h265', bitDepth: h265.bitDepth); + } + + if (isVp9Codec(value) || _vp9Short.hasMatch(value)) { + return const ParsedVideoCodecString(family: 'vp9', bitDepth: 8); + } + + final av1Short = _av1Short.firstMatch(value); + if (isAv1Codec(value) || av1Short != null) { + final parts = value.split('.'); + final bitDepthTerm = + av1Short?.group(1) ?? (parts.length > 3 ? parts[3] : null); + return ParsedVideoCodecString( + family: 'av1', + bitDepth: bitDepthTerm == '10' ? 10 : 8, + ); + } + + return null; +} + +bool isVideoCodecString(Object? value, VideoCodec family, VideoBitDepth bitDepth) { + if (value is! String) return false; + final parsed = parseVideoCodecString(value); + return parsed?.family == family && parsed?.bitDepth == bitDepth; +} diff --git a/flutter/packages/aval_format/lib/src/video/geometry.dart b/flutter/packages/aval_format/lib/src/video/geometry.dart new file mode 100644 index 0000000..fa28982 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/video/geometry.dart @@ -0,0 +1,118 @@ +/// Shared opaque/packed-alpha storage geometry derivation. +/// +/// Dart port of `packages/format/src/video/geometry.ts`. +library; + +import '../errors.dart'; +import '../model.dart' show Rect; +import 'model.dart' show VideoRenditionGeometry, VideoRenditionGeometryInput; + +const int packedAlphaGutter = 8; + +const int _maxSafeInteger = 9007199254740991; + +/// Derive the shared opaque/packed-alpha storage geometry for one codec policy. +/// +/// Codec adapters own the encoded-surface alignment; this function owns every +/// cross-codec packing and decoded-byte calculation. +VideoRenditionGeometry deriveVideoRenditionGeometry( + VideoRenditionGeometryInput input, +) { + final canvasWidth = _positive(input.canvasWidth, 'canvasWidth'); + final canvasHeight = _positive(input.canvasHeight, 'canvasHeight'); + final visibleWidth = _positive(input.visibleWidth, 'visibleWidth'); + final visibleHeight = _positive(input.visibleHeight, 'visibleHeight'); + if (visibleWidth > canvasWidth || visibleHeight > canvasHeight) { + _invalid('visible color rectangle must fit the logical canvas'); + } + if (BigInt.from(visibleWidth) * BigInt.from(canvasHeight) != + BigInt.from(visibleHeight) * BigInt.from(canvasWidth)) { + _invalid('visible color rectangle must retain the canvas aspect ratio'); + } + if (input.layout != 'opaque' && input.layout != 'packed-alpha') { + _invalid('layout must be opaque or packed-alpha'); + } + final widthAlignment = + _positive(input.storage.widthAlignment, 'storage.widthAlignment'); + final heightAlignment = + _positive(input.storage.heightAlignment, 'storage.heightAlignment'); + + // Every supported production profile is 4:2:0, so each pane is even before + // codec-specific padding is applied. + final paneWidth = _align(visibleWidth, 2, 'visibleWidth'); + final paneHeight = _align(visibleHeight, 2, 'visibleHeight'); + final visibleColorRect = Rect(0, 0, visibleWidth, visibleHeight); + var storageHeight = paneHeight; + Rect? visibleAlphaRect; + if (input.layout == 'packed-alpha') { + visibleAlphaRect = Rect( + 0, + _add(paneHeight, packedAlphaGutter, 'alpha y'), + visibleWidth, + visibleHeight, + ); + storageHeight = _add( + _product(2, paneHeight, 'packed height'), + packedAlphaGutter, + 'packed height', + ); + } + + final codedWidth = _align(paneWidth, widthAlignment, 'codedWidth'); + final codedHeight = _align(storageHeight, heightAlignment, 'codedHeight'); + final decodedStorageRect = Rect(0, 0, paneWidth, storageHeight); + final visibleColorArea = + _product(visibleWidth, visibleHeight, 'visible color area'); + final decodedRgbaBytes = _product( + _product(paneWidth, storageHeight, 'decoded pixels'), + 4, + 'decoded RGBA bytes', + ); + final codedRgbaBytes = _product( + _product(codedWidth, codedHeight, 'coded pixels'), + 4, + 'coded RGBA bytes', + ); + + return VideoRenditionGeometry( + layout: input.layout, + visibleColorRect: visibleColorRect, + visibleAlphaRect: visibleAlphaRect, + decodedStorageRect: decodedStorageRect, + codedWidth: codedWidth, + codedHeight: codedHeight, + visibleColorArea: visibleColorArea, + decodedRgbaBytes: decodedRgbaBytes, + codedRgbaBytes: codedRgbaBytes, + ); +} + +int _positive(int value, String path) { + if (value < 1 || value > _maxSafeInteger) { + _invalid('$path must be a positive safe integer'); + } + return value; +} + +int _align(int value, int alignment, String path) { + final remainder = value % alignment; + return remainder == 0 ? value : _add(value, alignment - remainder, path); +} + +int _add(int left, int right, String path) { + if (left > _maxSafeInteger - right) { + _invalid('$path exceeds the safe integer range'); + } + return left + right; +} + +int _product(int left, int right, String path) { + if (left != 0 && right > _maxSafeInteger ~/ left) { + _invalid('$path exceeds the safe integer range'); + } + return left * right; +} + +Never _invalid(String message) { + throw FormatError(FormatErrorCode.profileInvalid, message); +} diff --git a/flutter/packages/aval_format/lib/src/video/model.dart b/flutter/packages/aval_format/lib/src/video/model.dart new file mode 100644 index 0000000..87dd02e --- /dev/null +++ b/flutter/packages/aval_format/lib/src/video/model.dart @@ -0,0 +1,62 @@ +/// Shared codec-agnostic video surface types. +/// +/// Dart port of `packages/format/src/video/model.ts`. +library; + +import '../model.dart' show Rect, VideoLayout; + +/// Codec-owned encoded-surface alignment policy. +class VideoStoragePolicy { + const VideoStoragePolicy({ + required this.widthAlignment, + required this.heightAlignment, + }); + + /// Required encoded-surface width multiple. + final int widthAlignment; + + /// Required encoded-surface height multiple. + final int heightAlignment; +} + +class VideoRenditionGeometryInput { + const VideoRenditionGeometryInput({ + required this.canvasWidth, + required this.canvasHeight, + required this.layout, + required this.visibleWidth, + required this.visibleHeight, + required this.storage, + }); + + final int canvasWidth; + final int canvasHeight; + final VideoLayout layout; + final int visibleWidth; + final int visibleHeight; + final VideoStoragePolicy storage; +} + +class VideoRenditionGeometry { + const VideoRenditionGeometry({ + required this.layout, + required this.visibleColorRect, + this.visibleAlphaRect, + required this.decodedStorageRect, + required this.codedWidth, + required this.codedHeight, + required this.visibleColorArea, + required this.decodedRgbaBytes, + required this.codedRgbaBytes, + }); + + final VideoLayout layout; + final Rect visibleColorRect; + final Rect? visibleAlphaRect; + final Rect decodedStorageRect; + final int codedWidth; + final int codedHeight; + final int visibleColorArea; + final int decodedRgbaBytes; + final int codedRgbaBytes; +} diff --git a/flutter/packages/aval_format/lib/src/vp9/bit_reader.dart b/flutter/packages/aval_format/lib/src/vp9/bit_reader.dart new file mode 100644 index 0000000..2663967 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/vp9/bit_reader.dart @@ -0,0 +1,53 @@ +/// Bounded MSB-first reader for VP9 uncompressed headers. +/// +/// Dart port of `packages/format/src/vp9/bit-reader.ts`. +library; + +import 'dart:typed_data'; + +import '../errors.dart'; + +/// Bounded MSB-first reader for VP9 uncompressed headers. +class Vp9BitReader { + Vp9BitReader(this._bytes, this._path); + + final Uint8List _bytes; + final String _path; + int _bitOffset = 0; + + int get bitOffset => _bitOffset; + + int get bitsRemaining => _bytes.length * 8 - _bitOffset; + + bool readBit(String label) { + if (_bitOffset >= _bytes.length * 8) { + _fail('truncated $label'); + } + final byte = _bytes[_bitOffset ~/ 8]; + final shift = 7 - (_bitOffset % 8); + _bitOffset += 1; + return ((byte >> shift) & 1) == 1; + } + + int readBits(int width, String label) { + if (width < 0 || width > 32) { + _fail('invalid bit width for $label'); + } + if (bitsRemaining < width) _fail('truncated $label'); + var value = 0; + for (var index = 0; index < width; index += 1) { + value = value * 2 + (readBit(label) ? 1 : 0); + } + return value; + } + + int readByte(String label) => readBits(8, label); + + Never _fail(String message) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'VP9 $message', + FormatErrorDetails(path: _path, offset: _bitOffset ~/ 8), + ); + } +} diff --git a/flutter/packages/aval_format/lib/src/vp9/codec.dart b/flutter/packages/aval_format/lib/src/vp9/codec.dart new file mode 100644 index 0000000..d89b79c --- /dev/null +++ b/flutter/packages/aval_format/lib/src/vp9/codec.dart @@ -0,0 +1,105 @@ +/// VP9 profile-0 level table and codec-string identification. +/// +/// Dart port of `packages/format/src/vp9/codec.ts`. +library; + +import '../errors.dart'; + +/// One of the supported VP9 level identifiers (`"10"` … `"62"`). +/// +/// The TypeScript source models this as a string-literal union; equality is +/// on the string value, so a [String] typedef preserves behavior. +typedef Vp9Level = String; + +/// Fully-qualified VP9 codec string, e.g. `"vp09.00.10.08.01.01.01.01.00"`. +typedef Vp9Codec = String; + +class _Vp9LevelLimit { + const _Vp9LevelLimit({ + required this.level, + required this.maximumLumaSampleRate, + required this.maximumLumaPictureSize, + required this.maximumBitrate, + required this.maximumDimension, + }); + + final String level; + final int maximumLumaSampleRate; + final int maximumLumaPictureSize; + final int maximumBitrate; + final int maximumDimension; +} + +const List<_Vp9LevelLimit> _levels = [ + _Vp9LevelLimit(level: '10', maximumLumaSampleRate: 829440, maximumLumaPictureSize: 36864, maximumBitrate: 200000, maximumDimension: 512), + _Vp9LevelLimit(level: '11', maximumLumaSampleRate: 2764800, maximumLumaPictureSize: 73728, maximumBitrate: 800000, maximumDimension: 768), + _Vp9LevelLimit(level: '20', maximumLumaSampleRate: 4608000, maximumLumaPictureSize: 122880, maximumBitrate: 1800000, maximumDimension: 960), + _Vp9LevelLimit(level: '21', maximumLumaSampleRate: 9216000, maximumLumaPictureSize: 245760, maximumBitrate: 3600000, maximumDimension: 1344), + _Vp9LevelLimit(level: '30', maximumLumaSampleRate: 20736000, maximumLumaPictureSize: 552960, maximumBitrate: 7200000, maximumDimension: 2048), + _Vp9LevelLimit(level: '31', maximumLumaSampleRate: 36864000, maximumLumaPictureSize: 983040, maximumBitrate: 12000000, maximumDimension: 2752), + _Vp9LevelLimit(level: '40', maximumLumaSampleRate: 83558400, maximumLumaPictureSize: 2228224, maximumBitrate: 18000000, maximumDimension: 4160), + _Vp9LevelLimit(level: '41', maximumLumaSampleRate: 160432128, maximumLumaPictureSize: 2228224, maximumBitrate: 30000000, maximumDimension: 4160), + _Vp9LevelLimit(level: '50', maximumLumaSampleRate: 311951360, maximumLumaPictureSize: 8912896, maximumBitrate: 60000000, maximumDimension: 8384), + _Vp9LevelLimit(level: '51', maximumLumaSampleRate: 588251136, maximumLumaPictureSize: 8912896, maximumBitrate: 120000000, maximumDimension: 8384), + _Vp9LevelLimit(level: '52', maximumLumaSampleRate: 1176502272, maximumLumaPictureSize: 8912896, maximumBitrate: 180000000, maximumDimension: 8384), + _Vp9LevelLimit(level: '60', maximumLumaSampleRate: 1176502272, maximumLumaPictureSize: 35651584, maximumBitrate: 180000000, maximumDimension: 16832), + _Vp9LevelLimit(level: '61', maximumLumaSampleRate: 2353004544, maximumLumaPictureSize: 35651584, maximumBitrate: 240000000, maximumDimension: 16832), + _Vp9LevelLimit(level: '62', maximumLumaSampleRate: 4706009088, maximumLumaPictureSize: 35651584, maximumBitrate: 480000000, maximumDimension: 16832), +]; + +class DeriveVp9CodecInput { + const DeriveVp9CodecInput({ + required this.width, + required this.height, + required this.codedFramesPerSecond, + required this.averageBitrate, + }); + + final num width; + final num height; + final num codedFramesPerSecond; + final num averageBitrate; +} + +Vp9Codec deriveVp9Codec(DeriveVp9CodecInput input) { + for (final value in [ + input.width, + input.height, + input.codedFramesPerSecond, + input.averageBitrate, + ]) { + if (!value.isFinite || value <= 0) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'VP9 level inputs must be positive', + ); + } + } + final pictureSize = input.width * input.height; + final sampleRate = pictureSize * input.codedFramesPerSecond; + _Vp9LevelLimit? level; + for (final candidate in _levels) { + if (pictureSize <= candidate.maximumLumaPictureSize && + sampleRate <= candidate.maximumLumaSampleRate && + input.averageBitrate <= candidate.maximumBitrate && + input.width <= candidate.maximumDimension && + input.height <= candidate.maximumDimension) { + level = candidate; + break; + } + } + if (level == null) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'VP9 stream exceeds level 6.2', + ); + } + return 'vp09.00.${level.level}.08.01.01.01.01.00'; +} + +final RegExp _vp9CodecPattern = RegExp( + r'^vp09\.00\.(?:10|11|20|21|30|31|40|41|50|51|52|60|61|62)\.08\.01\.01\.01\.01\.00$', +); + +bool isVp9Codec(Object? value) => + value is String && _vp9CodecPattern.hasMatch(value); diff --git a/flutter/packages/aval_format/lib/src/vp9/frame_header.dart b/flutter/packages/aval_format/lib/src/vp9/frame_header.dart new file mode 100644 index 0000000..0550808 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/vp9/frame_header.dart @@ -0,0 +1,194 @@ +/// VP9 uncompressed frame-header parsing for the AVAL profile-0 subset. +/// +/// Dart port of `packages/format/src/vp9/frame-header.ts`. +library; + +import 'dart:typed_data'; + +import '../errors.dart'; +import 'bit_reader.dart'; + +const int _vp9FrameMarker = 2; +const int _vp9SyncCode = 0x498342; +const int _vp9ColorSpaceBt709 = 2; + +class Vp9ColorConfig { + const Vp9ColorConfig({ + this.bitDepth = 8, + this.chromaSubsampling = 1, + this.colorPrimaries = 1, + this.transferCharacteristics = 1, + this.matrixCoefficients = 1, + this.fullRange = false, + }); + + final int bitDepth; + final int chromaSubsampling; + final int colorPrimaries; + final int transferCharacteristics; + final int matrixCoefficients; + final bool fullRange; + + @override + bool operator ==(Object other) => + other is Vp9ColorConfig && + other.bitDepth == bitDepth && + other.chromaSubsampling == chromaSubsampling && + other.colorPrimaries == colorPrimaries && + other.transferCharacteristics == transferCharacteristics && + other.matrixCoefficients == matrixCoefficients && + other.fullRange == fullRange; + + @override + int get hashCode => Object.hash(bitDepth, chromaSubsampling, colorPrimaries, + transferCharacteristics, matrixCoefficients, fullRange); +} + +class Vp9FrameHeader { + const Vp9FrameHeader({ + required this.profile, + required this.key, + required this.showFrame, + required this.showExistingFrame, + required this.displayedFrameCount, + required this.errorResilient, + this.width, + this.height, + this.renderWidth, + this.renderHeight, + this.color, + }); + + final int profile; + final bool key; + final bool showFrame; + final bool showExistingFrame; + final int displayedFrameCount; + final bool errorResilient; + final int? width; + final int? height; + final int? renderWidth; + final int? renderHeight; + final Vp9ColorConfig? color; + + @override + bool operator ==(Object other) => + other is Vp9FrameHeader && + other.profile == profile && + other.key == key && + other.showFrame == showFrame && + other.showExistingFrame == showExistingFrame && + other.displayedFrameCount == displayedFrameCount && + other.errorResilient == errorResilient && + other.width == width && + other.height == height && + other.renderWidth == renderWidth && + other.renderHeight == renderHeight && + other.color == color; + + @override + int get hashCode => Object.hash(profile, key, showFrame, showExistingFrame, + displayedFrameCount, errorResilient, width, height, renderWidth, + renderHeight, color); +} + +/// Parse the bounded VP9 uncompressed header needed by the AVAL profile. +Vp9FrameHeader parseVp9FrameHeader(Uint8List bytes, [String path = 'vp9.frame']) { + if (bytes.isEmpty) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'VP9 frame is empty', + FormatErrorDetails(path: path), + ); + } + final reader = Vp9BitReader(bytes, path); + _requireVp9( + reader.readBits(2, 'frame_marker') == _vp9FrameMarker, + path, + 'frame_marker must equal 2', + ); + final profile = (reader.readBit('profile_low') ? 1 : 0) | + ((reader.readBit('profile_high') ? 1 : 0) << 1); + if (profile == 3) { + _requireVp9(!reader.readBit('reserved_zero'), path, + 'reserved profile bit must be zero'); + } + _requireVp9(profile == 0, path, 'only 8-bit 4:2:0 profile 0 is supported'); + + final showExistingFrame = reader.readBit('show_existing_frame'); + if (showExistingFrame) { + reader.readBits(3, 'frame_to_show_map_idx'); + return const Vp9FrameHeader( + profile: 0, + key: false, + showFrame: true, + showExistingFrame: true, + displayedFrameCount: 1, + errorResilient: false, + ); + } + + final key = !reader.readBit('frame_type'); + final showFrame = reader.readBit('show_frame'); + final errorResilient = reader.readBit('error_resilient_mode'); + if (!key) { + return Vp9FrameHeader( + profile: 0, + key: key, + showFrame: showFrame, + showExistingFrame: false, + displayedFrameCount: showFrame ? 1 : 0, + errorResilient: errorResilient, + ); + } + + _requireVp9( + reader.readBits(24, 'frame_sync_code') == _vp9SyncCode, + path, + 'key frame sync code is invalid', + ); + final colorSpace = reader.readBits(3, 'color_space'); + _requireVp9( + colorSpace == _vp9ColorSpaceBt709, + path, + 'key frame must signal BT.709 color space', + ); + _requireVp9(!reader.readBit('color_range'), path, + 'key frame must use limited range'); + + final width = reader.readBits(16, 'frame_width_minus_1') + 1; + final height = reader.readBits(16, 'frame_height_minus_1') + 1; + _requireVp9(width > 0 && height > 0, path, 'key frame dimensions are invalid'); + final renderAndFrameSizeDifferent = + reader.readBit('render_and_frame_size_different'); + final renderWidth = renderAndFrameSizeDifferent + ? reader.readBits(16, 'render_width_minus_1') + 1 + : width; + final renderHeight = renderAndFrameSizeDifferent + ? reader.readBits(16, 'render_height_minus_1') + 1 + : height; + + return Vp9FrameHeader( + profile: 0, + key: key, + showFrame: showFrame, + showExistingFrame: false, + displayedFrameCount: showFrame ? 1 : 0, + errorResilient: errorResilient, + width: width, + height: height, + renderWidth: renderWidth, + renderHeight: renderHeight, + color: const Vp9ColorConfig(), + ); +} + +void _requireVp9(bool condition, String path, String message) { + if (!condition) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'VP9 $message', + FormatErrorDetails(path: path), + ); + } +} diff --git a/flutter/packages/aval_format/lib/src/vp9/index.dart b/flutter/packages/aval_format/lib/src/vp9/index.dart new file mode 100644 index 0000000..a65006e --- /dev/null +++ b/flutter/packages/aval_format/lib/src/vp9/index.dart @@ -0,0 +1,20 @@ +/// VP9 profile-0 subsystem public surface. +/// +/// Dart port of `packages/format/src/vp9/index.ts`. Mirrors its export list. +library; + +export 'bit_reader.dart' show Vp9BitReader; +export 'codec.dart' + show deriveVp9Codec, isVp9Codec, DeriveVp9CodecInput, Vp9Codec, Vp9Level; +export 'frame_header.dart' + show parseVp9FrameHeader, Vp9ColorConfig, Vp9FrameHeader; +export 'inspector.dart' + show + inspectVp9Rendition, + Vp9PacketInput, + Vp9PacketInspection, + Vp9RenditionInspection, + Vp9RenditionInspectionInput, + Vp9UnitInput, + Vp9UnitInspection; +export 'superframe.dart' show splitVp9Superframe; diff --git a/flutter/packages/aval_format/lib/src/vp9/inspector.dart b/flutter/packages/aval_format/lib/src/vp9/inspector.dart new file mode 100644 index 0000000..91c31af --- /dev/null +++ b/flutter/packages/aval_format/lib/src/vp9/inspector.dart @@ -0,0 +1,216 @@ +/// VP9 profile-0 rendition inspection preserving hidden/reference frames. +/// +/// Dart port of `packages/format/src/vp9/inspector.ts`. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import '../errors.dart'; +import 'codec.dart'; +import 'frame_header.dart'; +import 'superframe.dart'; + +class Vp9PacketInput { + const Vp9PacketInput({ + required this.bytes, + required this.key, + required this.timestamp, + }); + + final Uint8List bytes; + final bool key; + final int timestamp; +} + +class Vp9UnitInput { + const Vp9UnitInput({ + required this.id, + required this.packets, + required this.expectedDisplayedFrames, + }); + + final String id; + final List packets; + final int expectedDisplayedFrames; +} + +class Vp9RenditionInspectionInput { + const Vp9RenditionInspectionInput({ + required this.width, + required this.height, + required this.frameRate, + required this.averageBitrate, + required this.units, + }); + + final int width; + final int height; + final ({int numerator, int denominator}) frameRate; + final int averageBitrate; + final List units; +} + +class Vp9PacketInspection { + const Vp9PacketInspection({ + required this.timestamp, + required this.chunkType, + required this.codedFrames, + required this.displayedFrameCount, + }); + + final int timestamp; + final String chunkType; + final List codedFrames; + final int displayedFrameCount; +} + +class Vp9UnitInspection { + const Vp9UnitInspection({ + required this.id, + required this.packets, + required this.displayedFrameCount, + }); + + final String id; + final List packets; + final int displayedFrameCount; +} + +class Vp9RenditionInspection { + const Vp9RenditionInspection({ + required this.codec, + required this.width, + required this.height, + required this.bitDepth, + required this.units, + }); + + final Vp9Codec codec; + final int width; + final int height; + final int bitDepth; + final List units; +} + +/// Inspect profile-0 VP9 packets while preserving hidden/reference frames. +Vp9RenditionInspection inspectVp9Rendition(Vp9RenditionInspectionInput input) { + _requirePositiveInteger(input.width, 'width'); + _requirePositiveInteger(input.height, 'height'); + _requirePositiveInteger(input.frameRate.numerator, 'frameRate.numerator'); + _requirePositiveInteger(input.frameRate.denominator, 'frameRate.denominator'); + _requirePositiveInteger(input.averageBitrate, 'averageBitrate'); + if (input.units.isEmpty) { + _invalid('VP9 rendition requires at least one unit', 'units'); + } + + final unitIds = {}; + final units = []; + var maximumCodedFramesPerDisplayedFrame = 1.0; + for (var unitIndex = 0; unitIndex < input.units.length; unitIndex += 1) { + final unit = input.units[unitIndex]; + final unitPath = 'units[$unitIndex]'; + if (unit.id.isEmpty) { + _invalid('VP9 unit id is invalid', '$unitPath.id'); + } + if (unitIds.contains(unit.id)) { + _invalid('VP9 unit id is duplicated', '$unitPath.id'); + } + unitIds.add(unit.id); + _requirePositiveInteger( + unit.expectedDisplayedFrames, '$unitPath.expectedDisplayedFrames'); + if (unit.packets.isEmpty) { + _invalid('VP9 unit requires packets', '$unitPath.packets'); + } + + final packets = []; + var displayedFrameCount = 0; + var codedFrameCount = 0; + for (var packetIndex = 0; + packetIndex < unit.packets.length; + packetIndex += 1) { + final packet = unit.packets[packetIndex]; + final packetPath = '$unitPath.packets[$packetIndex]'; + if (packet.timestamp < 0 || packet.timestamp > maxSafeInteger) { + _invalid('VP9 packet timestamp is invalid', '$packetPath.timestamp'); + } + final splitFrames = + splitVp9Superframe(packet.bytes, '$packetPath.bytes'); + final codedFrames = []; + for (var frameIndex = 0; frameIndex < splitFrames.length; frameIndex += 1) { + codedFrames.add(parseVp9FrameHeader( + splitFrames[frameIndex], + '$packetPath.codedFrames[$frameIndex]', + )); + } + final packetDisplayedFrames = codedFrames.fold( + 0, + (total, frame) => total + frame.displayedFrameCount, + ); + if (codedFrames.isEmpty) { + _invalid('VP9 packet contains no coded frames', packetPath); + } + final first = codedFrames[0]; + if (packetIndex == 0 && !first.key) { + _invalid('VP9 unit must start with a key frame', packetPath); + } + if (packet.key != first.key) { + _invalid('VP9 chunk key assertion disagrees with the bitstream', + '$packetPath.key'); + } + displayedFrameCount += packetDisplayedFrames; + codedFrameCount += codedFrames.length; + packets.add(Vp9PacketInspection( + timestamp: packet.timestamp, + chunkType: first.key ? 'key' : 'delta', + codedFrames: codedFrames, + displayedFrameCount: packetDisplayedFrames, + )); + } + if (displayedFrameCount != unit.expectedDisplayedFrames) { + _invalid('VP9 displayed frame count disagrees with the authored unit', + unitPath); + } + maximumCodedFramesPerDisplayedFrame = math.max( + maximumCodedFramesPerDisplayedFrame, + codedFrameCount / displayedFrameCount, + ); + units.add(Vp9UnitInspection( + id: unit.id, + packets: packets, + displayedFrameCount: displayedFrameCount, + )); + } + + final displayFramesPerSecond = + input.frameRate.numerator / input.frameRate.denominator; + final codec = deriveVp9Codec(DeriveVp9CodecInput( + width: input.width, + height: input.height, + codedFramesPerSecond: + displayFramesPerSecond * maximumCodedFramesPerDisplayedFrame, + averageBitrate: input.averageBitrate, + )); + return Vp9RenditionInspection( + codec: codec, + width: input.width, + height: input.height, + bitDepth: 8, + units: units, + ); +} + +void _requirePositiveInteger(int value, String path) { + if (value <= 0 || value > maxSafeInteger) { + _invalid('VP9 value must be a positive safe integer', path); + } +} + +Never _invalid(String message, String path) { + throw FormatError( + FormatErrorCode.profileInvalid, + message, + FormatErrorDetails(path: path), + ); +} diff --git a/flutter/packages/aval_format/lib/src/vp9/superframe.dart b/flutter/packages/aval_format/lib/src/vp9/superframe.dart new file mode 100644 index 0000000..a5896bb --- /dev/null +++ b/flutter/packages/aval_format/lib/src/vp9/superframe.dart @@ -0,0 +1,71 @@ +/// VP9 superframe splitting into owned coded frames. +/// +/// Dart port of `packages/format/src/vp9/superframe.ts`. +library; + +import 'dart:typed_data'; + +import '../checked_integer.dart' show maxSafeInteger; +import '../errors.dart'; + +const int _superframeMarkerMask = 0xe0; +const int _superframeMarker = 0xc0; + +/// Split a VP9 packet into owned coded frames, including hidden alt-ref frames. +List splitVp9Superframe(Uint8List bytes, [String path = 'vp9']) { + _requireVp9(bytes.isNotEmpty, path, 'packet is empty'); + final marker = bytes[bytes.length - 1]; + if ((marker & _superframeMarkerMask) != _superframeMarker) { + return [Uint8List.fromList(bytes)]; + } + + final frameCount = (marker & 0x07) + 1; + final magnitude = ((marker >> 3) & 0x03) + 1; + final indexBytes = 2 + frameCount * magnitude; + _requireVp9(bytes.length > indexBytes, path, 'superframe index is truncated'); + final indexStart = bytes.length - indexBytes; + _requireVp9(bytes[indexStart] == marker, path, 'superframe markers disagree'); + + final sizes = []; + var cursor = indexStart + 1; + var payloadBytes = 0; + for (var frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + var size = 0; + var multiplier = 1; + for (var byteIndex = 0; byteIndex < magnitude; byteIndex += 1) { + _requireVp9(cursor < bytes.length, path, 'superframe size is truncated'); + final byte = bytes[cursor]; + size += byte * multiplier; + multiplier *= 256; + cursor += 1; + } + _requireVp9(size > 0, path, 'superframe contains an empty coded frame'); + _requireVp9( + payloadBytes + size <= maxSafeInteger, + path, + 'superframe payload size is unsafe', + ); + payloadBytes += size; + sizes.add(size); + } + _requireVp9(payloadBytes == indexStart, path, + 'superframe sizes do not cover the payload'); + + final frames = []; + cursor = 0; + for (final size in sizes) { + frames.add(bytes.sublist(cursor, cursor + size)); + cursor += size; + } + return frames; +} + +void _requireVp9(bool condition, String path, String message) { + if (!condition) { + throw FormatError( + FormatErrorCode.profileInvalid, + 'VP9 $message', + FormatErrorDetails(path: path), + ); + } +} diff --git a/flutter/packages/aval_format/lib/src/writer.dart b/flutter/packages/aval_format/lib/src/writer.dart new file mode 100644 index 0000000..4ca02e8 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/writer.dart @@ -0,0 +1,109 @@ +/// Writes one byte-canonical version-1.0 aval asset. +/// +/// Dart port of `packages/format/src/writer.ts`. +library; + +import 'dart:typed_data'; + +import 'access_unit_index.dart' show encodeEncodedChunkIndex; +import 'canonical_json.dart' show serializeCanonicalJson; +import 'constants.dart' show formatHeaderLength; +import 'errors.dart'; +import 'header.dart' show encodeHeader; +import 'layout.dart' show ChunkPayloadShape, planCanonicalAssetLayout; +import 'manifest_json.dart' show compiledManifestToJson; +import 'model.dart'; +import 'parser.dart' show validateCompleteAsset; +import 'writer_normalize.dart' show NormalizedWriterInput, normalizeWriterInput; + +class _WriterLayout { + const _WriterLayout({ + required this.indexOffset, + required this.indexLength, + required this.records, + required this.fileLength, + }); + + final int indexOffset; + final int indexLength; + final List records; + final int fileLength; +} + +/// Writes one byte-canonical version-1.0 aval asset. +Uint8List writeCanonicalAsset(CanonicalAssetInput input, [FormatOptions? options]) { + try { + final normalized = normalizeWriterInput(input, options); + final manifest = normalized.manifest; + final manifestBytes = serializeCanonicalJson(compiledManifestToJson(manifest), options); + final finalLayout = _deriveLayout(normalized, manifest, manifestBytes, options); + + final header = FormatHeader( + declaredFileLength: finalLayout.fileLength, + manifestLength: manifestBytes.length, + indexOffset: finalLayout.indexOffset, + indexLength: finalLayout.indexLength, + ); + final headerBytes = encodeHeader(header, options); + final indexBytes = encodeEncodedChunkIndex(finalLayout.records, manifest, options); + if (indexBytes.length != finalLayout.indexLength) { + throw FormatError(FormatErrorCode.writerInvalid, 'encoded index length changed'); + } + + Uint8List bytes; + try { + bytes = Uint8List(finalLayout.fileLength); + } catch (_) { + throw FormatError( + FormatErrorCode.writerInvalid, + 'final file allocation of ${finalLayout.fileLength} bytes failed', + ); + } + bytes.setRange(0, headerBytes.length, headerBytes); + bytes.setRange(formatHeaderLength, formatHeaderLength + manifestBytes.length, manifestBytes); + bytes.setRange(finalLayout.indexOffset, finalLayout.indexOffset + indexBytes.length, indexBytes); + + for (var index = 0; index < normalized.chunks.length; index += 1) { + final payload = normalized.chunks[index]; + final record = index < finalLayout.records.length ? finalLayout.records[index] : null; + if (record == null) { + throw FormatError(FormatErrorCode.writerInvalid, 'encoded-chunk layout is sparse'); + } + bytes.setRange(record.byteOffset, record.byteOffset + payload.bytes.length, payload.bytes); + } + validateCompleteAsset(bytes: bytes, options: options); + return bytes; + } on FormatError { + rethrow; + } catch (_) { + throw FormatError(FormatErrorCode.writerInvalid, 'canonical asset could not be written'); + } +} + +_WriterLayout _deriveLayout( + NormalizedWriterInput normalized, + CompiledManifest manifest, + Uint8List manifestBytes, [ + FormatOptions? options, +]) { + final plan = planCanonicalAssetLayout( + manifestBytes.length, + manifest, + normalized.chunks + .map((chunk) => ChunkPayloadShape( + byteLength: chunk.bytes.length, + presentationTimestamp: chunk.presentationTimestamp, + duration: chunk.duration, + randomAccess: chunk.randomAccess, + displayedFrameCount: chunk.displayedFrameCount, + )) + .toList(), + options, + ); + return _WriterLayout( + indexOffset: plan.indexOffset, + indexLength: plan.indexLength, + records: plan.records, + fileLength: plan.fileRange.length, + ); +} diff --git a/flutter/packages/aval_format/lib/src/writer_fixed_point.dart b/flutter/packages/aval_format/lib/src/writer_fixed_point.dart new file mode 100644 index 0000000..34385a5 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/writer_fixed_point.dart @@ -0,0 +1,69 @@ +/// Deterministic byte-stable fixed-point iteration runner. +/// +/// Dart port of `packages/format/src/writer-fixed-point.ts`. +library; + +import 'dart:typed_data'; + +import 'errors.dart'; + +class ByteFixedPointStep { + const ByteFixedPointStep({required this.value, required this.bytes, required this.result}); + + final TValue value; + final Uint8List bytes; + final TResult result; +} + +class ByteFixedPointResult extends ByteFixedPointStep { + const ByteFixedPointResult({ + required super.value, + required super.bytes, + required super.result, + required this.iterations, + }); + + final int iterations; +} + +/// Internal deterministic fixed-point runner with an injectable test seam. +ByteFixedPointResult resolveByteStableFixedPoint( + TValue initialValue, + Uint8List initialBytes, + int maximumIterations, + ByteFixedPointStep Function(TValue value, Uint8List bytes) advance, +) { + if (maximumIterations < 1) { + throw FormatError( + FormatErrorCode.writerInvalid, + 'fixed-point iteration limit must be a positive safe integer', + ); + } + var value = initialValue; + var bytes = initialBytes; + for (var iteration = 1; iteration <= maximumIterations; iteration += 1) { + final next = advance(value, bytes); + if (_equalBytes(bytes, next.bytes)) { + return ByteFixedPointResult( + value: next.value, + bytes: next.bytes, + result: next.result, + iterations: iteration, + ); + } + value = next.value; + bytes = next.bytes; + } + throw FormatError( + FormatErrorCode.writerNonconvergent, + 'canonical layout did not converge in $maximumIterations iterations', + ); +} + +bool _equalBytes(Uint8List left, Uint8List right) { + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) return false; + } + return true; +} diff --git a/flutter/packages/aval_format/lib/src/writer_normalize.dart b/flutter/packages/aval_format/lib/src/writer_normalize.dart new file mode 100644 index 0000000..4ed3755 --- /dev/null +++ b/flutter/packages/aval_format/lib/src/writer_normalize.dart @@ -0,0 +1,639 @@ +/// Clones, canonicalizes, and validates writer metadata without copying +/// payload bytes. +/// +/// Dart port of `packages/format/src/writer-normalize.ts`. Unlike the TS source +/// (which receives fully `unknown` JSON), this port's public entry point takes +/// the strongly-typed `CanonicalAssetInput` from `model.dart`. It first +/// serializes that typed tree into the same untyped `Map` +/// shape the TS normalizer works with, then reuses the identical +/// canonicalization/validation pipeline before handing off to +/// `validateCompiledManifest`. +library; + +import 'dart:typed_data'; + +import 'checked_integer.dart' show checkedAdd; +import 'constants.dart' show resolveFormatBudgets; +import 'errors.dart'; +import 'graph_adapter.dart' show adaptManifestToMotionGraph; +import 'manifest_schema.dart' show validateCompiledManifest; +import 'manifest_validation.dart'; +import 'model.dart'; + +const List _bindingSources = [ + 'activate', + 'engagement.off', + 'engagement.on', + 'focus.in', + 'focus.out', + 'hidden', + 'pointer.enter', + 'pointer.leave', + 'visible', +]; +const List _unitKinds = ['body', 'bridge', 'reversible', 'one-shot']; +const List _manifestInputKeys = [ + 'formatVersion', + 'generator', + 'codec', + 'bitstream', + 'layout', + 'canvas', + 'frameRate', + 'renditions', + 'units', + 'initialState', + 'states', + 'edges', + 'bindings', + 'readiness', + 'limits', +]; + +class NormalizedWriterInput { + const NormalizedWriterInput({required this.manifest, required this.chunks}); + + final CompiledManifest manifest; + final List chunks; +} + +class _NormalizedUnitBase { + const _NormalizedUnitBase({ + required this.value, + required this.id, + required this.frameCount, + required this.digests, + }); + + final Map value; + final String id; + final int frameCount; + final Map digests; +} + +/// Clones, canonicalizes, and validates writer metadata without copying +/// payloads. +NormalizedWriterInput normalizeWriterInput(CanonicalAssetInput input, + [FormatOptions? options]) { + try { + final budgets = resolveFormatBudgets(options); + final root = { + 'manifest': _manifestInputToMap(input.manifest), + 'chunks': input.chunks.map(_chunkInputToMap).toList(), + }; + exactKeys(root, ['manifest', 'chunks'], 'writer input'); + final sourceManifest = record(root['manifest'], 'manifest input'); + exactKeys(sourceManifest, _manifestInputKeys, 'manifest input'); + final sourceRenditions = _boundedInputObjectArray( + sourceManifest['renditions'], 'manifest.renditions', budgets.maxRenditions, 1); + final renditionIds = _authoredRenditionIds(sourceRenditions); + final sourceUnits = _sortById( + _boundedInputObjectArray(sourceManifest['units'], 'manifest.units', budgets.maxUnits, 1), + 'units'); + final unitBases = <_NormalizedUnitBase>[ + for (var unitIndex = 0; unitIndex < sourceUnits.length; unitIndex += 1) + _normalizeUnitBase(sourceUnits[unitIndex], unitIndex, renditionIds, budgets), + ]; + final blobCount = unitBases.length * renditionIds.length; + if (blobCount > budgets.maxBlobRanges) { + _budget('blob range count'); + } + + final suppliedChunks = _normalizeChunkInputs( + _boundedInputObjectArray(root['chunks'], 'chunks', budgets.maxChunkRecords, 1), + budgets.maxChunkBytes, + ); + final groups = _groupChunks(suppliedChunks); + final unitSpans = + List>>.generate(unitBases.length, (_) => >[]); + final orderedChunks = []; + var chunkStart = 0; + for (final rendition in renditionIds) { + for (var unitIndex = 0; unitIndex < unitBases.length; unitIndex += 1) { + final unit = unitBases[unitIndex]; + final key = _chunkGroupKey(rendition, unit.id); + final group = groups[key]; + if (group == null || group.isEmpty) { + _invalid('missing encoded chunks for $rendition/${unit.id}'); + } + groups.remove(key); + group.sort((left, right) => left.decodeIndex - right.decodeIndex); + var displayedFrames = 0; + for (var index = 0; index < group.length; index += 1) { + final chunk = group[index]; + if (chunk.decodeIndex != index) { + _invalid('$rendition/${unit.id} decode indexes must be contiguous from zero'); + } + if (index == 0 && !chunk.randomAccess) { + _invalid('$rendition/${unit.id} must begin with a random-access chunk'); + } + displayedFrames = checkedAdd( + displayedFrames, + chunk.displayedFrameCount, + budgets.maxTotalUnitFrames, + 'unit displayed frame count', + ); + orderedChunks.add(chunk); + } + if (displayedFrames != unit.frameCount) { + _invalid('$rendition/${unit.id} must display exactly ${unit.frameCount} frames'); + } + final sha256 = unit.digests[rendition]; + if (sha256 == null) _invalid('${unit.id} is missing digest for $rendition'); + unitSpans[unitIndex].add({ + 'rendition': rendition, + 'chunkStart': chunkStart, + 'chunkCount': group.length, + 'frameCount': unit.frameCount, + 'sha256': sha256, + }); + chunkStart = + checkedAdd(chunkStart, group.length, budgets.maxChunkRecords, 'chunk span end'); + } + } + if (groups.isNotEmpty) _invalid('chunks contain an unknown rendition or unit'); + if (orderedChunks.length != suppliedChunks.length) { + _invalid('chunks contain duplicate identities'); + } + + final sourceStates = _boundedInputObjectArray( + sourceManifest['states'], 'manifest.states', budgets.maxStates, 1); + final sourceEdges = + _boundedInputObjectArray(sourceManifest['edges'], 'manifest.edges', budgets.maxEdges); + final sourceBindings = _boundedInputObjectArray( + sourceManifest['bindings'], 'manifest.bindings', budgets.maxBindings); + final units = >[ + for (var index = 0; index < unitBases.length; index += 1) + {...unitBases[index].value, 'chunks': unitSpans[index]}, + ]; + final manifestCandidate = { + ...sourceManifest, + 'renditions': sourceRenditions, + 'units': units, + 'states': _sortById(sourceStates, 'states'), + 'edges': _sortById(sourceEdges, 'edges'), + 'bindings': _normalizeBindings(sourceBindings), + 'readiness': _normalizeReadiness(sourceManifest['readiness'], budgets), + }; + final manifest = validateCompiledManifest(manifestCandidate, options); + adaptManifestToMotionGraph(manifest); + return NormalizedWriterInput(manifest: manifest, chunks: orderedChunks); + } on FormatError catch (error) { + if (error.code == FormatErrorCode.budgetExceeded || + error.code == FormatErrorCode.integerUnsafe) { + rethrow; + } + throw FormatError( + FormatErrorCode.writerInvalid, + error.message, + FormatErrorDetails(path: error.path, offset: error.offset), + ); + } catch (_) { + throw FormatError( + FormatErrorCode.writerInvalid, 'writer input could not be normalized'); + } +} + +// --- Typed CompiledManifestInput -> untyped Map serialization ------------- + +Map _manifestInputToMap(CompiledManifestInput manifest) => { + 'formatVersion': manifest.formatVersion, + 'generator': manifest.generator, + 'codec': manifest.codec, + 'bitstream': manifest.bitstream, + 'layout': manifest.layout, + 'canvas': _canvasToMap(manifest.canvas), + 'frameRate': _rationalToMap(manifest.frameRate), + 'renditions': manifest.renditions.map(_renditionToMap).toList(), + 'units': manifest.units.map(_unitInputToMap).toList(), + 'initialState': manifest.initialState, + 'states': manifest.states.map(_stateToMap).toList(), + 'edges': manifest.edges.map(_edgeToMap).toList(), + 'bindings': manifest.bindings.map(_bindingToMap).toList(), + 'readiness': _readinessToMap(manifest.readiness), + 'limits': _limitsToMap(manifest.limits), + }; + +Map _canvasToMap(Canvas canvas) => { + 'width': canvas.width, + 'height': canvas.height, + 'fit': canvas.fit, + 'pixelAspect': canvas.pixelAspect, + 'colorSpace': canvas.colorSpace, + }; + +Map _rationalToMap(Rational rational) => + {'numerator': rational.numerator, 'denominator': rational.denominator}; + +Map _alphaLayoutToMap(AlphaLayout alphaLayout) { + if (alphaLayout is StackedAlphaLayout) { + return { + 'type': 'stacked', + 'colorRect': alphaLayout.colorRect.toList(), + 'alphaRect': alphaLayout.alphaRect.toList(), + }; + } + return {'type': 'opaque', 'colorRect': alphaLayout.colorRect.toList()}; +} + +Map _renditionToMap(ProductionRendition rendition) => { + 'id': rendition.id, + 'codec': rendition.codec, + 'bitDepth': rendition.bitDepth, + 'codedWidth': rendition.codedWidth, + 'codedHeight': rendition.codedHeight, + 'alphaLayout': _alphaLayoutToMap(rendition.alphaLayout), + 'bitrate': {'average': rendition.bitrate.average, 'peak': rendition.bitrate.peak}, + }; + +Map _chunkDigestToMap(ChunkDigestInput chunk) => + {'rendition': chunk.rendition, 'sha256': chunk.sha256}; + +Map _portToMap(Port port) => + {'id': port.id, 'entryFrame': port.entryFrame, 'portalFrames': port.portalFrames}; + +Map _residencyEndpointToMap(ResidencyEndpoint endpoint) => + {'state': endpoint.state, 'port': endpoint.port, 'frames': endpoint.frames}; + +Map _unitInputToMap(UnitInput unit) { + final base = { + 'id': unit.id, + 'kind': unit.kind, + 'frameCount': unit.frameCount, + 'chunks': unit.chunks.map(_chunkDigestToMap).toList(), + }; + if (unit is BodyUnitInput) { + return { + ...base, + 'playback': unit.playback, + 'ports': unit.ports.map(_portToMap).toList(), + }; + } + if (unit is ReversibleUnitInput) { + return { + ...base, + 'residency': { + 'endpoints': unit.residency.endpoints.map(_residencyEndpointToMap).toList(), + }, + }; + } + return base; +} + +Map _stateToMap(State state) { + final base = {'id': state.id, 'bodyUnit': state.bodyUnit}; + return state.initialUnit == null ? base : {...base, 'initialUnit': state.initialUnit}; +} + +Map _startToMap(Start start) { + if (start is PortalStart) { + return { + 'type': 'portal', + 'sourcePort': start.sourcePort, + 'targetPort': start.targetPort, + 'maxWaitFrames': start.maxWaitFrames, + }; + } + if (start is FinishStart) { + return {'type': 'finish', 'targetPort': start.targetPort, 'maxWaitFrames': start.maxWaitFrames}; + } + return {'type': 'cut', 'targetPort': start.targetPort, 'maxWaitFrames': 1}; +} + +Map _triggerToMap(Trigger trigger) { + if (trigger is EventTrigger) { + return {'type': 'event', 'name': trigger.name}; + } + return {'type': 'completion'}; +} + +Map _transitionToMap(Transition transition) { + if (transition is LockedTransition) { + return {'kind': 'locked', 'unit': transition.unit}; + } + final reversible = transition as ReversibleTransition; + final base = { + 'kind': 'reversible', + 'unit': reversible.unit, + 'direction': reversible.direction, + }; + return reversible.reverseOf == null ? base : {...base, 'reverseOf': reversible.reverseOf}; +} + +Map _edgeToMap(Edge edge) { + final base = { + 'id': edge.id, + 'from': edge.from, + 'to': edge.to, + 'start': _startToMap(edge.start), + 'continuity': edge.continuity, + }; + if (edge.trigger != null) { + base['trigger'] = _triggerToMap(edge.trigger!); + } + if (edge is CutEdge) { + base['targetRunwayFrames'] = edge.targetRunwayFrames; + } else if (edge is NonCutEdge && edge.transition != null) { + base['transition'] = _transitionToMap(edge.transition!); + } + return base; +} + +Map _bindingToMap(Binding binding) => + {'source': binding.source, 'event': binding.event}; + +Map _readinessToMap(Readiness readiness) => { + 'policy': readiness.policy, + 'bootstrapUnits': readiness.bootstrapUnits, + 'immediateEdges': readiness.immediateEdges, + }; + +Map _limitsToMap(DeclaredLimits limits) => { + 'maxCompiledBytes': limits.maxCompiledBytes, + 'maxRuntimeBytes': limits.maxRuntimeBytes, + 'decodedPixelBytes': limits.decodedPixelBytes, + 'persistentCacheBytes': limits.persistentCacheBytes, + 'runtimeWorkingSetBytes': limits.runtimeWorkingSetBytes, + }; + +Map _chunkInputToMap(EncodedChunkInput chunk) => { + 'rendition': chunk.rendition, + 'unit': chunk.unit, + 'decodeIndex': chunk.decodeIndex, + 'presentationTimestamp': chunk.presentationTimestamp, + 'duration': chunk.duration, + 'randomAccess': chunk.randomAccess, + 'displayedFrameCount': chunk.displayedFrameCount, + 'bytes': chunk.bytes, + }; + +// --- Untyped canonicalization pipeline (mirrors writer-normalize.ts) ------- + +_NormalizedUnitBase _normalizeUnitBase( + Map value, + int unitIndex, + List renditionIds, + FormatBudgets budgets, +) { + final path = 'units[$unitIndex]'; + final kind = oneOf(value['kind'], _unitKinds, '$path.kind'); + if (kind == 'body') { + exactKeys(value, ['id', 'kind', 'playback', 'frameCount', 'ports', 'chunks'], path); + } else if (kind == 'reversible') { + exactKeys(value, ['id', 'kind', 'frameCount', 'residency', 'chunks'], path); + } else { + exactKeys(value, ['id', 'kind', 'frameCount', 'chunks'], path); + } + final id = identifier(value['id'], '$path.id'); + final frameCount = positiveInteger(value['frameCount'], '$path.frameCount'); + final digests = _normalizeDigests(value['chunks'], renditionIds, '$path.chunks'); + + final rest = {...value}..remove('chunks'); + if (kind == 'body') { + final sortedPorts = _sortById( + _boundedInputObjectArray(value['ports'], '$path.ports', budgets.maxPortsPerBody), + '$path.ports', + ); + final ports = >[]; + for (var index = 0; index < sortedPorts.length; index += 1) { + final port = sortedPorts[index]; + final portPath = '$path.ports[$index]'; + exactKeys(port, ['id', 'entryFrame', 'portalFrames'], portPath); + ports.add({ + ...port, + 'portalFrames': _numericSort( + _boundedInputArray(port['portalFrames'], '$portPath.portalFrames', frameCount, 1), + '$portPath.portalFrames', + ), + }); + } + return _NormalizedUnitBase( + value: {...rest, 'kind': kind, 'ports': ports}, + id: id, + frameCount: frameCount, + digests: digests, + ); + } + if (kind == 'reversible') { + final residency = record(value['residency'], '$path.residency'); + exactKeys(residency, ['endpoints'], '$path.residency'); + final endpoints = _exactInputArray(residency['endpoints'], '$path.residency.endpoints', 2) + .map((endpoint) => record(endpoint, '$path.residency.endpoints')) + .toList(); + final normalizedEndpoints = >[]; + for (var index = 0; index < endpoints.length; index += 1) { + final endpointPath = '$path.residency.endpoints[$index]'; + final endpoint = endpoints[index]; + exactKeys(endpoint, ['state', 'port', 'frames'], endpointPath); + normalizedEndpoints.add({ + ...endpoint, + 'state': identifier(endpoint['state'], '$endpointPath.state'), + 'port': identifier(endpoint['port'], '$endpointPath.port'), + }); + } + normalizedEndpoints.sort((left, right) { + final byState = compareAscii(left['state'] as String, right['state'] as String); + return byState != 0 ? byState : compareAscii(left['port'] as String, right['port'] as String); + }); + return _NormalizedUnitBase( + value: {...rest, 'kind': kind, 'residency': {...residency, 'endpoints': normalizedEndpoints}}, + id: id, + frameCount: frameCount, + digests: digests, + ); + } + return _NormalizedUnitBase( + value: {...rest, 'kind': kind}, + id: id, + frameCount: frameCount, + digests: digests, + ); +} + +Map _normalizeDigests( + Object? value, List renditionIds, String path) { + final inputs = _exactInputArray(value, path, renditionIds.length); + final supplied = {}; + for (var index = 0; index < inputs.length; index += 1) { + final input = record(inputs[index], '$path[$index]'); + exactKeys(input, ['rendition', 'sha256'], '$path[$index]'); + final rendition = identifier(input['rendition'], '$path[$index].rendition'); + if (input['sha256'] is! String) _invalid('$path[$index].sha256 must be a string'); + if (supplied.containsKey(rendition)) _invalid('$path duplicates rendition $rendition'); + supplied[rendition] = input['sha256'] as String; + } + for (final rendition in renditionIds) { + if (!supplied.containsKey(rendition)) _invalid('$path is missing rendition $rendition'); + } + if (supplied.length != renditionIds.length) { + _invalid('$path references an unknown rendition'); + } + return supplied; +} + +List _authoredRenditionIds(List> values) { + final seen = {}; + final ids = []; + for (var index = 0; index < values.length; index += 1) { + final id = identifier(values[index]['id'], 'renditions[$index].id'); + if (seen.contains(id)) _invalid('renditions[$index].id duplicates $id'); + seen.add(id); + ids.add(id); + } + return ids; +} + +List _normalizeChunkInputs( + List> values, int maxBytes) { + final result = []; + for (var index = 0; index < values.length; index += 1) { + final path = 'chunks[$index]'; + final input = values[index]; + exactKeys( + input, + [ + 'rendition', + 'unit', + 'decodeIndex', + 'presentationTimestamp', + 'duration', + 'randomAccess', + 'displayedFrameCount', + 'bytes', + ], + path, + ); + if (input['randomAccess'] is! bool) _invalid('$path.randomAccess must be boolean'); + final bytes = input['bytes']; + if (bytes is! Uint8List) _invalid('$path.bytes must be a Uint8Array'); + if (bytes.isEmpty) _invalid('$path.bytes must not be empty'); + if (bytes.length > maxBytes) _budget('$path.bytes'); + final displayedFrameCount = + nonNegativeInteger(input['displayedFrameCount'], '$path.displayedFrameCount'); + final duration = nonNegativeInteger(input['duration'], '$path.duration'); + if (displayedFrameCount > 0 && duration == 0) { + _invalid('$path.duration must be positive when the chunk displays frames'); + } + result.add(EncodedChunkInput( + rendition: identifier(input['rendition'], '$path.rendition'), + unit: identifier(input['unit'], '$path.unit'), + decodeIndex: nonNegativeInteger(input['decodeIndex'], '$path.decodeIndex'), + presentationTimestamp: + nonNegativeInteger(input['presentationTimestamp'], '$path.presentationTimestamp'), + duration: duration, + randomAccess: input['randomAccess'] as bool, + displayedFrameCount: displayedFrameCount, + bytes: bytes, + )); + } + return result; +} + +Map> _groupChunks(List values) { + final groups = >{}; + final identities = {}; + for (final chunk in values) { + final identity = '${_chunkGroupKey(chunk.rendition, chunk.unit)} ${chunk.decodeIndex}'; + if (identities.contains(identity)) _invalid('duplicate encoded chunk $identity'); + identities.add(identity); + final key = _chunkGroupKey(chunk.rendition, chunk.unit); + (groups[key] ??= []).add(chunk); + } + return groups; +} + +List> _normalizeBindings(List> values) { + final bindings = >[]; + for (var index = 0; index < values.length; index += 1) { + final value = values[index]; + final path = 'bindings[$index]'; + exactKeys(value, ['source', 'event'], path); + bindings.add({ + ...value, + 'source': oneOf(value['source'], _bindingSources, '$path.source'), + 'event': identifier(value['event'], '$path.event'), + }); + } + bindings.sort((left, right) { + final source = compareAscii(left['source'] as String, right['source'] as String); + return source != 0 ? source : compareAscii(left['event'] as String, right['event'] as String); + }); + return bindings; +} + +Map _normalizeReadiness(Object? value, FormatBudgets budgets) { + final readiness = record(value, 'manifest.readiness'); + exactKeys(readiness, ['policy', 'bootstrapUnits', 'immediateEdges'], 'manifest.readiness'); + final bootstrapUnits = _stringArray( + _boundedInputArray(readiness['bootstrapUnits'], 'readiness.bootstrapUnits', budgets.maxUnits), + 'readiness.bootstrapUnits', + )..sort(compareAscii); + final immediateEdges = _stringArray( + _boundedInputArray(readiness['immediateEdges'], 'readiness.immediateEdges', budgets.maxEdges), + 'readiness.immediateEdges', + )..sort(compareAscii); + return {...readiness, 'bootstrapUnits': bootstrapUnits, 'immediateEdges': immediateEdges}; +} + +List> _sortById(List> values, String path) { + final identified = values + .map((entry) => (entry: entry, id: identifier(entry['id'], '$path.id'))) + .toList(); + identified.sort((left, right) => compareAscii(left.id, right.id)); + return identified.map((e) => e.entry).toList(); +} + +List _numericSort(List values, String path) { + final numbers = [ + for (var index = 0; index < values.length; index += 1) + nonNegativeInteger(values[index], '$path[$index]'), + ]; + numbers.sort(); + return numbers; +} + +List _stringArray(List value, String path) { + return [ + for (var index = 0; index < value.length; index += 1) + identifier(value[index], '$path[$index]') + ]; +} + +List _requireArray(Object? value, String path) { + if (value is! List) _invalid('$path must be an array'); + return value; +} + +List _boundedInputArray(Object? value, String path, int maximum, [int minimum = 0]) { + final array = _requireArray(value, path); + if (array.length > maximum) _budget('$path count'); + if (array.length < minimum) { + _invalid('$path must contain at least $minimum entries'); + } + return array; +} + +List _exactInputArray(Object? value, String path, int expectedLength) { + final array = _requireArray(value, path); + if (array.length != expectedLength) { + _invalid('$path must contain exactly $expectedLength entries'); + } + return array; +} + +List> _boundedInputObjectArray(Object? value, String path, int maximum, + [int minimum = 0]) { + return _boundedInputArray(value, path, maximum, minimum) + .map((entry) => record(entry, path)) + .toList(); +} + +String _chunkGroupKey(String rendition, String unit) => '$rendition $unit'; + +Never _budget(String label) { + throw FormatError(FormatErrorCode.budgetExceeded, '$label exceeds the active budget'); +} + +Never _invalid(String message) { + throw FormatError(FormatErrorCode.writerInvalid, message); +} diff --git a/flutter/packages/aval_format/pubspec.yaml b/flutter/packages/aval_format/pubspec.yaml new file mode 100644 index 0000000..51ba532 --- /dev/null +++ b/flutter/packages/aval_format/pubspec.yaml @@ -0,0 +1,17 @@ +name: aval_format +description: > + Canonical parser, validator, and types for AVAL binary assets. Pure Dart + port of @pixel-point/aval-format with full behavioral parity. +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.5.0 + +dependencies: + aval_graph: + path: ../aval_graph + +dev_dependencies: + test: ^1.25.0 + lints: ^4.0.0 diff --git a/flutter/packages/aval_format/test/access_unit_index_test.dart b/flutter/packages/aval_format/test/access_unit_index_test.dart new file mode 100644 index 0000000..5306527 --- /dev/null +++ b/flutter/packages/aval_format/test/access_unit_index_test.dart @@ -0,0 +1,300 @@ +// Dart port of packages/format/test/access-unit-index.test.ts. +import 'dart:typed_data'; + +import 'package:aval_format/src/access_unit_index.dart'; +import 'package:aval_format/src/checked_integer.dart' show writeUint32LE, writeUint64LE, maxSafeInteger; +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/model.dart'; +import 'package:test/test.dart'; + +final String _sha256Zeros = '0'.padRight(64, '0'); + +CompiledManifest _manifestWith({ + required ProductionRendition rendition, + required Unit unit, +}) => + CompiledManifest( + generator: 'test', + codec: rendition.codec, + bitstream: 'annex-b', + layout: 'opaque', + canvas: Canvas( + width: rendition.codedWidth, + height: rendition.codedHeight, + fit: 'contain', + pixelAspect: const [1, 1], + ), + frameRate: const Rational(numerator: 30, denominator: 1), + renditions: [rendition], + units: [unit], + initialState: 'a', + states: const [], + edges: const [], + bindings: const [], + readiness: const Readiness(bootstrapUnits: [], immediateEdges: []), + limits: const DeclaredLimits( + maxCompiledBytes: maxSafeInteger, + maxRuntimeBytes: maxSafeInteger, + decodedPixelBytes: 0, + persistentCacheBytes: 0, + runtimeWorkingSetBytes: 0, + ), + ); + +ProductionRendition _videoRendition() => ProductionRendition( + id: 'video', + codec: 'avc1.42E00A', + bitDepth: 8, + codedWidth: 16, + codedHeight: 16, + alphaLayout: OpaqueAlphaLayout(colorRect: const Rect(0, 0, 16, 16)), + bitrate: const Bitrate(average: 1, peak: 1), + ); + +Unit _bodyUnit({required int frameCount, required int chunkCount}) => BodyUnit( + id: 'body', + frameCount: frameCount, + playback: 'finite', + ports: const [], + chunks: [ + UnitChunkSpan( + rendition: 'video', + chunkStart: 0, + chunkCount: chunkCount, + frameCount: frameCount, + sha256: _sha256Zeros, + ), + ], + ); + +final CompiledManifest manifest = _manifestWith( + rendition: _videoRendition(), + unit: _bodyUnit(frameCount: 2, chunkCount: 2), +); + +final List kRecords = [ + const EncodedChunkRecord( + byteOffset: 128, + byteLength: 4, + presentationTimestamp: 1, + duration: 1, + randomAccess: true, + displayedFrameCount: 1, + ), + const EncodedChunkRecord( + byteOffset: 132, + byteLength: 5, + presentationTimestamp: 0, + duration: 1, + randomAccess: false, + displayedFrameCount: 1, + ), +]; + +const String goldenHex = '41564c49300000000200000000000000' + '800000000000000004000000010000000100000000000000010000000000000001000000000000000000000000000000' + '840000000000000005000000010000000000000000000000010000000000000000000000000000000000000000000000'; + +String _hex(Uint8List bytes) => bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +FormatError _expectFormatError(dynamic Function() operation, FormatErrorCode code) { + try { + operation(); + } on FormatError catch (error) { + expect(error.code, code); + return error; + } + fail('expected operation to throw'); +} + +void main() { + group('version-1.0 encoded-chunk index', () { + test('encodes the exact 16 + 48N canonical bytes', () { + final bytes = encodeEncodedChunkIndex(kRecords, manifest); + expect(bytes.length, 112); + expect(_hex(bytes), goldenHex); + }); + + test('preserves decode order independently from presentation timestamps', () { + final bytes = encodeEncodedChunkIndex(kRecords, manifest); + final parsed = parseEncodedChunkIndex(bytes, manifest); + expect(parsed.length, kRecords.length); + for (var i = 0; i < parsed.length; i += 1) { + expect(parsed[i].byteOffset, kRecords[i].byteOffset); + expect(parsed[i].byteLength, kRecords[i].byteLength); + expect(parsed[i].presentationTimestamp, kRecords[i].presentationTimestamp); + expect(parsed[i].duration, kRecords[i].duration); + expect(parsed[i].randomAccess, kRecords[i].randomAccess); + expect(parsed[i].displayedFrameCount, kRecords[i].displayedFrameCount); + } + expect(parsed.map((r) => r.presentationTimestamp).toList(), [1, 0]); + bytes.fillRange(0, bytes.length, 0); + expect(parsed[0].byteOffset, kRecords[0].byteOffset); + }); + + test('supports hidden chunks and multiple chunks per displayed frame timeline', () { + final hiddenManifest = _manifestWith( + rendition: _videoRendition(), + unit: _bodyUnit(frameCount: 1, chunkCount: 2), + ); + final records = [ + EncodedChunkRecord( + byteOffset: kRecords[0].byteOffset, + byteLength: kRecords[0].byteLength, + presentationTimestamp: kRecords[0].presentationTimestamp, + duration: 0, + randomAccess: kRecords[0].randomAccess, + displayedFrameCount: 0, + ), + EncodedChunkRecord( + byteOffset: kRecords[1].byteOffset, + byteLength: kRecords[1].byteLength, + presentationTimestamp: 0, + duration: kRecords[1].duration, + randomAccess: kRecords[1].randomAccess, + displayedFrameCount: kRecords[1].displayedFrameCount, + ), + ]; + final parsed = parseEncodedChunkIndex( + encodeEncodedChunkIndex(records, hiddenManifest), + hiddenManifest, + ); + expect(parsed.length, records.length); + for (var i = 0; i < parsed.length; i += 1) { + expect(parsed[i].duration, records[i].duration); + expect(parsed[i].displayedFrameCount, records[i].displayedFrameCount); + } + }); + + test('rejects every truncation and any trailing byte', () { + final bytes = encodeEncodedChunkIndex(kRecords, manifest); + for (var length = 0; length < bytes.length; length += 1) { + _expectFormatError( + () => parseEncodedChunkIndex(Uint8List.sublistView(bytes, 0, length), manifest), + FormatErrorCode.indexInvalid, + ); + } + final trailing = Uint8List(bytes.length + 1)..setRange(0, bytes.length, bytes); + _expectFormatError(() => parseEncodedChunkIndex(trailing, manifest), FormatErrorCode.indexInvalid); + }); + + test('rejects magic, size, reserved bytes, and unknown flag bits', () { + const offsets = [0, 4, 6, 12, 16 + 36, 16 + 40]; + for (final offset in offsets) { + final bytes = encodeEncodedChunkIndex(kRecords, manifest); + bytes[offset] = (bytes[offset] ^ 1) & 0xff; + _expectFormatError(() => parseEncodedChunkIndex(bytes, manifest), FormatErrorCode.indexInvalid); + } + final flag = encodeEncodedChunkIndex(kRecords, manifest); + flag[16 + 32] = 2; + _expectFormatError(() => parseEncodedChunkIndex(flag, manifest), FormatErrorCode.indexInvalid); + }); + + test('requires independent random-access unit entry and exact displayed coverage', () { + final entry = encodeEncodedChunkIndex(kRecords, manifest); + writeUint32LE(entry, 16 + 32, 0); + _expectFormatError(() => parseEncodedChunkIndex(entry, manifest), FormatErrorCode.indexInvalid); + + final coverage = encodeEncodedChunkIndex(kRecords, manifest); + writeUint32LE(coverage, 16 + 12, 0); + _expectFormatError(() => parseEncodedChunkIndex(coverage, manifest), FormatErrorCode.indexInvalid); + + final duration = encodeEncodedChunkIndex(kRecords, manifest); + writeUint64LE(duration, 16 + 24, BigInt.zero); + _expectFormatError(() => parseEncodedChunkIndex(duration, manifest), FormatErrorCode.indexInvalid); + }); + + test('rejects zero/over-budget byte lengths and unsafe timestamps', () { + final zero = encodeEncodedChunkIndex(kRecords, manifest); + writeUint32LE(zero, 16 + 8, 0); + _expectFormatError(() => parseEncodedChunkIndex(zero, manifest), FormatErrorCode.indexInvalid); + + _expectFormatError( + () => parseEncodedChunkIndex( + encodeEncodedChunkIndex(kRecords, manifest), + manifest, + const FormatOptions(budgets: {'maxChunkBytes': 4}), + ), + FormatErrorCode.budgetExceeded, + ); + + final unsafe = encodeEncodedChunkIndex(kRecords, manifest); + writeUint64LE(unsafe, 16 + 16, BigInt.from(maxSafeInteger) + BigInt.one); + _expectFormatError(() => parseEncodedChunkIndex(unsafe, manifest), FormatErrorCode.integerUnsafe); + }); + + test('cross-checks the canonical manifest chunk spans', () { + final wrongSpanManifest = _manifestWith( + rendition: _videoRendition(), + unit: BodyUnit( + id: 'body', + frameCount: 2, + playback: 'finite', + ports: const [], + chunks: [ + UnitChunkSpan( + rendition: 'video', + chunkStart: 1, + chunkCount: 2, + frameCount: 2, + sha256: _sha256Zeros, + ), + ], + ), + ); + _expectFormatError( + () => parseEncodedChunkIndex( + encodeEncodedChunkIndex(kRecords, manifest), + wrongSpanManifest, + ), + FormatErrorCode.indexInvalid, + ); + }); + + test('round-trips an index above the former scale', () { + const recordCount = 100000; + final bigManifest = _manifestWith( + rendition: _videoRendition(), + unit: _bodyUnit(frameCount: recordCount, chunkCount: recordCount), + ); + final records = List.generate( + recordCount, + (index) => EncodedChunkRecord( + byteOffset: 8000000 + index, + byteLength: 1, + presentationTimestamp: index, + duration: 1, + randomAccess: index == 0, + displayedFrameCount: 1, + ), + ); + + final bytes = encodeEncodedChunkIndex(records, bigManifest); + final parsed = parseEncodedChunkIndex(bytes, bigManifest); + + expect(bytes.length, greaterThan(4 * 1024 * 1024)); + expect(parsed.length, recordCount); + expect(parsed.last.presentationTimestamp, recordCount - 1); + }, timeout: const Timeout(Duration(seconds: 20))); + + test('honors record/index budgets and wraps hostile inputs', () { + final bytes = encodeEncodedChunkIndex(kRecords, manifest); + _expectFormatError( + () => parseEncodedChunkIndex( + bytes, + manifest, + const FormatOptions(budgets: {'maxChunkRecords': 1}), + ), + FormatErrorCode.budgetExceeded, + ); + _expectFormatError( + () => parseEncodedChunkIndex( + bytes, + manifest, + const FormatOptions(budgets: {'maxIndexBytes': 111}), + ), + FormatErrorCode.budgetExceeded, + ); + }); + }); +} diff --git a/flutter/packages/aval_format/test/av1_inspector_test.dart b/flutter/packages/aval_format/test/av1_inspector_test.dart new file mode 100644 index 0000000..a8eac16 --- /dev/null +++ b/flutter/packages/aval_format/test/av1_inspector_test.dart @@ -0,0 +1,97 @@ +/// Dart port of `packages/format/test/av1-inspector.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/av1/index.dart'; +import 'package:aval_format/src/errors.dart'; +import 'package:test/test.dart'; + +// Hex "00000002a7ff36be4404040410". +final Uint8List sequence = Uint8List.fromList([ + 0x00, 0x00, 0x00, 0x02, 0xa7, 0xff, 0x36, 0xbe, 0x44, 0x04, 0x04, 0x04, 0x10, +]); + +Uint8List obu(int type, Uint8List payload) { + return Uint8List.fromList([ + type << 3 | 0x02, + payload.length, + ...payload, + ]); +} + +Uint8List packet(List parts) { + final length = parts.fold(0, (total, part) => total + part.length); + final output = Uint8List(length); + var cursor = 0; + for (final part in parts) { + output.setRange(cursor, cursor + part.length, part); + cursor += part.length; + } + return output; +} + +void main() { + group('AV1 rendition inspection', () { + test( + 'derives a fully qualified codec and preserves frame display semantics', + () { + final key = packet([ + obu(2, Uint8List(0)), + obu(1, sequence), + obu(6, Uint8List.fromList([0x14])), + ]); + final hiddenAndShown = packet([ + obu(2, Uint8List(0)), + obu(6, Uint8List.fromList([0x24])), + obu(6, Uint8List.fromList([0x34])), + ]); + final inspection = inspectAv1Rendition(Av1RenditionInspectionInput( + width: 64, + height: 32, + bitDepth: 8, + units: [ + Av1UnitInput( + id: 'idle', + expectedDisplayedFrames: 2, + chunks: [ + Av1ChunkInput(bytes: key, key: true, timestamp: 0), + Av1ChunkInput(bytes: hiddenAndShown, key: false, timestamp: 1), + ], + ), + ], + )); + + expect(inspection.codec, 'av01.0.00M.08.0.110.01.01.01.0'); + expect(inspection.sequence.bitDepth, 8); + expect(inspection.sequence.maxWidth, 64); + expect(inspection.sequence.maxHeight, 32); + expect(inspection.units[0].displayedFrameCount, 2); + }); + + test('rejects units without a shown key start and display mismatches', () { + final key = packet([ + obu(2, Uint8List(0)), + obu(1, sequence), + obu(6, Uint8List.fromList([0x14])), + ]); + expect( + () => inspectAv1Rendition(Av1RenditionInspectionInput( + width: 64, + height: 32, + bitDepth: 8, + units: [ + Av1UnitInput( + id: 'idle', + expectedDisplayedFrames: 2, + chunks: [Av1ChunkInput(bytes: key, key: true, timestamp: 0)], + ), + ], + )), + throwsA(predicate((error) => + error is FormatError && + RegExp('displayed frame count').hasMatch(error.message))), + ); + }); + }); +} diff --git a/flutter/packages/aval_format/test/av1_obu_test.dart b/flutter/packages/aval_format/test/av1_obu_test.dart new file mode 100644 index 0000000..28587a1 --- /dev/null +++ b/flutter/packages/aval_format/test/av1_obu_test.dart @@ -0,0 +1,57 @@ +/// Dart port of `packages/format/test/av1-obu.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/av1/index.dart'; +import 'package:aval_format/src/errors.dart'; +import 'package:test/test.dart'; + +void main() { + group('AV1 low-overhead OBU parsing', () { + test( + 'parses temporal delimiter, sequence, and frame OBUs into owned payloads', + () { + final bytes = Uint8List.fromList( + [0x12, 0x00, 0x0a, 0x02, 0xaa, 0xbb, 0x32, 0x01, 0x14]); + final parsed = parseAv1LowOverheadObus(bytes); + expect(parsed, equals([ + Av1Obu(type: 2, temporalId: 0, spatialId: 0, payload: Uint8List(0)), + Av1Obu( + type: 1, + temporalId: 0, + spatialId: 0, + payload: Uint8List.fromList([0xaa, 0xbb])), + Av1Obu( + type: 6, + temporalId: 0, + spatialId: 0, + payload: Uint8List.fromList([0x14])), + ])); + bytes.fillRange(0, bytes.length, 0); + expect(parsed[1].payload, equals(Uint8List.fromList([0xaa, 0xbb]))); + }); + + test('requires canonical bounded LEB128 and valid OBU headers', () { + expect(readAv1Leb128(Uint8List.fromList([0x81, 0x01]), 0), + equals(const Av1Leb128(value: 129, length: 2))); + for (final bytes in [ + Uint8List.fromList([0x80, 0x00]), + Uint8List.fromList([0x80]), + Uint8List.fromList([0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]), + ]) { + expect(() => readAv1Leb128(bytes, 0), throwsA(isA())); + } + for (final bytes in [ + Uint8List.fromList([0x92, 0x00]), + Uint8List.fromList([0x10]), + Uint8List.fromList([0x42, 0x00]), + Uint8List.fromList([0x12, 0x01, 0x00]), + Uint8List.fromList([0x32, 0x02, 0x14]), + ]) { + expect( + () => parseAv1LowOverheadObus(bytes), throwsA(isA())); + } + }); + }); +} diff --git a/flutter/packages/aval_format/test/av1_sequence_header_test.dart b/flutter/packages/aval_format/test/av1_sequence_header_test.dart new file mode 100644 index 0000000..21bbd36 --- /dev/null +++ b/flutter/packages/aval_format/test/av1_sequence_header_test.dart @@ -0,0 +1,46 @@ +/// Dart port of `packages/format/test/av1-sequence-header.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/av1/index.dart'; +import 'package:aval_format/src/errors.dart'; +import 'package:test/test.dart'; + +// Hex "00000002a7ff36be4404040410". +final Uint8List libaom64x32_8bit = Uint8List.fromList([ + 0x00, 0x00, 0x00, 0x02, 0xa7, 0xff, 0x36, 0xbe, 0x44, 0x04, 0x04, 0x04, 0x10, +]); + +void main() { + group('AV1 sequence-header parsing', () { + test('parses the libaom Main-profile BT.709 sequence header', () { + final sequence = parseAv1SequenceHeader(libaom64x32_8bit); + expect(sequence.profile, 0); + expect(sequence.level, 0); + expect(sequence.tier, 'M'); + expect(sequence.bitDepth, 8); + expect(sequence.maxWidth, 64); + expect(sequence.maxHeight, 32); + expect(sequence.monochrome, isFalse); + expect(sequence.subsamplingX, 1); + expect(sequence.subsamplingY, 1); + expect(sequence.colorPrimaries, 1); + expect(sequence.transferCharacteristics, 1); + expect(sequence.matrixCoefficients, 1); + expect(sequence.fullRange, isFalse); + }); + + test('rejects truncation and color-description mutation', () { + expect( + () => parseAv1SequenceHeader(libaom64x32_8bit.sublist(0, 4)), + throwsA(predicate((error) => + error is FormatError && + RegExp('truncated').hasMatch(error.message))), + ); + final changed = Uint8List.fromList(libaom64x32_8bit); + changed[10] = changed[10] ^ 0x80; + expect(() => parseAv1SequenceHeader(changed), throwsA(isA())); + }); + }); +} diff --git a/flutter/packages/aval_format/test/canonical_json_test.dart b/flutter/packages/aval_format/test/canonical_json_test.dart new file mode 100644 index 0000000..27c1341 --- /dev/null +++ b/flutter/packages/aval_format/test/canonical_json_test.dart @@ -0,0 +1,283 @@ +// Dart port of packages/format/test/canonical-json.test.ts. +// +// A few TS assertions have no Dart equivalent and are adapted or dropped, +// noted at each site: Proxy-based `getOwnPropertyDescriptor` call counting +// (no Proxy in Dart), `Object.isFrozen`/null-prototype checks (Dart values +// returned here are plain Map/List; there is no runtime "frozen" bit to +// assert on), and getter/accessor property rejection (Dart Map values are +// always plain data, never accessors). +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:aval_format/src/canonical_json.dart'; +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/model.dart' show FormatOptions; +import 'package:test/test.dart'; + +Uint8List _utf8(String value) => Uint8List.fromList(utf8.encode(value)); + +String _text(Uint8List value) => utf8.decode(value); + +FormatError _expectCode(dynamic Function() action, FormatErrorCode code) { + try { + action(); + } on FormatError catch (error) { + expect(error.code, code); + return error; + } + fail('Expected FormatError $code'); +} + +void main() { + group('canonical JSON serialization', () { + test('shares a strict noncanonical source-document parser', () { + final value = parseStrictJson(_utf8('{ "z": 2, "a": 1 }')) as Map; + expect(value, {'z': 2, 'a': 1}); + _expectCode( + () => parseStrictJson(_utf8('{"a":1,"\\u0061":2}')), + FormatErrorCode.jsonDuplicateKey, + ); + }); + + test('writes the recursively minified canonical form', () { + final value = { + 'z': [true, false, null, -42], + 'a': {'b': 2, 'a': 1}, + }; + expect(_text(serializeCanonicalJson(value)), '{"a":{"a":1,"b":2},"z":[true,false,null,-42]}'); + }); + + test('uses the same writer for explicitly bounded high-cardinality output', () { + final values = List.generate(25000, (index) => index); + final bytes = serializeCanonicalJsonWithLimits( + {'values': values}, + const CanonicalJsonWriteLimits( + maxBytes: 1024 * 1024, + maxDepth: 16, + maxNodes: 30000, + maxStringBytes: 4096, + ), + ); + expect(jsonDecode(_text(bytes)), {'values': values}); + _expectCode( + () => serializeCanonicalJsonWithLimits( + {'values': values}, + const CanonicalJsonWriteLimits( + maxBytes: 1024, + maxDepth: 16, + maxNodes: 30000, + maxStringBytes: 1024, + ), + ), + FormatErrorCode.budgetExceeded, + ); + }); + + test('sorts keys by unsigned UTF-8 bytes rather than UTF-16 code units', () { + // The third key is U+E000 (a Private Use Area character, invisible in + // most renderers) — NOT an empty string. Its UTF-8 encoding is 3 bytes + // (0xEE 0x80 0x80), sorting after 'e-acute' (0xC3 0xA9) and before the + // astral char below (0xF0 0x90 0x80 0x80): exactly why the TS source + // picked it — it demonstrates UTF-8 byte order diverging from naive + // UTF-16 code-unit order. + const astral = '\u{10000}'; + const privateUse = '\u{E000}'; + final value = {astral: 2, privateUse: 1, 'é': 3, 'z': 4}; + expect( + _text(serializeCanonicalJson(value)), + '{"z":4,"é":3,"$privateUse":1,"$astral":2}', + ); + expect(compareUtf8Strings(privateUse, astral), lessThan(0)); + }); + + test('uses only the prescribed escapes and preserves all other scalars', () { + // Includes literal U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH + // SEPARATOR) between the NUL escape and 'é' — both are >= 0x20 so the + // writer passes them through as literal UTF-8 scalars, unescaped. + const value = '"\\\b\t\n\f\r\u0000/\u2028\u2029\u00e9\u{1F600}'; + expect( + _text(serializeCanonicalJson(value)), + '"\\"\\\\\\b\\t\\n\\f\\r\\u0000/\u2028\u2029\u00e9\u{1F600}"', + ); + }); + + test('writes minimum and maximum safe integers in shortest decimal form', () { + expect( + _text(serializeCanonicalJson([-9007199254740991, 0, 9007199254740991])), + '[-9007199254740991,0,9007199254740991]', + ); + }); + + test('rejects unsupported values and cycles stably', () { + _expectCode(() => serializeCanonicalJson(3.14), FormatErrorCode.integerUnsafe); + + final cycle = []; + cycle.add(cycle); + _expectCode(() => serializeCanonicalJson(cycle), FormatErrorCode.inputInvalid); + }); + + test('rejects dangerous keys and lone UTF-16 surrogates', () { + _expectCode( + () => serializeCanonicalJson({'constructor': 1}), + FormatErrorCode.jsonDangerousKey, + ); + _expectCode(() => serializeCanonicalJson('\ud800'), FormatErrorCode.inputInvalid); + _expectCode(() => serializeCanonicalJson('\udc00'), FormatErrorCode.inputInvalid); + }); + + test('bounds object-key and manifest-byte work before completing', () { + _expectCode( + () => serializeCanonicalJson( + {'a': 1, 'b': 2}, + const FormatOptions(budgets: {'maxJsonNodes': 2}), + ), + FormatErrorCode.budgetExceeded, + ); + _expectCode( + () => serializeCanonicalJson( + {'a': 1, 'b': 2, 'c': 3}, + const FormatOptions(budgets: {'maxManifestBytes': 1}), + ), + FormatErrorCode.budgetExceeded, + ); + _expectCode( + () => serializeCanonicalJson({'a'.padRight(4097, 'a'): 1}), + FormatErrorCode.budgetExceeded, + ); + }); + }); + + group('canonical JSON parsing', () { + test('returns a value tree matching the exact JSON structure', () { + final parsed = parseCanonicalJson(_utf8('{"a":{"b":1},"items":[{"c":2}]}')) as Map; + final nested = parsed['a'] as Map; + final items = parsed['items'] as List; + final item = items[0] as Map; + expect(nested['b'], 1); + expect(item['c'], 2); + }); + + test('accepts every canonical primitive and literal non-ASCII scalar', () { + for (final source in ['null', 'true', 'false', '0', '-1', '"é😀 /"', '[]', '{}']) { + expect(() => parseCanonicalJson(_utf8(source)), returnsNormally); + } + }); + + test('detects duplicate keys after escape decoding before canonical comparison', () { + final error = _expectCode( + () => parseCanonicalJson(_utf8('{"a":1,"\\u0061":2}')), + FormatErrorCode.jsonDuplicateKey, + ); + expect(error.offset, 7); + }); + + test('rejects dangerous decoded keys', () { + for (final source in [ + '{"__proto__":1}', + '{"prototype":1}', + '{"constructor":1}', + '{"\\u005f_proto__":1}', + ]) { + _expectCode(() => parseCanonicalJson(_utf8(source)), FormatErrorCode.jsonDangerousKey); + } + }); + + test('rejects noncanonical spellings', () { + for (final source in [ + ' true', + 'true\n', + '[1, 2]', + '{"b":1,"a":2}', + '"\\/"', + '"\\u0061"', + '"\\u001B"', + '"\\uD83D\\uDE00"', + '-0', + '01', + '1.0', + '1e0', + ]) { + _expectCode(() => parseCanonicalJson(_utf8(source)), FormatErrorCode.jsonNoncanonical); + } + }); + + test('rejects fatal UTF-8 cases', () { + for (final source in [ + [0xef, 0xbb, 0xbf, 0x6e, 0x75, 0x6c, 0x6c], + [0x22, 0x80, 0x22], + [0x22, 0xc0, 0xaf, 0x22], + [0x22, 0xe0, 0x80, 0xaf, 0x22], + [0x22, 0xed, 0xa0, 0x80, 0x22], + [0x22, 0xf4, 0x90, 0x80, 0x80, 0x22], + [0x22, 0xf0, 0x9f, 0x98], + [0x22, 0xe2, 0x28, 0xa1, 0x22], + ]) { + _expectCode( + () => parseCanonicalJson(Uint8List.fromList(source)), + FormatErrorCode.jsonInvalid, + ); + } + }); + + test('rejects malformed JSON without a built-in exception', () { + for (final source in [ + '"\\ud800"', + '"\\udc00"', + '"\\ud800x"', + '"\\ud800\\u0041"', + '"\\uZZZZ"', + '"\\x20"', + '"raw\nnewline"', + '[1,]', + '{"a":1,}', + 'tru', + '', + ]) { + _expectCode(() => parseCanonicalJson(_utf8(source)), FormatErrorCode.jsonInvalid); + } + }); + + test('rejects integers outside the safe range', () { + _expectCode(() => parseCanonicalJson(_utf8('9007199254740992')), FormatErrorCode.integerUnsafe); + _expectCode(() => parseCanonicalJson(_utf8('-9007199254740992')), FormatErrorCode.integerUnsafe); + _expectCode( + () => parseCanonicalJson(_utf8('999999999999999999999999999999')), + FormatErrorCode.integerUnsafe, + ); + }); + + test('enforces manifest, depth, node, and decoded string budgets', () { + _expectCode( + () => parseCanonicalJson(_utf8('null'), const FormatOptions(budgets: {'maxManifestBytes': 3})), + FormatErrorCode.budgetExceeded, + ); + _expectCode( + () => parseCanonicalJson(_utf8('[[0]]'), const FormatOptions(budgets: {'maxJsonDepth': 2})), + FormatErrorCode.budgetExceeded, + ); + _expectCode( + () => parseCanonicalJson(_utf8('[0,1]'), const FormatOptions(budgets: {'maxJsonNodes': 2})), + FormatErrorCode.budgetExceeded, + ); + _expectCode( + () => parseCanonicalJson(_utf8('"éé"'), const FormatOptions(budgets: {'maxJsonStringBytes': 3})), + FormatErrorCode.budgetExceeded, + ); + _expectCode( + () => parseCanonicalJson( + _utf8('"\\u00e9\\u00e9"'), + const FormatOptions(budgets: {'maxJsonStringBytes': 3}), + ), + FormatErrorCode.budgetExceeded, + ); + }); + + test('reports the first byte that differs from canonical form', () { + final error = _expectCode( + () => parseCanonicalJson(_utf8('{"b":1,"a":2}')), + FormatErrorCode.jsonNoncanonical, + ); + expect(error.offset, 2); + }); + }); +} diff --git a/flutter/packages/aval_format/test/checked_integer_test.dart b/flutter/packages/aval_format/test/checked_integer_test.dart new file mode 100644 index 0000000..5924050 --- /dev/null +++ b/flutter/packages/aval_format/test/checked_integer_test.dart @@ -0,0 +1,146 @@ +// Dart port of packages/format/test/checked-integer.test.ts. +import 'dart:typed_data'; + +import 'package:aval_format/src/checked_integer.dart'; +import 'package:aval_format/src/constants.dart'; +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/model.dart' show FormatOptions; +import 'package:test/test.dart'; + +FormatError _expectFormatError(dynamic Function() operation, FormatErrorCode code) { + try { + operation(); + } on FormatError catch (error) { + expect(error.code, code); + return error; + } + fail('expected operation to throw'); +} + +void main() { + group('checked integer arithmetic', () { + test('accepts zero and the largest safe integer', () { + expect(checkedNonNegativeInteger(0), 0); + expect(checkedNonNegativeInteger(maxSafeInteger), maxSafeInteger); + expect(checkedAdd(maxSafeInteger, 0), maxSafeInteger); + expect(checkedMultiply(maxSafeInteger, 1), maxSafeInteger); + }); + + test('separates unsafe arithmetic from active-budget failures', () { + _expectFormatError(() => checkedAdd(maxSafeInteger, 1), FormatErrorCode.integerUnsafe); + _expectFormatError(() => checkedMultiply(maxSafeInteger, 2), FormatErrorCode.integerUnsafe); + _expectFormatError(() => checkedAdd(4, 5, 8), FormatErrorCode.budgetExceeded); + _expectFormatError(() => checkedMultiply(3, 3, 8), FormatErrorCode.budgetExceeded); + for (final value in [-1]) { + _expectFormatError(() => checkedNonNegativeInteger(value), FormatErrorCode.integerUnsafe); + } + }); + + test('aligns and calculates ranges without overflowing', () { + expect(align8(0), 0); + expect(align8(1), 8); + expect(align8(8), 8); + expect(align8(maxSafeInteger - 7), maxSafeInteger - 7); + _expectFormatError(() => align8(maxSafeInteger), FormatErrorCode.integerUnsafe); + expect(checkedRangeEnd(10, 5), 15); + expect(rangeContains(10, 10, 10, 10), true); + expect(rangeContains(10, 10, 9, 1), false); + expect(rangeContains(10, 10, 20, 0), true); + expect(rangeContains(10, 10, 20, 1), false); + }); + + test('converts uint64 values only after bigint safety and budget checks', () { + expect(bigintToSafeNumber(BigInt.from(maxSafeInteger)), maxSafeInteger); + _expectFormatError( + () => bigintToSafeNumber(BigInt.from(maxSafeInteger) + BigInt.one), + FormatErrorCode.integerUnsafe, + ); + _expectFormatError( + () => bigintToSafeNumber(BigInt.from(9), 8), + FormatErrorCode.budgetExceeded, + ); + }); + }); + + group('bounded little-endian byte access', () { + test('round-trips values through an unaligned Uint8List view', () { + final storage = Uint8List(40); + final view = Uint8List.sublistView(storage, 3, 35); + + writeUint16LE(view, 1, 0xabcd); + writeUint32LE(view, 3, 0xfedcba98); + writeUint64LE(view, 7, BigInt.parse('123456789abcde', radix: 16)); + + expect(readUint16LE(view, 1), 0xabcd); + expect(readUint32LE(view, 3), 0xfedcba98); + expect(readUint64LEBigInt(view, 7), BigInt.parse('123456789abcde', radix: 16)); + expect(storage[2], 0); + expect(storage[3], 0); + }); + + test('reads bigint before rejecting MAX_SAFE_INTEGER + 1', () { + final bytes = Uint8List(8); + writeUint64LE(bytes, 0, BigInt.from(maxSafeInteger) + BigInt.one); + expect(readUint64LEBigInt(bytes, 0), BigInt.from(maxSafeInteger) + BigInt.one); + _expectFormatError(() => readUint64LE(bytes, 0), FormatErrorCode.integerUnsafe); + }); + + test('prechecks every complete read and write range', () { + for (var length = 0; length < 8; length += 1) { + final bytes = Uint8List(length); + _expectFormatError( + () => readUint64LEBigInt(bytes, 0, FormatErrorCode.indexInvalid), + FormatErrorCode.indexInvalid, + ); + _expectFormatError( + () => writeUint64LE(bytes, 0, BigInt.zero, FormatErrorCode.indexInvalid), + FormatErrorCode.indexInvalid, + ); + } + _expectFormatError( + () => requireByteRange(Uint8List(4), 3, 2, FormatErrorCode.layoutInvalid), + FormatErrorCode.layoutInvalid, + ); + }); + }); + + group('budgets and stable errors', () { + test('merges only lower safe overrides into an immutable result', () { + final resolved = resolveFormatBudgets( + const FormatOptions(budgets: {'maxManifestBytes': 512, 'maxEdges': 0}), + ); + expect(resolved.maxManifestBytes, 512); + expect(resolved.maxEdges, 0); + expect(resolved.maxFileBytes, formatDefaultBudgets.maxFileBytes); + }); + + test('rejects raised, negative, and unknown overrides', () { + _expectFormatError( + () => resolveFormatBudgets( + FormatOptions(budgets: {'maxFileBytes': formatDefaultBudgets.maxFileBytes + 1}), + ), + FormatErrorCode.inputInvalid, + ); + _expectFormatError( + () => resolveFormatBudgets(const FormatOptions(budgets: {'maxEdges': -1})), + FormatErrorCode.inputInvalid, + ); + _expectFormatError( + () => resolveFormatBudgets(const FormatOptions(budgets: {'unknown': 1})), + FormatErrorCode.inputInvalid, + ); + }); + + test('carries the stable FormatError properties', () { + final error = FormatError( + FormatErrorCode.headerInvalid, + 'bad header', + const FormatErrorDetails(path: 'header.magic', offset: 3), + ); + expect(error.name, 'FormatError'); + expect(error.code, FormatErrorCode.headerInvalid); + expect(error.path, 'header.magic'); + expect(error.offset, 3); + }); + }); +} diff --git a/flutter/packages/aval_format/test/compile_bundle_report_test.dart b/flutter/packages/aval_format/test/compile_bundle_report_test.dart new file mode 100644 index 0000000..09fbdee --- /dev/null +++ b/flutter/packages/aval_format/test/compile_bundle_report_test.dart @@ -0,0 +1,199 @@ +// Dart port of `packages/format/test/compile-bundle-report.test.ts`. +// +// The module is imported directly (not via the package barrel) while the +// top-level barrel is in flux, per the porting brief. These tests exercise +// `parseCompileBundleReport`; running them requires the concurrently-authored +// `lib/src/video/codec-string.dart` dependency to be present. + +import 'package:aval_format/src/compile_bundle_report.dart'; +import 'package:test/test.dart'; + +/// Mirrors the TS `.toThrow(/pattern/)` assertions: the port throws a +/// [FormatException] whose message is `compile bundle report: `. +Matcher _reportError(String pattern) => throwsA( + isA() + .having((e) => e.toString(), 'message', matches(pattern)), + ); + +void main() { + group('compile bundle report', () { + test('validates, detaches, and rebuilds the browser-facing report contract', + () { + final source = validReport(); + final parsed = parseCompileBundleReport(source); + + final asset = parsed.assets[0]; + expect(asset.codec, 'h264'); + expect(asset.path, 'h264.avl'); + expect(asset.codecString, 'avc1.64001E'); + + final encoding = parsed.encodings[0]; + expect(encoding, isA()); + encoding as CompileBundleReportH264Encoding; + expect(encoding.preset, 'medium'); + expect(encoding.renditions, hasLength(1)); + final rendition = encoding.renditions[0]; + expect(rendition.id, 'video.main'); + expect(rendition.width, 640); + expect(rendition.height, 360); + expect(rendition.crf, 30); + + // Detachment: the returned lists are unmodifiable copies. + expect(() => parsed.assets.add(asset), throwsUnsupportedError); + expect(() => parsed.encodings[0].renditions.add(rendition), + throwsUnsupportedError); + + // Mutating the source after parsing must not affect the parsed report. + ((source['assets']! as List)[0]! as Map)[ + 'bytes'] = 1; + expect(parsed.assets[0].bytes, 1234); + }); + + test('rejects codec strings outside the supported AVAL codec contract', () { + final source = validReport(); + final asset = + (source['assets']! as List)[0]! as Map; + asset['codecString'] = 'avc1.000000'; + asset['type'] = 'application/vnd.aval; codecs="avc1.000000"'; + expect(() => parseCompileBundleReport(source), + _reportError(r'codecString.*supported codec string')); + }); + + test('rejects asset and encoding order drift', () { + final source = validReport(); + final asset = + (source['assets']! as List)[0]! as Map; + asset['codec'] = 'vp9'; + asset['path'] = 'vp9.avl'; + expect(() => parseCompileBundleReport(source), + _reportError(r'must match the encoding')); + }); + + test('rejects integrity metadata that disagrees with the SHA-256 digest', + () { + final source = validReport(); + final asset = + (source['assets']! as List)[0]! as Map; + asset['integrity'] = 'sha256-AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE='; + expect(() => parseCompileBundleReport(source), + _reportError(r'integrity.*declared sha256 digest')); + }); + + test('accepts the compiler empty arguments and full path-free text limits', + () { + final source = validReport(); + final invocation = + (source['invocations']! as List)[0]! as Map; + invocation['arguments'] = ['', 'x' * (17 * 1024)]; + source['warnings'] = ['w' * (5 * 1024)]; + + final parsed = parseCompileBundleReport(source); + + expect(parsed.invocations[0].arguments[0], ''); + expect(parsed.invocations[0].arguments[1], hasLength(17 * 1024)); + expect(parsed.warnings[0], hasLength(5 * 1024)); + }); + + test('enforces the compiler warning-count limit', () { + final source = validReport(); + source['warnings'] = List.generate(4097, (_) => 'warning'); + + expect(() => parseCompileBundleReport(source), + _reportError(r'warnings.*0 through 4096 entries')); + }); + + test('rejects presets outside the compiler encoder allowlists', () { + final source = validReport(); + final encoding = + (source['encodings']! as List)[0]! as Map; + encoding['preset'] = 'not-an-x264-preset'; + + expect(() => parseCompileBundleReport(source), + _reportError(r'preset.*must be one of')); + }); + + test('rejects malformed toolchain provenance', () { + final source = validReport(); + source['toolchain'] = {}; + + expect(() => parseCompileBundleReport(source), + _reportError(r'toolchain\.ffmpeg.*required')); + }); + + test('requires source markup derived from the ordered assets', () { + final source = validReport(); + source['sourceMarkup'] = ''; + + expect(() => parseCompileBundleReport(source), + _reportError(r'sourceMarkup.*ordered asset metadata')); + }); + }); +} + +Map validReport() { + final asset = { + 'codec': 'h264', + 'path': 'h264.avl', + 'bytes': 1234, + 'sha256': '0' * 64, + 'codecString': 'avc1.64001E', + 'type': 'application/vnd.aval; codecs="avc1.64001E"', + 'integrity': 'sha256-${'A' * 43}=', + }; + return { + 'reportVersion': '1.0', + 'assets': [asset], + 'encodings': [ + { + 'codec': 'h264', + 'preset': 'medium', + 'renditions': [ + { + 'id': 'video.main', + 'width': 640, + 'height': 360, + 'crf': 30, + }, + ], + }, + ], + 'invocations': [ + { + 'operation': 'h264:video.main:loop:encode', + 'tool': 'ffmpeg', + 'arguments': ['-c:v', 'libx264'], + }, + ], + 'warnings': [], + 'toolchain': validToolchain(), + 'sourceMarkup': + '', + }; +} + +Map validToolchain() => { + 'ffmpeg': { + 'executableSha256': '1' * 64, + 'executableIdentity': executableIdentity('1'), + 'version': 'ffmpeg version 8.0-test', + 'versionOutputSha256': '2' * 64, + 'configurationSha256': '3' * 64, + 'encodersOutputSha256': '4' * 64, + 'calibrationSha256': '5' * 64, + }, + 'ffprobe': { + 'executableSha256': '6' * 64, + 'executableIdentity': executableIdentity('2'), + 'version': 'ffprobe version 8.0-test', + 'versionOutputSha256': '7' * 64, + }, + 'aggregateMemoryLimit': 'derived', + }; + +Map executableIdentity(String inode) => { + 'device': '1', + 'inode': inode, + 'size': 123, + 'mtimeNanoseconds': '1000', + 'ctimeNanoseconds': '1001', + }; diff --git a/flutter/packages/aval_format/test/deflate_mutation_test.dart b/flutter/packages/aval_format/test/deflate_mutation_test.dart new file mode 100644 index 0000000..047162c --- /dev/null +++ b/flutter/packages/aval_format/test/deflate_mutation_test.dart @@ -0,0 +1,59 @@ +/// Dart port of `packages/format/test/deflate-mutation.test.ts`. +/// +/// Uses `dart:io`'s `ZLibEncoder(raw: true)` test-only, exactly as +/// `deflate_test.dart` does, to generate the one real compressed vector this +/// fixed-seed mutation sweep repeatedly corrupts. +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/png/deflate.dart'; +import 'package:test/test.dart'; + +void main() { + group('DEFLATE fixed-seed mutations', () { + test('never escapes stable failure or the exact bounded output', () { + final source = Uint8List.fromList( + List.generate( + 2048, + (index) => (index * 17 + (index >> 3) * 41) & 0xff, + ), + ); + final raw = Uint8List.fromList( + ZLibEncoder(raw: true, level: 9).convert(source), + ); + var seed = 0xa341316c; + for (var iteration = 0; iteration < 512; iteration += 1) { + seed = (_imul(seed ^ (seed >> 16), 0x45d9f3b) + iteration) & 0xffffffff; + final mutated = Uint8List.fromList(raw); + final offset = seed % mutated.length; + mutated[offset] = mutated[offset] ^ (1 << ((seed >> 11) & 7)); + try { + final output = inflateDeflate( + DeflateInflateInput( + deflate: mutated, + expectedOutputLength: source.length, + ), + ); + expect(output.length, equals(source.length)); + } catch (error) { + expect(error, isA()); + expect( + (error as FormatError).code, + equals(FormatErrorCode.pngDeflateInvalid), + ); + expect(error.message.length, lessThan(256)); + } + mutated.fillRange(0, mutated.length, 0); + } + }); + }); +} + +/// 32-bit `Math.imul` equivalent (low 32 bits of the product are identical +/// whether the operands are treated as signed or unsigned). +int _imul(int a, int b) { + return (a * b) & 0xffffffff; +} diff --git a/flutter/packages/aval_format/test/deflate_test.dart b/flutter/packages/aval_format/test/deflate_test.dart new file mode 100644 index 0000000..0a25e9b --- /dev/null +++ b/flutter/packages/aval_format/test/deflate_test.dart @@ -0,0 +1,420 @@ +/// Dart port of `packages/format/test/deflate.test.ts`. +/// +/// The TS source uses `node:zlib`'s `deflateRawSync` purely to generate real +/// compressed test vectors (stored/fixed/dynamic Huffman blocks) to exercise +/// the hand-rolled inflater under test; it is never used by production code. +/// This Dart port makes the same pragmatic test-only choice with `dart:io`'s +/// `ZLibEncoder(raw: true, ...)` (an SDK library, not a pub package, and +/// never linked into `lib/src/png/*.dart`), which supports the same +/// raw-DEFLATE, level, and strategy (`Z_FIXED`) knobs as `deflateRawSync`. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/png/deflate.dart'; +import 'package:test/test.dart'; + +void main() { + group('bounded RFC 1951 inflater', () { + for (final kind in const ['stored', 'fixed', 'dynamic']) { + test('inflates independently generated $kind blocks', () { + final source = Uint8List.fromList( + List.generate( + 4097, + (index) => (index * 37 + (index ~/ 11) * 19) & 0xff, + ), + ); + final encoder = ZLibEncoder( + raw: true, + level: kind == 'stored' ? 0 : (kind == 'dynamic' ? 9 : 6), + strategy: kind == 'fixed' + ? ZLibOption.strategyFixed + : ZLibOption.strategyDefault, + ); + final raw = Uint8List.fromList(encoder.convert(source)); + expect( + (raw[0] >> 1) & 0x3, + equals(kind == 'stored' ? 0 : (kind == 'fixed' ? 1 : 2)), + ); + expect( + inflateDeflate( + DeflateInflateInput( + deflate: raw, + expectedOutputLength: source.length, + ), + ), + equals(source), + ); + }); + } + + test("inflates the compiler's multi-block stored shape beyond 65,535 bytes", () { + final source = Uint8List.fromList( + List.generate(70000, (index) => index & 0xff), + ); + final raw = Uint8List.fromList( + ZLibEncoder(raw: true, level: 0).convert(source), + ); + expect( + inflateDeflate( + DeflateInflateInput(deflate: raw, expectedOutputLength: source.length), + ), + equals(source), + ); + }); + + test('inflates output above the former 2 MiB ceiling', () { + final source = Uint8List(2 * 1024 * 1024 + 1)..fillRange(0, 2 * 1024 * 1024 + 1, 0x5a); + final raw = Uint8List.fromList( + ZLibEncoder(raw: true, level: 0).convert(source), + ); + expect( + inflateDeflate( + DeflateInflateInput(deflate: raw, expectedOutputLength: source.length), + ), + equals(source), + ); + }); + + test('rejects invalid stored complements and output overruns', () { + final valid = Uint8List.fromList(const [1, 3, 0, 0xfc, 0xff, 1, 2, 3]); + expect( + inflateDeflate(DeflateInflateInput(deflate: valid, expectedOutputLength: 3)), + equals(Uint8List.fromList(const [1, 2, 3])), + ); + final complement = Uint8List.fromList(valid); + complement[3] = complement[3] ^ 1; + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput(deflate: complement, expectedOutputLength: 3), + ), + ); + final padding = Uint8List.fromList(valid); + padding[0] = padding[0] | 0x08; + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput(deflate: padding, expectedOutputLength: 3), + ), + ); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput(deflate: valid, expectedOutputLength: 2), + ), + ); + }); + + test('rejects reserved block/literal/distance symbols and missing history', () { + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput( + deflate: Uint8List.fromList(const [0x07]), + expectedOutputLength: 0, + ), + ), + ); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput( + deflate: _fixedBlock(const [286, 256]), + expectedOutputLength: 0, + ), + ), + ); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput( + deflate: _fixedLengthDistanceBlock(257, 30), + expectedOutputLength: 3, + ), + ), + ); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput( + deflate: _fixedLengthDistanceBlock(257, 0), + expectedOutputLength: 3, + ), + ), + ); + }); + + test('rejects empty, oversubscribed, incomplete, and leading-repeat dynamic trees', () { + for (final raw in [ + _dynamicHeader(const [0, 0, 0, 0]), + _dynamicHeader(const [1, 1, 1, 1]), + _dynamicHeader(const [2, 2, 0, 0]), + _dynamicLeadingRepeat16(), + _dynamicRepeatOverflow(), + ]) { + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput(deflate: raw, expectedOutputLength: 0), + ), + ); + } + }); + + test('accepts the RFC 1951 empty distance alphabet for a literal-only block', () { + final source = Uint8List(257); + expect( + inflateDeflate( + DeflateInflateInput( + deflate: _dynamicLiteralOnlyBlock(source.length), + expectedOutputLength: source.length, + ), + ), + equals(source), + ); + }); + + test('rejects a length symbol when the dynamic distance alphabet is empty', () { + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput( + deflate: _dynamicLengthWithoutDistanceBlock(), + expectedOutputLength: 3, + ), + ), + ); + }); + + test('requires EOB, a final block, zero terminal pad bits, and no trailing byte', () { + final empty = _fixedBlock(const [256]); + expect( + inflateDeflate(DeflateInflateInput(deflate: empty, expectedOutputLength: 0)), + equals(Uint8List(0)), + ); + + final missingEob = _fixedBlock(const [65], includeEob: false); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput(deflate: missingEob, expectedOutputLength: 1), + ), + ); + + final nonfinalStored = Uint8List.fromList(const [0, 0, 0, 0xff, 0xff]); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput(deflate: nonfinalStored, expectedOutputLength: 0), + ), + ); + + final nonzeroPad = Uint8List.fromList(empty); + nonzeroPad[nonzeroPad.length - 1] = nonzeroPad[nonzeroPad.length - 1] | 0x80; + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput(deflate: nonzeroPad, expectedOutputLength: 0), + ), + ); + + final trailing = Uint8List(empty.length + 1); + trailing.setRange(0, empty.length, empty); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput(deflate: trailing, expectedOutputLength: 0), + ), + ); + }); + + test('rejects short/long output and enforces the frozen work formula', () { + final source = Uint8List.fromList(utf8.encode('bounded output')); + final raw = Uint8List.fromList(ZLibEncoder(raw: true).convert(source)); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput( + deflate: raw, + expectedOutputLength: source.length - 1, + ), + ), + ); + _expectDeflateError( + () => inflateDeflate( + DeflateInflateInput( + deflate: raw, + expectedOutputLength: source.length + 1, + ), + ), + ); + expect(calculateDeflateWorkLimit(10, 20), equals(32 * 30 + 4096)); + _expectDeflateError( + () => calculateDeflateWorkLimit(maxSafeIntegerForTest, 1), + ); + _expectDeflateError( + () => inflateDeflateWithLimit( + DeflateInflateInput(deflate: raw, expectedOutputLength: source.length), + 5, + ), + ); + // Note: the TS source additionally asserts that `inflateDeflate(null)` + // and `inflateDeflate({ deflate: null, ... })` raise PNG_DEFLATE_INVALID + // at runtime. Dart's `DeflateInflateInput` has non-nullable required + // fields, so passing `null` for either is a compile-time error rather + // than a runtime condition; those two assertions have no Dart + // equivalent and are intentionally omitted. + }); + }); +} + +/// `Number.MAX_SAFE_INTEGER`, duplicated locally so this test file has no +/// dependency on package internals beyond the public `png/deflate.dart` API. +const int maxSafeIntegerForTest = 9007199254740991; + +Uint8List _dynamicHeader(List codeLengths) { + final writer = _LsbBitWriter() + ..bits(1, 1).bits(2, 2) // final dynamic block + ..bits(0, 5).bits(0, 5).bits(0, 4); // 257, 1, 4 + for (final length in codeLengths) { + writer.bits(length, 3); + } + return writer.finish(); +} + +Uint8List _dynamicLeadingRepeat16() { + final writer = _LsbBitWriter() + ..bits(1, 1).bits(2, 2) + ..bits(0, 5).bits(0, 5).bits(0, 4); + // Order is 16,17,18,0: symbols 16 and 0 form a complete one-bit tree. + writer.bits(1, 3).bits(0, 3).bits(0, 3).bits(1, 3); + writer.bits(1, 1); // symbol 16 before any prior length + return writer.finish(); +} + +Uint8List _dynamicRepeatOverflow() { + final writer = _LsbBitWriter() + ..bits(1, 1).bits(2, 2) + ..bits(0, 5).bits(0, 5).bits(0, 4); + // Symbols 18 and 0 form a complete one-bit code-length tree. + writer.bits(0, 3).bits(0, 3).bits(1, 3).bits(1, 3); + writer.bits(1, 1).bits(127, 7); // 138 zeros + writer.bits(1, 1).bits(110, 7); // 121 more exceeds total 258 + return writer.finish(); +} + +Uint8List _dynamicLiteralOnlyBlock(int literalCount) { + final writer = _dynamicZeroOneLengthHeader(257); + _writeZeroOneCodeLengths( + writer, + List.generate(257, (symbol) => symbol == 0 || symbol == 256 ? 1 : 0), + const [0], + ); + for (var index = 0; index < literalCount; index += 1) { + writer.bits(0, 1); // literal zero + } + writer.bits(1, 1); // end-of-block 256 + return writer.finish(); +} + +Uint8List _dynamicLengthWithoutDistanceBlock() { + final writer = _dynamicZeroOneLengthHeader(258); + _writeZeroOneCodeLengths( + writer, + List.generate(258, (symbol) => symbol == 256 || symbol == 257 ? 1 : 0), + const [0], + ); + writer.bits(1, 1); // length symbol 257; no distance alphabet follows + return writer.finish(); +} + +_LsbBitWriter _dynamicZeroOneLengthHeader(int literalCodeCount) { + final writer = _LsbBitWriter() + ..bits(1, 1).bits(2, 2) // final dynamic block + ..bits(literalCodeCount - 257, 5).bits(0, 5).bits(14, 4); + const order = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1]; + for (final symbol in order) { + writer.bits(symbol == 0 || symbol == 1 ? 1 : 0, 3); + } + return writer; +} + +void _writeZeroOneCodeLengths( + _LsbBitWriter writer, + List literalLengths, + List distanceLengths, +) { + // Code-length symbols zero and one form the complete one-bit alphabet. + for (final length in [...literalLengths, ...distanceLengths]) { + writer.bits(length, 1); + } +} + +Uint8List _fixedBlock(List symbols, {bool includeEob = true}) { + final writer = _LsbBitWriter()..bits(1, 1).bits(1, 2); + for (final symbol in symbols) { + _writeFixedLiteral(writer, symbol); + } + if (includeEob && (symbols.isEmpty || symbols.last != 256)) { + _writeFixedLiteral(writer, 256); + } + return writer.finish(); +} + +Uint8List _fixedLengthDistanceBlock(int lengthSymbol, int distanceSymbol) { + final writer = _LsbBitWriter()..bits(1, 1).bits(1, 2); + _writeFixedLiteral(writer, lengthSymbol); + writer.bits(_reverseBits(distanceSymbol, 5), 5); + _writeFixedLiteral(writer, 256); + return writer.finish(); +} + +void _writeFixedLiteral(_LsbBitWriter writer, int symbol) { + int code; + int length; + if (symbol <= 143) { + code = 0x30 + symbol; + length = 8; + } else if (symbol <= 255) { + code = 0x190 + symbol - 144; + length = 9; + } else if (symbol <= 279) { + code = symbol - 256; + length = 7; + } else { + code = 0xc0 + symbol - 280; + length = 8; + } + writer.bits(_reverseBits(code, length), length); +} + +int _reverseBits(int value, int width) { + var result = 0; + for (var index = 0; index < width; index += 1) { + result = (result << 1) | ((value >> index) & 1); + } + return result; +} + +class _LsbBitWriter { + final List _bits = []; + + _LsbBitWriter bits(int value, int count) { + for (var bit = 0; bit < count; bit += 1) { + _bits.add((value >> bit) & 1); + } + return this; + } + + Uint8List finish() { + final bytes = Uint8List((_bits.length / 8).ceil()); + for (var index = 0; index < _bits.length; index += 1) { + bytes[index ~/ 8] |= _bits[index] << (index & 7); + } + return bytes; + } +} + +void _expectDeflateError(Object? Function() action) { + try { + action(); + } catch (error) { + expect(error, isA()); + expect( + (error as FormatError).code, + equals(FormatErrorCode.pngDeflateInvalid), + ); + return; + } + fail('expected DEFLATE failure'); +} diff --git a/flutter/packages/aval_format/test/graph_adapter_test.dart b/flutter/packages/aval_format/test/graph_adapter_test.dart new file mode 100644 index 0000000..6765df9 --- /dev/null +++ b/flutter/packages/aval_format/test/graph_adapter_test.dart @@ -0,0 +1,145 @@ +// Dart port of packages/format/test/graph-adapter.test.ts (1.0). +// +// `GraphStateDefinition`/`GraphEdgeDefinition`/etc. in aval_graph do not +// override `==`, so this port asserts field-by-field rather than one deep +// `toEqual`, and drops the `Object.isFrozen`-style checks (no Dart +// equivalent; aval_graph's `List.unmodifiable` copies play the same +// "cannot be caller-mutated" role structurally). +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/graph_adapter.dart'; +import 'package:aval_format/src/manifest_schema.dart'; +import 'package:aval_format/src/model.dart'; +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +import 'manifest_fixture.dart'; + +void main() { + group('adaptManifestToMotionGraph', () { + test('maps the complete manifest graph to the hand-written canonical golden', () { + final graph = adaptManifestToMotionGraph(validateCompiledManifest(validManifest())); + final definition = graph.definition; + + expect(definition.initialState, 'a-a'); + expect(definition.states.map((s) => s.id).toList(), ['a-a', 'a-b', 'a-c']); + + final stateA = definition.states[0]; + expect(stateA.body.unitId, 'body-a'); + expect(stateA.body.kind, GraphBodyKind.loop); + expect(stateA.body.frameCount, 4); + expect(stateA.body.ports[0].id, 'default'); + expect(stateA.body.ports[0].portalFrames, [0, 2]); + expect(stateA.initialUnit?.unitId, 'intro-a'); + expect(stateA.initialUnit?.frameCount, 2); + + final stateB = definition.states[1]; + expect(stateB.body.kind, GraphBodyKind.finite); + expect(stateB.body.frameCount, 3); + expect(stateB.body.ports[0].portalFrames, [2]); + expect(stateB.initialUnit, isNull); + + final stateC = definition.states[2]; + expect(stateC.body.kind, GraphBodyKind.held); + expect(stateC.body.frameCount, 1); + expect(stateC.body.ports[0].portalFrames, [0]); + + expect(definition.edges.map((e) => e.id).toList(), [ + 'edge-ab', + 'edge-ac', + 'edge-ba', + 'edge-bc', + 'edge-cb', + ]); + + final edgeAb = definition.edges[0]; + expect(edgeAb.from, 'a-a'); + expect(edgeAb.to, 'a-b'); + expect((edgeAb.trigger as GraphEdgeTriggerEvent).name, 'go-b'); + final startAb = edgeAb.start as GraphStartPolicyPortal; + expect(startAb.sourcePort, 'default'); + expect(startAb.targetPort, 'default'); + expect(startAb.maxWaitFrames, 1); + final transitionAb = edgeAb.transition as GraphTransitionLocked; + expect(transitionAb.unitId, 'bridge-ab'); + expect(transitionAb.frameCount, 2); + expect(edgeAb.continuity, GraphContinuity.exactAuthored); + + final edgeAc = definition.edges[1]; + expect(edgeAc.start, isA()); + expect(edgeAc.continuity, GraphContinuity.cut); + expect(edgeAc.transition, isNull); + + final edgeBa = definition.edges[2]; + expect(edgeBa.trigger, isA()); + expect(edgeBa.start, isA()); + + final edgeBc = definition.edges[3]; + final transitionBc = edgeBc.transition as GraphTransitionReversible; + expect(transitionBc.unitId, 'rev-bc'); + expect(transitionBc.frameCount, 6); + expect(transitionBc.direction, TransitionDirection.forward); + expect(transitionBc.reverseOf, isNull); + expect(edgeBc.continuity, GraphContinuity.exactAuthored); + + final edgeCb = definition.edges[4]; + final transitionCb = edgeCb.transition as GraphTransitionReversible; + expect(transitionCb.direction, TransitionDirection.reverse); + expect(transitionCb.reverseOf, 'edge-bc'); + expect(edgeCb.continuity, GraphContinuity.exactReverse); + }); + + test('returns a graph detached from the manifest', () { + final manifest = validateCompiledManifest(validManifest()); + final graph = adaptManifestToMotionGraph(manifest); + + expect(graph.definition.states, isNot(same(manifest.states))); + }); + + test('wraps graph geometry and ambiguity failures as GRAPH_INVALID', () { + final manifest = validManifest(); + final edges = (manifest['edges'] as List).cast>(); + final edge0 = Map.from(edges[0]); + final start = Map.from(edge0['start'] as Map); + start['maxWaitFrames'] = 0; + edge0['start'] = start; + final newEdges = [edge0, ...edges.skip(1)]; + final mutated = Map.from(manifest)..['edges'] = newEdges; + + final schemaValid = validateCompiledManifest(mutated); + expect( + () => adaptManifestToMotionGraph(schemaValid), + throwsA(predicate((e) => e is FormatError && e.code == FormatErrorCode.graphInvalid)), + ); + }); + + test('wraps malformed trusted input without leaking built-in errors', () { + // TS passes a hand-built `{ ...validManifest(), units: [] }` cast as a + // trusted CompiledManifest. Here the model is typed, so we rebuild the + // same trusted-but-malformed value directly with empty units. + final valid = validateCompiledManifest(validManifest()); + final malformed = CompiledManifest( + generator: valid.generator, + codec: valid.codec, + bitstream: valid.bitstream, + layout: valid.layout, + canvas: valid.canvas, + frameRate: valid.frameRate, + renditions: valid.renditions, + units: const [], + initialState: valid.initialState, + states: valid.states, + edges: valid.edges, + bindings: valid.bindings, + readiness: valid.readiness, + limits: valid.limits, + ); + + try { + adaptManifestToMotionGraph(malformed); + fail('expected graph adaptation to fail'); + } on FormatError catch (error) { + expect(error.code, FormatErrorCode.graphInvalid); + } + }); + }); +} diff --git a/flutter/packages/aval_format/test/grass_rabbit_integration_test.dart b/flutter/packages/aval_format/test/grass_rabbit_integration_test.dart new file mode 100644 index 0000000..e57169f --- /dev/null +++ b/flutter/packages/aval_format/test/grass_rabbit_integration_test.dart @@ -0,0 +1,323 @@ +// Format-1.0 integration checkpoint: prove the ported Dart parser produces, +// for the real `examples/grass-rabbit/public/grass-rabbit/h264.avl` asset, the +// exact structure authored in `motion.json`, and that the resulting graph +// installs into `aval_graph`'s `MotionGraphEngine`. +// +// Unlike the other tests in this directory, which build synthetic fixtures, +// this one reads the shipped example asset end-to-end. The `.avl` lives outside +// the package (in `examples/`), so the asset is located by walking up from the +// current directory to the repo root — which works whether the suite is +// launched with `dart test` or `flutter test` from the package directory (the +// documented run convention). +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart'; +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +/// Resolves a path relative to the repository root by walking up from +/// [Directory.current] until the grass-rabbit 1.0 asset is found. +Directory _repoRoot() { + var dir = Directory.current.absolute; + for (var i = 0; i < 12; i += 1) { + final marker = + File('${dir.path}/examples/grass-rabbit/public/grass-rabbit/h264.avl'); + if (marker.existsSync()) return dir; + final parent = dir.parent; + if (parent.path == dir.path) break; + dir = parent; + } + throw StateError( + 'could not locate the aval repository root ' + '(examples/grass-rabbit/public/grass-rabbit/h264.avl) ' + 'from ${Directory.current.path}', + ); +} + +File _repoFile(String relative) => File('${_repoRoot().path}/$relative'); + +void main() { + group('grass-rabbit h264.avl end-to-end parse vs. motion.json source', () { + late Uint8List assetBytes; + late Map motionJson; + late ParsedFrontIndex frontIndex; + + setUpAll(() { + assetBytes = _repoFile('examples/grass-rabbit/public/grass-rabbit/h264.avl') + .readAsBytesSync(); + motionJson = jsonDecode( + _repoFile('examples/grass-rabbit/motion.json').readAsStringSync(), + ) as Map; + frontIndex = parseFrontIndex(assetBytes); + }); + + test('parses the format-1.0 header consistently with the real asset', () { + final header = frontIndex.header; + // Fixed version-1.0 container invariants. + expect(header.major, 1); + expect(header.minor, 0); + expect(header.headerLength, 64); + expect(header.requiredFeatureFlags, 0); + expect(header.manifestOffset, 64); + // Declared file length matches the actual asset bytes. + expect(header.declaredFileLength, assetBytes.length); + expect(header.declaredFileLength, 450174); + // Layout: header + manifest + index + aligned blobs. + expect(header.manifestLength, 3472); + expect(header.indexOffset, 3536); + expect(header.indexLength, 14944); + // Index ends exactly where the first chunk payload begins. + expect( + header.indexOffset + header.indexLength, + frontIndex.records.first.byteOffset, + reason: 'first chunk payload begins immediately after the front index', + ); + }); + + test('manifest carries the format-1.0 top-level fields', () { + final manifest = frontIndex.manifest; + expect(manifest.formatVersion, '1.0'); + expect(manifest.codec, 'h264'); + expect(manifest.bitstream, 'annex-b'); + expect(manifest.layout, 'opaque'); + expect(manifest.initialState, 'idle'); + expect(manifest.generator, 'aval-compiler/1.0'); + expect(manifest.canvas.width, 1280); + expect(manifest.canvas.height, 720); + expect(manifest.canvas.fit, 'contain'); + expect(manifest.canvas.pixelAspect, const [1, 1]); + expect(manifest.frameRate.numerator, 24); + expect(manifest.frameRate.denominator, 1); + }); + + test('manifest counts agree with motion.json', () { + final manifest = frontIndex.manifest; + expect(manifest.units.length, (motionJson['units'] as List).length); + expect(manifest.states.length, (motionJson['states'] as List).length); + expect(manifest.edges.length, (motionJson['edges'] as List).length); + expect(manifest.bindings.length, (motionJson['bindings'] as List).length); + expect(manifest.units.length, 5); + expect(manifest.states.length, 4); + expect(manifest.edges.length, 5); + expect(manifest.bindings.length, 2); + }); + + test('unit list: ids, kinds, playback, frame counts, and chunk spans', () { + final units = frontIndex.manifest.units; + // The compiler reorders units alphabetically by id in the manifest: + // hover-in, hover-loop, hover-out, idle-loop, intro. + expect(units.map((u) => u.id).toList(), const [ + 'hover-in', + 'hover-loop', + 'hover-out', + 'idle-loop', + 'intro', + ]); + // Each unit has exactly one chunk span (closed-GOP per unit). + for (final unit in units) { + expect(unit.chunks.length, 1, reason: '${unit.id} should have one span'); + final span = unit.chunks.single; + expect(span.rendition, 'video.1x'); + expect(span.chunkCount, unit.frameCount, + reason: '${unit.id} chunkCount should equal frameCount'); + expect(span.frameCount, unit.frameCount, + reason: '${unit.id} span.frameCount should equal unit.frameCount'); + } + + // Frame counts match motion.json source ranges (end - start). + final motionUnits = { + for (final u in (motionJson['units'] as List)) + (u as Map)['id'] as String: u, + }; + final frameCountById = { + 'intro': 30, + 'idle-loop': 70, + 'hover-in': 67, + 'hover-loop': 96, + 'hover-out': 48, + }; + for (final unit in units) { + expect(unit.frameCount, frameCountById[unit.id], + reason: '${unit.id} frame count'); + final src = motionUnits[unit.id]!; + expect(unit.kind, src['kind']); + if (unit is BodyUnit) { + expect(unit.playback, src['playback']); + expect(unit.ports.length, (src['ports'] as List).length); + } + } + + // Chunk spans are laid out in manifest order with a running cursor + // (each unit's chunkStart = sum of prior units' frameCount). + var running = 0; + for (final unit in units) { + expect(unit.chunks.single.chunkStart, running, + reason: '${unit.id} chunkStart should be running cursor'); + running += unit.frameCount; + } + expect(running, 311, reason: 'total chunk count across all units'); + }); + + test('state list matches motion.json (canonical order is alphabetical)', () { + final states = frontIndex.manifest.states; + expect(states.map((s) => s.id).toList(), const [ + 'entering', + 'exiting', + 'hover', + 'idle', + ]); + final motionStates = { + for (final s in (motionJson['states'] as List)) + (s as Map)['id'] as String: s, + }; + for (final state in states) { + final src = motionStates[state.id]!; + expect(state.bodyUnit, src['bodyUnit']); + expect(state.initialUnit, src['initialUnit']); + } + // idle is the initial state and bootstraps via the intro one-shot. + final idle = states.firstWhere((s) => s.id == 'idle'); + expect(idle.initialUnit, 'intro'); + // Other states have no initialUnit. + for (final state in states.where((s) => s.id != 'idle')) { + expect(state.initialUnit, isNull, + reason: '${state.id} should not declare an initialUnit'); + } + }); + + test('edge list matches motion.json topology and triggers', () { + final edges = frontIndex.manifest.edges; + expect(edges.length, 5); + final motionEdgesById = { + for (final e in (motionJson['edges'] as List)) + (e as Map)['id'] as String: e, + }; + for (final edge in edges) { + final src = motionEdgesById[edge.id]!; + expect(edge.from, src['from']); + expect(edge.to, src['to']); + expect(edge.continuity, src['continuity']); + final srcStart = src['start'] as Map; + expect(edge.start.type, srcStart['type']); + } + // Portal edges carry event triggers; finish edges carry completion or + // event triggers per motion.json. + final eventNames = edges + .map((e) => e.trigger) + .whereType() + .map((t) => t.name) + .toSet(); + expect(eventNames, {'hover.enter', 'hover.leave'}); + // Edges with start.type=portal must be event-triggered; start.type=finish + // may be event or completion. + for (final edge in edges) { + final trig = edge.trigger; + if (edge.start.type == 'portal') { + expect(trig, isA(), + reason: '${edge.id} portal start must be event-triggered'); + } + } + }); + + test('single rendition with AVC 1.0 geometry', () { + final renditions = frontIndex.manifest.renditions; + expect(renditions.length, 1); + final rendition = renditions.single; + expect(rendition.id, 'video.1x'); + expect(rendition.codec, 'avc1.64001E'); + expect(rendition.bitDepth, 8); + expect(rendition.codedWidth, 640); + expect(rendition.codedHeight, 368); + expect(rendition.alphaLayout, isA()); + expect(rendition.bitrate.average, rendition.bitrate.peak, + reason: 'CBR asset has average == peak'); + expect(rendition.bitrate.average, greaterThan(0)); + }); + + test('encoded-chunk index: 311 records, contiguous within aligned blobs', () { + final records = frontIndex.records; + expect(records.length, 311); + final units = frontIndex.manifest.units; + + // Walk records grouped by unit. Each unit's records live in a single + // formatAlignment-aligned blob; within a blob they are contiguous. + var cursor = 0; + var previousBlobEnd = + frontIndex.header.indexOffset + frontIndex.header.indexLength; + for (final unit in units) { + final span = unit.chunks.single; + final blobStart = records[cursor].byteOffset; + expect(blobStart % formatAlignment, 0, + reason: '${unit.id} blob must be formatAlignment-aligned'); + expect(blobStart, greaterThanOrEqualTo(previousBlobEnd), + reason: '${unit.id} blob must not overlap the previous'); + var expectedOffset = blobStart; + for (var i = 0; i < span.chunkCount; i += 1) { + final record = records[cursor]; + expect(record.byteOffset, expectedOffset, + reason: '${unit.id} chunk $i must be contiguous within blob'); + expect(record.displayedFrameCount, 1, + reason: '${unit.id} chunk $i covers one displayed frame'); + if (i == 0) { + expect(record.randomAccess, isTrue, + reason: '${unit.id} first chunk must be random access (IDR)'); + } + expectedOffset += record.byteLength; + cursor += 1; + } + previousBlobEnd = expectedOffset; + } + expect(cursor, records.length); + // The final blob ends exactly at the declared file length (no trailing pad). + expect(previousBlobEnd, frontIndex.header.declaredFileLength); + }); + + test('validateCompleteAsset accepts the real asset end-to-end', () { + final validated = validateCompleteAsset(bytes: assetBytes); + expect(validated.fileRange.offset, 0); + expect(validated.fileRange.length, assetBytes.length); + expect(validated.frontIndex.records.length, 311); + // Supplying the already-parsed front index must agree. + expect( + () => validateCompleteAsset(bytes: assetBytes, frontIndex: frontIndex), + returnsNormally, + ); + }); + + test('parsed graph installs into MotionGraphEngine with expected topology', () { + final graph = frontIndex.graph; + + // State names in manifest (canonical alphabetical) order. + final stateNames = graph.definition.states.map((s) => s.id).toList(); + expect(stateNames, const ['entering', 'exiting', 'hover', 'idle']); + expect(graph.definition.initialState, 'idle'); + + // Event names come from authored bindings: hover.enter / hover.leave. + final eventNames = graph.definition.edges + .map((e) => e.trigger) + .whereType() + .map((t) => t.name) + .toSet(); + expect(eventNames, {'hover.enter', 'hover.leave'}); + final motionEvents = (motionJson['edges'] as List) + .cast>() + .map((e) => e['trigger'] as Map) + .where((t) => t['type'] == 'event') + .map((t) => t['name']) + .toSet(); + expect(eventNames, motionEvents); + + // The engine installs the graph successfully and settles on the initial + // state in the preparing phase. + final engine = MotionGraphEngine(); + final result = engine.install(graph); + expect(result.snapshot.readiness, MotionGraphReadiness.preparing); + expect(result.snapshot.requestedState, 'idle'); + expect(result.snapshot.visualState, 'idle'); + expect(result.snapshot.presentation, + const GraphPresentationStatic(state: 'idle')); + }); + }); +} diff --git a/flutter/packages/aval_format/test/h264_fixture.dart b/flutter/packages/aval_format/test/h264_fixture.dart new file mode 100644 index 0000000..5752276 --- /dev/null +++ b/flutter/packages/aval_format/test/h264_fixture.dart @@ -0,0 +1,612 @@ +/// Bitstream fixture builders for the H264 test suite (SPS/PPS/slice/AUD Annex +/// B construction), plus mutable inspection-input builders since the +/// production `H264Profile`/`H264UnitInput`/`H264AccessUnitInput` types +/// (`lib/src/h264/types.dart`) are immutable. +/// +/// Dart port of `packages/format/test/h264-fixture.ts`. Not a test itself — +/// imported as a plain helper library by the other `h264_*_test.dart` files. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/h264/index.dart'; +import 'package:aval_format/src/model.dart' show Rect; + +class _BitWriter { + final List _bits = []; + + _BitWriter bit(bool value) { + _bits.add(value ? 1 : 0); + return this; + } + + _BitWriter bits(int value, int width) { + for (var shift = width - 1; shift >= 0; shift -= 1) { + bit(((value >> shift) & 1) == 1); + } + return this; + } + + _BitWriter ue(int value) { + final code = value + 1; + final width = code.bitLength; + for (var index = 1; index < width; index += 1) { + bit(false); + } + return bits(code, width); + } + + _BitWriter se(int value) => ue(value <= 0 ? -2 * value : 2 * value - 1); + + _BitWriter trailing() { + bit(true); + while (_bits.length % 8 != 0) { + bit(false); + } + return this; + } + + Uint8List toBytes() { + if (_bits.length % 8 != 0) { + throw StateError('fixture bits must be byte-aligned'); + } + final bytes = Uint8List(_bits.length ~/ 8); + for (var index = 0; index < _bits.length; index += 1) { + if (_bits[index] == 1) { + final byteIndex = index ~/ 8; + bytes[byteIndex] = bytes[byteIndex] | (1 << (7 - (index % 8))); + } + } + return bytes; + } +} + +class HrdFixtureOptions { + const HrdFixtureOptions({ + required this.bitRateValueMinus1, + required this.cpbSizeValueMinus1, + this.bitRateScale, + this.cpbSizeScale, + }); + + final int bitRateValueMinus1; + final int cpbSizeValueMinus1; + final int? bitRateScale; + final int? cpbSizeScale; +} + +class SpsFixtureOptions { + const SpsFixtureOptions({ + this.profileIdc, + this.compatibility, + this.levelIdc, + this.spsId, + this.picOrderCountType, + this.maxNumRefFrames, + this.widthInMacroblocks, + this.heightInMacroblocks, + this.crop, + this.numUnitsInTick, + this.timeScale, + this.fixedFrameRate, + this.maxNumReorderFrames, + this.maxDecFrameBuffering, + this.includeVui, + this.includeBitstreamRestriction, + this.bt709Limited, + this.pixelAspectRatio, + this.hrd, + }); + + final int? profileIdc; + final int? compatibility; + final int? levelIdc; + final int? spsId; + + /// `0 | 1 | 2`. + final int? picOrderCountType; + final int? maxNumRefFrames; + final int? widthInMacroblocks; + final int? heightInMacroblocks; + + /// `[left, right, top, bottom]`. + final List? crop; + final int? numUnitsInTick; + final int? timeScale; + final bool? fixedFrameRate; + final int? maxNumReorderFrames; + final int? maxDecFrameBuffering; + final bool? includeVui; + final bool? includeBitstreamRestriction; + final bool? bt709Limited; + + /// `[width, height]`. + final List? pixelAspectRatio; + final HrdFixtureOptions? hrd; + + SpsFixtureOptions copyWith({ + int? profileIdc, + int? compatibility, + int? levelIdc, + int? spsId, + int? picOrderCountType, + int? maxNumRefFrames, + int? widthInMacroblocks, + int? heightInMacroblocks, + List? crop, + int? numUnitsInTick, + int? timeScale, + bool? fixedFrameRate, + int? maxNumReorderFrames, + int? maxDecFrameBuffering, + bool? includeVui, + bool? includeBitstreamRestriction, + bool? bt709Limited, + List? pixelAspectRatio, + HrdFixtureOptions? hrd, + }) { + return SpsFixtureOptions( + profileIdc: profileIdc ?? this.profileIdc, + compatibility: compatibility ?? this.compatibility, + levelIdc: levelIdc ?? this.levelIdc, + spsId: spsId ?? this.spsId, + picOrderCountType: picOrderCountType ?? this.picOrderCountType, + maxNumRefFrames: maxNumRefFrames ?? this.maxNumRefFrames, + widthInMacroblocks: widthInMacroblocks ?? this.widthInMacroblocks, + heightInMacroblocks: heightInMacroblocks ?? this.heightInMacroblocks, + crop: crop ?? this.crop, + numUnitsInTick: numUnitsInTick ?? this.numUnitsInTick, + timeScale: timeScale ?? this.timeScale, + fixedFrameRate: fixedFrameRate ?? this.fixedFrameRate, + maxNumReorderFrames: maxNumReorderFrames ?? this.maxNumReorderFrames, + maxDecFrameBuffering: maxDecFrameBuffering ?? this.maxDecFrameBuffering, + includeVui: includeVui ?? this.includeVui, + includeBitstreamRestriction: + includeBitstreamRestriction ?? this.includeBitstreamRestriction, + bt709Limited: bt709Limited ?? this.bt709Limited, + pixelAspectRatio: pixelAspectRatio ?? this.pixelAspectRatio, + hrd: hrd ?? this.hrd, + ); + } +} + +Uint8List makeSps([SpsFixtureOptions options = const SpsFixtureOptions()]) { + final writer = _BitWriter(); + writer + .bits(options.profileIdc ?? 100, 8) + .bits(options.compatibility ?? 0, 8) + .bits(options.levelIdc ?? 32, 8) + .ue(options.spsId ?? 0) + .ue(1) // chroma_format_idc: 4:2:0 + .ue(0) // bit_depth_luma_minus8 + .ue(0) // bit_depth_chroma_minus8 + .bit(false) // qpprime_y_zero_transform_bypass_flag + .bit(false) // seq_scaling_matrix_present_flag + .ue(0); + final pocType = options.picOrderCountType ?? 0; + writer.ue(pocType); + if (pocType == 0) { + writer.ue(0); + } else if (pocType == 1) { + writer.bit(true).se(0).se(0).ue(1).se(2); + } + writer + .ue(options.maxNumRefFrames ?? 4) + .bit(false) + .ue((options.widthInMacroblocks ?? 4) - 1) + .ue((options.heightInMacroblocks ?? 4) - 1) + .bit(true) + .bit(true); + final crop = options.crop; + writer.bit(crop != null); + if (crop != null) { + writer.ue(crop[0]).ue(crop[1]).ue(crop[2]).ue(crop[3]); + } + writer.bit(options.includeVui != false); + if (options.includeVui != false) { + final pixelAspectRatio = options.pixelAspectRatio; + writer.bit(pixelAspectRatio != null); + if (pixelAspectRatio != null) { + writer + .bits(255, 8) + .bits(pixelAspectRatio[0], 16) + .bits(pixelAspectRatio[1], 16); + } + writer.bit(false); // overscan + writer.bit(options.bt709Limited != false); + if (options.bt709Limited != false) { + writer.bits(5, 3).bit(false).bit(true).bits(1, 8).bits(1, 8).bits(1, 8); + } + writer.bit(false); // chroma location + writer + .bit(true) + .bits(options.numUnitsInTick ?? 1, 32) + .bits(options.timeScale ?? 60, 32) + .bit(options.fixedFrameRate != false); + writer.bit(options.hrd != null); + if (options.hrd != null) { + _writeHrd(writer, options.hrd!); + } + writer.bit(false); // vcl hrd + if (options.hrd != null) { + writer.bit(true); // low delay HRD + } + writer.bit(false); // pic struct + writer.bit(options.includeBitstreamRestriction != false); + if (options.includeBitstreamRestriction != false) { + writer + .bit(true) + .ue(2) + .ue(1) + .ue(16) + .ue(16) + .ue(options.maxNumReorderFrames ?? 2) + .ue(options.maxDecFrameBuffering ?? 4); + } + } + return nal(0x67, writer.trailing().toBytes(), 4); +} + +void _writeHrd(_BitWriter writer, HrdFixtureOptions hrd) { + writer + .ue(0) + .bits(hrd.bitRateScale ?? 0, 4) + .bits(hrd.cpbSizeScale ?? 0, 4) + .ue(hrd.bitRateValueMinus1) + .ue(hrd.cpbSizeValueMinus1) + .bit(false) + .bits(23, 5) + .bits(23, 5) + .bits(23, 5) + .bits(0, 5); +} + +class PpsFixtureOptions { + const PpsFixtureOptions({ + this.ppsId, + this.spsId, + this.entropyCoding, + this.sliceGroupsMinus1, + this.refList0Minus1, + this.weightedPrediction, + this.weightedBipredIdc, + this.bottomFieldPicOrder, + this.picInitQpMinus26, + this.picInitQsMinus26, + this.chromaQpIndexOffset, + this.deblockingFilterControl, + this.constrainedIntraPrediction, + this.redundantPictures, + this.transform8x8, + }); + + final int? ppsId; + final int? spsId; + final bool? entropyCoding; + final int? sliceGroupsMinus1; + final int? refList0Minus1; + final bool? weightedPrediction; + + /// `0 | 1 | 2`. + final int? weightedBipredIdc; + final bool? bottomFieldPicOrder; + final int? picInitQpMinus26; + final int? picInitQsMinus26; + final int? chromaQpIndexOffset; + final bool? deblockingFilterControl; + final bool? constrainedIntraPrediction; + final bool? redundantPictures; + final bool? transform8x8; +} + +Uint8List makePps([PpsFixtureOptions options = const PpsFixtureOptions()]) { + final writer = _BitWriter(); + writer + .ue(options.ppsId ?? 0) + .ue(options.spsId ?? 0) + .bit(options.entropyCoding != false) + .bit(options.bottomFieldPicOrder == true) + .ue(options.sliceGroupsMinus1 ?? 0) + .ue(options.refList0Minus1 ?? 0) + .ue(0) + .bit(options.weightedPrediction == true) + .bits(options.weightedBipredIdc ?? 2, 2) + .se(options.picInitQpMinus26 ?? 0) + .se(options.picInitQsMinus26 ?? 0) + .se(options.chromaQpIndexOffset ?? -2) + .bit(options.deblockingFilterControl != false) + .bit(options.constrainedIntraPrediction == true) + .bit(options.redundantPictures == true); + writer + .bit(options.transform8x8 != false) + .bit(false) // pic_scaling_matrix_present_flag + .se(options.chromaQpIndexOffset ?? -2); + return nal(0x68, writer.trailing().toBytes(), 4); +} + +class SliceFixtureOptions { + const SliceFixtureOptions({ + required this.idr, + required this.frameNum, + this.reference, + this.sliceType, + this.firstMacroblock, + this.ppsId, + this.idrPicId, + this.picOrderCountType, + this.picOrderCntLsb, + this.referenceListModification, + this.adaptiveMarking, + this.adaptiveMarkingOperation, + this.longTermReference, + this.sliceQpDelta, + }); + + final bool idr; + final int frameNum; + final bool? reference; + + /// `"I" | "P" | "B"`. + final String? sliceType; + final int? firstMacroblock; + final int? ppsId; + final int? idrPicId; + + /// `0 | 1 | 2`. + final int? picOrderCountType; + final int? picOrderCntLsb; + final bool? referenceListModification; + final bool? adaptiveMarking; + + /// `0 | 1 | 2`. + final int? adaptiveMarkingOperation; + final bool? longTermReference; + final int? sliceQpDelta; +} + +Uint8List makeSlice(SliceFixtureOptions options) { + final normalizedType = options.sliceType == 'B' + ? 1 + : options.sliceType == 'I' + ? 2 + : 0; + final writer = _BitWriter(); + writer + .ue(options.firstMacroblock ?? 0) + .ue(normalizedType) + .ue(options.ppsId ?? 0) + .bits(options.frameNum, 4); + if (options.idr) { + writer.ue(options.idrPicId ?? 0); + } + if (options.picOrderCountType == 0) { + writer.bits(options.picOrderCntLsb ?? 0, 4); + } + if (normalizedType == 1) { + writer.bit(false); // direct_spatial_mv_pred_flag + } + if (normalizedType == 0 || normalizedType == 1) { + writer.bit(false); // num_ref_idx_active_override_flag + writer.bit(options.referenceListModification == true); + if (options.referenceListModification == true) { + writer.ue(3); + } + if (normalizedType == 1) { + writer.bit(false); // ref_pic_list_modification_flag_l1 + } + } + if (options.idr) { + writer.bit(false).bit(options.longTermReference == true); + } else if (options.reference != false) { + writer.bit(options.adaptiveMarking == true); + if (options.adaptiveMarking == true) { + final operation = options.adaptiveMarkingOperation ?? 0; + writer.ue(operation); + if (operation == 1 || operation == 2) { + writer.ue(0); + } + if (operation != 0) { + writer.ue(0); + } + } + } + if (normalizedType != 2) { + writer.ue(0); // cabac_init_idc + } + writer.se(options.sliceQpDelta ?? 0).ue(0).se(0).se(0); + // One opaque fixture bit stands in for CAVLC slice_data; the inspector does + // not attempt to entropy-decode macroblocks. + writer.bit(true).trailing(); + final header = options.idr + ? 0x65 + : options.reference == false + ? 0x01 + : 0x41; + return nal(header, writer.toBytes(), 4); +} + +Uint8List makeAud([int primaryPicType = 0]) { + final writer = _BitWriter(); + writer.bits(primaryPicType, 3); + return nal(0x09, writer.trailing().toBytes(), 4); +} + +H264AccessUnitInput makeAccessUnit({ + required bool idr, + required int frameNum, + bool? key, + Uint8List? sps, + Uint8List? pps, + Uint8List? aud, + List? slices, + int? picOrderCountType, + int? picOrderCntLsb, + String? sliceType, + bool? reference, +}) { + final resolvedSlices = slices ?? + [ + makeSlice( + SliceFixtureOptions( + idr: idr, + frameNum: frameNum, + sliceType: sliceType ?? (idr ? 'I' : 'P'), + reference: reference, + picOrderCountType: picOrderCountType ?? 0, + picOrderCntLsb: picOrderCntLsb ?? frameNum * 2, + ), + ), + ]; + return H264AccessUnitInput( + key: key ?? idr, + bytes: concat([aud, sps, pps, ...resolvedSlices]), + ); +} + +/// Mutable stand-in for a TS `{ bytes, key }` access-unit object literal. +class MutableAccessUnit { + MutableAccessUnit({required this.bytes, required this.key}); + + Uint8List bytes; + bool key; + + H264AccessUnitInput toAccessUnitInput() => + H264AccessUnitInput(bytes: bytes, key: key); + + static MutableAccessUnit from(H264AccessUnitInput input) => + MutableAccessUnit(bytes: input.bytes, key: input.key); +} + +/// Mutable stand-in for a TS `{ id, accessUnits }` unit object literal. +class MutableUnit { + MutableUnit({required this.id, required this.accessUnits}); + + String id; + List accessUnits; + + H264UnitInput toUnitInput() => H264UnitInput( + id: id, + accessUnits: + accessUnits.map((unit) => unit.toAccessUnitInput()).toList(), + ); +} + +/// Mutable stand-in for the TS `MutableInspectionInput` interface. The +/// production `H264Profile` is immutable, and no H264 test mutates profile +/// fields after construction, so the profile is carried as an immutable value. +class MutableInspectionInput { + MutableInspectionInput({required this.profile, required this.units}); + + H264Profile profile; + List units; + + H264RenditionInspectionInput toInspectionInput() => + H264RenditionInspectionInput( + profile: profile, + units: units.map((unit) => unit.toUnitInput()).toList(), + ); +} + +MutableInspectionInput validInspectionInput({ + SpsFixtureOptions? spsOptions, + PpsFixtureOptions? ppsOptions, + List? units, +}) { + final resolvedSpsOptions = (spsOptions ?? const SpsFixtureOptions()).copyWith( + compatibility: spsOptions?.compatibility ?? 0, + bt709Limited: spsOptions?.bt709Limited ?? true, + ); + final sps = makeSps(resolvedSpsOptions); + final pps = makePps(ppsOptions ?? const PpsFixtureOptions()); + + final resolvedUnits = units ?? + [ + MutableUnit( + id: 'idle', + accessUnits: [ + MutableAccessUnit.from( + makeAccessUnit( + idr: true, + frameNum: 0, + sps: sps, + pps: pps, + aud: makeAud(0), + ), + ), + MutableAccessUnit.from( + makeAccessUnit(idr: false, frameNum: 1, aud: makeAud(1)), + ), + ], + ), + MutableUnit( + id: 'hover', + accessUnits: [ + MutableAccessUnit.from( + makeAccessUnit( + idr: true, + frameNum: 0, + sps: sps, + pps: pps, + aud: makeAud(0), + ), + ), + MutableAccessUnit.from( + makeAccessUnit(idr: false, frameNum: 1, aud: makeAud(1)), + ), + ], + ), + ]; + + final widthInMacroblocks = spsOptions?.widthInMacroblocks ?? 4; + final heightInMacroblocks = spsOptions?.heightInMacroblocks ?? 4; + return MutableInspectionInput( + profile: H264Profile( + codedWidth: widthInMacroblocks * 16, + codedHeight: heightInMacroblocks * 16, + expectedVisibleRect: Rect( + 0, + 0, + widthInMacroblocks * 16, + heightInMacroblocks * 16, + ), + frameRate: H264FrameRate(numerator: 30, denominator: 1), + ), + units: resolvedUnits, + ); +} + +Uint8List nal(int header, Uint8List rbsp, [int prefixLength = 3]) { + final escaped = _escapeRbsp(rbsp); + final output = Uint8List(prefixLength + 1 + escaped.length); + output[prefixLength - 1] = 1; + output[prefixLength] = header; + output.setRange(prefixLength + 1, output.length, escaped); + return output; +} + +Uint8List concat(List parts) { + final present = parts.whereType().toList(); + final totalLength = + present.fold(0, (length, part) => length + part.length); + final result = Uint8List(totalLength); + var offset = 0; + for (final part in present) { + result.setRange(offset, offset + part.length, part); + offset += part.length; + } + return result; +} + +Uint8List _escapeRbsp(Uint8List rbsp) { + final bytes = []; + var zeroCount = 0; + for (final byte in rbsp) { + if (zeroCount == 2 && byte <= 3) { + bytes.add(3); + zeroCount = 0; + } + bytes.add(byte); + zeroCount = byte == 0 ? zeroCount + 1 : 0; + } + return Uint8List.fromList(bytes); +} diff --git a/flutter/packages/aval_format/test/h265_fixture.dart b/flutter/packages/aval_format/test/h265_fixture.dart new file mode 100644 index 0000000..33372f6 --- /dev/null +++ b/flutter/packages/aval_format/test/h265_fixture.dart @@ -0,0 +1,526 @@ +/// Bitstream fixture builders for the HEVC test suite (VPS/SPS/PPS/slice/AUD +/// Annex-B construction) plus inspection-input builders. +/// +/// Dart port of `packages/format/test/h265-fixture.ts`. Not a test itself — +/// imported as a plain helper library by the other `h265_*_test.dart` files. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/h265/index.dart'; + +class H265BitWriter { + final List _bits = []; + + H265BitWriter bit(bool value) { + _bits.add(value ? 1 : 0); + return this; + } + + H265BitWriter bits(int value, int width) { + for (var shift = width - 1; shift >= 0; shift -= 1) { + // TS: Math.floor(value / 2 ** shift) % 2. + bit((value ~/ (1 << shift)) % 2 == 1); + } + return this; + } + + H265BitWriter ue(int value) { + final code = value + 1; + final width = code.bitLength; // floor(log2(code)) + 1 + for (var index = 1; index < width; index += 1) { + bit(false); + } + return bits(code, width); + } + + H265BitWriter se(int value) => ue(value <= 0 ? -2 * value : value * 2 - 1); + + H265BitWriter trailing() { + bit(true); + while (_bits.length % 8 != 0) { + bit(false); + } + return this; + } + + H265BitWriter opaqueByte([int value = 0x55]) => bits(value, 8); + + Uint8List toBytes() { + if (_bits.length % 8 != 0) { + throw StateError('fixture bitstream must be byte aligned'); + } + final bytes = Uint8List(_bits.length ~/ 8); + for (var index = 0; index < _bits.length; index += 1) { + if (_bits[index] == 1) { + final byteIndex = index ~/ 8; + bytes[byteIndex] = bytes[byteIndex] | (1 << (7 - (index % 8))); + } + } + return bytes; + } +} + +class H265PtlFixtureOptions { + const H265PtlFixtureOptions({ + this.profileSpace, + this.tier, + this.profileIdc, + this.compatibilityProfileIndexes, + this.constraintBytes, + this.levelIdc, + }); + + final int? profileSpace; + final bool? tier; + final int? profileIdc; + final List? compatibilityProfileIndexes; + final List? constraintBytes; + final int? levelIdc; +} + +void _writePtl( + H265BitWriter writer, [ + H265PtlFixtureOptions options = const H265PtlFixtureOptions(), +]) { + writer + .bits(options.profileSpace ?? 0, 2) + .bit(options.tier == true) + .bits(options.profileIdc ?? 1, 5); + final compatible = (options.compatibilityProfileIndexes ?? [1, 2]).toSet(); + for (var index = 0; index < 32; index += 1) { + writer.bit(compatible.contains(index)); + } + final constraints = options.constraintBytes ?? [0x90, 0, 0, 0, 0, 0]; + for (var index = 0; index < 6; index += 1) { + writer.bits(index < constraints.length ? constraints[index] : 0, 8); + } + writer.bits(options.levelIdc ?? 30, 8); +} + +Uint8List makeH265Vps([ + H265PtlFixtureOptions ptl = const H265PtlFixtureOptions(), + int id = 0, +]) { + final writer = H265BitWriter() + .bits(id, 4) + .bit(true) + .bit(true) + .bits(0, 6) + .bits(0, 3) + .bit(true) + .bits(0xffff, 16); + _writePtl(writer, ptl); + writer + .bit(true) + .ue(4) + .ue(2) + .ue(0) + .bits(0, 6) + .ue(0) + .bit(false) + .bit(false) + .trailing(); + return h265Nal(32, writer.toBytes()); +} + +class H265SpsFixtureOptions { + const H265SpsFixtureOptions({ + this.ptl, + this.vpsId, + this.spsId, + this.width, + this.height, + this.crop, + this.bitDepthMinus8, + this.maxReorder, + this.maxBufferMinus1, + this.numUnitsInTick, + this.timeScale, + this.color, + this.fullRange, + this.includeVui, + this.longTermReferences, + }); + + final H265PtlFixtureOptions? ptl; + final int? vpsId; + final int? spsId; + final int? width; + final int? height; + + /// `[left, right, top, bottom]`. + final List? crop; + final int? bitDepthMinus8; + final int? maxReorder; + final int? maxBufferMinus1; + final int? numUnitsInTick; + final int? timeScale; + + /// `[primaries, transfer, matrix]`. + final List? color; + final bool? fullRange; + final bool? includeVui; + final bool? longTermReferences; +} + +Uint8List makeH265Sps([ + H265SpsFixtureOptions options = const H265SpsFixtureOptions(), +]) { + final writer = H265BitWriter() + .bits(options.vpsId ?? 0, 4) + .bits(0, 3) + .bit(true); + _writePtl(writer, options.ptl ?? const H265PtlFixtureOptions()); + writer + .ue(options.spsId ?? 0) + .ue(1) + .ue(options.width ?? 64) + .ue(options.height ?? 64); + final crop = options.crop; + writer.bit(crop != null); + if (crop != null) { + writer.ue(crop[0]).ue(crop[1]).ue(crop[2]).ue(crop[3]); + } + writer + .ue(options.bitDepthMinus8 ?? 0) + .ue(options.bitDepthMinus8 ?? 0) + .ue(4) + .bit(true) + .ue(options.maxBufferMinus1 ?? 4) + .ue(options.maxReorder ?? 2) + .ue(0) + .ue(0) + .ue(3) + .ue(0) + .ue(3) + .ue(0) + .ue(0) + .bit(false) + .bit(false) + .bit(true) + .bit(false) + .ue(0) + .bit(options.longTermReferences == true); + if (options.longTermReferences == true) writer.ue(0); + writer + .bit(true) + .bit(true) + .bit(options.includeVui != false); + if (options.includeVui != false) { + final color = options.color ?? [1, 1, 1]; + writer + .bit(true) + .bits(1, 8) + .bit(false) + .bit(true) + .bits(5, 3) + .bit(options.fullRange == true) + .bit(true) + .bits(color[0], 8) + .bits(color[1], 8) + .bits(color[2], 8) + .bit(false) + .bit(false) + .bit(false) + .bit(false) + .bit(false) + .bit(true) + .bits(options.numUnitsInTick ?? 1, 32) + .bits(options.timeScale ?? 5, 32) + .bit(false) + .bit(false) + .bit(false); + } + writer.bit(false).trailing(); + return h265Nal(33, writer.toBytes()); +} + +Uint8List makeH265Pps([int spsId = 0, int ppsId = 0]) { + final writer = H265BitWriter() + .ue(ppsId) + .ue(spsId) + .bit(false) + .bit(false) + .bits(0, 3) + .bit(true) + .bit(false) + .ue(0) + .ue(0) + .se(0) + .bit(false) + .bit(false) + .bit(false) + .se(0) + .se(0) + .bit(false) + .bit(false) + .bit(false) + .bit(false) + .bit(false) + .bit(true) + .bit(true) + .bit(true) + .bit(false) + .bit(false) + .se(0) + .se(0) + .bit(false) + .bit(false) + .ue(0) + .bit(false) + .bit(false) + .trailing(); + return h265Nal(34, writer.toBytes()); +} + +Uint8List makeH265Aud(int pictureType) { + return h265Nal( + 35, + (H265BitWriter().bits(pictureType, 3).trailing()).toBytes(), + ); +} + +class H265SliceFixtureOptions { + const H265SliceFixtureOptions({ + required this.nalType, + required this.sliceType, + this.poc, + this.negativeReferences, + this.positiveReferences, + this.noOutputOfPriorPictures, + this.ppsId, + this.opaqueBytes, + }); + + final int nalType; + + /// One of `"I"`, `"P"`, `"B"`. + final String sliceType; + final int? poc; + final List? negativeReferences; + final List? positiveReferences; + final bool? noOutputOfPriorPictures; + final int? ppsId; + final int? opaqueBytes; +} + +Uint8List makeH265Slice(H265SliceFixtureOptions options) { + final writer = H265BitWriter().bit(true); + if (options.nalType >= 16 && options.nalType <= 21) { + writer.bit(options.noOutputOfPriorPictures == true); + } + writer.ue(options.ppsId ?? 0).ue( + options.sliceType == 'I' + ? 2 + : options.sliceType == 'P' + ? 1 + : 0, + ); + if (options.nalType != 19 && options.nalType != 20) { + writer.bits(options.poc ?? 0, 8).bit(false); + final negative = options.negativeReferences ?? const []; + final positive = options.positiveReferences ?? const []; + writer.ue(negative.length).ue(positive.length); + var previous = 0; + for (final delta in negative) { + if (delta >= previous || delta >= 0) { + throw StateError('negative RPS must decrease'); + } + writer.ue(previous - delta - 1).bit(true); + previous = delta; + } + previous = 0; + for (final delta in positive) { + if (delta <= previous) { + throw StateError('positive RPS must increase'); + } + writer.ue(delta - previous - 1).bit(true); + previous = delta; + } + writer.bit(false); + } + writer.trailing(); + for (var index = 0; index < (options.opaqueBytes ?? 4); index += 1) { + writer.opaqueByte(0x55 + (index % 2)); + } + return h265Nal(options.nalType, writer.toBytes()); +} + +class H265AccessUnitFixtureOptions { + const H265AccessUnitFixtureOptions({ + required this.slice, + this.vps, + this.sps, + this.pps, + this.metadata, + this.prefixLength, + }); + + final H265SliceFixtureOptions slice; + final Uint8List? vps; + final Uint8List? sps; + final Uint8List? pps; + final List? metadata; + + /// `3` or `4`. + final int? prefixLength; +} + +H265AccessUnitInput makeH265AccessUnit(H265AccessUnitFixtureOptions options) { + final isKey = options.slice.nalType >= 16 && options.slice.nalType <= 21; + final pictureType = options.slice.sliceType == 'I' + ? 0 + : options.slice.sliceType == 'P' + ? 1 + : 2; + final nals = [ + makeH265Aud(pictureType), + if (options.vps != null) options.vps!, + if (options.sps != null) options.sps!, + if (options.pps != null) options.pps!, + ...(options.metadata ?? const []), + makeH265Slice(options.slice), + ]; + final bytes = concat(nals); + if (options.prefixLength == 3) { + return H265AccessUnitInput(key: isKey, bytes: replaceStartCodes(bytes, 3)); + } + return H265AccessUnitInput(key: isKey, bytes: bytes); +} + +H265UnitInput makeH265Unit([String id = 'idle']) { + final vps = makeH265Vps(); + final sps = makeH265Sps(); + final pps = makeH265Pps(); + return H265UnitInput( + id: id, + accessUnits: [ + makeH265AccessUnit(H265AccessUnitFixtureOptions( + vps: vps, + sps: sps, + pps: pps, + slice: const H265SliceFixtureOptions(nalType: 20, sliceType: 'I'), + )), + makeH265AccessUnit(const H265AccessUnitFixtureOptions( + slice: H265SliceFixtureOptions( + nalType: 1, + sliceType: 'P', + poc: 4, + negativeReferences: [-4], + ), + )), + makeH265AccessUnit(const H265AccessUnitFixtureOptions( + slice: H265SliceFixtureOptions( + nalType: 1, + sliceType: 'B', + poc: 2, + negativeReferences: [-2], + positiveReferences: [2], + ), + )), + makeH265AccessUnit(const H265AccessUnitFixtureOptions( + slice: H265SliceFixtureOptions( + nalType: 0, + sliceType: 'B', + poc: 1, + negativeReferences: [-1], + positiveReferences: [1], + ), + )), + makeH265AccessUnit(const H265AccessUnitFixtureOptions( + slice: H265SliceFixtureOptions( + nalType: 0, + sliceType: 'B', + poc: 3, + negativeReferences: [-1], + positiveReferences: [1], + ), + )), + makeH265AccessUnit(const H265AccessUnitFixtureOptions( + slice: H265SliceFixtureOptions( + nalType: 1, + sliceType: 'P', + poc: 5, + negativeReferences: [-1], + ), + )), + ], + ); +} + +H265RenditionInspectionInput validH265InspectionInput([ + List? units, +]) { + return H265RenditionInspectionInput( + profile: const H265MainProfile( + codedWidth: 64, + codedHeight: 64, + frameRate: H265FrameRate(numerator: 5, denominator: 1), + requireBt709LimitedRange: true, + ), + units: units ?? [makeH265Unit()], + ); +} + +Uint8List h265Nal( + int type, + Uint8List rbsp, [ + int prefixLength = 4, + int temporalId = 0, +]) { + final escaped = _escapeRbsp(rbsp); + final output = Uint8List(prefixLength + 2 + escaped.length); + output.setAll(0, prefixLength == 4 ? const [0, 0, 0, 1] : const [0, 0, 1]); + output[prefixLength] = type << 1; + output[prefixLength + 1] = temporalId + 1; + output.setAll(prefixLength + 2, escaped); + return output; +} + +Uint8List concat(List parts) { + final length = parts.fold(0, (total, part) => total + part.length); + final output = Uint8List(length); + var offset = 0; + for (final part in parts) { + output.setAll(offset, part); + offset += part.length; + } + return output; +} + +Uint8List _escapeRbsp(Uint8List rbsp) { + final output = []; + var zeroCount = 0; + for (final byte in rbsp) { + if (zeroCount == 2 && byte <= 3) { + output.add(3); + zeroCount = 0; + } + output.add(byte); + zeroCount = byte == 0 ? zeroCount + 1 : 0; + } + return Uint8List.fromList(output); +} + +Uint8List replaceStartCodes(Uint8List bytes, int length) { + final parts = []; + var start = 0; + for (var index = 0; index + 3 < bytes.length; index += 1) { + if (bytes[index] == 0 && + bytes[index + 1] == 0 && + bytes[index + 2] == 0 && + bytes[index + 3] == 1) { + if (index > start) { + parts.add(Uint8List.sublistView(bytes, start, index)); + } + parts.add( + Uint8List.fromList(length == 4 ? const [0, 0, 0, 1] : const [0, 0, 1]), + ); + start = index + 4; + index += 3; + } + } + parts.add(Uint8List.sublistView(bytes, start)); + return concat(parts); +} diff --git a/flutter/packages/aval_format/test/header_test.dart b/flutter/packages/aval_format/test/header_test.dart new file mode 100644 index 0000000..d40ec26 --- /dev/null +++ b/flutter/packages/aval_format/test/header_test.dart @@ -0,0 +1,173 @@ +// Dart port of packages/format/test/header.test.ts. +import 'dart:typed_data'; + +import 'package:aval_format/src/checked_integer.dart' + show writeUint32LE, writeUint64LE, maxSafeInteger; +import 'package:aval_format/src/constants.dart'; +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/header.dart'; +import 'package:aval_format/src/model.dart' show FormatHeader, FormatOptions; +import 'package:test/test.dart'; + +final FormatHeader kHeader = FormatHeader( + declaredFileLength: 136, + manifestLength: 8, + indexOffset: 72, + indexLength: 64, +); + +const String goldenHex = '41564c460d0a1a0a' + '01000000' + '40000000' + '00000000' + '00000000' + '8800000000000000' + '4000000000000000' + '0800000000000000' + '4800000000000000' + '4000000000000000'; + +String hex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +FormatError _expectFormatError(dynamic Function() operation, FormatErrorCode code) { + try { + operation(); + } on FormatError catch (error) { + expect(error.code, code); + return error; + } + fail('expected operation to throw'); +} + +void main() { + group('version-1.0 header codec', () { + test('emits the exact canonical 64-byte little-endian header', () { + final bytes = encodeHeader(kHeader); + expect(bytes.length, 64); + expect(hex(bytes), goldenHex); + expect(bytes.sublist(0, 8), formatMagic); + }); + + test('parses the exact fields', () { + final parsed = parseHeader(encodeHeader(kHeader)); + expect(parsed.major, kHeader.major); + expect(parsed.minor, kHeader.minor); + expect(parsed.headerLength, kHeader.headerLength); + expect(parsed.declaredFileLength, kHeader.declaredFileLength); + expect(parsed.manifestOffset, kHeader.manifestOffset); + expect(parsed.manifestLength, kHeader.manifestLength); + expect(parsed.indexOffset, kHeader.indexOffset); + expect(parsed.indexLength, kHeader.indexLength); + }); + + test('supports an unaligned Uint8List view without reading adjacent bytes', () { + final storage = Uint8List(70)..fillRange(0, 70, 0xa5); + final view = Uint8List.sublistView(storage, 3, 67); + view.setRange(0, 64, encodeHeader(kHeader)); + + final parsed = parseHeader(view); + expect(parsed.declaredFileLength, kHeader.declaredFileLength); + expect(storage.sublist(0, 3), [0xa5, 0xa5, 0xa5]); + expect(storage.sublist(67), [0xa5, 0xa5, 0xa5]); + }); + + test('rejects truncation at every byte boundary with one stable error', () { + final bytes = encodeHeader(kHeader); + for (var length = 0; length < formatHeaderLength; length += 1) { + _expectFormatError( + () => parseHeader(Uint8List.sublistView(bytes, 0, length)), + FormatErrorCode.headerInvalid, + ); + } + }); + + test('rejects every noncanonical fixed header field', () { + final mutations = <(int, int, FormatErrorCode)>[ + (0, 0, FormatErrorCode.headerInvalid), + (8, 2, FormatErrorCode.versionUnsupported), + (10, 2, FormatErrorCode.versionUnsupported), + (12, 63, FormatErrorCode.headerInvalid), + (16, 1, FormatErrorCode.featureUnsupported), + (20, 1, FormatErrorCode.headerInvalid), + ]; + for (final (offset, value, code) in mutations) { + final bytes = encodeHeader(kHeader); + bytes[offset] = value; + _expectFormatError(() => parseHeader(bytes), code); + } + }); + + test('rejects unsafe uint64 fields but accepts files above the former ceiling', () { + final unsafe = encodeHeader(kHeader); + writeUint64LE(unsafe, 24, BigInt.from(maxSafeInteger) + BigInt.one); + _expectFormatError(() => parseHeader(unsafe), FormatErrorCode.integerUnsafe); + + final large = FormatHeader( + declaredFileLength: 40 * 1024 * 1024, + manifestLength: kHeader.manifestLength, + indexOffset: kHeader.indexOffset, + indexLength: kHeader.indexLength, + ); + expect(parseHeader(encodeHeader(large)).declaredFileLength, large.declaredFileLength); + _expectFormatError( + () => parseHeader( + encodeHeader(large), + const FormatOptions(budgets: {'maxFileBytes': 32 * 1024 * 1024}), + ), + FormatErrorCode.budgetExceeded, + ); + }); + + test('enforces canonical offsets, index shape, count, and containment', () { + final wrongManifestOffset = encodeHeader(kHeader); + writeUint64LE(wrongManifestOffset, 32, 65); + _expectFormatError(() => parseHeader(wrongManifestOffset), FormatErrorCode.headerInvalid); + + final wrongIndexOffset = encodeHeader(kHeader); + writeUint64LE(wrongIndexOffset, 48, 80); + _expectFormatError(() => parseHeader(wrongIndexOffset), FormatErrorCode.headerInvalid); + + final partialRecord = encodeHeader(kHeader); + writeUint64LE(partialRecord, 56, 17); + _expectFormatError(() => parseHeader(partialRecord), FormatErrorCode.headerInvalid); + + final outsideFile = encodeHeader(kHeader); + writeUint64LE(outsideFile, 24, 135); + _expectFormatError(() => parseHeader(outsideFile), FormatErrorCode.headerInvalid); + + final formerRecordLimit = FormatHeader( + declaredFileLength: 200000, + manifestLength: kHeader.manifestLength, + indexOffset: kHeader.indexOffset, + indexLength: chunkIndexHeaderLength + chunkIndexRecordLength * 3601, + ); + expect(parseHeader(encodeHeader(formerRecordLimit)).indexLength, formerRecordLimit.indexLength); + + final outsideUint32 = FormatHeader( + declaredFileLength: 72 + chunkIndexHeaderLength + chunkIndexRecordLength * 0x100000000, + manifestLength: kHeader.manifestLength, + indexOffset: kHeader.indexOffset, + indexLength: chunkIndexHeaderLength + chunkIndexRecordLength * 0x100000000, + ); + _expectFormatError(() => encodeHeader(outsideUint32), FormatErrorCode.budgetExceeded); + }); + + test('honors lower-only active budgets', () { + _expectFormatError( + () => parseHeader(encodeHeader(kHeader), const FormatOptions(budgets: {'maxFileBytes': 135})), + FormatErrorCode.budgetExceeded, + ); + _expectFormatError( + () => parseHeader(encodeHeader(kHeader), const FormatOptions(budgets: {'maxManifestBytes': 7})), + FormatErrorCode.budgetExceeded, + ); + }); + + test('does not mistake reserved bytes for part of a numeric field', () { + final bytes = encodeHeader(kHeader); + writeUint32LE(bytes, 20, 0x01020304); + _expectFormatError(() => parseHeader(bytes), FormatErrorCode.headerInvalid); + }); + }); +} diff --git a/flutter/packages/aval_format/test/manifest_fixture.dart b/flutter/packages/aval_format/test/manifest_fixture.dart new file mode 100644 index 0000000..d4edd12 --- /dev/null +++ b/flutter/packages/aval_format/test/manifest_fixture.dart @@ -0,0 +1,286 @@ +// Dart port of packages/format/test/manifest-fixture.ts (1.0). +// +// Builds untyped Map/List manifest trees (matching how the TS fixture builds +// plain JSON-shaped object literals) suitable as input to +// `validateCompiledManifest`. +library; + +final String _digest = '0'.padRight(64, '0'); + +String _numbered(String prefix, int index) => '$prefix-${index.toString().padLeft(2, '0')}'; + +Map _chunk(int chunkStart, int chunkCount) => { + 'rendition': 'video', + 'chunkStart': chunkStart, + 'chunkCount': chunkCount, + 'frameCount': chunkCount, + 'sha256': _digest, + }; + +Map _body( + String id, + String playback, + int frameCount, + List portalFrames, + int chunkStart, +) => + { + 'id': id, + 'kind': 'body', + 'playback': playback, + 'frameCount': frameCount, + 'ports': [ + {'id': 'default', 'entryFrame': 0, 'portalFrames': portalFrames}, + ], + 'chunks': [_chunk(chunkStart, frameCount)], + }; + +Map _basicUnit(String id, String kind, int frameCount, int chunkStart) => { + 'id': id, + 'kind': kind, + 'frameCount': frameCount, + 'chunks': [_chunk(chunkStart, frameCount)], + }; + +/// A fresh compact manifest covering every graph-bearing 1.0 unit kind. +Map validManifest() => { + 'formatVersion': '1.0', + 'generator': 'aval-tests', + 'codec': 'h264', + 'bitstream': 'annex-b', + 'layout': 'opaque', + 'canvas': { + 'width': 2, + 'height': 2, + 'fit': 'contain', + 'pixelAspect': [1, 1], + 'colorSpace': 'srgb', + }, + 'frameRate': {'numerator': 30, 'denominator': 1}, + 'renditions': [ + { + 'id': 'video', + 'codec': 'avc1.640020', + 'bitDepth': 8, + 'codedWidth': 16, + 'codedHeight': 16, + 'alphaLayout': { + 'type': 'opaque', + 'colorRect': [0, 0, 2, 2], + }, + 'bitrate': {'average': 1000, 'peak': 2000}, + }, + ], + 'units': [ + _body('body-a', 'loop', 4, [0, 2], 0), + _body('body-b', 'finite', 3, [2], 4), + _body('body-c', 'finite', 1, [0], 7), + _basicUnit('bridge-ab', 'bridge', 2, 8), + _basicUnit('intro-a', 'one-shot', 2, 10), + { + 'id': 'rev-bc', + 'kind': 'reversible', + 'frameCount': 6, + 'residency': { + 'endpoints': [ + {'state': 'a-b', 'port': 'default', 'frames': 6}, + {'state': 'a-c', 'port': 'default', 'frames': 6}, + ], + }, + 'chunks': [_chunk(12, 6)], + }, + ], + 'initialState': 'a-a', + 'states': [ + {'id': 'a-a', 'bodyUnit': 'body-a', 'initialUnit': 'intro-a'}, + {'id': 'a-b', 'bodyUnit': 'body-b'}, + {'id': 'a-c', 'bodyUnit': 'body-c'}, + ], + 'edges': [ + { + 'id': 'edge-ab', + 'from': 'a-a', + 'to': 'a-b', + 'trigger': {'type': 'event', 'name': 'go-b'}, + 'start': { + 'type': 'portal', + 'sourcePort': 'default', + 'targetPort': 'default', + 'maxWaitFrames': 1, + }, + 'transition': {'kind': 'locked', 'unit': 'bridge-ab'}, + 'continuity': 'exact-authored', + }, + { + 'id': 'edge-ac', + 'from': 'a-a', + 'to': 'a-c', + 'trigger': {'type': 'event', 'name': 'go-c'}, + 'start': {'type': 'cut', 'targetPort': 'default', 'maxWaitFrames': 1}, + 'continuity': 'cut', + 'targetRunwayFrames': 6, + }, + { + 'id': 'edge-ba', + 'from': 'a-b', + 'to': 'a-a', + 'trigger': {'type': 'completion'}, + 'start': {'type': 'finish', 'targetPort': 'default', 'maxWaitFrames': 2}, + 'continuity': 'exact-authored', + }, + { + 'id': 'edge-bc', + 'from': 'a-b', + 'to': 'a-c', + 'trigger': {'type': 'event', 'name': 'go-c'}, + 'start': { + 'type': 'portal', + 'sourcePort': 'default', + 'targetPort': 'default', + 'maxWaitFrames': 2, + }, + 'transition': {'kind': 'reversible', 'unit': 'rev-bc', 'direction': 'forward'}, + 'continuity': 'exact-authored', + }, + { + 'id': 'edge-cb', + 'from': 'a-c', + 'to': 'a-b', + 'trigger': {'type': 'event', 'name': 'go-b'}, + 'start': { + 'type': 'portal', + 'sourcePort': 'default', + 'targetPort': 'default', + 'maxWaitFrames': 0, + }, + 'transition': { + 'kind': 'reversible', + 'unit': 'rev-bc', + 'direction': 'reverse', + 'reverseOf': 'edge-bc', + }, + 'continuity': 'exact-reverse', + }, + ], + 'bindings': [ + {'source': 'activate', 'event': 'go-c'}, + {'source': 'pointer.enter', 'event': 'go-b'}, + ], + 'readiness': { + 'policy': 'all-routes', + 'bootstrapUnits': ['body-a', 'body-b', 'body-c', 'bridge-ab', 'intro-a'], + 'immediateEdges': ['edge-ab', 'edge-ac'], + }, + 'limits': { + 'maxCompiledBytes': 32 * 1024, + 'maxRuntimeBytes': 64 * 1024, + 'decodedPixelBytes': 1024, + 'persistentCacheBytes': 0, + 'runtimeWorkingSetBytes': 1024, + }, + }; + +/// A valid manifest exactly at the state/edge/unit/blob/frame ceilings. +Map limitManifest() { + final bodyUnits = List>.generate(32, (index) { + return { + 'id': _numbered('body', index), + 'kind': 'body', + 'playback': 'finite', + 'frameCount': 1, + 'ports': [ + {'id': 'default', 'entryFrame': 0, 'portalFrames': [0]}, + ], + 'chunks': >[], + }; + }); + final bridgeUnits = List>.generate(64, (index) { + return { + 'id': _numbered('bridge', index), + 'kind': 'bridge', + 'frameCount': index < 36 ? 14 : 13, + 'chunks': >[], + }; + }); + final units = [...bodyUnits, ...bridgeUnits]; + var chunkStart = 0; + for (final unit in units) { + final frameCount = unit['frameCount'] as int; + (unit['chunks'] as List>).add({ + 'rendition': 'video', + 'chunkStart': chunkStart, + 'chunkCount': frameCount, + 'frameCount': frameCount, + 'sha256': _digest, + }); + chunkStart += frameCount; + } + + final states = List>.generate(32, (index) { + return {'id': _numbered('state', index), 'bodyUnit': _numbered('body', index)}; + }); + final edges = List>.generate(64, (index) { + final from = index % 32; + final targetStep = index < 32 ? 1 : 2; + return { + 'id': _numbered('edge', index), + 'from': _numbered('state', from), + 'to': _numbered('state', (from + targetStep) % 32), + 'start': { + 'type': 'portal', + 'sourcePort': 'default', + 'targetPort': 'default', + 'maxWaitFrames': 0, + }, + 'transition': {'kind': 'locked', 'unit': _numbered('bridge', index)}, + 'continuity': 'exact-authored', + }; + }); + + return { + 'formatVersion': '1.0', + 'generator': 'aval-limit-tests', + 'codec': 'h264', + 'bitstream': 'annex-b', + 'layout': 'opaque', + 'canvas': { + 'width': 2, + 'height': 2, + 'fit': 'contain', + 'pixelAspect': [1, 1], + 'colorSpace': 'srgb', + }, + 'frameRate': {'numerator': 60, 'denominator': 1}, + 'renditions': [ + { + 'id': 'video', + 'codec': 'avc1.640020', + 'bitDepth': 8, + 'codedWidth': 16, + 'codedHeight': 16, + 'alphaLayout': { + 'type': 'opaque', + 'colorRect': [0, 0, 2, 2], + }, + 'bitrate': {'average': 1000, 'peak': 2000}, + }, + ], + 'units': units, + 'initialState': 'state-00', + 'states': states, + 'edges': edges, + 'bindings': >[], + 'readiness': { + 'policy': 'all-routes', + 'bootstrapUnits': ['body-00', 'body-01', 'body-02', 'bridge-00', 'bridge-32'], + 'immediateEdges': ['edge-00', 'edge-32'], + }, + 'limits': { + 'maxCompiledBytes': 32 * 1024 * 1024, + 'maxRuntimeBytes': 64 * 1024 * 1024, + 'decodedPixelBytes': 1024, + 'persistentCacheBytes': 0, + 'runtimeWorkingSetBytes': 1024, + }, + }; +} diff --git a/flutter/packages/aval_format/test/manifest_schema_test.dart b/flutter/packages/aval_format/test/manifest_schema_test.dart new file mode 100644 index 0000000..d5653be --- /dev/null +++ b/flutter/packages/aval_format/test/manifest_schema_test.dart @@ -0,0 +1,247 @@ +// Dart port of packages/format/test/manifest-schema.test.ts (1.0). +// +// TS-only assertions with no Dart analog are adapted: `toEqual(source)` / +// `not.toBe(source)` become field-by-field checks (the schema returns a typed +// `CompiledManifest`, not a Map), and `expectDeepFrozen` is dropped (Dart has +// no `Object.isFrozen`; the model is immutable by construction). +import 'dart:collection'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/manifest_schema.dart'; +import 'package:aval_format/src/model.dart'; +import 'package:test/test.dart'; + +import 'manifest_fixture.dart'; + +Object? _deepClone(Object? value) { + if (value is Map) { + return { + for (final entry in value.entries) entry.key as String: _deepClone(entry.value), + }; + } + if (value is List) { + return [for (final entry in value) _deepClone(entry)]; + } + return value; +} + +Map _mutableManifest() => _deepClone(validManifest()) as Map; + +void _configureCodec(Map manifest, String codec, [int bitDepth = 8]) { + manifest['codec'] = codec; + manifest['bitstream'] = + codec == 'vp9' ? 'frame' : (codec == 'av1' ? 'low-overhead' : 'annex-b'); + final rendition = (manifest['renditions'] as List)[0] as Map; + rendition['codec'] = { + 'h264': 'avc1.640020', + 'h265': 'hvc1.1.6.L93.B0', + 'vp9': 'vp09.00.10.08', + 'av1': bitDepth == 10 + ? 'av01.0.08M.10.0.110.01.01.01.0' + : 'av01.0.08M.08.0.110.01.01.01.0', + }[codec]; + rendition['bitDepth'] = bitDepth; +} + +FormatError _expectManifestInvalid(Object? value, [String? path]) { + try { + validateCompiledManifest(value); + } on FormatError catch (error) { + expect(error.code, FormatErrorCode.manifestInvalid); + if (path != null) expect(error.path, path); + return error; + } + fail('expected manifest validation to fail'); +} + +/// A `Map` whose enumeration throws, mirroring the TS `new Proxy({}, { ownKeys() +/// { throw } })` hostile record. +class _ThrowingMap extends MapBase { + @override + Object? operator [](Object? key) => throw StateError('hostile'); + @override + void operator []=(String key, Object? value) {} + @override + void clear() {} + @override + Iterable get keys => throw StateError('hostile'); + @override + Object? remove(Object? key) => null; +} + +void main() { + group('validateCompiledManifest 1.0', () { + test('validates, detaches, and freezes the canonical manifest', () { + final result = validateCompiledManifest(validManifest()); + expect(result.formatVersion, '1.0'); + expect(result.generator, 'aval-tests'); + expect(result.codec, 'h264'); + expect(result.bitstream, 'annex-b'); + expect(result.layout, 'opaque'); + expect(result.renditions.map((r) => r.id).toList(), ['video']); + expect(result.units.length, 6); + expect(result.states.length, 3); + expect(result.edges.length, 5); + }); + + test('supports the four codec families and AV1 10-bit', () { + for (final pair in const [ + ['h264', 8], + ['h265', 8], + ['vp9', 8], + ['av1', 8], + ['av1', 10], + ]) { + final codec = pair[0] as String; + final bitDepth = pair[1] as int; + final manifest = _mutableManifest(); + _configureCodec(manifest, codec, bitDepth); + expect(validateCompiledManifest(manifest).renditions[0].bitDepth, bitDepth); + } + }); + + test('requires exact codec, bitstream, and bit-depth agreement', () { + final mutations = )>[ + (value) { + value['bitstream'] = 'frame'; + }, + (value) { + ((value['renditions'] as List)[0] as Map)['codec'] = 'vp09.00.10.08'; + }, + (value) { + ((value['renditions'] as List)[0] as Map)['bitDepth'] = 10; + }, + (value) { + _configureCodec(value, 'av1', 10); + ((value['renditions'] as List)[0] as Map)['codec'] = + 'av01.0.08M.08.0.110.01.01.01.0'; + }, + ]; + for (final mutate in mutations) { + final manifest = _mutableManifest(); + mutate(manifest); + _expectManifestInvalid(manifest); + } + }); + + test('supports and strictly validates the shared packed-alpha layout', () { + final manifest = _mutableManifest(); + manifest['layout'] = 'packed-alpha'; + final rendition = (manifest['renditions'] as List)[0] as Map; + rendition['codedHeight'] = 32; + rendition['alphaLayout'] = { + 'type': 'stacked', + 'colorRect': [0, 0, 2, 2], + 'alphaRect': [0, 10, 2, 2], + }; + (manifest['limits'] as Map)['decodedPixelBytes'] = 16 * 32 * 4; + (manifest['limits'] as Map)['runtimeWorkingSetBytes'] = 16 * 32 * 4; + expect(validateCompiledManifest(manifest).layout, 'packed-alpha'); + + ((rendition['alphaLayout'] as Map)['alphaRect'] as List)[1] = 9; + _expectManifestInvalid(manifest, 'renditions[0].alphaLayout.alphaRect'); + }); + + test('preserves authored rendition quality order and rejects duplicate IDs', () { + final manifest = _mutableManifest(); + final renditions = manifest['renditions'] as List; + final high = renditions[0] as Map; + final low = { + ...high, + 'id': 'low', + 'bitrate': {'average': 500, 'peak': 1000}, + }; + manifest['renditions'] = [high, low]; + var start = 18; + for (final entry in manifest['units'] as List) { + final unit = entry as Map; + final frameCount = unit['frameCount'] as int; + (unit['chunks'] as List).add({ + 'rendition': 'low', + 'chunkStart': start, + 'chunkCount': frameCount, + 'frameCount': frameCount, + 'sha256': '0'.padRight(64, '0'), + }); + start += frameCount; + } + expect( + validateCompiledManifest(manifest).renditions.map((r) => r.id).toList(), + ['video', 'low'], + ); + ((manifest['renditions'] as List)[1] as Map)['id'] = 'video'; + _expectManifestInvalid(manifest, 'renditions[1].id'); + }); + + test('requires canonical decode-order spans and independent frame coverage metadata', () { + final mutations = )>[ + (value) { + ((((value['units'] as List)[0] as Map)['chunks'] as List)[0] + as Map)['chunkStart'] = 1; + }, + (value) { + ((((value['units'] as List)[0] as Map)['chunks'] as List)[0] + as Map)['chunkCount'] = 0; + }, + (value) { + ((((value['units'] as List)[0] as Map)['chunks'] as List)[0] + as Map)['frameCount'] = 3; + }, + (value) { + ((((value['units'] as List)[0] as Map)['chunks'] as List)[0] + as Map)['rendition'] = 'other'; + }, + ]; + for (final mutate in mutations) { + final manifest = _mutableManifest(); + mutate(manifest); + _expectManifestInvalid(manifest); + } + }); + + test('rejects old wire/profile fields instead of dispatching versions', () { + final oldVersion = _mutableManifest(); + oldVersion['formatVersion'] = '0.1'; + _expectManifestInvalid(oldVersion, 'formatVersion'); + + final legacyProfile = _mutableManifest(); + ((legacyProfile['renditions'] as List)[0] as Map)['profile'] = + 'reference-rgba-v0'; + _expectManifestInvalid(legacyProfile); + + final legacySamples = _mutableManifest(); + final unit0 = (legacySamples['units'] as List)[0] as Map; + unit0['samples'] = unit0['chunks']; + unit0.remove('chunks'); + _expectManifestInvalid(legacySamples); + }); + + test('honors chunk, frame, rendition, unit, and blob budgets', () { + final budgetSets = >[ + {'maxChunkRecords': 17}, + {'maxTotalUnitFrames': 17}, + {'maxRenditions': 0}, + {'maxUnits': 5}, + {'maxBlobRanges': 5}, + ]; + for (final budgets in budgetSets) { + expect( + () => validateCompiledManifest(validManifest(), FormatOptions(budgets: budgets)), + throwsA(isA()), + ); + } + }); + + test('validates the graph-heavy ceiling fixture', () { + final result = validateCompiledManifest(limitManifest()); + expect(result.units.length, 96); + expect(result.states.length, 32); + expect(result.edges.length, 64); + }); + + test('never leaks built-in errors for hostile input', () { + expect(() => validateCompiledManifest(null), throwsA(isA())); + expect(() => validateCompiledManifest(_ThrowingMap()), throwsA(isA())); + }); + }); +} diff --git a/flutter/packages/aval_format/test/png_decode_test.dart b/flutter/packages/aval_format/test/png_decode_test.dart new file mode 100644 index 0000000..79eba4a --- /dev/null +++ b/flutter/packages/aval_format/test/png_decode_test.dart @@ -0,0 +1,167 @@ +/// Dart port of `packages/format/test/png-decode.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/png/decode.dart'; +import 'package:aval_format/src/png/profile.dart'; +import 'package:test/test.dart'; + +import 'png_test_fixture.dart'; + +void main() { + group('pure restricted PNG decode', () { + for (final compression in const ['stored', 'fixed', 'dynamic']) { + test('decodes $compression DEFLATE into caller-owned RGBA', () { + const width = 7; + const height = 6; + final rgba = patternedRgba(width, height); + final plan = validatePngProfile( + PngProfileValidationInput( + png: makeTestPng( + TestPngInput( + width: width, + height: height, + rgba: rgba, + filters: const [0, 1, 2, 3, 4], + compression: compression, + ), + ), + expectedWidth: width, + expectedHeight: height, + ), + ); + final decoded = decodePngRgba(plan); + expect(decoded.width, equals(width)); + expect(decoded.height, equals(height)); + expect(decoded.rgba, equals(rgba)); + decoded.rgba.fillRange(0, decoded.rgba.length, 0); + expect(decodePngRgba(plan).rgba, equals(rgba)); + }); + } + + test('validates independently inflated bytes for the later native adapter', () { + const width = 3; + const height = 3; + final rgba = patternedRgba(width, height); + final filtered = filterRgba(rgba, width, height, const [4, 3, 2]); + final plan = validatePngProfile( + PngProfileValidationInput( + png: makeTestPng( + TestPngInput( + width: width, + height: height, + rgba: rgba, + filters: const [4, 3, 2], + ), + ), + expectedWidth: width, + expectedHeight: height, + ), + ); + expect( + decodePngRgbaFromInflated(plan, filtered).rgba, + equals(rgba), + ); + + final corrupt = Uint8List.fromList(filtered); + corrupt[1] = corrupt[1] ^ 1; + _expectDecodeError(() => decodePngRgbaFromInflated(plan, corrupt)); + _expectDecodeError( + () => decodePngRgbaFromInflated( + plan, + Uint8List.sublistView(filtered, 1), + ), + ); + }); + + test('rejects short/long inflate, Adler mismatch, and invalid scanline filters', () { + for (final filteredLength in [17, 19]) { + final plan = validatePngProfile( + PngProfileValidationInput( + png: makeTestPng( + TestPngInput( + width: 2, + height: 2, + zlib: storedZlib(Uint8List(filteredLength)), + ), + ), + expectedWidth: 2, + expectedHeight: 2, + ), + ); + _expectDecodeError(() => decodePngRgba(plan)); + } + + final wrongAdler = storedZlib(Uint8List(18)); + wrongAdler[wrongAdler.length - 1] = wrongAdler[wrongAdler.length - 1] ^ 1; + final adlerPlan = validatePngProfile( + PngProfileValidationInput( + png: makeTestPng( + TestPngInput(width: 2, height: 2, zlib: wrongAdler), + ), + expectedWidth: 2, + expectedHeight: 2, + ), + ); + _expectDecodeError(() => decodePngRgba(adlerPlan)); + + final invalidFilter = Uint8List(18); + invalidFilter[0] = 5; + final filterPlan = validatePngProfile( + PngProfileValidationInput( + png: makeTestPng( + TestPngInput( + width: 2, + height: 2, + zlib: storedZlib(invalidFilter), + ), + ), + expectedWidth: 2, + expectedHeight: 2, + ), + ); + _expectDecodeError(() => decodePngRgba(filterPlan)); + }); + + test('decodes exact authored geometry and payloads above the former limits', () { + const width = 1024; + const height = 513; + final rgba = patternedRgba(width, height); + final plan = validatePngProfile( + PngProfileValidationInput( + png: makeTestPng( + TestPngInput( + width: width, + height: height, + rgba: rgba, + compression: 'stored', + ), + ), + expectedWidth: width, + expectedHeight: height, + ), + ); + expect(plan.byteRange.length, greaterThan(2 * 1024 * 1024)); + expect(decodePngRgba(plan).rgba, equals(rgba)); + }); + }); +} + +void _expectDecodeError(Object? Function() action) { + try { + action(); + } catch (error) { + expect(error, isA()); + expect( + (error as FormatError).code, + anyOf( + FormatErrorCode.pngDeflateInvalid, + FormatErrorCode.pngScanlineInvalid, + ), + ); + return; + } + fail('expected PNG decode failure'); +} diff --git a/flutter/packages/aval_format/test/png_profile_mutation_test.dart b/flutter/packages/aval_format/test/png_profile_mutation_test.dart new file mode 100644 index 0000000..950019f --- /dev/null +++ b/flutter/packages/aval_format/test/png_profile_mutation_test.dart @@ -0,0 +1,54 @@ +/// Dart port of `packages/format/test/png-profile-mutation.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/png/profile.dart'; +import 'package:test/test.dart'; + +import 'png_test_fixture.dart'; + +void main() { + group('strict PNG fixed-seed mutations', () { + test('returns a detached plan or one stable bounded rejection for every byte mutation', () { + final source = makeTestPng( + TestPngInput(width: 4, height: 3, compression: 'dynamic'), + ); + var seed = 0x6d2b79f5; + for (var iteration = 0; iteration < 512; iteration += 1) { + seed = (_imul(seed ^ (seed >> 15), 1 | seed) + 0x9e3779b9) & 0xffffffff; + final bytes = Uint8List.fromList(source); + final offset = seed % bytes.length; + bytes[offset] = bytes[offset] ^ (1 << ((seed >> 8) & 7)); + try { + final plan = validatePngProfile( + PngProfileValidationInput( + png: bytes, + expectedWidth: 4, + expectedHeight: 3, + ), + ); + bytes.fillRange(0, bytes.length, 0); + expect(plan.copyZlibBytes().any((byte) => byte != 0), isTrue); + } catch (error) { + expect(error, isA()); + expect( + (error as FormatError).code, + anyOf( + FormatErrorCode.pngEnvelopeInvalid, + FormatErrorCode.budgetExceeded, + ), + ); + expect(error.message.length, lessThan(256)); + } + } + }); + }); +} + +/// 32-bit `Math.imul` equivalent (low 32 bits of the product are identical +/// whether the operands are treated as signed or unsigned). +int _imul(int a, int b) { + return (a * b) & 0xffffffff; +} diff --git a/flutter/packages/aval_format/test/png_profile_test.dart b/flutter/packages/aval_format/test/png_profile_test.dart new file mode 100644 index 0000000..55ea2db --- /dev/null +++ b/flutter/packages/aval_format/test/png_profile_test.dart @@ -0,0 +1,360 @@ +/// Dart port of `packages/format/test/png-profile.test.ts`. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/model.dart' show FormatOptions; +import 'package:aval_format/src/png/crc32.dart'; +import 'package:aval_format/src/png/profile.dart'; +import 'package:test/test.dart'; + +import 'png_test_fixture.dart'; + +final Uint8List _signature = Uint8List.fromList(const [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +void main() { + group('strict restricted PNG profile', () { + for (final compression in const ['stored', 'fixed', 'dynamic']) { + test('accepts $compression zlib and returns one immutable owned decode plan', () { + final source = makeTestPng( + TestPngInput( + width: 3, + height: 2, + compression: compression, + idatSplits: const [1, 2, 0], + ), + ); + final original = Uint8List.fromList(source); + final plan = validatePngProfile( + PngProfileValidationInput( + png: source, + expectedWidth: 3, + expectedHeight: 2, + ), + ); + + expect(plan.width, equals(3)); + expect(plan.height, equals(2)); + expect(plan.byteRange.offset, equals(0)); + expect(plan.byteRange.length, equals(source.length)); + expect(plan.expectedFilteredBytes, equals(26)); + expect(plan.expectedRgbaBytes, equals(24)); + expect(plan.deflateRange.offset, equals(2)); + expect(plan.deflateRange.length, equals(plan.zlibByteLength - 6)); + + final firstCopy = plan.copyZlibBytes(); + source.fillRange(0, source.length, 0); + firstCopy.fillRange(0, firstCopy.length, 0); + expect(plan.copyZlibBytes(), isNot(equals(firstCopy))); + expect(original.any((byte) => byte != 0), isTrue); + }); + } + + test('accepts the optional canonical sRGB only immediately after IHDR', () { + expect( + () => _validate(makeTestPng(TestPngInput(width: 2, height: 2))), + returnsNormally, + ); + expect( + () => _validate( + makeTestPng( + TestPngInput(width: 2, height: 2, includeSrgb: false), + ), + ), + returnsNormally, + ); + + final parts = _canonicalParts(); + for (final png in [ + concatenate([ + _signature, + parts.ihdr, + parts.srgb, + parts.srgb, + parts.idat, + parts.iend, + ]), + concatenate([ + _signature, + parts.ihdr, + parts.idat, + parts.srgb, + parts.iend, + ]), + concatenate([ + _signature, + parts.ihdr, + chunk('sRGB', Uint8List.fromList(const [1])), + parts.idat, + parts.iend, + ]), + ]) { + _expectPngError(() => _validate(png)); + } + }); + + test('rejects every whole-file truncation and trailing byte', () { + final png = makeTestPng(TestPngInput(width: 2, height: 2)); + for (var length = 0; length < png.length; length += 1) { + _expectPngError(() => _validate(Uint8List.sublistView(png, 0, length))); + } + final trailing = Uint8List(png.length + 1); + trailing.setRange(0, png.length, png); + _expectPngError(() => _validate(trailing)); + }); + + test('rejects CRC, chunk length/count/order/type, and terminal-shape violations', () { + final badCrc = makeTestPng(TestPngInput(width: 2, height: 2)); + badCrc[29] = badCrc[29] ^ 1; + _expectPngError(() => _validate(badCrc)); + + final crcParts = _canonicalParts(); + for (final key in const ['ihdr', 'srgb', 'idat', 'iend']) { + final corrupted = _CanonicalParts( + ihdr: Uint8List.fromList(crcParts.ihdr), + srgb: Uint8List.fromList(crcParts.srgb), + idat: Uint8List.fromList(crcParts.idat), + iend: Uint8List.fromList(crcParts.iend), + zlib: crcParts.zlib, + ); + final target = corrupted.byKey(key); + target[target.length - 1] = target[target.length - 1] ^ 1; + _expectPngError( + () => _validate( + concatenate([ + _signature, + corrupted.ihdr, + corrupted.srgb, + corrupted.idat, + corrupted.iend, + ]), + ), + ); + } + + final hugeLength = makeTestPng(TestPngInput(width: 2, height: 2)); + writeUint32Be(hugeLength, 8, 0xffffffff); + _expectPngError(() => _validate(hugeLength)); + + final parts = _canonicalParts(); + for (final png in [ + concatenate([_signature, parts.idat, parts.ihdr, parts.iend]), + concatenate([ + _signature, + parts.ihdr, + parts.idat, + chunk('tEXt', Uint8List(0)), + parts.iend, + ]), + concatenate([ + _signature, + parts.ihdr, + parts.idat, + chunk('IEND', Uint8List.fromList(const [0])), + ]), + concatenate([_signature, parts.ihdr, parts.iend]), + concatenate([ + _signature, + parts.ihdr, + parts.idat, + parts.iend, + parts.iend, + ]), + ]) { + _expectPngError(() => _validate(png)); + } + + final tooManyIdat = List.generate( + 255, + (index) => chunk('IDAT', index == 0 ? parts.zlib : Uint8List(0)), + ); + final exactIdat = tooManyIdat.sublist(0, 254); + expect( + () => _validate( + concatenate([_signature, parts.ihdr, ...exactIdat, parts.iend]), + ), + returnsNormally, + ); + _expectPngError( + () => _validate( + concatenate([_signature, parts.ihdr, ...tooManyIdat, parts.iend]), + ), + ); + }); + + test('rejects IHDR fields, descriptor mismatch, and noncanonical sRGB', () { + for (final offset in const [16, 20, 24, 25, 26, 27, 28]) { + final png = makeTestPng(TestPngInput(width: 2, height: 2)); + if (offset == 16 || offset == 20) { + writeUint32Be(png, offset, 0); + } else { + png[offset] = png[offset] ^ 1; + } + _rewriteChunkCrc(png, 8); + _expectPngError(() => _validate(png)); + } + _expectPngError( + () => validatePngProfile( + PngProfileValidationInput( + png: makeTestPng(TestPngInput(width: 2, height: 2)), + expectedWidth: 3, + expectedHeight: 2, + ), + ), + ); + }); + + test('rejects unrepresentable IHDR products with checked arithmetic', () { + final png = makeTestPng(TestPngInput(width: 1, height: 1)); + writeUint32Be(png, 16, 0xffffffff); + writeUint32Be(png, 20, 0xffffffff); + _rewriteChunkCrc(png, 8); + + _expectPngError( + () => validatePngProfile( + PngProfileValidationInput( + png: png, + expectedWidth: 0xffffffff, + expectedHeight: 0xffffffff, + ), + ), + FormatErrorCode.integerUnsafe, + ); + }); + + test('rejects invalid zlib method/window/check/dictionary and a missing trailer', () { + final filtered = Uint8List(18); + final validZlib = storedZlib(filtered); + final mutations = [ + (zlib) => zlib[0] = 0x79, + (zlib) => zlib[0] = 0x88, + (zlib) => zlib[1] = zlib[1] ^ 1, + (zlib) { + zlib[1] = zlib[1] | 0x20; + zlib[1] = (zlib[1] + (31 - ((zlib[0] * 256 + zlib[1]) % 31))) & 0xff; + }, + ]; + for (final mutate in mutations) { + final zlib = Uint8List.fromList(validZlib); + mutate(zlib); + _expectPngError( + () => _validate(makeTestPng(TestPngInput(width: 2, height: 2, zlib: zlib))), + ); + } + _expectPngError( + () => _validate( + makeTestPng( + TestPngInput( + width: 2, + height: 2, + zlib: Uint8List.sublistView(validZlib, 0, 5), + ), + ), + ), + ); + }); + + test('honors a caller-lowered byte budget and validates checksum authorities', () { + final png = makeTestPng(TestPngInput(width: 2, height: 2)); + _expectPngError( + () => validatePngProfile( + PngProfileValidationInput( + png: png, + expectedWidth: 2, + expectedHeight: 2, + options: FormatOptions( + budgets: {'maxPngBytes': png.length - 1}, + ), + ), + ), + FormatErrorCode.budgetExceeded, + ); + final vector = Uint8List.fromList(utf8.encode('123456789')); + expect(crc32(vector), equals(0xcbf43926)); + expect(crc32(vector), equals(testCrc32(vector))); + expect(adler32(vector), equals(testAdler32(vector))); + }); + }); +} + +PngDecodePlan _validate(Uint8List png) { + return validatePngProfile( + PngProfileValidationInput(png: png, expectedWidth: 2, expectedHeight: 2), + ); +} + +class _CanonicalParts { + _CanonicalParts({ + required this.ihdr, + required this.srgb, + required this.idat, + required this.iend, + required this.zlib, + }); + + final Uint8List ihdr; + final Uint8List srgb; + final Uint8List idat; + final Uint8List iend; + final Uint8List zlib; + + Uint8List byKey(String key) { + switch (key) { + case 'ihdr': + return ihdr; + case 'srgb': + return srgb; + case 'idat': + return idat; + case 'iend': + return iend; + default: + throw ArgumentError('unknown canonical part $key'); + } + } +} + +_CanonicalParts _canonicalParts() { + const width = 2; + const height = 2; + final ihdrPayload = Uint8List(13); + writeUint32Be(ihdrPayload, 0, width); + writeUint32Be(ihdrPayload, 4, height); + ihdrPayload.setRange(8, 13, const [8, 6, 0, 0, 0]); + final filtered = Uint8List(height * (1 + width * 4)); + final zlib = storedZlib(filtered); + return _CanonicalParts( + ihdr: chunk('IHDR', ihdrPayload), + srgb: chunk('sRGB', Uint8List.fromList(const [0])), + idat: chunk('IDAT', zlib), + iend: chunk('IEND', Uint8List(0)), + zlib: zlib, + ); +} + +void _rewriteChunkCrc(Uint8List png, int chunkOffset) { + final length = readUint32Be(png, chunkOffset); + writeUint32Be( + png, + chunkOffset + 8 + length, + testCrc32(Uint8List.sublistView(png, chunkOffset + 4, chunkOffset + 8 + length)), + ); +} + +FormatError _expectPngError( + Object? Function() action, [ + FormatErrorCode code = FormatErrorCode.pngEnvelopeInvalid, +]) { + try { + action(); + } catch (error) { + expect(error, isA()); + expect((error as FormatError).code, equals(code)); + return error; + } + fail('expected PNG validation failure'); +} diff --git a/flutter/packages/aval_format/test/png_test_fixture.dart b/flutter/packages/aval_format/test/png_test_fixture.dart new file mode 100644 index 0000000..a365f29 --- /dev/null +++ b/flutter/packages/aval_format/test/png_test_fixture.dart @@ -0,0 +1,284 @@ +/// Synthetic in-memory PNG/zlib byte-stream builders shared by the PNG and +/// DEFLATE test suites. +/// +/// Dart port of `packages/format/test/png-test-fixture.ts`. The TS source +/// uses `node:zlib`'s `deflateSync` purely to generate real "fixed"/"dynamic" +/// Huffman-coded test vectors (never in production code). This package has +/// zero external dependencies and its production `lib/src/png/*.dart` never +/// touches `dart:io`, but for this *test-only* fixture the equivalent +/// pragmatic choice is `dart:io`'s `ZLibEncoder` (an SDK library, not a pub +/// package, and never linked into the shipped library) — it mirrors the TS +/// test's own use of a native platform compressor solely to build inputs +/// that exercise this package's hand-rolled inflater. +library; + +import 'dart:io'; +import 'dart:typed_data'; + +const List _pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/// `"stored" | "fixed" | "dynamic"`. +typedef PngCompression = String; + +class TestPngInput { + const TestPngInput({ + required this.width, + required this.height, + this.rgba, + this.filters, + this.compression = 'stored', + this.includeSrgb = true, + this.idatSplits, + this.zlib, + }); + + final int width; + final int height; + final Uint8List? rgba; + final List? filters; + final PngCompression compression; + final bool includeSrgb; + final List? idatSplits; + final Uint8List? zlib; +} + +Uint8List makeTestPng(TestPngInput input) { + final rgba = input.rgba ?? patternedRgba(input.width, input.height); + final filtered = filterRgba( + rgba, + input.width, + input.height, + input.filters ?? const [0], + ); + final zlib = input.zlib ?? _compress(filtered, input.compression); + final ihdr = Uint8List(13); + writeUint32Be(ihdr, 0, input.width); + writeUint32Be(ihdr, 4, input.height); + ihdr.setRange(8, 13, const [8, 6, 0, 0, 0]); + final chunks = [chunk('IHDR', ihdr)]; + if (input.includeSrgb) { + chunks.add(chunk('sRGB', Uint8List.fromList(const [0]))); + } + final segments = _splitBytes(zlib, input.idatSplits); + for (final segment in segments) { + chunks.add(chunk('IDAT', segment)); + } + chunks.add(chunk('IEND', Uint8List(0))); + return concatenate([Uint8List.fromList(_pngSignature), ...chunks]); +} + +Uint8List makeSizedTestPng( + int width, + int height, + int minimumLength, [ + int marker = 0, +]) { + final rgba = patternedRgba(width, height); + if (rgba.isNotEmpty) rgba[0] = marker & 0xff; + final filtered = filterRgba(rgba, width, height, const [0]); + final base = makeTestPng( + TestPngInput( + width: width, + height: height, + rgba: rgba, + zlib: storedZlib(filtered), + ), + ); + final rawExtra = ((minimumLength - base.length) / 5).ceil(); + final extraEmptyBlocks = rawExtra < 0 ? 0 : rawExtra; + return makeTestPng( + TestPngInput( + width: width, + height: height, + rgba: rgba, + zlib: storedZlib(filtered, extraEmptyBlocks), + ), + ); +} + +Uint8List patternedRgba(int width, int height) { + final rgba = Uint8List(width * height * 4); + for (var index = 0; index < rgba.length; index += 1) { + rgba[index] = (index * 73 + (index ~/ 4) * 29 + 17) & 0xff; + } + return rgba; +} + +Uint8List filterRgba( + Uint8List rgba, + int width, + int height, + List filters, +) { + final stride = width * 4; + if (rgba.length != stride * height) { + throw StateError('test RGBA length mismatch'); + } + final result = Uint8List(height * (stride + 1)); + for (var y = 0; y < height; y += 1) { + final filter = filters[y % filters.length]; + if (filter < 0 || filter > 4) { + throw StateError('test filter is invalid'); + } + final target = y * (stride + 1); + result[target] = filter; + for (var x = 0; x < stride; x += 1) { + final raw = rgba[y * stride + x]; + final left = x >= 4 ? rgba[y * stride + x - 4] : 0; + final up = y > 0 ? rgba[(y - 1) * stride + x] : 0; + final upperLeft = y > 0 && x >= 4 ? rgba[(y - 1) * stride + x - 4] : 0; + final int predictor; + if (filter == 0) { + predictor = 0; + } else if (filter == 1) { + predictor = left; + } else if (filter == 2) { + predictor = up; + } else if (filter == 3) { + predictor = (left + up) ~/ 2; + } else { + predictor = _paeth(left, up, upperLeft); + } + result[target + 1 + x] = (raw - predictor) & 0xff; + } + } + return result; +} + +Uint8List storedZlib(Uint8List bytes, [int extraEmptyBlocks = 0]) { + final blockLengths = []; + var remaining = bytes.length; + while (remaining > 65535) { + blockLengths.add(65535); + remaining -= 65535; + } + for (var index = 0; index < extraEmptyBlocks; index += 1) { + blockLengths.add(0); + } + blockLengths.add(remaining); + final result = Uint8List(2 + blockLengths.length * 5 + bytes.length + 4); + result.setRange(0, 2, const [0x78, 0x01]); + var source = 0; + var target = 2; + for (var index = 0; index < blockLengths.length; index += 1) { + final length = blockLengths[index]; + result[target] = index == blockLengths.length - 1 ? 1 : 0; + result[target + 1] = length & 0xff; + result[target + 2] = (length >> 8) & 0xff; + final complement = (~length) & 0xffff; + result[target + 3] = complement & 0xff; + result[target + 4] = (complement >> 8) & 0xff; + target += 5; + result.setRange(target, target + length, bytes, source); + source += length; + target += length; + } + writeUint32Be(result, target, testAdler32(bytes)); + return result; +} + +Uint8List rebuildPngWithZlib(Uint8List source, Uint8List zlib) { + final width = readUint32Be(source, 16); + final height = readUint32Be(source, 20); + return makeTestPng(TestPngInput(width: width, height: height, zlib: zlib)); +} + +Uint8List chunk(String type, Uint8List payload) { + if (type.length != 4) { + throw StateError('test chunk type must have four bytes'); + } + final result = Uint8List(payload.length + 12); + writeUint32Be(result, 0, payload.length); + for (var index = 0; index < 4; index += 1) { + result[4 + index] = type.codeUnitAt(index); + } + result.setRange(8, 8 + payload.length, payload); + writeUint32Be( + result, + 8 + payload.length, + testCrc32(Uint8List.sublistView(result, 4, 8 + payload.length)), + ); + return result; +} + +Uint8List concatenate(List parts) { + final length = parts.fold(0, (total, part) => total + part.length); + final result = Uint8List(length); + var offset = 0; + for (final part in parts) { + result.setRange(offset, offset + part.length, part); + offset += part.length; + } + return result; +} + +int readUint32Be(Uint8List bytes, int offset) { + return bytes[offset] * 0x1000000 + + bytes[offset + 1] * 0x10000 + + bytes[offset + 2] * 0x100 + + bytes[offset + 3]; +} + +void writeUint32Be(Uint8List bytes, int offset, int value) { + bytes[offset] = (value >> 24) & 0xff; + bytes[offset + 1] = (value >> 16) & 0xff; + bytes[offset + 2] = (value >> 8) & 0xff; + bytes[offset + 3] = value & 0xff; +} + +int testCrc32(Uint8List bytes) { + var crc = 0xffffffff; + for (final byte in bytes) { + crc ^= byte; + for (var bit = 0; bit < 8; bit += 1) { + crc = (crc & 1) == 0 ? crc >> 1 : (crc >> 1) ^ 0xedb88320; + } + } + return (crc ^ 0xffffffff) & 0xffffffff; +} + +int testAdler32(Uint8List bytes) { + var a = 1; + var b = 0; + for (final byte in bytes) { + a = (a + byte) % 65521; + b = (b + a) % 65521; + } + return ((b << 16) | a) & 0xffffffff; +} + +Uint8List _compress(Uint8List bytes, PngCompression compression) { + if (compression == 'stored') return storedZlib(bytes); + final encoder = ZLibEncoder( + level: compression == 'fixed' ? 6 : 9, + strategy: compression == 'fixed' + ? ZLibOption.strategyFixed + : ZLibOption.strategyDefault, + ); + return Uint8List.fromList(encoder.convert(bytes)); +} + +List _splitBytes(Uint8List bytes, List? requested) { + if (requested == null) return [bytes]; + final result = []; + var offset = 0; + for (final length in requested) { + if (length < 0 || offset + length > bytes.length) { + throw StateError('test IDAT split is invalid'); + } + result.add(Uint8List.sublistView(bytes, offset, offset + length)); + offset += length; + } + result.add(Uint8List.sublistView(bytes, offset)); + return result; +} + +int _paeth(int left, int up, int upperLeft) { + final prediction = left + up - upperLeft; + final leftDistance = (prediction - left).abs(); + final upDistance = (prediction - up).abs(); + final upperLeftDistance = (prediction - upperLeft).abs(); + return leftDistance <= upDistance && leftDistance <= upperLeftDistance + ? left + : (upDistance <= upperLeftDistance ? up : upperLeft); +} diff --git a/flutter/packages/aval_format/test/png_unfilter_test.dart b/flutter/packages/aval_format/test/png_unfilter_test.dart new file mode 100644 index 0000000..02e6493 --- /dev/null +++ b/flutter/packages/aval_format/test/png_unfilter_test.dart @@ -0,0 +1,82 @@ +/// Dart port of `packages/format/test/png-unfilter.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/png/unfilter.dart'; +import 'package:test/test.dart'; + +import 'png_test_fixture.dart'; + +void main() { + group('PNG RGBA scanline reconstruction', () { + test('reconstructs filters 0 through 4 independently and mixed', () { + const width = 5; + const height = 7; + final rgba = patternedRgba(width, height); + for (final filters in >[ + [0], + [1], + [2], + [3], + [4], + [0, 1, 2, 3, 4], + ]) { + final filtered = filterRgba(rgba, width, height, filters); + expect(_unfilter(filtered, width, height), equals(rgba)); + } + }); + + test('uses modulo-256 Sub/Average arithmetic and PNG Paeth ties', () { + final rgba = Uint8List.fromList(const [ + 250, 1, 128, 255, 2, 255, 0, 1, // + 1, 250, 255, 0, 255, 2, 128, 254, + ]); + for (final filter in [1, 3, 4]) { + final filtered = filterRgba(rgba, 2, 2, [filter]); + expect(_unfilter(filtered, 2, 2), equals(rgba)); + } + + final tie = Uint8List(18); + tie.setRange(0, 9, const [0, 2, 0, 0, 0, 3, 0, 0, 0]); + tie[9] = 4; + tie[10] = 254; // up=2 reconstructs current-row left to zero. + tie[14] = 10; // left=0 and upper-left=2 tie; PNG selects left. + expect(_unfilter(tie, 2, 2)[12], equals(10)); + }); + + test('rejects wrong lengths, dimensions, and filter bytes with scanline code', () { + _expectScanlineError(() => _unfilter(Uint8List(17), 2, 2)); + _expectScanlineError(() => _unfilter(Uint8List(18), 0, 2)); + _expectScanlineError(() { + final filtered = Uint8List(18); + filtered[0] = 5; + return _unfilter(filtered, 2, 2); + }); + }); + }); +} + +Uint8List _unfilter(Uint8List filtered, int width, int height) { + return unfilterPngRgba( + PngUnfilterInput( + filtered: filtered, + layout: derivePngRgbaLayout(width, height), + ), + ); +} + +void _expectScanlineError(Object? Function() action) { + try { + action(); + } catch (error) { + expect(error, isA()); + expect( + (error as FormatError).code, + equals(FormatErrorCode.pngScanlineInvalid), + ); + return; + } + fail('expected PNG scanline failure'); +} diff --git a/flutter/packages/aval_format/test/round_trip_test.dart b/flutter/packages/aval_format/test/round_trip_test.dart new file mode 100644 index 0000000..4952681 --- /dev/null +++ b/flutter/packages/aval_format/test/round_trip_test.dart @@ -0,0 +1,56 @@ +// Dart port of packages/format/test/round-trip.test.ts. +import 'package:aval_format/src/parser.dart'; +import 'package:aval_format/src/writer.dart'; +import 'package:test/test.dart'; + +import 'writer_fixture.dart'; + +void main() { + group('canonical writer/parser round trip', () { + test('reconstructs writer input from parsed metadata and caller payloads byte-identically', () { + final callerInput = shuffledWriterInput(twoRenditionWriterInput()); + final first = writeCanonicalAsset(callerInput); + final parsed = parseFrontIndex(first); + final reconstructed = writerInputFromParsed(parsed, callerInput.chunks); + final second = writeCanonicalAsset(reconstructed); + + expect(byteIdentity(first, second), true); + final layout = validateCompleteAsset(bytes: second, frontIndex: parsed); + expect(layout.fileRange.offset, 0); + expect(layout.fileRange.length, second.length); + }); + + test('preserves every derived chunk span and payload byte range', () { + final input = twoRenditionWriterInput(); + final bytes = writeCanonicalAsset(input); + final parsed = parseFrontIndex(bytes); + + expect(parsed.records.length, input.chunks.length); + for (var index = 0; index < parsed.records.length; index += 1) { + final record = parsed.records[index]; + final slice = bytes.sublist(record.byteOffset, record.byteOffset + record.byteLength); + expect(slice, input.chunks[index].bytes); + } + + for (var unitIndex = 0; unitIndex < parsed.manifest.units.length; unitIndex += 1) { + final unit = parsed.manifest.units[unitIndex]; + for (var renditionIndex = 0; renditionIndex < unit.chunks.length; renditionIndex += 1) { + final span = unit.chunks[renditionIndex]; + final previousRenditions = parsed.manifest.units.fold( + 0, + (sum, candidate) => + sum + + candidate.chunks + .sublist(0, renditionIndex) + .fold(0, (inner, candidateSpan) => inner + candidateSpan.chunkCount), + ); + final prefix = parsed.manifest.units + .sublist(0, unitIndex) + .fold(0, (sum, candidate) => sum + candidate.chunks[renditionIndex].chunkCount); + expect(span.chunkStart, previousRenditions + prefix); + expect(span.frameCount, unit.frameCount); + } + } + }); + }); +} diff --git a/flutter/packages/aval_format/test/utf8_test.dart b/flutter/packages/aval_format/test/utf8_test.dart new file mode 100644 index 0000000..f4f5af9 --- /dev/null +++ b/flutter/packages/aval_format/test/utf8_test.dart @@ -0,0 +1,58 @@ +// Dart port of packages/format/test/utf8.test.ts. +import 'dart:typed_data'; + +import 'package:aval_format/src/utf8.dart'; +import 'package:test/test.dart'; + +Never _rejectUnicode(String message, [int? offset]) { + throw Exception('$message@$offset'); +} + +void main() { + group('UTF-8 scalar primitives', () { + test('iterates and encodes every UTF-8 width without platform codecs', () { + const value = 'Aé€😀'; + + expect(utf8ByteLength(value, _rejectUnicode), 10); + expect( + encodeUtf8String(value, _rejectUnicode), + [0x41, 0xc3, 0xa9, 0xe2, 0x82, 0xac, 0xf0, 0x9f, 0x98, 0x80], + ); + final scalar = readStringScalar(value, 3, _rejectUnicode); + expect(scalar.codePoint, 0x1f600); + expect(scalar.width, 2); + }); + + test('strictly decodes scalars and reports the failing byte', () { + final good = readUtf8Scalar(Uint8List.fromList([0xe2, 0x82, 0xac]), 0, _rejectUnicode); + expect(good.codePoint, 0x20ac); + expect(good.width, 3); + + expect( + () => readUtf8Scalar(Uint8List.fromList([0xe2, 0x28, 0xa1]), 0, _rejectUnicode), + throwsA(predicate((e) => e.toString().contains('Invalid UTF-8 continuation byte@1'))), + ); + expect( + () => readUtf8Scalar(Uint8List.fromList([0xed, 0xa0, 0x80]), 0, _rejectUnicode), + throwsA(predicate((e) => e.toString().contains('Invalid UTF-8 scalar value@0'))), + ); + }); + + test('rejects both forms of unpaired UTF-16 surrogate', () { + expect( + () => utf8ByteLength('\ud800', _rejectUnicode), + throwsA(predicate((e) => e.toString().contains('String contains a lone high surrogate@0'))), + ); + expect( + () => utf8ByteLength('\udc00', _rejectUnicode), + throwsA(predicate((e) => e.toString().contains('String contains a lone low surrogate@0'))), + ); + }); + + test('compares byte strings unsigned and treats a prefix as smaller', () { + expect(compareBytes([0x7f], [0x80]), lessThan(0)); + expect(compareBytes([1], [1, 0]), lessThan(0)); + expect(compareBytes([2], [1, 255]), greaterThan(0)); + }); + }); +} diff --git a/flutter/packages/aval_format/test/video_geometry_test.dart b/flutter/packages/aval_format/test/video_geometry_test.dart new file mode 100644 index 0000000..b37961d --- /dev/null +++ b/flutter/packages/aval_format/test/video_geometry_test.dart @@ -0,0 +1,96 @@ +// Dart port of `packages/format/test/video-geometry.test.ts`. +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/model.dart' show Rect; +import 'package:aval_format/src/video/geometry.dart'; +import 'package:aval_format/src/video/model.dart'; +import 'package:test/test.dart'; + +void main() { + group('deriveVideoRenditionGeometry', () { + test('derives opaque geometry using codec-owned storage alignment', () { + final result = deriveVideoRenditionGeometry( + const VideoRenditionGeometryInput( + canvasWidth: 15, + canvasHeight: 17, + layout: 'opaque', + visibleWidth: 15, + visibleHeight: 17, + storage: VideoStoragePolicy(widthAlignment: 16, heightAlignment: 16), + ), + ); + expect(result.layout, 'opaque'); + expect(result.visibleColorRect, const Rect(0, 0, 15, 17)); + expect(result.visibleAlphaRect, isNull); + expect(result.decodedStorageRect, const Rect(0, 0, 16, 18)); + expect(result.codedWidth, 16); + expect(result.codedHeight, 32); + expect(result.visibleColorArea, 255); + expect(result.decodedRgbaBytes, 16 * 18 * 4); + expect(result.codedRgbaBytes, 16 * 32 * 4); + }); + + test('uses one shared packed-alpha layout for every codec', () { + final result = deriveVideoRenditionGeometry( + const VideoRenditionGeometryInput( + canvasWidth: 15, + canvasHeight: 17, + layout: 'packed-alpha', + visibleWidth: 15, + visibleHeight: 17, + storage: VideoStoragePolicy(widthAlignment: 2, heightAlignment: 2), + ), + ); + expect(result.layout, 'packed-alpha'); + expect(result.visibleColorRect, const Rect(0, 0, 15, 17)); + expect(result.visibleAlphaRect, Rect(0, 18 + packedAlphaGutter, 15, 17)); + expect(result.decodedStorageRect, const Rect(0, 0, 16, 44)); + expect(result.codedWidth, 16); + expect(result.codedHeight, 44); + expect(result.visibleColorArea, 255); + expect(result.decodedRgbaBytes, 16 * 44 * 4); + expect(result.codedRgbaBytes, 16 * 44 * 4); + }); + + test('rejects aspect drift, canvas overflow, invalid policy, and unsafe products', + () { + const maxSafeInteger = 9007199254740991; + final inputs = [ + const VideoRenditionGeometryInput( + canvasWidth: 16, + canvasHeight: 9, + layout: 'opaque', + visibleWidth: 15, + visibleHeight: 9, + storage: VideoStoragePolicy(widthAlignment: 2, heightAlignment: 2), + ), + const VideoRenditionGeometryInput( + canvasWidth: 16, + canvasHeight: 9, + layout: 'opaque', + visibleWidth: 17, + visibleHeight: 9, + storage: VideoStoragePolicy(widthAlignment: 2, heightAlignment: 2), + ), + const VideoRenditionGeometryInput( + canvasWidth: 16, + canvasHeight: 9, + layout: 'opaque', + visibleWidth: 16, + visibleHeight: 9, + storage: VideoStoragePolicy(widthAlignment: 0, heightAlignment: 2), + ), + const VideoRenditionGeometryInput( + canvasWidth: maxSafeInteger, + canvasHeight: 9, + layout: 'opaque', + visibleWidth: 16, + visibleHeight: 9, + storage: VideoStoragePolicy(widthAlignment: 2, heightAlignment: 2), + ), + ]; + for (final input in inputs) { + expect(() => deriveVideoRenditionGeometry(input), throwsA(isA())); + } + }); + }); +} diff --git a/flutter/packages/aval_format/test/vp9_frame_header_test.dart b/flutter/packages/aval_format/test/vp9_frame_header_test.dart new file mode 100644 index 0000000..46b18af --- /dev/null +++ b/flutter/packages/aval_format/test/vp9_frame_header_test.dart @@ -0,0 +1,68 @@ +/// Dart port of `packages/format/test/vp9-frame-header.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/vp9/index.dart'; +import 'package:test/test.dart'; + +final Uint8List key64x32Bt709 = Uint8List.fromList( + [0x82, 0x49, 0x83, 0x42, 0x40, 0x03, 0xf0, 0x01, 0xf6, 0x08], +); + +void main() { + group('VP9 uncompressed frame headers', () { + test('parses a limited-range BT.709 profile-0 key frame', () { + expect( + parseVp9FrameHeader(key64x32Bt709), + equals(const Vp9FrameHeader( + profile: 0, + key: true, + showFrame: true, + showExistingFrame: false, + displayedFrameCount: 1, + errorResilient: false, + width: 64, + height: 32, + renderWidth: 64, + renderHeight: 32, + color: Vp9ColorConfig( + bitDepth: 8, + chromaSubsampling: 1, + colorPrimaries: 1, + transferCharacteristics: 1, + matrixCoefficients: 1, + fullRange: false, + ), + )), + ); + }); + + test('retains hidden inter and show-existing semantics', () { + final hidden = parseVp9FrameHeader(Uint8List.fromList([0x84])); + expect(hidden.key, isFalse); + expect(hidden.showFrame, isFalse); + expect(hidden.showExistingFrame, isFalse); + expect(hidden.displayedFrameCount, 0); + + final showExisting = parseVp9FrameHeader(Uint8List.fromList([0x88])); + expect(showExisting.key, isFalse); + expect(showExisting.showFrame, isTrue); + expect(showExisting.showExistingFrame, isTrue); + expect(showExisting.displayedFrameCount, 1); + }); + + test('rejects truncation, profiles other than zero, and wrong color', () { + for (final bytes in [ + Uint8List(0), + Uint8List.fromList([0x82, 0x49]), + Uint8List.fromList([0x92]), + Uint8List.fromList( + [0x82, 0x49, 0x83, 0x42, 0x00, 0x03, 0xf0, 0x01, 0xf6]), + ]) { + expect(() => parseVp9FrameHeader(bytes), throwsA(isA())); + } + }); + }); +} diff --git a/flutter/packages/aval_format/test/vp9_inspector_test.dart b/flutter/packages/aval_format/test/vp9_inspector_test.dart new file mode 100644 index 0000000..de6d691 --- /dev/null +++ b/flutter/packages/aval_format/test/vp9_inspector_test.dart @@ -0,0 +1,89 @@ +/// Dart port of `packages/format/test/vp9-inspector.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/vp9/index.dart'; +import 'package:test/test.dart'; + +final Uint8List key = Uint8List.fromList( + [0x82, 0x49, 0x83, 0x42, 0x40, 0x03, 0xf0, 0x01, 0xf6, 0x08], +); + +void main() { + group('VP9 rendition inspection', () { + test('derives a fully qualified codec and permits hidden frames', () { + const marker = 0xc1; + final hiddenAndShown = + Uint8List.fromList([0x84, 0x86, marker, 1, 1, marker]); + final inspection = inspectVp9Rendition(Vp9RenditionInspectionInput( + width: 64, + height: 32, + frameRate: (numerator: 30, denominator: 1), + averageBitrate: 100000, + units: [ + Vp9UnitInput( + id: 'idle', + expectedDisplayedFrames: 2, + packets: [ + Vp9PacketInput(bytes: key, key: true, timestamp: 0), + Vp9PacketInput(bytes: hiddenAndShown, key: false, timestamp: 1), + ], + ), + ], + )); + + expect(inspection.codec, 'vp09.00.10.08.01.01.01.01.00'); + expect(inspection.width, 64); + expect(inspection.height, 32); + expect(inspection.bitDepth, 8); + expect(inspection.units[0].displayedFrameCount, 2); + }); + + test('rejects a non-key unit start and authored display mismatch', () { + Vp9RenditionInspectionInput base(List units) => + Vp9RenditionInspectionInput( + width: 64, + height: 32, + frameRate: (numerator: 30, denominator: 1), + averageBitrate: 100000, + units: units, + ); + + expect( + () => inspectVp9Rendition(base([ + Vp9UnitInput( + id: 'idle', + expectedDisplayedFrames: 1, + packets: [ + Vp9PacketInput( + bytes: Uint8List.fromList([0x86]), + key: false, + timestamp: 0, + ), + ], + ), + ])), + throwsA(predicate((error) => + error is FormatError && + RegExp('start with a key').hasMatch(error.message))), + ); + + expect( + () => inspectVp9Rendition(base([ + Vp9UnitInput( + id: 'idle', + expectedDisplayedFrames: 2, + packets: [ + Vp9PacketInput(bytes: key, key: true, timestamp: 0), + ], + ), + ])), + throwsA(predicate((error) => + error is FormatError && + RegExp('displayed frame count').hasMatch(error.message))), + ); + }); + }); +} diff --git a/flutter/packages/aval_format/test/vp9_superframe_test.dart b/flutter/packages/aval_format/test/vp9_superframe_test.dart new file mode 100644 index 0000000..61d5e0a --- /dev/null +++ b/flutter/packages/aval_format/test/vp9_superframe_test.dart @@ -0,0 +1,37 @@ +/// Dart port of `packages/format/test/vp9-superframe.test.ts`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/vp9/index.dart'; +import 'package:test/test.dart'; + +void main() { + group('VP9 superframe parsing', () { + test('splits hidden and displayed coded frames into owned bytes', () { + const marker = 0xc1; + final packet = Uint8List.fromList( + [0x84, 0xaa, 0x86, 0xbb, 0xcc, marker, 2, 3, marker]); + final frames = splitVp9Superframe(packet); + expect(frames, equals([ + Uint8List.fromList([0x84, 0xaa]), + Uint8List.fromList([0x86, 0xbb, 0xcc]), + ])); + packet.fillRange(0, packet.length, 0); + expect(frames[0], equals(Uint8List.fromList([0x84, 0xaa]))); + }); + + test('returns a detached single frame and rejects malformed indexes', () { + final packet = Uint8List.fromList([0x86, 0x01]); + final frames = splitVp9Superframe(packet); + packet.fillRange(0, packet.length, 0); + expect(frames, equals([Uint8List.fromList([0x86, 0x01])])); + expect( + () => splitVp9Superframe(Uint8List.fromList([0x84, 0xc1, 1, 1, 0xc1])), + throwsA(predicate((error) => + error is FormatError && RegExp('sizes').hasMatch(error.message))), + ); + }); + }); +} diff --git a/flutter/packages/aval_format/test/writer_fixed_point_test.dart b/flutter/packages/aval_format/test/writer_fixed_point_test.dart new file mode 100644 index 0000000..527082a --- /dev/null +++ b/flutter/packages/aval_format/test/writer_fixed_point_test.dart @@ -0,0 +1,46 @@ +// Dart port of packages/format/test/writer-fixed-point.test.ts. +import 'dart:typed_data'; + +import 'package:aval_format/src/errors.dart'; +import 'package:aval_format/src/writer_fixed_point.dart'; +import 'package:test/test.dart'; + +void main() { + group('writer fixed-point runner', () { + test('returns the first byte-stable value and its associated result', () { + final result = resolveByteStableFixedPoint( + 0, + Uint8List.fromList([0]), + 4, + (value, bytes) => ByteFixedPointStep( + value: value + 1, + bytes: Uint8List.fromList([(value + 1).clamp(0, 1)]), + result: 'step-${value + 1}', + ), + ); + + expect(result.value, 2); + expect(result.bytes, [1]); + expect(result.result, 'step-2'); + expect(result.iterations, 2); + }); + + test('deterministically forces the planned non-convergence branch', () { + expect( + () => resolveByteStableFixedPoint( + false, + Uint8List.fromList([0]), + 4, + (value, bytes) => ByteFixedPointStep( + value: !value, + bytes: Uint8List.fromList([value ? 0 : 1]), + result: null, + ), + ), + throwsA( + predicate((e) => e is FormatError && e.code == FormatErrorCode.writerNonconvergent), + ), + ); + }); + }); +} diff --git a/flutter/packages/aval_format/test/writer_fixture.dart b/flutter/packages/aval_format/test/writer_fixture.dart new file mode 100644 index 0000000..29add37 --- /dev/null +++ b/flutter/packages/aval_format/test/writer_fixture.dart @@ -0,0 +1,313 @@ +// Dart port of packages/format/test/writer-fixture.ts. +import 'dart:typed_data'; + +import 'package:aval_format/src/manifest_schema.dart' show validateCompiledManifest; +import 'package:aval_format/src/model.dart'; + +import 'manifest_fixture.dart'; + +/// Converts a validated compiled manifest back into writer-input shape +/// (`CompiledManifestInput`, whose units carry `ChunkDigestInput` +/// instead of the fully-derived `UnitChunkSpan`). +CompiledManifestInput manifestInputFromCompiled(CompiledManifest manifest) { + return CompiledManifestInput( + generator: manifest.generator, + codec: manifest.codec, + bitstream: manifest.bitstream, + layout: manifest.layout, + canvas: manifest.canvas, + frameRate: manifest.frameRate, + renditions: manifest.renditions, + units: manifest.units.map(_unitInputFrom).toList(), + initialState: manifest.initialState, + states: manifest.states, + edges: manifest.edges, + bindings: manifest.bindings, + readiness: manifest.readiness, + limits: manifest.limits, + ); +} + +UnitInput _unitInputFrom(Unit unit) { + final chunks = unit.chunks + .map((chunk) => ChunkDigestInput(rendition: chunk.rendition, sha256: chunk.sha256)) + .toList(); + if (unit is BodyUnit) { + return BodyUnitInput( + id: unit.id, + frameCount: unit.frameCount, + chunks: chunks, + playback: unit.playback, + ports: unit.ports, + ); + } + if (unit is BridgeUnit) { + return BridgeUnitInput(id: unit.id, frameCount: unit.frameCount, chunks: chunks); + } + if (unit is ReversibleUnit) { + return ReversibleUnitInput( + id: unit.id, + frameCount: unit.frameCount, + chunks: chunks, + residency: unit.residency, + ); + } + final oneShot = unit as OneShotUnit; + return OneShotUnitInput(id: oneShot.id, frameCount: oneShot.frameCount, chunks: chunks); +} + +/// Reconstructs the typed [CompiledManifest] the untyped +/// `manifest_fixture.dart` builds, by round-tripping it through the real +/// schema validator (the fixture is authored as an untyped Map tree exactly +/// like the TS source's object literal, and this is the one place both +/// typed and untyped worlds need to meet for the writer fixture). +CompiledManifest _validCompiledManifest() { + return validateCompiledManifest(validManifest()); +} + +/// A fresh valid writer input with one encoded chunk per displayed frame. +CanonicalAssetInput validWriterInput({String generatorSuffix = ''}) { + final compiled = _validCompiledManifest(); + final baseManifest = manifestInputFromCompiled(compiled); + final manifest = CompiledManifestInput( + generator: baseManifest.generator + generatorSuffix, + codec: baseManifest.codec, + bitstream: baseManifest.bitstream, + layout: baseManifest.layout, + canvas: baseManifest.canvas, + frameRate: baseManifest.frameRate, + renditions: baseManifest.renditions, + units: baseManifest.units, + initialState: baseManifest.initialState, + states: baseManifest.states, + edges: baseManifest.edges, + bindings: baseManifest.bindings, + readiness: baseManifest.readiness, + limits: baseManifest.limits, + ); + + var ordinal = 0; + final chunks = []; + for (final rendition in compiled.renditions) { + for (final unit in compiled.units) { + for (var decodeIndex = 0; decodeIndex < unit.frameCount; decodeIndex += 1) { + chunks.add(EncodedChunkInput( + rendition: rendition.id, + unit: unit.id, + decodeIndex: decodeIndex, + presentationTimestamp: decodeIndex, + duration: 1, + randomAccess: decodeIndex == 0, + displayedFrameCount: 1, + bytes: Uint8List.fromList([0, 0, 1, ordinal++ & 0xff]), + )); + } + } + } + return CanonicalAssetInput(manifest: manifest, chunks: chunks); +} + +/// Extends the compact fixture to exercise authored rendition order. +CanonicalAssetInput twoRenditionWriterInput() { + final input = validWriterInput(); + final original = input.manifest.renditions[0]; + final alternate = ProductionRendition( + id: 'alternate', + codec: original.codec, + bitDepth: original.bitDepth, + codedWidth: original.codedWidth, + codedHeight: original.codedHeight, + alphaLayout: original.alphaLayout, + bitrate: const Bitrate(average: 500, peak: 1000), + ); + final units = input.manifest.units.map((unit) { + final firstDigest = unit.chunks[0].sha256; + final chunks = [ + ChunkDigestInput(rendition: alternate.id, sha256: firstDigest), + ...unit.chunks, + ]; + return _withChunks(unit, chunks); + }).toList(); + + final manifest = CompiledManifestInput( + generator: input.manifest.generator, + codec: input.manifest.codec, + bitstream: input.manifest.bitstream, + layout: input.manifest.layout, + canvas: input.manifest.canvas, + frameRate: input.manifest.frameRate, + renditions: [alternate, original], + units: units, + initialState: input.manifest.initialState, + states: input.manifest.states, + edges: input.manifest.edges, + bindings: input.manifest.bindings, + readiness: input.manifest.readiness, + limits: input.manifest.limits, + ); + + final chunks = [ + for (final chunk in input.chunks) + EncodedChunkInput( + rendition: alternate.id, + unit: chunk.unit, + decodeIndex: chunk.decodeIndex, + presentationTimestamp: chunk.presentationTimestamp, + duration: chunk.duration, + randomAccess: chunk.randomAccess, + displayedFrameCount: chunk.displayedFrameCount, + bytes: Uint8List.fromList(chunk.bytes), + ), + ...input.chunks, + ]; + return CanonicalAssetInput(manifest: manifest, chunks: chunks); +} + +/// Adds bytes to the first chunk for large-offset boundary tests. +CanonicalAssetInput largeChunkWriterInput(int extraPayloadBytes) { + if (extraPayloadBytes < 0) { + throw ArgumentError('extra payload bytes must be nonnegative'); + } + final input = validWriterInput(); + final maxCompiled = input.manifest.limits.maxCompiledBytes; + final bumped = extraPayloadBytes + 1024 * 1024; + final newMax = maxCompiled >= 32 * 1024 * 1024 && bumped <= maxCompiled + ? maxCompiled + : (bumped > 32 * 1024 * 1024 ? bumped : 32 * 1024 * 1024); + return CanonicalAssetInput( + manifest: CompiledManifestInput( + generator: input.manifest.generator, + codec: input.manifest.codec, + bitstream: input.manifest.bitstream, + layout: input.manifest.layout, + canvas: input.manifest.canvas, + frameRate: input.manifest.frameRate, + renditions: input.manifest.renditions, + units: input.manifest.units, + initialState: input.manifest.initialState, + states: input.manifest.states, + edges: input.manifest.edges, + bindings: input.manifest.bindings, + readiness: input.manifest.readiness, + limits: DeclaredLimits( + maxCompiledBytes: newMax, + maxRuntimeBytes: input.manifest.limits.maxRuntimeBytes, + decodedPixelBytes: input.manifest.limits.decodedPixelBytes, + persistentCacheBytes: input.manifest.limits.persistentCacheBytes, + runtimeWorkingSetBytes: input.manifest.limits.runtimeWorkingSetBytes, + ), + ), + chunks: [ + for (var ordinal = 0; ordinal < input.chunks.length; ordinal += 1) + ordinal == 0 + ? EncodedChunkInput( + rendition: input.chunks[ordinal].rendition, + unit: input.chunks[ordinal].unit, + decodeIndex: input.chunks[ordinal].decodeIndex, + presentationTimestamp: input.chunks[ordinal].presentationTimestamp, + duration: input.chunks[ordinal].duration, + randomAccess: input.chunks[ordinal].randomAccess, + displayedFrameCount: input.chunks[ordinal].displayedFrameCount, + bytes: Uint8List(1 + extraPayloadBytes) + ..fillRange(0, 1 + extraPayloadBytes, ordinal & 0xff), + ) + : input.chunks[ordinal], + ], + ); +} + +/// Rebuilds writer metadata from parsed values while reusing caller payloads. +CanonicalAssetInput writerInputFromParsed( + ParsedFrontIndex front, + List chunks, +) { + return CanonicalAssetInput( + manifest: manifestInputFromCompiled(front.manifest), + chunks: chunks, + ); +} + +UnitInput _withChunks(UnitInput unit, List chunks) { + if (unit is BodyUnitInput) { + return BodyUnitInput( + id: unit.id, + frameCount: unit.frameCount, + chunks: chunks, + playback: unit.playback, + ports: unit.ports, + ); + } + if (unit is BridgeUnitInput) { + return BridgeUnitInput(id: unit.id, frameCount: unit.frameCount, chunks: chunks); + } + if (unit is ReversibleUnitInput) { + return ReversibleUnitInput( + id: unit.id, + frameCount: unit.frameCount, + chunks: chunks, + residency: unit.residency, + ); + } + final oneShot = unit as OneShotUnitInput; + return OneShotUnitInput(id: oneShot.id, frameCount: oneShot.frameCount, chunks: chunks); +} + +/// Reverses all semantically unordered input arrays without changing meaning. +CanonicalAssetInput shuffledWriterInput(CanonicalAssetInput input) { + final manifest = input.manifest; + final units = manifest.units.reversed.map((unit) { + if (unit is BodyUnitInput) { + return BodyUnitInput( + id: unit.id, + frameCount: unit.frameCount, + chunks: unit.chunks.reversed.toList(), + playback: unit.playback, + ports: unit.ports.reversed + .map((port) => Port(id: port.id, portalFrames: port.portalFrames.reversed.toList())) + .toList(), + ); + } + if (unit is ReversibleUnitInput) { + return ReversibleUnitInput( + id: unit.id, + frameCount: unit.frameCount, + chunks: unit.chunks.reversed.toList(), + residency: ReversibleResidency(unit.residency.endpoints.reversed.toList()), + ); + } + return _withChunks(unit, unit.chunks.reversed.toList()); + }).toList(); + + final reversedManifest = CompiledManifestInput( + generator: manifest.generator, + codec: manifest.codec, + bitstream: manifest.bitstream, + layout: manifest.layout, + canvas: manifest.canvas, + frameRate: manifest.frameRate, + renditions: manifest.renditions.reversed.toList(), + units: units, + initialState: manifest.initialState, + states: manifest.states.reversed.toList(), + edges: manifest.edges.reversed.toList(), + bindings: manifest.bindings.reversed.toList(), + readiness: Readiness( + bootstrapUnits: manifest.readiness.bootstrapUnits.reversed.toList(), + immediateEdges: manifest.readiness.immediateEdges.reversed.toList(), + ), + limits: manifest.limits, + ); + + return CanonicalAssetInput( + manifest: reversedManifest, + chunks: input.chunks.reversed.toList(), + ); +} + +bool byteIdentity(List left, List right) { + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) return false; + } + return true; +} diff --git a/flutter/packages/aval_graph/analysis_options.yaml b/flutter/packages/aval_graph/analysis_options.yaml new file mode 100644 index 0000000..b192f98 --- /dev/null +++ b/flutter/packages/aval_graph/analysis_options.yaml @@ -0,0 +1,13 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - prefer_const_constructors + - prefer_final_locals + - unawaited_futures diff --git a/flutter/packages/aval_graph/lib/aval_graph.dart b/flutter/packages/aval_graph/lib/aval_graph.dart new file mode 100644 index 0000000..0bf197d --- /dev/null +++ b/flutter/packages/aval_graph/lib/aval_graph.dart @@ -0,0 +1,35 @@ +/// Public entry point for `aval_graph` — a pure-Dart, dependency-free port of +/// the TypeScript `@pixel-point/aval-graph` package (a deterministic state +/// graph for AVAL assets). +/// +/// Mirrors the exports of `packages/graph/src/index.ts` exactly: the same +/// names are public here and nothing else is (route plan, intent router, +/// request ledger, operation journal, and engine-state internals stay +/// package-private under `lib/src/`, matching the TypeScript original, which +/// never re-exports those modules from its own `index.ts`). +library aval_graph; + +export 'src/engine.dart' show MotionGraphEngine; +export 'src/errors.dart' + show MotionGraphError, MotionGraphErrorCode, MotionGraphValidationError; +export 'src/limits.dart' show GraphLimits, graphIdentifierPattern; +export 'src/model.dart' hide listEquals; +export 'src/portal_search.dart' + show + BodyBoundarySearch, + BodyFrameStep, + findFinishBoundary, + findNextPortalBoundary, + greatestFinishWaitFrames, + greatestPortalWaitFrames, + nextBodyFrame; +export 'src/ring_plan.dart' + show + RingArc, + RingRoute, + RingRouteArc, + RingRouteNone, + RingRouteTooLong, + planRingArc, + resolveRingRoute; +export 'src/validate.dart' show validateMotionGraphDefinition; diff --git a/flutter/packages/aval_graph/lib/src/engine.dart b/flutter/packages/aval_graph/lib/src/engine.dart new file mode 100644 index 0000000..3c8240e --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/engine.dart @@ -0,0 +1,852 @@ +/// Pure version-0 graph reducer, ported from +/// `packages/graph/src/engine.ts`. +/// +/// It owns authored cursors and emits abstract presentations/effects; hosts +/// own timers, codecs, and rendering. +library; + +import 'errors.dart'; +import 'engine_state.dart'; +import 'intent_router.dart'; +import 'model.dart'; +import 'operation_journal.dart'; +import 'portal_search.dart'; +import 'request_ledger.dart'; +import 'ring_plan.dart'; + +class MotionGraphEngine { + MotionGraphEngine({MotionGraphTurnPolicy turnPolicy = MotionGraphTurnPolicy.chain}) + : _turnPolicy = turnPolicy; + + final MotionGraphEngineState _runtime = MotionGraphEngineState(); + final MotionGraphTurnPolicy _turnPolicy; + + MotionGraphTurnPolicy get turnPolicy => _turnPolicy; + + /// Installs a graph definition. [definition] may be raw, untrusted data + /// (validated internally — see `validate.dart`) or an already-validated + /// [ValidatedMotionGraph]. + MotionGraphResult install(Object? definition) { + if (_runtime.readiness != MotionGraphReadiness.unready) { + throw const MotionGraphError( + MotionGraphErrorCode.graphValidation, + 'graph metadata can only be installed once', + ); + } + final initial = _runtime.installMetadata(definition); + _runtime.requestedState = initial; + _runtime.visualState = initial; + _runtime.presentation = GraphPresentationStatic(state: initial); + final effects = []; + _changeReadiness(MotionGraphReadiness.preparing, effects); + _runtime.phase = MotionGraphPhase.preparing; + return _runtime.record(MotionGraphOperation.install, effects); + } + + MotionGraphResult beginAnimated() { + _runtime.assertPhase(MotionGraphPhase.preparing, 'beginAnimated'); + final effects = []; + _changeReadiness(MotionGraphReadiness.animated, effects); + final initial = _runtime.definition().initialState; + final state = _runtime.state(initial); + + final initialUnit = state.initialUnit; + if (initialUnit != null) { + _runtime.phase = MotionGraphPhase.intro; + _runtime.presentation = GraphPresentationIntro( + state: initial, + unitId: initialUnit.unitId, + frameIndex: 0, + ); + } else { + _runtime.presentation = _runtime.bodyPresentation(initial, 0); + _runtime.phase = + _runtime.routes.pending == null ? MotionGraphPhase.stable : MotionGraphPhase.waiting; + } + return _runtime.record(MotionGraphOperation.beginAnimated, effects); + } + + MotionGraphResult resumeAnimated() { + _runtime.assertPhase(MotionGraphPhase.static, 'resumeAnimated'); + if (_runtime.readiness != MotionGraphReadiness.static) { + throw const MotionGraphError( + MotionGraphErrorCode.notReady, + 'resumeAnimated requires static readiness', + ); + } + final presentation = _runtime.presentation; + final requested = _runtime.requireRequestedState(); + final visual = _runtime.requireVisualState(); + if (presentation is! GraphPresentationStatic || + presentation.state != visual || + requested != visual || + _runtime.routes.hasRoute() || + _runtime.ledger.pendingRequestCount != 0) { + throw const MotionGraphError( + MotionGraphErrorCode.notReady, + 'resumeAnimated requires one settled static state', + ); + } + final effects = []; + _changeReadiness(MotionGraphReadiness.animated, effects); + final state = _runtime.state(visual); + final firstAnimatedActivation = _runtime.initialUnitPending; + final initialUnit = state.initialUnit; + if (firstAnimatedActivation && + visual == _runtime.definition().initialState && + initialUnit != null) { + _runtime.presentation = GraphPresentationIntro( + state: visual, + unitId: initialUnit.unitId, + frameIndex: 0, + ); + _runtime.phase = MotionGraphPhase.intro; + } else { + _runtime.presentation = _runtime.bodyPresentation(visual, 0); + _runtime.phase = MotionGraphPhase.stable; + } + return _runtime.record(MotionGraphOperation.resumeAnimated, effects); + } + + MotionGraphResult beginStatic(String reason) { + _runtime.assertPhase(MotionGraphPhase.preparing, 'beginStatic'); + final effects = []; + _changeReadiness(MotionGraphReadiness.static, effects, reason: reason); + effects.add(MotionGraphEffectFallback(reason: reason)); + _runtime.phase = MotionGraphPhase.static; + + final visual = _runtime.requireVisualState(); + final requested = _runtime.requireRequestedState(); + if (visual != requested) { + final edge = _runtime.edgeDirect(visual, requested); + if (edge == null) { + throw MotionGraphError( + MotionGraphErrorCode.routeNotFound, + 'prepared target $requested has no direct route from $visual', + ); + } + _commitStaticEdge( + edge, + _runtime.routes.pending?.sequence ?? _runtime.journal.inputSequence, + effects, + true, + ); + } else { + _runtime.presentation = _runtime.staticPresentation(visual); + _runtime.routes.clear(); + } + return _runtime.record(MotionGraphOperation.beginStatic, effects); + } + + MotionGraphResult recoverStatic( + String reason, [ + MotionGraphRecoveryOptions options = const MotionGraphRecoveryOptions(), + ]) { + _runtime.assertInstalled('recoverStatic'); + if (_runtime.readiness == MotionGraphReadiness.disposed || + _runtime.readiness == MotionGraphReadiness.error) { + throw const MotionGraphError( + MotionGraphErrorCode.disposed, + 'graph cannot recover after termination', + ); + } + final retainedVisualState = options.retainedVisualState; + if (retainedVisualState != null && !_runtime.hasState(retainedVisualState)) { + throw const MotionGraphError( + MotionGraphErrorCode.graphValidation, + 'retained recovery visual state is not installed', + ); + } + final effects = []; + _changeReadiness(MotionGraphReadiness.static, effects, reason: reason); + effects.add(MotionGraphEffectFallback(reason: reason)); + final graphVisual = _runtime.requireVisualState(); + if (retainedVisualState != null) _runtime.visualState = retainedVisualState; + final visual = _runtime.requireVisualState(); + final requested = _runtime.requireRequestedState(); + + if (visual != requested || _runtime.routes.hasRoute()) { + final recovery = _runtime.routes.recoveryCandidate(); + final retainedOverride = + retainedVisualState != null && retainedVisualState != graphVisual; + final edge = retainedOverride + ? _runtime.edgeDirect(visual, requested) + : recovery?.edge ?? _runtime.edgeDirect(visual, requested); + if (edge != null) { + final hadStarted = + !retainedOverride && _runtime.routes.active?.edge.id == edge.id; + if (!hadStarted) { + effects.add( + _transitionStart(edge, recovery?.sequence ?? _runtime.journal.inputSequence), + ); + } + _runtime.presentation = _runtime.staticPresentation(requested); + _setVisualState(requested, effects); + effects.add(_transitionEnd(edge)); + } else { + _runtime.presentation = _runtime.staticPresentation(requested); + _setVisualState(requested, effects); + } + final settlement = _runtime.ledger.settlePending( + const GraphSettlementResolve(GraphSettlementResolveReason.staticRecovery), + ); + if (settlement != null) effects.add(settlement); + } else { + _runtime.presentation = _runtime.staticPresentation(visual); + } + _runtime.routes.clear(); + _runtime.phase = MotionGraphPhase.static; + return _runtime.record(MotionGraphOperation.recoverStatic, effects); + } + + MotionGraphResult failStatic([ + String message = 'static fallback could not be installed', + MotionGraphStaticFailureOptions options = const MotionGraphStaticFailureOptions(), + ]) { + _runtime.assertInstalled('failStatic'); + if (_runtime.readiness == MotionGraphReadiness.disposed) { + throw const MotionGraphError( + MotionGraphErrorCode.disposed, + 'disposed graph cannot fail static', + ); + } + final retainedVisualState = options.retainedVisualState; + if (retainedVisualState != null && !_runtime.hasState(retainedVisualState)) { + throw const MotionGraphError( + MotionGraphErrorCode.graphValidation, + 'retained visual state is not installed', + ); + } + final effects = []; + _changeReadiness(MotionGraphReadiness.error, effects, reason: message); + if (retainedVisualState != null) { + _runtime.visualState = retainedVisualState; + _runtime.presentation = _runtime.staticPresentation(retainedVisualState); + } + final settlement = _runtime.ledger.settlePending( + const GraphSettlementReject(GraphSettlementError.playbackFallbackError), + ); + if (settlement != null) effects.add(settlement); + _runtime.routes.clear(); + _runtime.phase = MotionGraphPhase.error; + return _runtime.record(MotionGraphOperation.failStatic, effects); + } + + MotionGraphResult request(GraphStateId target) { + final input = _runtime.journal.beginInput(); + if (!input.withinLimit) { + final standalone = _runtime.ledger.settleNew( + const GraphSettlementReject(GraphSettlementError.inputOverflowError), + ); + return _runtime.record( + MotionGraphOperation.request, + [standalone.effect], + metadata: OperationResultMetadata( + accepted: false, + joined: false, + sequence: input.sequence, + requestId: standalone.requestId, + ), + ); + } + + if (_runtime.readiness == MotionGraphReadiness.unready) { + return _rejectedRequest(target, input.sequence, GraphSettlementError.notReadyError); + } + if (_runtime.readiness == MotionGraphReadiness.disposed || + _runtime.readiness == MotionGraphReadiness.error) { + return _rejectedRequest(target, input.sequence, GraphSettlementError.abortError); + } + if (!_runtime.hasState(target)) { + return _rejectedRequest(target, input.sequence, GraphSettlementError.routeError); + } + + return _applyStateIntent(planStateIntent(_intentContext(), target), target, input.sequence); + } + + MotionGraphResult send(String event) { + final input = _runtime.journal.beginInput(); + if (!input.withinLimit || _runtime.readiness == MotionGraphReadiness.unready) { + return _runtime.record( + MotionGraphOperation.send, + const [], + metadata: OperationResultMetadata(accepted: false, sequence: input.sequence), + ); + } + if (_runtime.readiness == MotionGraphReadiness.disposed || + _runtime.readiness == MotionGraphReadiness.error) { + return _runtime.record( + MotionGraphOperation.send, + const [], + metadata: OperationResultMetadata(accepted: false, sequence: input.sequence), + ); + } + + final plan = planEventIntent(_intentContext(), event); + if (plan is EventIntentPlanReject) { + return _runtime.record( + MotionGraphOperation.send, + const [], + metadata: OperationResultMetadata(accepted: false, sequence: input.sequence), + ); + } + final effects = []; + _applyEventIntent(plan, input.sequence, effects); + return _runtime.record( + MotionGraphOperation.send, + effects, + metadata: OperationResultMetadata(accepted: true, sequence: input.sequence), + ); + } + + /// Whether `send(event)` would be accepted now, without allocating an + /// input. + bool canSend(String event) { + if (!_runtime.journal.canBeginInput() || + _runtime.readiness == MotionGraphReadiness.unready || + _runtime.readiness == MotionGraphReadiness.disposed || + _runtime.readiness == MotionGraphReadiness.error) { + return false; + } + return planEventIntent(_intentContext(), event) is! EventIntentPlanReject; + } + + /// Landings [request] would visit now, in order, or `null` when unreachable. + /// An empty plan means the target is already held. Does not advance the graph. + List? planFor(GraphStateId target) { + if (_runtime.readiness == MotionGraphReadiness.unready || + _runtime.readiness == MotionGraphReadiness.disposed || + _runtime.readiness == MotionGraphReadiness.error || + !_runtime.hasState(target)) { + return null; + } + final source = _departureState(); + if (source == null) return null; + if (source == target) return const []; + if (_runtime.edgeDirect(source, target) != null) { + return List.unmodifiable([target]); + } + final route = resolveRingRoute(_runtime.indexes(), source, target); + if (route is! RingRouteArc) return null; + if (_turnPolicy == MotionGraphTurnPolicy.direct) { + return List.unmodifiable([target]); + } + return List.unmodifiable(route.states); + } + + GraphStateId? _departureState() { + final visual = _runtime.visualState; + if (visual == null) return null; + final pending = _runtime.routes.pending; + if (pending != null) return pending.edge.to; + final followOn = _runtime.routes.followOn; + if (followOn != null) return followOn.edge.to; + final reversal = _runtime.routes.reversal; + if (reversal != null) return reversal.edge.to; + final active = _runtime.routes.active; + if (active != null) return active.edge.to; + return visual; + } + + MotionGraphResult tick(MotionGraphTickOptions options) { + _runtime.assertInstalled('tick'); + if (_runtime.readiness == MotionGraphReadiness.disposed || + _runtime.readiness == MotionGraphReadiness.error) { + throw const MotionGraphError(MotionGraphErrorCode.disposed, 'terminated graph cannot tick'); + } + _runtime.journal.beginTick(options.contentOrdinal); + final effects = []; + final routeReady = options.routeReady ?? true; + + switch (_runtime.phase) { + case MotionGraphPhase.preparing: + case MotionGraphPhase.static: + break; + case MotionGraphPhase.intro: + _tickIntro(); + break; + case MotionGraphPhase.stable: + _tickStable(routeReady, effects); + break; + case MotionGraphPhase.waiting: + _tickWaiting(routeReady, effects); + break; + case MotionGraphPhase.locked: + _tickLocked(effects); + break; + case MotionGraphPhase.reversible: + _tickReversible(effects); + break; + case MotionGraphPhase.unready: + case MotionGraphPhase.disposed: + case MotionGraphPhase.error: + throw const MotionGraphError(MotionGraphErrorCode.notReady, 'graph is not tickable'); + } + _runtime.journal.completeTick(); + return _runtime.record(MotionGraphOperation.tick, effects); + } + + /// Runs the exact tick reducer and rolls every mutation back before + /// return. The immutable result can be used to prepare media; only [tick] + /// commits it. + MotionGraphResult previewTick(MotionGraphTickOptions options) { + final checkpoint = _runtime.checkpoint(); + try { + return tick(options); + } finally { + _runtime.restore(checkpoint); + } + } + + MotionGraphResult dispose([ + MotionGraphDisposeOptions options = const MotionGraphDisposeOptions(), + ]) { + if (_runtime.readiness == MotionGraphReadiness.disposed) { + return _runtime.record(MotionGraphOperation.dispose, const []); + } + final retainedVisualState = options.retainedVisualState; + if (retainedVisualState != null && !_runtime.hasState(retainedVisualState)) { + throw const MotionGraphError( + MotionGraphErrorCode.graphValidation, + 'retained visual state is not installed', + ); + } + if (retainedVisualState != null) { + _runtime.visualState = retainedVisualState; + } + final effects = []; + final settlement = _runtime.ledger.settlePending( + const GraphSettlementReject(GraphSettlementError.abortError), + ); + if (settlement != null) effects.add(settlement); + _changeReadiness(MotionGraphReadiness.disposed, effects); + _runtime.phase = MotionGraphPhase.disposed; + _runtime.presentation = null; + _runtime.routes.clear(); + return _runtime.record(MotionGraphOperation.dispose, effects); + } + + MotionGraphSnapshot snapshot() => _runtime.snapshot(); + + List getTrace() => _runtime.getTrace(); + + MotionGraphResult _applyStateIntent( + StateIntentPlan plan, + GraphStateId target, + int sequence, + ) { + if (plan is StateIntentPlanReject) { + return _rejectedRequest(target, sequence, GraphSettlementError.routeError); + } + if (plan is StateIntentPlanStandaloneNoop) { + return _noopRequest(sequence); + } + + final effects = []; + final admission = _runtime.ledger.request(target); + if (plan is StateIntentPlanJoinPending) { + return _acceptedRequest(admission, sequence, effects); + } + + _setRequestedState(target, sequence, effects); + _appendSuperseded(admission, effects); + + if (plan is StateIntentPlanCancelBeforeStable || plan is StateIntentPlanCancelPending) { + _runtime.routes.cancelPending(); + if (plan is StateIntentPlanCancelPending) _runtime.phase = MotionGraphPhase.stable; + final settled = _runtime.ledger.settlePending( + const GraphSettlementResolve(GraphSettlementResolveReason.stableNoop), + ); + if (settled != null) effects.add(settled); + return _acceptedRequest(admission, sequence, effects, joined: false); + } + + if (plan is StateIntentPlanReplacePending) { + _runtime.routes.replacePending(plan.edge, sequence); + if (_runtime.phase != MotionGraphPhase.preparing && _runtime.phase != MotionGraphPhase.intro) { + _runtime.phase = MotionGraphPhase.waiting; + } + } else if (plan is StateIntentPlanContinueActiveTarget) { + _runtime.routes.clearFollowOn(); + _runtime.routes.clearReversal(); + } else if (plan is StateIntentPlanContinueReversalTarget) { + _runtime.routes.clearFollowOn(); + } else if (plan is StateIntentPlanQueueReversal) { + _runtime.routes.queueReversal(plan.edge, sequence); + } else if (plan is StateIntentPlanQueueFollowOn) { + _runtime.routes.queueFollowOn(plan.edge, sequence); + } else if (plan is StateIntentPlanStaticCommit) { + _commitStaticEdge(plan.edge, sequence, effects, false); + } + return _acceptedRequest(admission, sequence, effects); + } + + void _applyEventIntent( + EventIntentPlan plan, + int sequence, + List effects, + ) { + if (plan is EventIntentPlanAcceptNoop) return; + + if (plan is EventIntentPlanCancelPending) { + _setRequestedState(plan.edge.to, sequence, effects); + _abortPendingForEvent(effects); + _runtime.routes.cancelPending(); + if (_runtime.phase == MotionGraphPhase.waiting) _runtime.phase = MotionGraphPhase.stable; + return; + } + + final GraphEdgeDefinition edge; + if (plan is EventIntentPlanReplacePending) { + edge = plan.edge; + } else if (plan is EventIntentPlanContinueActiveTarget) { + edge = plan.edge; + } else if (plan is EventIntentPlanQueueReversal) { + edge = plan.edge; + } else if (plan is EventIntentPlanQueueFollowOn) { + edge = plan.edge; + } else if (plan is EventIntentPlanStaticCommit) { + edge = plan.edge; + } else { + // EventIntentPlanReject: unreachable — send() filters it out first. + return; + } + + _setRequestedState(edge.to, sequence, effects); + _abortPendingForEvent(effects); + + if (plan is EventIntentPlanReplacePending) { + _runtime.routes.replacePending(edge, sequence); + if (_runtime.phase != MotionGraphPhase.preparing && _runtime.phase != MotionGraphPhase.intro) { + _runtime.phase = MotionGraphPhase.waiting; + } + } else if (plan is EventIntentPlanContinueActiveTarget) { + _runtime.routes.clearFollowOn(); + _runtime.routes.clearReversal(); + } else if (plan is EventIntentPlanQueueReversal) { + _runtime.routes.queueReversal(edge, sequence); + } else if (plan is EventIntentPlanQueueFollowOn) { + _runtime.routes.queueFollowOn(edge, sequence); + } else if (plan is EventIntentPlanStaticCommit) { + _commitStaticEdge(edge, sequence, effects, false); + } + } + + MotionGraphResult _acceptedRequest( + RequestAdmission admission, + int sequence, + List effects, { + bool? joined, + }) { + return _runtime.record( + MotionGraphOperation.request, + effects, + metadata: OperationResultMetadata( + accepted: true, + joined: joined ?? admission.joined, + sequence: sequence, + requestId: admission.requestId, + ), + ); + } + + IntentContext _intentContext() { + final phase = _runtime.phase; + if (phase == MotionGraphPhase.unready || + phase == MotionGraphPhase.disposed || + phase == MotionGraphPhase.error) { + throw StateError('phase ${phase.name} cannot route intent'); + } + return IntentContext( + phase: phase, + visualState: _runtime.requireVisualState(), + routes: _runtime.routes, + indexes: _runtime.indexes(), + hasPendingRequests: _runtime.ledger.pendingRequestCount > 0, + ); + } + + void _tickIntro() { + final presentation = _runtime.presentation; + if (presentation is! GraphPresentationIntro) { + throw StateError('intro phase has no intro presentation'); + } + final state = _runtime.state(presentation.state); + final initial = state.initialUnit; + if (initial == null) throw StateError('intro state has no initial unit'); + if (presentation.frameIndex + 1 < initial.frameCount) { + _runtime.presentation = GraphPresentationIntro( + state: presentation.state, + unitId: presentation.unitId, + frameIndex: presentation.frameIndex + 1, + ); + return; + } + _runtime.presentation = _runtime.bodyPresentation(state.id, 0); + // Consumption is a graph-timeline decision at the authored join. Hosts + // that fail to draw this result recover through their static-failure + // lane; they do not partially rewind an already committed graph tick. + _runtime.initialUnitPending = false; + _runtime.phase = + _runtime.routes.pending == null ? MotionGraphPhase.stable : MotionGraphPhase.waiting; + } + + void _tickStable(bool routeReady, List effects) { + final presentation = _runtime.bodyPresentationOrThrow(); + final completion = _runtime.indexes().completionEdgesByState[presentation.state]; + final state = _runtime.state(presentation.state); + if (completion != null && + presentation.frameIndex == state.body.frameCount - 1 && + (routeReady || completion.start is GraphStartPolicyCut)) { + final sequence = _runtime.journal.allocateInternalSequence(); + _setRequestedState(completion.to, sequence, effects); + _runtime.journal.incrementRouteOperations(); + _startEdge(completion, sequence, effects); + return; + } + final next = nextBodyFrame(state.body, presentation.frameIndex); + _runtime.presentation = _runtime.bodyPresentation(state.id, next.frameIndex); + } + + void _tickWaiting(bool routeReady, List effects) { + final pending = _runtime.requirePendingRoute(); + final edge = pending.edge; + final presentation = _runtime.bodyPresentationOrThrow(); + final state = _runtime.state(presentation.state); + if (edge.from != state.id) { + throw StateError('pending edge source does not match body presentation'); + } + + final start = edge.start; + if (start is GraphStartPolicyCut) { + _runtime.journal.incrementRouteOperations(); + _startEdge(edge, pending.sequence, effects); + return; + } + + final boundary = start is GraphStartPolicyPortal + ? findNextPortalBoundary(state.body, start.sourcePort, presentation.frameIndex) + : findFinishBoundary(state.body, presentation.frameIndex); + + if (boundary.eligibleNow && routeReady) { + _runtime.journal.incrementRouteOperations(); + _startEdge(edge, pending.sequence, effects); + return; + } + + final next = nextBodyFrame(state.body, presentation.frameIndex); + _runtime.presentation = _runtime.bodyPresentation(state.id, next.frameIndex); + } + + void _tickLocked(List effects) { + final edge = _runtime.requireActiveRoute().edge; + final transition = edge.transition; + final presentation = _runtime.presentation; + if (transition is! GraphTransitionLocked || presentation is! GraphPresentationLocked) { + throw StateError('locked phase has inconsistent transition state'); + } + if (presentation.frameIndex + 1 < transition.frameCount) { + _runtime.presentation = GraphPresentationLocked( + edgeId: presentation.edgeId, + unitId: presentation.unitId, + frameIndex: presentation.frameIndex + 1, + ); + return; + } + _commitActiveEdge(edge, effects); + } + + void _tickReversible(List effects) { + var active = _runtime.requireActiveRoute(); + var edge = active.edge; + final presentation = _runtime.presentation; + if (presentation is! GraphPresentationReversible) { + throw StateError('reversible phase has no reversible presentation'); + } + + if (_runtime.routes.reversal != null) { + active = _runtime.routes.activateReversal(); + edge = active.edge; + effects.add(_transitionStart(edge, active.sequence)); + } + + final transition = edge.transition; + if (transition is! GraphTransitionReversible) { + throw StateError('active reversible edge has no reversible transition'); + } + final next = transition.direction == TransitionDirection.forward + ? presentation.frameIndex + 1 + : presentation.frameIndex - 1; + if (next < 0 || next >= transition.frameCount) { + _commitActiveEdge(edge, effects); + return; + } + _runtime.presentation = GraphPresentationReversible( + edgeId: edge.id, + unitId: transition.unitId, + frameIndex: next, + direction: transition.direction, + ); + } + + void _startEdge(GraphEdgeDefinition edge, int sequence, List effects) { + _runtime.routes.activate(edge, sequence); + effects.add(_transitionStart(edge, sequence)); + final transition = edge.transition; + if (transition == null) { + _commitActiveEdge(edge, effects); + return; + } + if (transition is GraphTransitionLocked) { + _runtime.phase = MotionGraphPhase.locked; + _runtime.presentation = + GraphPresentationLocked(edgeId: edge.id, unitId: transition.unitId, frameIndex: 0); + return; + } + final reversible = transition as GraphTransitionReversible; + _runtime.phase = MotionGraphPhase.reversible; + _runtime.presentation = GraphPresentationReversible( + edgeId: edge.id, + unitId: reversible.unitId, + frameIndex: reversible.direction == TransitionDirection.forward + ? 0 + : reversible.frameCount - 1, + direction: reversible.direction, + ); + } + + void _commitActiveEdge(GraphEdgeDefinition edge, List effects) { + _runtime.presentation = _runtime.bodyPresentation(edge.to, 0); + _setVisualState(edge.to, effects); + effects.add(_transitionEnd(edge)); + final completion = _runtime.routes.completeActive(); + + if (completion.promoted != null) { + _runtime.phase = MotionGraphPhase.waiting; + return; + } + + _runtime.phase = MotionGraphPhase.stable; + if (_runtime.requestedState == _runtime.visualState) { + final settlement = _runtime.ledger.settlePending( + const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted), + ); + if (settlement != null) effects.add(settlement); + } + } + + void _commitStaticEdge( + GraphEdgeDefinition edge, + int sequence, + List effects, + bool preparationCommit, + ) { + effects.add(_transitionStart(edge, sequence)); + _runtime.presentation = _runtime.staticPresentation(edge.to); + _setVisualState(edge.to, effects); + effects.add(_transitionEnd(edge)); + final settlement = _runtime.ledger.settlePending( + GraphSettlementResolve( + preparationCommit + ? GraphSettlementResolveReason.staticRecovery + : GraphSettlementResolveReason.targetCommitted, + ), + ); + if (settlement != null) effects.add(settlement); + _runtime.routes.clear(); + _runtime.phase = MotionGraphPhase.static; + } + + void _setRequestedState(GraphStateId target, int sequence, List effects) { + final previous = _runtime.requireRequestedState(); + if (previous == target) return; + _runtime.requestedState = target; + effects.add( + MotionGraphEffectRequestedStateChange(from: previous, to: target, sequence: sequence), + ); + } + + void _setVisualState(GraphStateId target, List effects) { + if (_runtime.readiness == MotionGraphReadiness.static && + target != _runtime.definition().initialState) { + // A deliberate static-state commit must not leave an intro armed to + // replay later if the host returns to the initial state before + // re-entry. + _runtime.initialUnitPending = false; + } + final previous = _runtime.requireVisualState(); + if (previous == target) return; + _runtime.visualState = target; + effects.add(MotionGraphEffectVisualStateChange(from: previous, to: target)); + } + + MotionGraphEffect _transitionStart(GraphEdgeDefinition edge, int sequence) { + return MotionGraphEffectTransitionStart( + edgeId: edge.id, + from: edge.from, + to: edge.to, + sequence: sequence, + ); + } + + MotionGraphEffect _transitionEnd(GraphEdgeDefinition edge) { + return MotionGraphEffectTransitionEnd(edgeId: edge.id, from: edge.from, to: edge.to); + } + + MotionGraphResult _noopRequest(int sequence) { + final standalone = _runtime.ledger.settleNew( + const GraphSettlementResolve(GraphSettlementResolveReason.stableNoop), + ); + return _runtime.record( + MotionGraphOperation.request, + [standalone.effect], + metadata: OperationResultMetadata( + accepted: true, + joined: false, + sequence: sequence, + requestId: standalone.requestId, + ), + ); + } + + MotionGraphResult _rejectedRequest( + GraphStateId target, + int sequence, + GraphSettlementError error, + ) { + final standalone = _runtime.ledger.settleNew(GraphSettlementReject(error)); + return _runtime.record( + MotionGraphOperation.request, + [standalone.effect], + metadata: OperationResultMetadata( + accepted: false, + joined: false, + sequence: sequence, + requestId: standalone.requestId, + ), + ); + } + + void _appendSuperseded(RequestAdmission admission, List effects) { + final superseded = admission.superseded; + if (superseded != null) effects.add(superseded); + } + + void _abortPendingForEvent(List effects) { + final settlement = _runtime.ledger.settlePending( + const GraphSettlementReject(GraphSettlementError.abortError), + ); + if (settlement != null) effects.add(settlement); + } + + void _changeReadiness( + MotionGraphReadiness next, + List effects, { + String? reason, + }) { + final previous = _runtime.readiness; + if (previous == next) return; + _runtime.readiness = next; + effects.add(MotionGraphEffectReadinessChange(from: previous, to: next, reason: reason)); + } +} diff --git a/flutter/packages/aval_graph/lib/src/engine_state.dart b/flutter/packages/aval_graph/lib/src/engine_state.dart new file mode 100644 index 0000000..58de8f1 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/engine_state.dart @@ -0,0 +1,235 @@ +/// Package-private mechanical storage for the canonical graph reducer, +/// ported from `packages/graph/src/engine-state.ts`. +library; + +import 'errors.dart'; +import 'model.dart'; +import 'operation_journal.dart'; +import 'request_ledger.dart'; +import 'route_plan.dart'; +import 'validate.dart'; + +class MotionGraphEngineCheckpoint { + const MotionGraphEngineCheckpoint({ + required this.readiness, + required this.phase, + required this.initialUnitPending, + required this.requestedState, + required this.visualState, + required this.presentation, + required this.ledger, + required this.journal, + required this.routes, + }); + + final MotionGraphReadiness readiness; + final MotionGraphPhase phase; + final bool initialUnitPending; + final GraphStateId? requestedState; + final GraphStateId? visualState; + final GraphPresentation? presentation; + final RequestLedgerCheckpoint ledger; + final OperationJournalCheckpoint journal; + final RoutePlanCheckpoint routes; +} + +/// Package-private mechanical storage for the canonical graph reducer. +class MotionGraphEngineState { + final RequestLedger ledger = RequestLedger(); + final OperationJournal journal = OperationJournal(); + final RoutePlan routes = RoutePlan(); + + MotionGraphReadiness readiness = MotionGraphReadiness.unready; + MotionGraphPhase phase = MotionGraphPhase.unready; + bool initialUnitPending = false; + GraphStateId? requestedState; + GraphStateId? visualState; + GraphPresentation? presentation; + + ValidatedMotionGraph? _graph; + ValidatedGraphIndexes? _indexes; + + /// Accepts either a raw, untrusted definition (validated here) or an + /// already-[ValidatedMotionGraph]. Dart's runtime type system lets this + /// simply check `is ValidatedMotionGraph`, unlike the TypeScript original's + /// `isValidatedGraph` duck-typing helper, which had to distinguish the two + /// shapes by hand because both are plain objects at runtime there. + GraphStateId installMetadata(Object? definition) { + final graph = definition is ValidatedMotionGraph + ? definition + : validateMotionGraphDefinition(definition); + _graph = graph; + final indexes = getValidatedGraphIndexes(graph); + _indexes = indexes; + initialUnitPending = + indexes.statesById[graph.definition.initialState]?.initialUnit != null; + return graph.definition.initialState; + } + + MotionGraphSnapshot snapshot() { + final currentPresentation = presentation; + return MotionGraphSnapshot( + readiness: readiness, + phase: phase, + initialUnitPending: initialUnitPending, + requestedState: requestedState, + visualState: visualState, + prospectiveState: routes.prospectiveState(visualState), + isTransitioning: _isTransitioning(), + presentation: presentation, + pendingEdgeId: routes.pending?.edge.id, + activeEdgeId: routes.active?.edge.id, + followOnEdgeId: routes.followOn?.edge.id, + direction: currentPresentation is GraphPresentationReversible + ? currentPresentation.direction + : null, + contentOrdinal: journal.contentOrdinal, + inputSequence: journal.inputSequence, + pendingRequestCount: ledger.pendingRequestCount, + inputsSinceTick: journal.inputsSinceTick, + routeOperationsLastTick: journal.routeOperationsLastTick, + ); + } + + MotionGraphEngineCheckpoint checkpoint() { + return MotionGraphEngineCheckpoint( + readiness: readiness, + phase: phase, + initialUnitPending: initialUnitPending, + requestedState: requestedState, + visualState: visualState, + presentation: presentation, + ledger: ledger.checkpoint(), + journal: journal.checkpoint(), + routes: routes.checkpoint(), + ); + } + + void restore(MotionGraphEngineCheckpoint checkpoint) { + readiness = checkpoint.readiness; + phase = checkpoint.phase; + initialUnitPending = checkpoint.initialUnitPending; + requestedState = checkpoint.requestedState; + visualState = checkpoint.visualState; + presentation = checkpoint.presentation; + ledger.restore(checkpoint.ledger); + journal.restore(checkpoint.journal); + routes.restore(checkpoint.routes); + } + + MotionGraphResult record( + MotionGraphOperation operation, + List effects, { + OperationResultMetadata metadata = const OperationResultMetadata(), + }) { + return journal.record(CompletedOperation( + operation: operation, + metadata: metadata, + presentation: presentation, + effects: effects, + snapshot: snapshot(), + )); + } + + List getTrace() => journal.getTrace(); + + GraphPresentationBody bodyPresentation(GraphStateId stateId, int frameIndex) { + final resolved = state(stateId); + return GraphPresentationBody( + state: stateId, + unitId: resolved.body.unitId, + frameIndex: frameIndex, + ); + } + + GraphPresentationStatic staticPresentation(GraphStateId stateId) { + return GraphPresentationStatic(state: stateId); + } + + GraphPresentationBody bodyPresentationOrThrow() { + final current = presentation; + if (current is! GraphPresentationBody) { + throw StateError('graph phase requires a body presentation'); + } + return current; + } + + SequencedEdge requirePendingRoute() { + final pending = routes.pending; + if (pending == null) throw StateError('graph has no pending edge'); + return pending; + } + + SequencedEdge requireActiveRoute() { + final active = routes.active; + if (active == null) throw StateError('graph has no active edge'); + return active; + } + + GraphEdgeDefinition? edgeDirect(GraphStateId from, GraphStateId to) { + return indexes().directEdgesByState[from]?[to]; + } + + GraphStateDefinition state(GraphStateId id) { + final found = indexes().statesById[id]; + if (found == null) { + throw StateError('validated graph has no state $id'); + } + return found; + } + + bool hasState(GraphStateId id) => indexes().statesById.containsKey(id); + + MotionGraphDefinition definition() { + final graph = _graph; + if (graph == null) throw StateError('graph metadata is unavailable'); + return graph.definition; + } + + ValidatedGraphIndexes indexes() { + final found = _indexes; + if (found == null) throw StateError('graph indexes are unavailable'); + return found; + } + + GraphStateId requireVisualState() { + final found = visualState; + if (found == null) throw StateError('visual state is unavailable'); + return found; + } + + GraphStateId requireRequestedState() { + final found = requestedState; + if (found == null) throw StateError('requested state is unavailable'); + return found; + } + + void assertInstalled(String operation) { + if (_graph == null) { + throw MotionGraphError( + MotionGraphErrorCode.notReady, + '$operation requires graph metadata', + ); + } + } + + void assertPhase(MotionGraphPhase expected, String operation) { + assertInstalled(operation); + if (phase != expected) { + throw MotionGraphError( + MotionGraphErrorCode.notReady, + '$operation requires phase ${expected.name}, not ${phase.name}', + ); + } + } + + bool _isTransitioning() { + if (phase == MotionGraphPhase.disposed || phase == MotionGraphPhase.error) { + return false; + } + return phase == MotionGraphPhase.waiting || + phase == MotionGraphPhase.locked || + phase == MotionGraphPhase.reversible || + requestedState != visualState; + } +} diff --git a/flutter/packages/aval_graph/lib/src/errors.dart b/flutter/packages/aval_graph/lib/src/errors.dart new file mode 100644 index 0000000..b0d2119 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/errors.dart @@ -0,0 +1,48 @@ +/// Machine-readable failure classification for [MotionGraphError]. +/// +/// Mirrors the TypeScript `MotionGraphErrorCode` string union. Each value +/// exposes [wireValue], the exact SCREAMING_SNAKE_CASE string used by the +/// original TypeScript package, for hosts that log or match on the code. +enum MotionGraphErrorCode { + graphValidation('GRAPH_VALIDATION'), + notReady('NOT_READY'), + routeNotFound('ROUTE_NOT_FOUND'), + inputOverflow('INPUT_OVERFLOW'), + nonConsecutiveTick('NON_CONSECUTIVE_TICK'), + playbackFallback('PLAYBACK_FALLBACK'), + disposed('DISPOSED'); + + const MotionGraphErrorCode(this.wireValue); + + /// The original TypeScript literal string for this code. + final String wireValue; +} + +/// Base error type thrown by the graph engine and its validator. +/// +/// Mirrors the TypeScript `MotionGraphError` class. Dart exceptions are not +/// caught by default the way JavaScript errors can propagate uncaught, so +/// this implements [Exception] (rather than extending [Error]) to signal +/// that callers are expected to catch and handle it. +class MotionGraphError implements Exception { + const MotionGraphError(this.code, this.message, {this.cause}); + + final MotionGraphErrorCode code; + final String message; + final Object? cause; + + @override + String toString() => 'MotionGraphError(${code.wireValue}): $message'; +} + +/// Thrown when an untrusted graph definition fails structural validation. +/// +/// Mirrors the TypeScript `MotionGraphValidationError` class, which always +/// carries the `GRAPH_VALIDATION` code. +class MotionGraphValidationError extends MotionGraphError { + const MotionGraphValidationError(String message, {Object? cause}) + : super(MotionGraphErrorCode.graphValidation, message, cause: cause); + + @override + String toString() => 'MotionGraphValidationError: $message'; +} diff --git a/flutter/packages/aval_graph/lib/src/intent_router.dart b/flutter/packages/aval_graph/lib/src/intent_router.dart new file mode 100644 index 0000000..3f981fc --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/intent_router.dart @@ -0,0 +1,552 @@ +/// Pure intent resolution, ported from +/// `packages/graph/src/intent-router.ts`. +/// +/// Every function here only reads [IntentContext]; none of them mutate +/// routes, effects, or request groups. The engine applies the returned plan. +library; + +import 'model.dart'; +import 'ring_plan.dart'; +import 'route_plan.dart'; +import 'validate.dart'; + +/// Read-only view the router needs to resolve one request or event. +/// +/// [phase] must never be `unready`, `disposed`, or `error` — the engine only +/// constructs an [IntentContext] after checking that itself, matching the +/// TypeScript `RoutablePhase` exclusion type. +class IntentContext { + const IntentContext({ + required this.phase, + required this.visualState, + required this.routes, + required this.indexes, + required this.hasPendingRequests, + this.turnInFlight = false, + }); + + final MotionGraphPhase phase; + final GraphStateId visualState; + final RoutePlanView routes; + final ValidatedGraphIndexes indexes; + final bool hasPendingRequests; + + /// Whether a chained turn is in flight (makes pending routes provisional). + final bool turnInFlight; +} + +/// The steps a chained turn still owes after the routed edge. +class TurnChainPlan { + const TurnChainPlan({ + required this.ring, + required this.after, + required this.remaining, + }); + + final GraphRingId ring; + + /// Edge the remainder continues from. + final GraphEdgeId after; + final List remaining; +} + +sealed class StateIntentPlan { + const StateIntentPlan(); +} + +class StateIntentPlanReject extends StateIntentPlan { + const StateIntentPlanReject(); + + @override + bool operator ==(Object other) => other is StateIntentPlanReject; + + @override + int get hashCode => (StateIntentPlanReject).hashCode; + + @override + String toString() => 'StateIntentPlan.reject()'; +} + +class StateIntentPlanStandaloneNoop extends StateIntentPlan { + const StateIntentPlanStandaloneNoop(); + + @override + bool operator ==(Object other) => other is StateIntentPlanStandaloneNoop; + + @override + int get hashCode => (StateIntentPlanStandaloneNoop).hashCode; + + @override + String toString() => 'StateIntentPlan.standaloneNoop()'; +} + +class StateIntentPlanCancelBeforeStable extends StateIntentPlan { + const StateIntentPlanCancelBeforeStable(); + + @override + bool operator ==(Object other) => other is StateIntentPlanCancelBeforeStable; + + @override + int get hashCode => (StateIntentPlanCancelBeforeStable).hashCode; + + @override + String toString() => 'StateIntentPlan.cancelBeforeStable()'; +} + +class StateIntentPlanJoinPending extends StateIntentPlan { + const StateIntentPlanJoinPending(); + + @override + bool operator ==(Object other) => other is StateIntentPlanJoinPending; + + @override + int get hashCode => (StateIntentPlanJoinPending).hashCode; + + @override + String toString() => 'StateIntentPlan.joinPending()'; +} + +class StateIntentPlanCancelPending extends StateIntentPlan { + const StateIntentPlanCancelPending(); + + @override + bool operator ==(Object other) => other is StateIntentPlanCancelPending; + + @override + int get hashCode => (StateIntentPlanCancelPending).hashCode; + + @override + String toString() => 'StateIntentPlan.cancelPending()'; +} + +class StateIntentPlanReplacePending extends StateIntentPlan { + const StateIntentPlanReplacePending(this.edge, {this.turn}); + + final GraphEdgeDefinition edge; + final TurnChainPlan? turn; + + @override + bool operator ==(Object other) => + other is StateIntentPlanReplacePending && + other.edge == edge && + other.turn == turn; + + @override + int get hashCode => Object.hash(StateIntentPlanReplacePending, edge, turn); + + @override + String toString() => 'StateIntentPlan.replacePending(edge: ${edge.id})'; +} + +class StateIntentPlanContinueActiveTarget extends StateIntentPlan { + const StateIntentPlanContinueActiveTarget(); + + @override + bool operator ==(Object other) => other is StateIntentPlanContinueActiveTarget; + + @override + int get hashCode => (StateIntentPlanContinueActiveTarget).hashCode; + + @override + String toString() => 'StateIntentPlan.continueActiveTarget()'; +} + +class StateIntentPlanContinueReversalTarget extends StateIntentPlan { + const StateIntentPlanContinueReversalTarget(); + + @override + bool operator ==(Object other) => other is StateIntentPlanContinueReversalTarget; + + @override + int get hashCode => (StateIntentPlanContinueReversalTarget).hashCode; + + @override + String toString() => 'StateIntentPlan.continueReversalTarget()'; +} + +class StateIntentPlanQueueReversal extends StateIntentPlan { + const StateIntentPlanQueueReversal(this.edge); + + final GraphEdgeDefinition edge; + + @override + bool operator ==(Object other) => + other is StateIntentPlanQueueReversal && other.edge == edge; + + @override + int get hashCode => Object.hash(StateIntentPlanQueueReversal, edge); + + @override + String toString() => 'StateIntentPlan.queueReversal(edge: ${edge.id})'; +} + +class StateIntentPlanQueueFollowOn extends StateIntentPlan { + const StateIntentPlanQueueFollowOn(this.edge, {this.turn}); + + final GraphEdgeDefinition edge; + final TurnChainPlan? turn; + + @override + bool operator ==(Object other) => + other is StateIntentPlanQueueFollowOn && + other.edge == edge && + other.turn == turn; + + @override + int get hashCode => Object.hash(StateIntentPlanQueueFollowOn, edge, turn); + + @override + String toString() => 'StateIntentPlan.queueFollowOn(edge: ${edge.id})'; +} + +class StateIntentPlanStaticCommit extends StateIntentPlan { + const StateIntentPlanStaticCommit(this.edge); + + final GraphEdgeDefinition edge; + + @override + bool operator ==(Object other) => + other is StateIntentPlanStaticCommit && other.edge == edge; + + @override + int get hashCode => Object.hash(StateIntentPlanStaticCommit, edge); + + @override + String toString() => 'StateIntentPlan.staticCommit(edge: ${edge.id})'; +} + +sealed class EventIntentPlan { + const EventIntentPlan(); +} + +class EventIntentPlanReject extends EventIntentPlan { + const EventIntentPlanReject(); + + @override + bool operator ==(Object other) => other is EventIntentPlanReject; + + @override + int get hashCode => (EventIntentPlanReject).hashCode; + + @override + String toString() => 'EventIntentPlan.reject()'; +} + +class EventIntentPlanAcceptNoop extends EventIntentPlan { + const EventIntentPlanAcceptNoop(); + + @override + bool operator ==(Object other) => other is EventIntentPlanAcceptNoop; + + @override + int get hashCode => (EventIntentPlanAcceptNoop).hashCode; + + @override + String toString() => 'EventIntentPlan.acceptNoop()'; +} + +class EventIntentPlanCancelPending extends EventIntentPlan { + const EventIntentPlanCancelPending(this.edge); + + final GraphEdgeDefinition edge; + + @override + bool operator ==(Object other) => + other is EventIntentPlanCancelPending && other.edge == edge; + + @override + int get hashCode => Object.hash(EventIntentPlanCancelPending, edge); + + @override + String toString() => 'EventIntentPlan.cancelPending(edge: ${edge.id})'; +} + +class EventIntentPlanReplacePending extends EventIntentPlan { + const EventIntentPlanReplacePending(this.edge); + + final GraphEdgeDefinition edge; + + @override + bool operator ==(Object other) => + other is EventIntentPlanReplacePending && other.edge == edge; + + @override + int get hashCode => Object.hash(EventIntentPlanReplacePending, edge); + + @override + String toString() => 'EventIntentPlan.replacePending(edge: ${edge.id})'; +} + +class EventIntentPlanContinueActiveTarget extends EventIntentPlan { + const EventIntentPlanContinueActiveTarget(this.edge); + + final GraphEdgeDefinition edge; + + @override + bool operator ==(Object other) => + other is EventIntentPlanContinueActiveTarget && other.edge == edge; + + @override + int get hashCode => Object.hash(EventIntentPlanContinueActiveTarget, edge); + + @override + String toString() => 'EventIntentPlan.continueActiveTarget(edge: ${edge.id})'; +} + +class EventIntentPlanQueueReversal extends EventIntentPlan { + const EventIntentPlanQueueReversal(this.edge); + + final GraphEdgeDefinition edge; + + @override + bool operator ==(Object other) => + other is EventIntentPlanQueueReversal && other.edge == edge; + + @override + int get hashCode => Object.hash(EventIntentPlanQueueReversal, edge); + + @override + String toString() => 'EventIntentPlan.queueReversal(edge: ${edge.id})'; +} + +class EventIntentPlanQueueFollowOn extends EventIntentPlan { + const EventIntentPlanQueueFollowOn(this.edge); + + final GraphEdgeDefinition edge; + + @override + bool operator ==(Object other) => + other is EventIntentPlanQueueFollowOn && other.edge == edge; + + @override + int get hashCode => Object.hash(EventIntentPlanQueueFollowOn, edge); + + @override + String toString() => 'EventIntentPlan.queueFollowOn(edge: ${edge.id})'; +} + +class EventIntentPlanStaticCommit extends EventIntentPlan { + const EventIntentPlanStaticCommit(this.edge); + + final GraphEdgeDefinition edge; + + @override + bool operator ==(Object other) => + other is EventIntentPlanStaticCommit && other.edge == edge; + + @override + int get hashCode => Object.hash(EventIntentPlanStaticCommit, edge); + + @override + String toString() => 'EventIntentPlan.staticCommit(edge: ${edge.id})'; +} + +/// Decide state intent without mutating routes, effects, or request groups. +StateIntentPlan planStateIntent(IntentContext context, GraphStateId target) { + final phase = context.phase; + final visualState = context.visualState; + + if (phase == MotionGraphPhase.preparing || phase == MotionGraphPhase.intro) { + if (target == visualState) { + return context.routes.pending != null || context.hasPendingRequests + ? const StateIntentPlanCancelBeforeStable() + : const StateIntentPlanStandaloneNoop(); + } + return _pendingOrReject(context, visualState, target); + } + + if (phase == MotionGraphPhase.stable) { + if (target == visualState) return const StateIntentPlanStandaloneNoop(); + return _pendingOrReject(context, visualState, target); + } + + if (phase == MotionGraphPhase.waiting) { + final pending = _requireSlot(context.routes.pending, 'waiting pending edge'); + if (target == pending.edge.to) return const StateIntentPlanJoinPending(); + if (target == visualState) return const StateIntentPlanCancelPending(); + return _pendingOrReject(context, visualState, target); + } + + if (phase == MotionGraphPhase.static) { + if (target == visualState) return const StateIntentPlanStandaloneNoop(); + final edge = _directEdge(context.indexes, visualState, target); + return edge == null + ? const StateIntentPlanReject() + : StateIntentPlanStaticCommit(edge); + } + + final active = _requireSlot(context.routes.active, 'active transition edge'); + final effective = context.routes.reversal ?? active; + if (target == active.edge.to) { + return const StateIntentPlanContinueActiveTarget(); + } + if (target == effective.edge.to) { + return const StateIntentPlanContinueReversalTarget(); + } + if (phase == MotionGraphPhase.reversible) { + final inverse = _inverseEdge(context.indexes, active.edge); + if (inverse != null && target == inverse.to) { + return StateIntentPlanQueueReversal(inverse); + } + } + final follow = _pendingTurnOrDirect(context, effective.edge.to, target); + return follow == null + ? const StateIntentPlanReject() + : StateIntentPlanQueueFollowOn(follow.edge, turn: follow.turn); +} + +/// Resolve and decide an event without mutating semantic state. +EventIntentPlan planEventIntent(IntentContext context, String event) { + final edge = _resolveEventEdge(context, event); + if (edge == null) return const EventIntentPlanReject(); + + if (context.phase == MotionGraphPhase.static) { + return EventIntentPlanStaticCommit(edge); + } + if ((context.phase == MotionGraphPhase.preparing || + context.phase == MotionGraphPhase.intro || + context.phase == MotionGraphPhase.waiting) && + context.routes.pending != null && + edge.to == context.visualState) { + return EventIntentPlanCancelPending(edge); + } + if (context.phase == MotionGraphPhase.waiting) { + final pending = _requireSlot(context.routes.pending, 'waiting pending edge'); + if (edge.id == pending.edge.id) return const EventIntentPlanAcceptNoop(); + } + + if (context.phase == MotionGraphPhase.locked || + context.phase == MotionGraphPhase.reversible) { + final active = _requireSlot(context.routes.active, 'active transition edge'); + final effective = context.routes.reversal ?? active; + if (edge.id == effective.edge.id && context.routes.followOn == null) { + return const EventIntentPlanAcceptNoop(); + } + if (edge.id == active.edge.id) { + return EventIntentPlanContinueActiveTarget(edge); + } + final inverse = context.phase == MotionGraphPhase.reversible + ? _inverseEdge(context.indexes, active.edge) + : null; + if (inverse?.id == edge.id) { + return EventIntentPlanQueueReversal(edge); + } + if (edge.id == context.routes.followOn?.edge.id) { + return const EventIntentPlanAcceptNoop(); + } + return EventIntentPlanQueueFollowOn(edge); + } + + if ((context.phase == MotionGraphPhase.preparing || + context.phase == MotionGraphPhase.intro) && + edge.id == context.routes.pending?.edge.id) { + return const EventIntentPlanAcceptNoop(); + } + return EventIntentPlanReplacePending(edge); +} + +GraphEdgeDefinition? _resolveEventEdge(IntentContext context, String event) { + if (context.phase == MotionGraphPhase.preparing || + context.phase == MotionGraphPhase.intro || + context.phase == MotionGraphPhase.waiting) { + final pending = context.routes.pending; + if (pending == null) { + if (context.phase == MotionGraphPhase.waiting) { + throw StateError('graph invariant missing waiting pending edge'); + } + return _eventEdge(context.indexes, context.visualState, event); + } + final inverse = _inverseEdge(context.indexes, pending.edge); + if (_hasEventTrigger(inverse, event)) return inverse; + return _eventEdge(context.indexes, context.visualState, event); + } + + if (context.phase == MotionGraphPhase.locked || + context.phase == MotionGraphPhase.reversible) { + final active = _requireSlot(context.routes.active, 'active transition edge'); + final inverse = context.phase == MotionGraphPhase.reversible + ? _inverseEdge(context.indexes, active.edge) + : null; + if (_hasEventTrigger(inverse, event)) return inverse; + if (_hasEventTrigger(active.edge, event)) return active.edge; + final effective = context.routes.reversal ?? active; + return _eventEdge(context.indexes, effective.edge.to, event); + } + + return _eventEdge(context.indexes, context.visualState, event); +} + +StateIntentPlan _pendingOrReject( + IntentContext context, + GraphStateId from, + GraphStateId target, +) { + final routed = _pendingTurnOrDirect(context, from, target); + return routed == null + ? const StateIntentPlanReject() + : StateIntentPlanReplacePending(routed.edge, turn: routed.turn); +} + +/// Direct neighbour edge, or first step of a ring arc with the remainder queued. +({GraphEdgeDefinition edge, TurnChainPlan? turn})? _pendingTurnOrDirect( + IntentContext context, + GraphStateId from, + GraphStateId target, +) { + final edge = _directEdge(context.indexes, from, target); + if (edge != null) return (edge: edge, turn: null); + return _turnPlan(context, from, target); +} + +/// Resolve a multi-step ring arc into its first step plus the queued remainder. +({GraphEdgeDefinition edge, TurnChainPlan turn})? _turnPlan( + IntentContext context, + GraphStateId from, + GraphStateId target, +) { + final route = resolveRingRoute(context.indexes, from, target); + if (route is! RingRouteArc) return null; + if (route.steps.isEmpty) return null; + final first = route.steps.first; + return ( + edge: first, + turn: TurnChainPlan( + ring: route.ring.id, + after: first.id, + remaining: List.unmodifiable(route.steps.skip(1)), + ), + ); +} + +GraphEdgeDefinition? _directEdge( + ValidatedGraphIndexes indexes, + GraphStateId from, + GraphStateId to, +) { + return indexes.directEdgesByState[from]?[to]; +} + +GraphEdgeDefinition? _eventEdge( + ValidatedGraphIndexes indexes, + GraphStateId from, + String event, +) { + return indexes.eventEdgesByState[from]?[event]; +} + +GraphEdgeDefinition? _inverseEdge( + ValidatedGraphIndexes indexes, + GraphEdgeDefinition edge, +) { + return indexes.inverseEdgesById[edge.id]; +} + +bool _hasEventTrigger(GraphEdgeDefinition? edge, String event) { + final trigger = edge?.trigger; + return trigger is GraphEdgeTriggerEvent && trigger.name == event; +} + +SequencedEdge _requireSlot(SequencedEdge? value, String label) { + if (value == null) throw StateError('graph invariant missing $label'); + return value; +} diff --git a/flutter/packages/aval_graph/lib/src/limits.dart b/flutter/packages/aval_graph/lib/src/limits.dart new file mode 100644 index 0000000..1407607 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/limits.dart @@ -0,0 +1,19 @@ +/// Identifier pattern shared by every graph ID (states, edges, ports, units). +/// +/// Direct port of `GRAPH_IDENTIFIER_PATTERN` in `packages/graph/src/limits.ts`. +final RegExp graphIdentifierPattern = RegExp(r'^[a-z][a-z0-9._-]{0,63}$'); + +/// Hard bounds enforced by [validateMotionGraphDefinition] and the runtime +/// engine, ported verbatim from `GRAPH_LIMITS` in +/// `packages/graph/src/limits.ts`. +abstract final class GraphLimits { + static const int maxStates = 32; + static const int maxEdges = 64; + static const int maxPortsPerBody = 16; + static const int maxInputsPerTick = 32; + static const int maxRoutingOperationsPerTick = 64; + static const int maxTraceRecords = 256; + static const int maxRings = 8; + static const int maxRingStates = 32; + static const int maxChainedSteps = 16; +} diff --git a/flutter/packages/aval_graph/lib/src/model.dart b/flutter/packages/aval_graph/lib/src/model.dart new file mode 100644 index 0000000..14d6164 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/model.dart @@ -0,0 +1,941 @@ +/// Public data model for the AVAL motion graph. +/// +/// This is a direct, field-for-field port of `packages/graph/src/model.ts`. +/// TypeScript discriminated unions become Dart sealed class hierarchies (one +/// concrete subclass per `kind`/`type` tag); TypeScript interfaces become +/// immutable Dart classes with `final` fields; TypeScript `readonly T[]` +/// becomes a `List` defensively copied into an unmodifiable list by the +/// constructor. TypeScript `number` frame/sequence counters become Dart +/// `int`; the TypeScript `bigint` content ordinal becomes Dart `BigInt`. +library; + +/// A state's stable identifier. Alias kept for readability parity with the +/// TypeScript `GraphStateId` type alias. +typedef GraphStateId = String; + +/// An edge's stable identifier. +typedef GraphEdgeId = String; + +/// A body, transition, or intro clip's stable identifier. +typedef GraphUnitId = String; + +/// A facing / locomotion ring's stable identifier. +typedef GraphRingId = String; + +/// Which arc a ring prefers when both directions are equally long. +enum GraphRingTieBreak { + forward, + backward; +} + +/// One signed step along a ring: `1` walks forward, `-1` walks backward. +typedef GraphTurnStep = int; + +/// How a multi-step ring request is served. +/// +/// [chain] walks every intermediate state (frame continuity). [direct] +/// collapses the arc into its departure boundary for reduced-motion hosts. +enum MotionGraphTurnPolicy { + chain, + direct; +} + +/// Direction of travel for a reversible transition or its live presentation. +enum TransitionDirection { + forward, + reverse; +} + +/// One authored departure/arrival handoff frame on a body. +class GraphPortDefinition { + GraphPortDefinition({required this.id, required List portalFrames}) + : portalFrames = List.unmodifiable(portalFrames); + + final String id; + + /// Always `0`; ports enter at the first authored frame of their body. + int get entryFrame => 0; + + final List portalFrames; + + @override + String toString() => + 'GraphPortDefinition(id: $id, entryFrame: 0, portalFrames: $portalFrames)'; +} + +/// Kind of content a state's body loops, finishes, or holds. +enum GraphBodyKind { + loop, + finite, + held; +} + +/// The looping/finite/held clip a state presents while stable. +class GraphBodyDefinition { + GraphBodyDefinition({ + required this.unitId, + required this.kind, + required this.frameCount, + required List ports, + }) : ports = List.unmodifiable(ports); + + final GraphUnitId unitId; + final GraphBodyKind kind; + final int frameCount; + final List ports; +} + +/// A one-shot clip authored to precede the initial state's body exactly once. +class GraphInitialUnitDefinition { + const GraphInitialUnitDefinition({ + required this.unitId, + required this.frameCount, + }); + + final GraphUnitId unitId; + final int frameCount; +} + +/// One node of the graph: an identity, its body, and an optional intro. +class GraphStateDefinition { + const GraphStateDefinition({ + required this.id, + required this.body, + this.initialUnit, + }); + + final GraphStateId id; + final GraphBodyDefinition body; + final GraphInitialUnitDefinition? initialUnit; +} + +/// The authored geometry used to decide when an edge may begin departure. +sealed class GraphStartPolicy { + const GraphStartPolicy(); + + int get maxWaitFrames; + + /// The port an edge arrives at on its target state. Common to every + /// variant (portal, finish, cut) so validation and routing code can read + /// it without a `switch`. + String get targetPort; +} + +/// Depart from a named port at or after its next eligible portal frame. +class GraphStartPolicyPortal extends GraphStartPolicy { + const GraphStartPolicyPortal({ + required this.sourcePort, + required this.targetPort, + required this.maxWaitFrames, + }); + + final String sourcePort; + @override + final String targetPort; + + @override + final int maxWaitFrames; +} + +/// Depart only once the source body reaches its final authored frame. +class GraphStartPolicyFinish extends GraphStartPolicy { + const GraphStartPolicyFinish({ + required this.targetPort, + required this.maxWaitFrames, + }); + + @override + final String targetPort; + + @override + final int maxWaitFrames; +} + +/// Depart immediately on the next tick, bypassing all body geometry. +class GraphStartPolicyCut extends GraphStartPolicy { + const GraphStartPolicyCut({required this.targetPort}); + + @override + final String targetPort; + + /// Always `1`; a cut has no geometric wait. + @override + int get maxWaitFrames => 1; +} + +/// The unit (and its geometry) an edge owns while actively transitioning. +sealed class GraphTransitionDefinition { + const GraphTransitionDefinition({ + required this.unitId, + required this.frameCount, + }); + + final GraphUnitId unitId; + final int frameCount; +} + +/// A one-directional transition clip that always plays to completion. +class GraphTransitionLocked extends GraphTransitionDefinition { + const GraphTransitionLocked({ + required super.unitId, + required super.frameCount, + }); +} + +/// A transition clip shared by an authored/inverse edge pair. +class GraphTransitionReversible extends GraphTransitionDefinition { + const GraphTransitionReversible({ + required super.unitId, + required super.frameCount, + required this.direction, + this.reverseOf, + }); + + final TransitionDirection direction; + + /// The edge ID this edge is the authored inverse of, if it declares one. + final GraphEdgeId? reverseOf; +} + +/// What causes an edge to become eligible for routing. +sealed class GraphEdgeTrigger { + const GraphEdgeTrigger(); +} + +/// The edge is only selectable by an explicit `send(event)` call. +class GraphEdgeTriggerEvent extends GraphEdgeTrigger { + const GraphEdgeTriggerEvent(this.name); + + final String name; +} + +/// The edge fires automatically when its source state's body finishes. +class GraphEdgeTriggerCompletion extends GraphEdgeTrigger { + const GraphEdgeTriggerCompletion(); +} + +/// How visual continuity is preserved across an edge's departure/arrival. +enum GraphContinuity { + exactAuthored('exact-authored'), + exactReverse('exact-reverse'), + cut('cut'); + + const GraphContinuity(this.wireValue); + + final String wireValue; +} + +/// One authored connection between two states. +class GraphEdgeDefinition { + const GraphEdgeDefinition({ + required this.id, + required this.from, + required this.to, + required this.start, + required this.continuity, + this.trigger, + this.transition, + this.ring, + this.step, + }); + + final GraphEdgeId id; + final GraphStateId from; + final GraphStateId to; + final GraphEdgeTrigger? trigger; + final GraphStartPolicy start; + final GraphTransitionDefinition? transition; + final GraphContinuity continuity; + + /// Ring this edge steps along. Present on turn edges only. + final GraphRingId? ring; + + /// Signed adjacency offset inside [ring]. Present on turn edges only (`1` or `-1`). + final GraphTurnStep? step; +} + +/// An ordered set of states along one axis. Adjacent members are joined by turn +/// edges so multi-step requests chain single steps rather than authoring every pair. +class GraphRingDefinition { + GraphRingDefinition({ + required this.id, + required List states, + required this.cyclic, + required this.tieBreak, + required this.maxChainedSteps, + }) : states = List.unmodifiable(states); + + final GraphRingId id; + final List states; + final bool cyclic; + final GraphRingTieBreak tieBreak; + final int maxChainedSteps; +} + +/// An untrusted, author-supplied graph definition. +class MotionGraphDefinition { + MotionGraphDefinition({ + required this.initialState, + required List states, + required List edges, + List? rings, + }) : states = List.unmodifiable(states), + edges = List.unmodifiable(edges), + rings = rings == null ? null : List.unmodifiable(rings); + + final GraphStateId initialState; + final List states; + final List edges; + final List? rings; +} + +/// A definition that has passed [validateMotionGraphDefinition]. +/// +/// Unlike the TypeScript original — where `ValidatedMotionGraph` carries a +/// compile-time-only `unique symbol` brand with *no* runtime enforcement — +/// Dart has no equivalent nominal-typing trick, so this class is a plain, +/// publicly constructible wrapper. The actual trust boundary lives where it +/// always lived at runtime in the TypeScript version too: +/// `getValidatedGraphIndexes` looks the instance up by identity in an +/// `Expando` populated only by `validateMotionGraphDefinition`, and throws +/// `MotionGraphValidationError` for any instance that was not produced by +/// it (see `validate.dart`). +class ValidatedMotionGraph { + const ValidatedMotionGraph(this.definition); + + final MotionGraphDefinition definition; +} + +/// Coarse lifecycle stage of the engine, independent of routing detail. +enum MotionGraphReadiness { + unready, + preparing, + animated, + static, + disposed, + error; +} + +/// Fine-grained tick behavior of the engine. +enum MotionGraphPhase { + unready, + preparing, + intro, + stable, + waiting, + locked, + reversible, + static, + disposed, + error; +} + +/// The exact frame the host should draw right now. +sealed class GraphPresentation { + const GraphPresentation(); +} + +class GraphPresentationStatic extends GraphPresentation { + const GraphPresentationStatic({required this.state}); + + final GraphStateId state; + + @override + bool operator ==(Object other) => + other is GraphPresentationStatic && other.state == state; + + @override + int get hashCode => Object.hash(GraphPresentationStatic, state); + + @override + String toString() => 'GraphPresentation.static(state: $state)'; +} + +class GraphPresentationIntro extends GraphPresentation { + const GraphPresentationIntro({ + required this.state, + required this.unitId, + required this.frameIndex, + }); + + final GraphStateId state; + final GraphUnitId unitId; + final int frameIndex; + + @override + bool operator ==(Object other) => + other is GraphPresentationIntro && + other.state == state && + other.unitId == unitId && + other.frameIndex == frameIndex; + + @override + int get hashCode => Object.hash(GraphPresentationIntro, state, unitId, frameIndex); + + @override + String toString() => + 'GraphPresentation.intro(state: $state, unitId: $unitId, frameIndex: $frameIndex)'; +} + +class GraphPresentationBody extends GraphPresentation { + const GraphPresentationBody({ + required this.state, + required this.unitId, + required this.frameIndex, + }); + + final GraphStateId state; + final GraphUnitId unitId; + final int frameIndex; + + @override + bool operator ==(Object other) => + other is GraphPresentationBody && + other.state == state && + other.unitId == unitId && + other.frameIndex == frameIndex; + + @override + int get hashCode => Object.hash(GraphPresentationBody, state, unitId, frameIndex); + + @override + String toString() => + 'GraphPresentation.body(state: $state, unitId: $unitId, frameIndex: $frameIndex)'; +} + +class GraphPresentationLocked extends GraphPresentation { + const GraphPresentationLocked({ + required this.edgeId, + required this.unitId, + required this.frameIndex, + }); + + final GraphEdgeId edgeId; + final GraphUnitId unitId; + final int frameIndex; + + @override + bool operator ==(Object other) => + other is GraphPresentationLocked && + other.edgeId == edgeId && + other.unitId == unitId && + other.frameIndex == frameIndex; + + @override + int get hashCode => Object.hash(GraphPresentationLocked, edgeId, unitId, frameIndex); + + @override + String toString() => + 'GraphPresentation.locked(edgeId: $edgeId, unitId: $unitId, frameIndex: $frameIndex)'; +} + +class GraphPresentationReversible extends GraphPresentation { + const GraphPresentationReversible({ + required this.edgeId, + required this.unitId, + required this.frameIndex, + required this.direction, + }); + + final GraphEdgeId edgeId; + final GraphUnitId unitId; + final int frameIndex; + final TransitionDirection direction; + + @override + bool operator ==(Object other) => + other is GraphPresentationReversible && + other.edgeId == edgeId && + other.unitId == unitId && + other.frameIndex == frameIndex && + other.direction == direction; + + @override + int get hashCode => + Object.hash(GraphPresentationReversible, edgeId, unitId, frameIndex, direction); + + @override + String toString() => + 'GraphPresentation.reversible(edgeId: $edgeId, unitId: $unitId, ' + 'frameIndex: $frameIndex, direction: $direction)'; +} + +/// Why a request's settlement resolved. +enum GraphSettlementResolveReason { + stableNoop('stable-noop'), + targetCommitted('target-committed'), + staticRecovery('static-recovery'); + + const GraphSettlementResolveReason(this.wireValue); + + final String wireValue; +} + +/// The rejection classification exposed by a settled request. +enum GraphSettlementError { + notReadyError('NotReadyError'), + routeError('RouteError'), + inputOverflowError('InputOverflowError'), + abortError('AbortError'), + playbackFallbackError('PlaybackFallbackError'); + + const GraphSettlementError(this.wireValue); + + final String wireValue; +} + +/// The host is always expected to apply this timing to a settlement; there +/// is currently exactly one value, mirroring the TypeScript literal type +/// `"microtask"`. +enum SettlementTiming { + microtask; +} + +/// The outcome of a settled request-completion group. +sealed class GraphSettlement { + const GraphSettlement(); + + SettlementTiming get timing => SettlementTiming.microtask; +} + +class GraphSettlementResolve extends GraphSettlement { + const GraphSettlementResolve(this.reason); + + final GraphSettlementResolveReason reason; + + @override + bool operator ==(Object other) => + other is GraphSettlementResolve && other.reason == reason; + + @override + int get hashCode => Object.hash(GraphSettlementResolve, reason); + + @override + String toString() => 'GraphSettlement.resolve(reason: $reason)'; +} + +class GraphSettlementReject extends GraphSettlement { + const GraphSettlementReject(this.error); + + final GraphSettlementError error; + + @override + bool operator ==(Object other) => + other is GraphSettlementReject && other.error == error; + + @override + int get hashCode => Object.hash(GraphSettlementReject, error); + + @override + String toString() => 'GraphSettlement.reject(error: $error)'; +} + +/// One observable side effect emitted by an engine operation, in the exact +/// order the engine produced it. +sealed class MotionGraphEffect { + const MotionGraphEffect(); +} + +class MotionGraphEffectReadinessChange extends MotionGraphEffect { + const MotionGraphEffectReadinessChange({ + required this.from, + required this.to, + this.reason, + }); + + final MotionGraphReadiness from; + final MotionGraphReadiness to; + final String? reason; + + @override + bool operator ==(Object other) => + other is MotionGraphEffectReadinessChange && + other.from == from && + other.to == to && + other.reason == reason; + + @override + int get hashCode => + Object.hash(MotionGraphEffectReadinessChange, from, to, reason); + + @override + String toString() => + 'MotionGraphEffect.readinessChange(from: $from, to: $to, reason: $reason)'; +} + +class MotionGraphEffectRequestedStateChange extends MotionGraphEffect { + const MotionGraphEffectRequestedStateChange({ + required this.from, + required this.to, + required this.sequence, + }); + + final GraphStateId from; + final GraphStateId to; + final int sequence; + + @override + bool operator ==(Object other) => + other is MotionGraphEffectRequestedStateChange && + other.from == from && + other.to == to && + other.sequence == sequence; + + @override + int get hashCode => + Object.hash(MotionGraphEffectRequestedStateChange, from, to, sequence); + + @override + String toString() => + 'MotionGraphEffect.requestedStateChange(from: $from, to: $to, sequence: $sequence)'; +} + +class MotionGraphEffectTransitionStart extends MotionGraphEffect { + const MotionGraphEffectTransitionStart({ + required this.edgeId, + required this.from, + required this.to, + required this.sequence, + }); + + final GraphEdgeId edgeId; + final GraphStateId from; + final GraphStateId to; + final int sequence; + + @override + bool operator ==(Object other) => + other is MotionGraphEffectTransitionStart && + other.edgeId == edgeId && + other.from == from && + other.to == to && + other.sequence == sequence; + + @override + int get hashCode => + Object.hash(MotionGraphEffectTransitionStart, edgeId, from, to, sequence); + + @override + String toString() => + 'MotionGraphEffect.transitionStart(edgeId: $edgeId, from: $from, to: $to, ' + 'sequence: $sequence)'; +} + +class MotionGraphEffectVisualStateChange extends MotionGraphEffect { + const MotionGraphEffectVisualStateChange({ + required this.from, + required this.to, + }); + + final GraphStateId from; + final GraphStateId to; + + @override + bool operator ==(Object other) => + other is MotionGraphEffectVisualStateChange && + other.from == from && + other.to == to; + + @override + int get hashCode => Object.hash(MotionGraphEffectVisualStateChange, from, to); + + @override + String toString() => + 'MotionGraphEffect.visualStateChange(from: $from, to: $to)'; +} + +class MotionGraphEffectTransitionEnd extends MotionGraphEffect { + const MotionGraphEffectTransitionEnd({ + required this.edgeId, + required this.from, + required this.to, + }); + + final GraphEdgeId edgeId; + final GraphStateId from; + final GraphStateId to; + + @override + bool operator ==(Object other) => + other is MotionGraphEffectTransitionEnd && + other.edgeId == edgeId && + other.from == from && + other.to == to; + + @override + int get hashCode => + Object.hash(MotionGraphEffectTransitionEnd, edgeId, from, to); + + @override + String toString() => + 'MotionGraphEffect.transitionEnd(edgeId: $edgeId, from: $from, to: $to)'; +} + +class MotionGraphEffectFallback extends MotionGraphEffect { + const MotionGraphEffectFallback({required this.reason}); + + final String reason; + + @override + bool operator ==(Object other) => + other is MotionGraphEffectFallback && other.reason == reason; + + @override + int get hashCode => Object.hash(MotionGraphEffectFallback, reason); + + @override + String toString() => 'MotionGraphEffect.fallback(reason: $reason)'; +} + +class MotionGraphEffectSettle extends MotionGraphEffect { + MotionGraphEffectSettle({ + required List requestIds, + required this.outcome, + }) : requestIds = List.unmodifiable(requestIds); + + final List requestIds; + final GraphSettlement outcome; + + @override + bool operator ==(Object other) => + other is MotionGraphEffectSettle && + listEquals(other.requestIds, requestIds) && + other.outcome == outcome; + + @override + int get hashCode => + Object.hash(MotionGraphEffectSettle, Object.hashAll(requestIds), outcome); + + @override + String toString() => + 'MotionGraphEffect.settle(requestIds: $requestIds, outcome: $outcome)'; +} + +/// Immutable snapshot of every observable engine field at one instant. +class MotionGraphSnapshot { + const MotionGraphSnapshot({ + required this.readiness, + required this.phase, + required this.initialUnitPending, + required this.requestedState, + required this.visualState, + required this.prospectiveState, + required this.isTransitioning, + required this.presentation, + required this.pendingEdgeId, + required this.activeEdgeId, + required this.followOnEdgeId, + required this.direction, + required this.contentOrdinal, + required this.inputSequence, + required this.pendingRequestCount, + required this.inputsSinceTick, + required this.routeOperationsLastTick, + }); + + final MotionGraphReadiness readiness; + final MotionGraphPhase phase; + + /// Whether the authored initial unit remains eligible before the initial + /// body. + final bool initialUnitPending; + final GraphStateId? requestedState; + final GraphStateId? visualState; + final GraphStateId? prospectiveState; + final bool isTransitioning; + final GraphPresentation? presentation; + final GraphEdgeId? pendingEdgeId; + final GraphEdgeId? activeEdgeId; + final GraphEdgeId? followOnEdgeId; + final TransitionDirection? direction; + final BigInt? contentOrdinal; + final int inputSequence; + final int pendingRequestCount; + final int inputsSinceTick; + final int routeOperationsLastTick; + + @override + bool operator ==(Object other) => + other is MotionGraphSnapshot && + other.readiness == readiness && + other.phase == phase && + other.initialUnitPending == initialUnitPending && + other.requestedState == requestedState && + other.visualState == visualState && + other.prospectiveState == prospectiveState && + other.isTransitioning == isTransitioning && + other.presentation == presentation && + other.pendingEdgeId == pendingEdgeId && + other.activeEdgeId == activeEdgeId && + other.followOnEdgeId == followOnEdgeId && + other.direction == direction && + other.contentOrdinal == contentOrdinal && + other.inputSequence == inputSequence && + other.pendingRequestCount == pendingRequestCount && + other.inputsSinceTick == inputsSinceTick && + other.routeOperationsLastTick == routeOperationsLastTick; + + @override + int get hashCode => Object.hash( + readiness, + phase, + initialUnitPending, + requestedState, + visualState, + prospectiveState, + isTransitioning, + presentation, + pendingEdgeId, + Object.hash( + activeEdgeId, + followOnEdgeId, + direction, + contentOrdinal, + inputSequence, + pendingRequestCount, + inputsSinceTick, + routeOperationsLastTick, + ), + ); + + @override + String toString() => + 'MotionGraphSnapshot(readiness: $readiness, phase: $phase, ' + 'requestedState: $requestedState, visualState: $visualState, ' + 'prospectiveState: $prospectiveState, presentation: $presentation)'; +} + +/// Which public engine method produced a [MotionGraphResult]. +enum MotionGraphOperation { + install('install'), + beginAnimated('begin-animated'), + resumeAnimated('resume-animated'), + beginStatic('begin-static'), + recoverStatic('recover-static'), + failStatic('fail-static'), + request('request'), + send('send'), + tick('tick'), + dispose('dispose'); + + const MotionGraphOperation(this.wireValue); + + final String wireValue; +} + +/// The full, immutable outcome of one engine operation. +class MotionGraphResult { + MotionGraphResult({ + required this.operation, + required this.presentation, + required List effects, + required this.snapshot, + this.accepted, + this.joined, + this.sequence, + this.requestId, + }) : effects = List.unmodifiable(effects); + + final MotionGraphOperation operation; + final bool? accepted; + final bool? joined; + final int? sequence; + final int? requestId; + final GraphPresentation? presentation; + final List effects; + final MotionGraphSnapshot snapshot; + + @override + bool operator ==(Object other) => + other is MotionGraphResult && + other.operation == operation && + other.accepted == accepted && + other.joined == joined && + other.sequence == sequence && + other.requestId == requestId && + other.presentation == presentation && + listEquals(other.effects, effects) && + other.snapshot == snapshot; + + @override + int get hashCode => Object.hash( + operation, + accepted, + joined, + sequence, + requestId, + presentation, + Object.hashAll(effects), + snapshot, + ); + + @override + String toString() => + 'MotionGraphResult(operation: $operation, accepted: $accepted, ' + 'presentation: $presentation, effects: $effects)'; +} + +/// Per-tick content clock supplied by the host. +class MotionGraphTickOptions { + const MotionGraphTickOptions({required this.contentOrdinal, this.routeReady}); + + final BigInt contentOrdinal; + final bool? routeReady; +} + +/// Host-supplied last successful draw identity for a failed presentation. +class MotionGraphStaticFailureOptions { + const MotionGraphStaticFailureOptions({this.retainedVisualState}); + + final GraphStateId? retainedVisualState; +} + +/// Last pixels actually drawn when an animated graph tick failed mid-barrier. +class MotionGraphRecoveryOptions { + const MotionGraphRecoveryOptions({this.retainedVisualState}); + + final GraphStateId? retainedVisualState; +} + +/// Host-supplied last successful draw identity for terminal disposal. +class MotionGraphDisposeOptions { + const MotionGraphDisposeOptions({this.retainedVisualState}); + + final GraphStateId? retainedVisualState; +} + +/// One retained entry of the engine's bounded operation trace. +class MotionGraphTraceRecord { + const MotionGraphTraceRecord({required this.index, required this.result}); + + final int index; + final MotionGraphResult result; + + @override + bool operator ==(Object other) => + other is MotionGraphTraceRecord && + other.index == index && + other.result == result; + + @override + int get hashCode => Object.hash(index, result); + + @override + String toString() => 'MotionGraphTraceRecord(index: $index, result: $result)'; +} + +/// Structural equality for two lists, comparing elements with `==`. +/// +/// Dart classes do not receive automatic structural equality the way plain +/// TypeScript objects do, and this package intentionally has no dependency +/// beyond the Dart SDK and `package:test` (so `package:collection`'s +/// `ListEquality` is unavailable). This tiny helper is shared by every +/// `List`-carrying value type in this library. +bool listEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var index = 0; index < a.length; index += 1) { + if (a[index] != b[index]) return false; + } + return true; +} diff --git a/flutter/packages/aval_graph/lib/src/operation_journal.dart b/flutter/packages/aval_graph/lib/src/operation_journal.dart new file mode 100644 index 0000000..d77fa26 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/operation_journal.dart @@ -0,0 +1,182 @@ +/// Operation counters and result trace, ported from +/// `packages/graph/src/operation-journal.ts`. +library; + +import 'errors.dart'; +import 'limits.dart'; +import 'model.dart'; + +/// Largest sequence number this journal will allocate before throwing, +/// matching JavaScript's `Number.MAX_SAFE_INTEGER` for observable parity +/// with the TypeScript original (see the note in `request_ledger.dart`). +const int _maxSafeInteger = 9007199254740991; + +class InputAdmission { + const InputAdmission({required this.sequence, required this.withinLimit}); + + final int sequence; + final bool withinLimit; +} + +class OperationResultMetadata { + const OperationResultMetadata({ + this.accepted, + this.joined, + this.sequence, + this.requestId, + }); + + final bool? accepted; + final bool? joined; + final int? sequence; + final int? requestId; +} + +class CompletedOperation { + const CompletedOperation({ + required this.operation, + required this.effects, + required this.presentation, + required this.snapshot, + this.metadata, + }); + + final MotionGraphOperation operation; + final List effects; + final GraphPresentation? presentation; + final MotionGraphSnapshot snapshot; + final OperationResultMetadata? metadata; +} + +class OperationJournalCheckpoint { + const OperationJournalCheckpoint({ + required this.contentOrdinal, + required this.inputSequence, + required this.inputsSinceTick, + required this.routeOperationsLastTick, + required this.traceIndex, + required this.trace, + }); + + final BigInt? contentOrdinal; + final int inputSequence; + final int inputsSinceTick; + final int routeOperationsLastTick; + final int traceIndex; + final List trace; +} + +/// Owns the monotonically increasing operation counters and immutable result +/// trace for a graph engine. Tick work remains external: callers admit a +/// tick, perform it, and complete it only after that work succeeds. +class OperationJournal { + BigInt? _contentOrdinal; + int _inputSequence = 0; + int _inputsSinceTick = 0; + int _routeOperationsLastTick = 0; + int _traceIndex = 0; + final List _trace = []; + + BigInt? get contentOrdinal => _contentOrdinal; + + int get inputSequence => _inputSequence; + + int get inputsSinceTick => _inputsSinceTick; + + int get routeOperationsLastTick => _routeOperationsLastTick; + + /// Pure admission query used by synchronous host-event acceptance checks. + bool canBeginInput() => + _inputsSinceTick < GraphLimits.maxInputsPerTick && + _inputSequence < _maxSafeInteger; + + InputAdmission beginInput() { + final sequence = _nextSequence(); + if (_inputsSinceTick >= GraphLimits.maxInputsPerTick) { + return InputAdmission(sequence: sequence, withinLimit: false); + } + _inputsSinceTick += 1; + return InputAdmission(sequence: sequence, withinLimit: true); + } + + int allocateInternalSequence() => _nextSequence(); + + void beginTick(BigInt contentOrdinal) { + final currentOrdinal = _contentOrdinal; + final expected = currentOrdinal == null ? BigInt.zero : currentOrdinal + BigInt.one; + if (contentOrdinal != expected) { + throw MotionGraphError( + MotionGraphErrorCode.nonConsecutiveTick, + 'content ordinal must be $expected', + ); + } + _contentOrdinal = contentOrdinal; + _routeOperationsLastTick = 0; + } + + /// Reset the input admission window only after the caller's tick succeeds. + void completeTick() { + _inputsSinceTick = 0; + } + + void incrementRouteOperations() { + _routeOperationsLastTick += 1; + if (_routeOperationsLastTick > GraphLimits.maxRoutingOperationsPerTick) { + throw const MotionGraphError( + MotionGraphErrorCode.graphValidation, + 'graph exceeded the per-tick routing-operation bound', + ); + } + } + + MotionGraphResult record(CompletedOperation completed) { + final metadata = completed.metadata; + final result = MotionGraphResult( + operation: completed.operation, + accepted: metadata?.accepted, + joined: metadata?.joined, + sequence: metadata?.sequence, + requestId: metadata?.requestId, + presentation: completed.presentation, + effects: completed.effects, + snapshot: completed.snapshot, + ); + _traceIndex += 1; + final record = MotionGraphTraceRecord(index: _traceIndex, result: result); + _trace.add(record); + if (_trace.length > GraphLimits.maxTraceRecords) { + _trace.removeRange(0, _trace.length - GraphLimits.maxTraceRecords); + } + return result; + } + + List getTrace() => List.unmodifiable(_trace); + + OperationJournalCheckpoint checkpoint() => OperationJournalCheckpoint( + contentOrdinal: _contentOrdinal, + inputSequence: _inputSequence, + inputsSinceTick: _inputsSinceTick, + routeOperationsLastTick: _routeOperationsLastTick, + traceIndex: _traceIndex, + trace: List.unmodifiable(_trace), + ); + + void restore(OperationJournalCheckpoint checkpoint) { + _contentOrdinal = checkpoint.contentOrdinal; + _inputSequence = checkpoint.inputSequence; + _inputsSinceTick = checkpoint.inputsSinceTick; + _routeOperationsLastTick = checkpoint.routeOperationsLastTick; + _traceIndex = checkpoint.traceIndex; + _trace + ..clear() + ..addAll(checkpoint.trace); + } + + int _nextSequence() { + _inputSequence += 1; + if (_inputSequence > _maxSafeInteger) { + throw RangeError('graph input sequence exceeds the safe-integer range'); + } + return _inputSequence; + } +} diff --git a/flutter/packages/aval_graph/lib/src/portal_search.dart b/flutter/packages/aval_graph/lib/src/portal_search.dart new file mode 100644 index 0000000..707cda4 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/portal_search.dart @@ -0,0 +1,282 @@ +/// Body-frame and portal geometry, ported from +/// `packages/graph/src/portal-search.ts`. +library; + +import 'errors.dart'; +import 'model.dart'; + +/// The next local frame for a body's presentation. +class BodyFrameStep { + const BodyFrameStep({ + required this.frameIndex, + required this.didAdvance, + required this.wrapped, + required this.isHeld, + }); + + /// The local body frame to present on the next content tick. + final int frameIndex; + + /// True when the next tick advances content, including a loop wrap. + final bool didAdvance; + + /// True only when a looping body crosses its last-to-first seam. + final bool wrapped; + + /// True when a finite or held body must keep its final frame displayed. + final bool isHeld; + + @override + bool operator ==(Object other) => + other is BodyFrameStep && + other.frameIndex == frameIndex && + other.didAdvance == didAdvance && + other.wrapped == wrapped && + other.isHeld == isHeld; + + @override + int get hashCode => Object.hash(frameIndex, didAdvance, wrapped, isHeld); + + @override + String toString() => + 'BodyFrameStep(frameIndex: $frameIndex, didAdvance: $didAdvance, ' + 'wrapped: $wrapped, isHeld: $isHeld)'; +} + +/// The result of searching a body for its next eligible departure boundary. +class BodyBoundarySearch { + const BodyBoundarySearch({ + required this.boundaryFrame, + required this.waitFrames, + required this.eligibleNow, + required this.wraps, + }); + + final int boundaryFrame; + + /// Number of body-frame advances between the displayed frame and boundary. + final int waitFrames; + + /// True when the currently displayed frame is already the boundary. + final bool eligibleNow; + + /// True when a looping body must cross its last-to-first seam. + final bool wraps; + + @override + bool operator ==(Object other) => + other is BodyBoundarySearch && + other.boundaryFrame == boundaryFrame && + other.waitFrames == waitFrames && + other.eligibleNow == eligibleNow && + other.wraps == wraps; + + @override + int get hashCode => + Object.hash(boundaryFrame, waitFrames, eligibleNow, wraps); + + @override + String toString() => + 'BodyBoundarySearch(boundaryFrame: $boundaryFrame, waitFrames: $waitFrames, ' + 'eligibleNow: $eligibleNow, wraps: $wraps)'; +} + +/// Return the next local frame for a body without introducing a wall clock. +/// +/// Finite bodies stop on their final authored frame; held bodies never +/// advance. +BodyFrameStep nextBodyFrame(GraphBodyDefinition body, int currentFrame) { + _assertBody(body); + _assertCurrentFrame(body, currentFrame); + + if (body.kind == GraphBodyKind.loop) { + final wrapped = currentFrame == body.frameCount - 1; + return BodyFrameStep( + frameIndex: wrapped ? 0 : currentFrame + 1, + didAdvance: true, + wrapped: wrapped, + isHeld: false, + ); + } + + final isHeld = currentFrame == body.frameCount - 1; + return BodyFrameStep( + frameIndex: isHeld ? currentFrame : currentFrame + 1, + didAdvance: !isHeld, + wrapped: false, + isHeld: isHeld, + ); +} + +/// Find the next eligible portal at or after the currently displayed body +/// frame. Looping bodies search circularly. Finite bodies never wrap and are +/// valid for portal departure only when their final held frame is a portal. +BodyBoundarySearch findNextPortalBoundary( + GraphBodyDefinition body, + String portId, + int currentFrame, +) { + final port = _resolveDeparturePort(body, portId); + _assertCurrentFrame(body, currentFrame); + + int? directBoundary; + for (final portalFrame in port.portalFrames) { + if (portalFrame >= currentFrame) { + directBoundary = portalFrame; + break; + } + } + + if (directBoundary != null) { + final waitFrames = directBoundary - currentFrame; + return _freezeBoundary(directBoundary, waitFrames, false); + } + + // _resolveDeparturePort guarantees that finite and held bodies end on a + // portal, so only a loop can reach this circular-search branch. + final boundaryFrame = port.portalFrames[0]; + final waitFrames = body.frameCount - currentFrame + boundaryFrame; + return _freezeBoundary(boundaryFrame, waitFrames, true); +} + +/// Compute the worst authored-frame wait to this port from any body phase. +/// +/// This is O(portal count), not O(frame count), so hostile large frame +/// counts cannot turn validation into an unbounded scan. +int greatestPortalWaitFrames(GraphBodyDefinition body, String portId) { + final port = _resolveDeparturePort(body, portId); + final portals = port.portalFrames; + + if (body.kind == GraphBodyKind.loop) { + var greatestWait = 0; + for (var index = 0; index < portals.length; index += 1) { + final previous = portals[index]; + final next = portals[(index + 1) % portals.length]; + final circularDistance = index == portals.length - 1 + ? body.frameCount - previous + next + : next - previous; + greatestWait = greatestWait > circularDistance - 1 + ? greatestWait + : circularDistance - 1; + } + return greatestWait; + } + + var greatestWait = portals[0]; + for (var index = 1; index < portals.length; index += 1) { + final candidate = portals[index] - portals[index - 1] - 1; + greatestWait = greatestWait > candidate ? greatestWait : candidate; + } + return greatestWait; +} + +/// Return the finite/held final-frame boundary from the displayed frame. +BodyBoundarySearch findFinishBoundary( + GraphBodyDefinition body, + int currentFrame, +) { + _assertFinishBody(body); + _assertCurrentFrame(body, currentFrame); + final boundaryFrame = body.frameCount - 1; + return _freezeBoundary(boundaryFrame, boundaryFrame - currentFrame, false); +} + +/// Return the greatest possible authored-frame wait for a finish policy. +int greatestFinishWaitFrames(GraphBodyDefinition body) { + _assertFinishBody(body); + return body.frameCount - 1; +} + +GraphPortDefinition _resolveDeparturePort( + GraphBodyDefinition body, + String portId, +) { + _assertBody(body); + final matchingPorts = body.ports.where((port) => port.id == portId).toList(); + if (matchingPorts.length != 1) { + throw MotionGraphValidationError( + matchingPorts.isEmpty + ? 'body ${body.unitId} has no port $portId' + : 'body ${body.unitId} has duplicate port $portId', + ); + } + + final port = matchingPorts[0]; + if (port.entryFrame != 0) { + throw MotionGraphValidationError( + 'port $portId on body ${body.unitId} must enter at frame zero', + ); + } + if (port.portalFrames.isEmpty) { + throw MotionGraphValidationError( + 'port $portId on body ${body.unitId} must declare a portal frame', + ); + } + + var previous = -1; + for (final portalFrame in port.portalFrames) { + if (portalFrame < 0 || portalFrame >= body.frameCount) { + throw MotionGraphValidationError( + 'port $portId on body ${body.unitId} has an out-of-range portal frame', + ); + } + if (portalFrame <= previous) { + throw MotionGraphValidationError( + 'port $portId on body ${body.unitId} portal frames must be sorted and unique', + ); + } + previous = portalFrame; + } + + if (body.kind != GraphBodyKind.loop && + port.portalFrames.last != body.frameCount - 1) { + throw MotionGraphValidationError( + 'finite port $portId on body ${body.unitId} must include the final frame', + ); + } + + return port; +} + +void _assertBody(GraphBodyDefinition body) { + if (body.frameCount <= 0) { + throw MotionGraphValidationError( + 'body ${body.unitId} frameCount must be a positive safe integer', + ); + } + if (body.kind == GraphBodyKind.held && body.frameCount != 1) { + throw MotionGraphValidationError( + 'held body ${body.unitId} must contain exactly one frame', + ); + } +} + +void _assertFinishBody(GraphBodyDefinition body) { + _assertBody(body); + if (body.kind == GraphBodyKind.loop) { + throw MotionGraphValidationError( + 'looping body ${body.unitId} cannot use a finish boundary', + ); + } +} + +void _assertCurrentFrame(GraphBodyDefinition body, int currentFrame) { + if (currentFrame < 0 || currentFrame >= body.frameCount) { + throw MotionGraphValidationError( + 'current frame for body ${body.unitId} is out of range', + ); + } +} + +BodyBoundarySearch _freezeBoundary( + int boundaryFrame, + int waitFrames, + bool wraps, +) { + return BodyBoundarySearch( + boundaryFrame: boundaryFrame, + waitFrames: waitFrames, + eligibleNow: waitFrames == 0, + wraps: wraps, + ); +} diff --git a/flutter/packages/aval_graph/lib/src/request_ledger.dart b/flutter/packages/aval_graph/lib/src/request_ledger.dart new file mode 100644 index 0000000..61f6e27 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/request_ledger.dart @@ -0,0 +1,170 @@ +/// Request completion-group tracker, ported from +/// `packages/graph/src/request-ledger.ts`. +library; + +import 'model.dart'; + +/// The largest request/sequence ID this package will allocate before +/// throwing, matching JavaScript's `Number.MAX_SAFE_INTEGER`. Dart's native +/// `int` can safely exceed this on the VM, but the bound is kept identical +/// to the TypeScript original so overflow behavior is observably the same. +const int _maxSafeInteger = 9007199254740991; + +/// The `settle` variant of [MotionGraphEffect], used as the ledger's result +/// type. Named to mirror the TypeScript `RequestSettleEffect` type alias +/// (`Extract`). +typedef RequestSettleEffect = MotionGraphEffectSettle; + +class RequestAdmission { + const RequestAdmission({ + required this.requestId, + required this.target, + required this.joined, + this.superseded, + }); + + final int requestId; + final GraphStateId target; + final bool joined; + final RequestSettleEffect? superseded; +} + +class StandaloneSettlement { + const StandaloneSettlement({required this.requestId, required this.effect}); + + final int requestId; + final RequestSettleEffect effect; +} + +class _PendingRequestGroup { + _PendingRequestGroup({required this.target, required this.requestIds}); + + final GraphStateId target; + final List requestIds; +} + +class RequestLedgerPendingCheckpoint { + const RequestLedgerPendingCheckpoint({ + required this.target, + required this.requestIds, + }); + + final GraphStateId target; + final List requestIds; +} + +class RequestLedgerCheckpoint { + const RequestLedgerCheckpoint({required this.nextRequestId, this.pending}); + + final int nextRequestId; + final RequestLedgerPendingCheckpoint? pending; +} + +/// Tracks request completion groups without owning promises or scheduling +/// work. +/// +/// Duplicate destinations join the current group. A different destination +/// atomically supersedes that group and returns its one `AbortError` effect +/// to the caller. Effects describe microtask timing, but the host remains +/// responsible for applying that timing. +class RequestLedger { + int _nextRequestId = 1; + _PendingRequestGroup? _pending; + + int get pendingRequestCount => _pending?.requestIds.length ?? 0; + + GraphStateId? get pendingTarget => _pending?.target; + + /// Adds a request to the surviving completion group for [target]. + RequestAdmission request(GraphStateId target) { + final requestId = _allocateRequestId(); + final pending = _pending; + + if (pending != null && pending.target == target) { + pending.requestIds.add(requestId); + return RequestAdmission( + requestId: requestId, + target: target, + joined: true, + ); + } + + final superseded = pending == null + ? null + : _createSettleEffect( + pending.requestIds, + const GraphSettlementReject(GraphSettlementError.abortError), + ); + + _pending = _PendingRequestGroup(target: target, requestIds: [requestId]); + + return RequestAdmission( + requestId: requestId, + target: target, + joined: false, + superseded: superseded, + ); + } + + /// Settles and clears the surviving group. Repeated settlement is a no-op. + RequestSettleEffect? settlePending(GraphSettlement outcome) { + final pending = _pending; + if (pending == null) return null; + + _pending = null; + return _createSettleEffect(pending.requestIds, outcome); + } + + /// Allocates and settles one request without replacing the surviving + /// group. This is used for stable no-ops and requests rejected before + /// admission. + StandaloneSettlement settleNew(GraphSettlement outcome) { + final requestId = _allocateRequestId(); + return StandaloneSettlement( + requestId: requestId, + effect: _createSettleEffect([requestId], outcome), + ); + } + + RequestLedgerCheckpoint checkpoint() { + final pending = _pending; + return RequestLedgerCheckpoint( + nextRequestId: _nextRequestId, + pending: pending == null + ? null + : RequestLedgerPendingCheckpoint( + target: pending.target, + requestIds: List.unmodifiable(pending.requestIds), + ), + ); + } + + void restore(RequestLedgerCheckpoint checkpoint) { + _nextRequestId = checkpoint.nextRequestId; + final pending = checkpoint.pending; + _pending = pending == null + ? null + : _PendingRequestGroup( + target: pending.target, + requestIds: List.of(pending.requestIds), + ); + } + + int _allocateRequestId() { + final requestId = _nextRequestId; + if (requestId > _maxSafeInteger) { + throw RangeError('request ID exceeds the safe-integer range'); + } + + _nextRequestId += 1; + return requestId; + } +} + +RequestSettleEffect _createSettleEffect( + List requestIds, + GraphSettlement outcome, +) { + final sortedRequestIds = List.of(requestIds)..sort(); + return MotionGraphEffectSettle(requestIds: sortedRequestIds, outcome: outcome); +} diff --git a/flutter/packages/aval_graph/lib/src/ring_plan.dart b/flutter/packages/aval_graph/lib/src/ring_plan.dart new file mode 100644 index 0000000..c6ede05 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/ring_plan.dart @@ -0,0 +1,154 @@ +/// Ring arc selection, ported from `packages/graph/src/ring-plan.ts`. +library; + +import 'model.dart'; +import 'validate.dart'; + +/// One resolved arc along a ring, excluding the state it departs from. +class RingArc { + const RingArc({ + required this.direction, + required this.states, + }); + + final GraphRingTieBreak direction; + + /// Ordered landings; the last entry is the requested target. + final List states; + + @override + bool operator ==(Object other) => + other is RingArc && + other.direction == direction && + listEquals(other.states, states); + + @override + int get hashCode => Object.hash(direction, Object.hashAll(states)); + + @override + String toString() => 'RingArc(direction: $direction, states: $states)'; +} + +/// A ring route resolved against authored edges. +sealed class RingRoute { + const RingRoute(); +} + +class RingRouteNone extends RingRoute { + const RingRouteNone(); +} + +class RingRouteTooLong extends RingRoute { + const RingRouteTooLong({required this.ring, required this.distance}); + + final GraphRingDefinition ring; + final int distance; +} + +class RingRouteArc extends RingRoute { + const RingRouteArc({ + required this.ring, + required this.direction, + required this.states, + required this.steps, + }); + + final GraphRingDefinition ring; + final GraphRingTieBreak direction; + final List states; + final List steps; +} + +/// Choose the shorter arc between two members of one ring. +/// +/// Distances are measured in steps, wrapping only on cyclic rings. Equal-length +/// arcs resolve through the ring's [GraphRingDefinition.tieBreak]. The +/// `maxChainedSteps` ceiling is not applied here. +RingArc? planRingArc( + GraphRingDefinition ring, + GraphStateId from, + GraphStateId to, +) { + final length = ring.states.length; + final fromIndex = ring.states.indexOf(from); + final toIndex = ring.states.indexOf(to); + if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) return null; + + final double forward; + final double backward; + if (ring.cyclic) { + forward = ((toIndex - fromIndex + length) % length).toDouble(); + backward = ((fromIndex - toIndex + length) % length).toDouble(); + } else { + forward = toIndex > fromIndex + ? (toIndex - fromIndex).toDouble() + : double.infinity; + backward = fromIndex > toIndex + ? (fromIndex - toIndex).toDouble() + : double.infinity; + } + if (!forward.isFinite && !backward.isFinite) return null; + + final GraphRingTieBreak direction; + if (forward < backward) { + direction = GraphRingTieBreak.forward; + } else if (backward < forward) { + direction = GraphRingTieBreak.backward; + } else { + direction = ring.tieBreak; + } + final distance = + direction == GraphRingTieBreak.forward ? forward.toInt() : backward.toInt(); + final offset = direction == GraphRingTieBreak.forward ? 1 : -1; + final states = []; + for (var step = 1; step <= distance; step += 1) { + final index = ((fromIndex + step * offset) % length + length) % length; + states.add(ring.states[index]); + } + return RingArc(direction: direction, states: List.unmodifiable(states)); +} + +/// Resolve the authored step edges which walk [from] to [to] along one ring. +/// +/// Rings are consulted in validated (ascending id) order and the first ring +/// that can serve the whole arc wins. +RingRoute resolveRingRoute( + ValidatedGraphIndexes indexes, + GraphStateId from, + GraphStateId to, +) { + RingRouteTooLong? refused; + for (final ring in indexes.ringsByState[from] ?? const []) { + final arc = planRingArc(ring, from, to); + if (arc == null) continue; + if (arc.states.length > ring.maxChainedSteps) { + refused ??= RingRouteTooLong(ring: ring, distance: arc.states.length); + continue; + } + final steps = _collectSteps(indexes, from, arc.states); + if (steps == null) continue; + return RingRouteArc( + ring: ring, + direction: arc.direction, + states: arc.states, + steps: steps, + ); + } + return refused ?? const RingRouteNone(); +} + +List? _collectSteps( + ValidatedGraphIndexes indexes, + GraphStateId from, + List states, +) { + final steps = []; + var cursor = from; + for (final state in states) { + final edge = indexes.directEdgesByState[cursor]?[state]; + if (edge == null) return null; + steps.add(edge); + cursor = state; + } + return List.unmodifiable(steps); +} diff --git a/flutter/packages/aval_graph/lib/src/route_plan.dart b/flutter/packages/aval_graph/lib/src/route_plan.dart new file mode 100644 index 0000000..fbe2b06 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/route_plan.dart @@ -0,0 +1,249 @@ +/// Route topology owner, ported from `packages/graph/src/route-plan.ts`. +/// +/// Every value here is immutable by construction (`final` fields, no +/// setters), which is the Dart equivalent of the TypeScript source calling +/// `Object.freeze()` on each returned value — so this port has no runtime +/// "is frozen" checks to make, unlike the TypeScript test suite which +/// verifies `Object.isFrozen(...)` defensively. +library; + +import 'model.dart'; + +/// An authored edge and the input sequence which selected it. +class SequencedEdge { + const SequencedEdge({required this.edge, required this.sequence}); + + final GraphEdgeDefinition edge; + final int sequence; + + @override + bool operator ==(Object other) => + other is SequencedEdge && other.edge == edge && other.sequence == sequence; + + @override + int get hashCode => Object.hash(edge, sequence); + + @override + String toString() => 'SequencedEdge(edge: ${edge.id}, sequence: $sequence)'; +} + +/// Read-only route topology consumed by intent routing and snapshots. +/// +/// The slot priority is significant: a follow-on is the final prospective +/// destination, followed by a queued reversal, the active edge, and finally a +/// pending edge waiting for its authored departure boundary. +abstract interface class RoutePlanView { + SequencedEdge? get pending; + SequencedEdge? get active; + SequencedEdge? get followOn; + SequencedEdge? get reversal; + + SequencedEdge? recoveryCandidate(); + GraphStateId? prospectiveState(GraphStateId? visualState); + bool hasRoute(); +} + +class ActiveRouteCompletion { + const ActiveRouteCompletion({required this.completed, this.promoted}); + + final SequencedEdge completed; + final SequencedEdge? promoted; +} + +class RoutePlanCheckpoint { + const RoutePlanCheckpoint({this.pending, this.active, this.followOn, this.reversal}); + + final SequencedEdge? pending; + final SequencedEdge? active; + final SequencedEdge? followOn; + final SequencedEdge? reversal; +} + +/// Owns the engine's small route plan and its cross-slot mutations. +/// +/// Graph lookup remains outside this class. Callers supply validated edges; +/// [RoutePlan] keeps each edge and its selecting sequence in one immutable +/// value so the two cannot drift apart during promotion or reversal. +class RoutePlan implements RoutePlanView { + SequencedEdge? _pending; + SequencedEdge? _active; + SequencedEdge? _followOn; + SequencedEdge? _reversal; + + @override + SequencedEdge? get pending => _pending; + + @override + SequencedEdge? get active => _active; + + @override + SequencedEdge? get followOn => _followOn; + + @override + SequencedEdge? get reversal => _reversal; + + /// Replace a waiting route and discard queued continuations. + SequencedEdge replacePending(GraphEdgeDefinition edge, int sequence) { + if (_active != null) { + throw StateError( + 'an active route must complete or clear before replacement', + ); + } + final pending = _freezeSequencedEdge(edge, sequence); + _pending = pending; + _followOn = null; + _reversal = null; + return pending; + } + + /// Cancel only the edge which is still waiting to depart. + SequencedEdge? cancelPending() { + final cancelled = _pending; + _pending = null; + return cancelled; + } + + /// Make an edge active. A matching pending slot is consumed atomically; + /// completion edges may activate directly when there is no pending slot. + SequencedEdge activate(GraphEdgeDefinition edge, int sequence) { + if (_active != null) { + throw StateError('a route is already active'); + } + final currentPending = _pending; + if (currentPending != null && + (currentPending.edge.id != edge.id || + currentPending.sequence != sequence)) { + throw StateError('activated route does not match the pending route'); + } + if (_followOn != null || _reversal != null) { + throw StateError('queued routes require an active route'); + } + + final active = currentPending ?? _freezeSequencedEdge(edge, sequence); + _pending = null; + _active = active; + return active; + } + + /// Queue or replace the one direct continuation after the effective edge. + SequencedEdge queueFollowOn(GraphEdgeDefinition edge, int sequence) { + final active = _requireActive(); + final effective = _reversal ?? active; + if (edge.from != effective.edge.to) { + throw StateError( + 'follow-on source must match the effective route target', + ); + } + final followOn = _freezeSequencedEdge(edge, sequence); + _followOn = followOn; + return followOn; + } + + SequencedEdge? clearFollowOn() { + final cleared = _followOn; + _followOn = null; + return cleared; + } + + /// Queue an inverse edge and cancel any continuation it supersedes. + SequencedEdge queueReversal(GraphEdgeDefinition edge, int sequence) { + final active = _requireActive(); + if (edge.from != active.edge.to || edge.to != active.edge.from) { + throw StateError('reversal must invert the active route'); + } + final reversal = _freezeSequencedEdge(edge, sequence); + _followOn = null; + _reversal = reversal; + return reversal; + } + + SequencedEdge? clearReversal() { + final cleared = _reversal; + _reversal = null; + return cleared; + } + + /// Promote a queued reversal to active without disturbing its follow-on. + SequencedEdge activateReversal() { + _requireActive(); + final reversal = _reversal; + if (reversal == null) { + throw StateError('route plan has no queued reversal'); + } + _active = reversal; + _reversal = null; + return reversal; + } + + /// Complete the active edge and promote its continuation to pending. + ActiveRouteCompletion completeActive() { + final completed = _requireActive(); + final currentFollowOn = _followOn; + if (currentFollowOn != null && currentFollowOn.edge.from != completed.edge.to) { + throw StateError( + 'follow-on source must match the completed route target', + ); + } + + final promoted = currentFollowOn; + _active = null; + _reversal = null; + _followOn = null; + _pending = promoted; + return ActiveRouteCompletion(completed: completed, promoted: promoted); + } + + /// Select the authored route which best represents recovery intent. + @override + SequencedEdge? recoveryCandidate() => + _followOn ?? _reversal ?? _active ?? _pending; + + /// Return the final state implied by the current route topology. + @override + GraphStateId? prospectiveState(GraphStateId? visualState) => + _followOn?.edge.to ?? + _reversal?.edge.to ?? + _active?.edge.to ?? + _pending?.edge.to ?? + visualState; + + @override + bool hasRoute() => + _pending != null || _active != null || _followOn != null || _reversal != null; + + void clear() { + _pending = null; + _active = null; + _followOn = null; + _reversal = null; + } + + RoutePlanCheckpoint checkpoint() => RoutePlanCheckpoint( + pending: _pending, + active: _active, + followOn: _followOn, + reversal: _reversal, + ); + + void restore(RoutePlanCheckpoint checkpoint) { + _pending = checkpoint.pending; + _active = checkpoint.active; + _followOn = checkpoint.followOn; + _reversal = checkpoint.reversal; + } + + SequencedEdge _requireActive() { + final active = _active; + if (active == null) { + throw StateError('route plan has no active route'); + } + return active; + } +} + +SequencedEdge _freezeSequencedEdge(GraphEdgeDefinition edge, int sequence) { + if (sequence < 0) { + throw RangeError('route sequence must be a non-negative safe integer'); + } + return SequencedEdge(edge: edge, sequence: sequence); +} diff --git a/flutter/packages/aval_graph/lib/src/validate.dart b/flutter/packages/aval_graph/lib/src/validate.dart new file mode 100644 index 0000000..2647496 --- /dev/null +++ b/flutter/packages/aval_graph/lib/src/validate.dart @@ -0,0 +1,897 @@ +/// Untrusted-definition validator, ported from +/// `packages/graph/src/validate.ts`. +/// +/// [validateMotionGraphDefinition] accepts genuinely untrusted, dynamically +/// shaped input — typically a `Map`/`List` tree decoded from JSON, exactly +/// like the TypeScript original, which walks its input as `unknown` rather +/// than trusting its nominal `MotionGraphDefinition` parameter type (nothing +/// stops a caller from handing TypeScript plain JSON at runtime either). +/// Dart cannot express "trust this type only after a runtime check" via a +/// compile-time brand the way the TypeScript source's `unique symbol` brand +/// pretends to (that brand has zero runtime effect in the original either — +/// see the doc comment on `ValidatedMotionGraph` in `model.dart`), so the +/// trust boundary here, too, is enforced entirely by [getValidatedGraphIndexes] +/// checking an [Expando] populated only by this function. +library; + +import 'dart:convert'; + +import 'errors.dart'; +import 'limits.dart'; +import 'model.dart'; +import 'portal_search.dart'; + +/// Package-private engine access to the indexes associated with a validated +/// clone. +class ValidatedGraphIndexes { + const ValidatedGraphIndexes({ + required this.statesById, + required this.edgesById, + required this.portsByState, + required this.directEdgesByState, + required this.eventEdgesByState, + required this.completionEdgesByState, + required this.inverseEdgesById, + required this.ringsById, + required this.ringsByState, + }); + + final Map statesById; + final Map edgesById; + final Map> portsByState; + final Map> + directEdgesByState; + final Map> eventEdgesByState; + final Map completionEdgesByState; + final Map inverseEdgesById; + final Map ringsById; + + /// Rings a state belongs to, in ascending ring-id order. + final Map> ringsByState; +} + +final Expando _indexesByGraph = + Expando('validatedGraphIndexes'); + +/// Clones and validates an untrusted graph definition. The returned +/// definition shares no lists or objects with the caller. +/// +/// [value] is expected to be a `Map` (or any `Map`) whose +/// entries mirror the TypeScript `MotionGraphDefinition` shape, with nested +/// `List`s for `states`/`edges`/`ports`/`portalFrames`. Anything else — the +/// wrong runtime type, a missing field, an out-of-range number — throws +/// [MotionGraphValidationError] with a path-qualified message. +ValidatedMotionGraph validateMotionGraphDefinition(Object? value) { + final input = _expectRecord(value, 'definition'); + final initialState = _expectIdentifier(input['initialState'], 'initialState'); + final stateInputs = _expectArray(input['states'], 'states'); + final edgeInputs = _expectArray(input['edges'], 'edges'); + + if (stateInputs.isEmpty || stateInputs.length > GraphLimits.maxStates) { + _invalid( + 'states must contain between 1 and ${GraphLimits.maxStates} entries', + ); + } + if (edgeInputs.length > GraphLimits.maxEdges) { + _invalid('edges must contain at most ${GraphLimits.maxEdges} entries'); + } + + final stateIds = {}; + final reservedUnitIds = {}; + final states = [ + for (var index = 0; index < stateInputs.length; index += 1) + _cloneState(stateInputs[index], index, initialState, stateIds, reservedUnitIds), + ]; + + final statesById = { + for (final state in states) state.id: state, + }; + if (!statesById.containsKey(initialState)) { + _invalid('initialState ${_quote(initialState)} does not reference a state'); + } + + final edgeIds = {}; + final transitionUnitKinds = {}; + final edges = [ + for (var index = 0; index < edgeInputs.length; index += 1) + _cloneEdge(edgeInputs[index], index, edgeIds, reservedUnitIds, transitionUnitKinds), + ]; + + final edgesById = { + for (final edge in edges) edge.id: edge, + }; + final portsByState = >{}; + for (final state in states) { + portsByState[state.id] = { + for (final port in state.body.ports) port.id: port, + }; + } + + final directMutable = >{}; + final eventMutable = >{}; + final completionEdgesByState = {}; + + for (final edge in edges) { + _validateEdgeReferencesAndGeometry( + edge, + statesById, + portsByState, + directMutable, + eventMutable, + completionEdgesByState, + ); + } + + final inverseEdgesById = _validateReversiblePairs(edges, edgesById); + _validateImmediateCompletionCycles(completionEdgesByState, statesById); + + final rings = _cloneRings(input['rings'], statesById); + final ringsById = { + for (final ring in rings) ring.id: ring, + }; + final ringsByState = _indexRingsByState(rings); + _validateRingStepOwnership(rings, directMutable); + for (final edge in edges) { + _validateTurnEdge(edge, ringsById); + } + + final definition = MotionGraphDefinition( + initialState: initialState, + states: states, + edges: edges, + rings: rings.isEmpty ? null : rings, + ); + final validated = ValidatedMotionGraph(definition); + final indexes = ValidatedGraphIndexes( + statesById: statesById, + edgesById: edgesById, + portsByState: portsByState, + directEdgesByState: directMutable, + eventEdgesByState: eventMutable, + completionEdgesByState: completionEdgesByState, + inverseEdgesById: inverseEdgesById, + ringsById: ringsById, + ringsByState: ringsByState, + ); + + _indexesByGraph[validated] = indexes; + return validated; +} + +/// Internal engine access to the indexes associated with a validated clone. +ValidatedGraphIndexes getValidatedGraphIndexes(ValidatedMotionGraph graph) { + final indexes = _indexesByGraph[graph]; + if (indexes == null) { + throw const MotionGraphValidationError( + 'graph was not produced by validateMotionGraphDefinition()', + ); + } + return indexes; +} + +GraphStateDefinition _cloneState( + Object? value, + int index, + String initialState, + Set stateIds, + Set reservedUnitIds, +) { + final path = 'states[$index]'; + final input = _expectRecord(value, path); + final id = _expectIdentifier(input['id'], '$path.id'); + _addUnique(stateIds, id, '$path.id', 'state ID'); + + final body = _cloneBody(input['body'], '$path.body'); + _reserveUnit(reservedUnitIds, body.unitId, '$path.body.unitId'); + + if (input['initialUnit'] == null) { + return GraphStateDefinition(id: id, body: body); + } + if (id != initialState) { + _invalid('$path.initialUnit is allowed only on the initial state'); + } + + final initialInput = _expectRecord(input['initialUnit'], '$path.initialUnit'); + final unitId = _expectIdentifier( + initialInput['unitId'], + '$path.initialUnit.unitId', + ); + final frameCount = _expectPositiveSafeInteger( + initialInput['frameCount'], + '$path.initialUnit.frameCount', + ); + _reserveUnit(reservedUnitIds, unitId, '$path.initialUnit.unitId'); + final initialUnit = GraphInitialUnitDefinition(unitId: unitId, frameCount: frameCount); + return GraphStateDefinition(id: id, body: body, initialUnit: initialUnit); +} + +GraphBodyDefinition _cloneBody(Object? value, String path) { + final input = _expectRecord(value, path); + final unitId = _expectIdentifier(input['unitId'], '$path.unitId'); + final kindValue = input['kind']; + GraphBodyKind kind; + if (kindValue == 'loop') { + kind = GraphBodyKind.loop; + } else if (kindValue == 'finite') { + kind = GraphBodyKind.finite; + } else if (kindValue == 'held') { + kind = GraphBodyKind.held; + } else { + _invalid('$path.kind must be loop, finite, or held'); + } + final frameCount = _expectPositiveSafeInteger(input['frameCount'], '$path.frameCount'); + if (kind == GraphBodyKind.held && frameCount != 1) { + _invalid('$path.frameCount must be 1 for a held body'); + } + + final portInputs = _expectArray(input['ports'], '$path.ports'); + if (portInputs.length > GraphLimits.maxPortsPerBody) { + _invalid( + '$path.ports must contain at most ${GraphLimits.maxPortsPerBody} entries', + ); + } + final portIds = {}; + final ports = [ + for (var index = 0; index < portInputs.length; index += 1) + _clonePort(portInputs[index], '$path.ports[$index]', frameCount, portIds), + ]; + return GraphBodyDefinition( + unitId: unitId, + kind: kind, + frameCount: frameCount, + ports: ports, + ); +} + +GraphPortDefinition _clonePort( + Object? value, + String path, + int frameCount, + Set portIds, +) { + final input = _expectRecord(value, path); + final id = _expectIdentifier(input['id'], '$path.id'); + _addUnique(portIds, id, '$path.id', 'port ID in one body'); + if (input['entryFrame'] != 0) { + _invalid('$path.entryFrame must be 0'); + } + + final portalInputs = _expectArray(input['portalFrames'], '$path.portalFrames'); + if (portalInputs.isEmpty) { + _invalid('$path.portalFrames must contain at least one frame'); + } + final portalFrames = []; + var previous = -1; + for (var index = 0; index < portalInputs.length; index += 1) { + final frame = _expectNonNegativeSafeInteger( + portalInputs[index], + '$path.portalFrames[$index]', + ); + if (frame >= frameCount) { + _invalid('$path.portalFrames[$index] must be less than frameCount'); + } + if (frame <= previous) { + _invalid('$path.portalFrames must be sorted and unique'); + } + portalFrames.add(frame); + previous = frame; + } + + return GraphPortDefinition(id: id, portalFrames: portalFrames); +} + +GraphEdgeDefinition _cloneEdge( + Object? value, + int index, + Set edgeIds, + Set reservedUnitIds, + Map transitionUnitKinds, +) { + final path = 'edges[$index]'; + final input = _expectRecord(value, path); + final id = _expectIdentifier(input['id'], '$path.id'); + _addUnique(edgeIds, id, '$path.id', 'edge ID'); + final from = _expectIdentifier(input['from'], '$path.from'); + final to = _expectIdentifier(input['to'], '$path.to'); + if (from == to) { + _invalid('$path must connect distinct states'); + } + + final triggerValue = input['trigger']; + final trigger = triggerValue == null ? null : _cloneTrigger(triggerValue, '$path.trigger'); + final start = _cloneStart(input['start'], '$path.start'); + final transitionValue = input['transition']; + final transition = + transitionValue == null ? null : _cloneTransition(transitionValue, '$path.transition'); + + final continuityValue = input['continuity']; + GraphContinuity continuity; + if (continuityValue == 'exact-authored') { + continuity = GraphContinuity.exactAuthored; + } else if (continuityValue == 'exact-reverse') { + continuity = GraphContinuity.exactReverse; + } else if (continuityValue == 'cut') { + continuity = GraphContinuity.cut; + } else { + _invalid('$path.continuity is invalid'); + } + + if (transition != null) { + if (reservedUnitIds.contains(transition.unitId)) { + _invalid( + '$path.transition.unitId ${_quote(transition.unitId)} is already used by a body or initial unit', + ); + } + final existingKind = transitionUnitKinds[transition.unitId]; + if (transition is GraphTransitionLocked) { + if (existingKind != null) { + _invalid( + '$path.transition.unitId ${_quote(transition.unitId)} is already used by another transition', + ); + } + transitionUnitKinds[transition.unitId] = 'locked'; + } else { + if (existingKind == 'locked') { + _invalid( + '$path.transition.unitId ${_quote(transition.unitId)} is already used by a locked transition', + ); + } + transitionUnitKinds[transition.unitId] = 'reversible'; + } + } + + final turn = _cloneTurnMembership(input, path); + + return GraphEdgeDefinition( + id: id, + from: from, + to: to, + start: start, + continuity: continuity, + trigger: trigger, + transition: transition, + ring: turn.ring, + step: turn.step, + ); +} + +({GraphRingId? ring, GraphTurnStep? step}) _cloneTurnMembership( + Map input, + String path, +) { + if (input['ring'] == null && input['step'] == null) { + return (ring: null, step: null); + } + final ring = _expectIdentifier(input['ring'], '$path.ring'); + final step = _asInteger(input['step']); + if (step != 1 && step != -1) { + _invalid('$path.step must be 1 or -1'); + } + return (ring: ring, step: step); +} + +GraphEdgeTrigger _cloneTrigger(Object? value, String path) { + final input = _expectRecord(value, path); + final type = input['type']; + if (type == 'completion') return const GraphEdgeTriggerCompletion(); + if (type == 'event') { + return GraphEdgeTriggerEvent(_expectIdentifier(input['name'], '$path.name')); + } + _invalid('$path.type must be event or completion'); +} + +GraphStartPolicy _cloneStart(Object? value, String path) { + final input = _expectRecord(value, path); + final type = input['type']; + if (type == 'portal') { + return GraphStartPolicyPortal( + sourcePort: _expectIdentifier(input['sourcePort'], '$path.sourcePort'), + targetPort: _expectIdentifier(input['targetPort'], '$path.targetPort'), + maxWaitFrames: _expectNonNegativeSafeInteger( + input['maxWaitFrames'], + '$path.maxWaitFrames', + ), + ); + } + if (type == 'finish') { + return GraphStartPolicyFinish( + targetPort: _expectIdentifier(input['targetPort'], '$path.targetPort'), + maxWaitFrames: _expectNonNegativeSafeInteger( + input['maxWaitFrames'], + '$path.maxWaitFrames', + ), + ); + } + if (type == 'cut') { + if (input['maxWaitFrames'] != 1) { + _invalid('$path.maxWaitFrames must be 1 for a cut'); + } + return GraphStartPolicyCut( + targetPort: _expectIdentifier(input['targetPort'], '$path.targetPort'), + ); + } + _invalid('$path.type must be portal, finish, or cut'); +} + +GraphTransitionDefinition _cloneTransition(Object? value, String path) { + final input = _expectRecord(value, path); + final unitId = _expectIdentifier(input['unitId'], '$path.unitId'); + final frameCount = _expectPositiveSafeInteger(input['frameCount'], '$path.frameCount'); + if (input['kind'] == 'locked') { + return GraphTransitionLocked(unitId: unitId, frameCount: frameCount); + } + if (input['kind'] == 'reversible') { + final directionValue = input['direction']; + TransitionDirection direction; + if (directionValue == 'forward') { + direction = TransitionDirection.forward; + } else if (directionValue == 'reverse') { + direction = TransitionDirection.reverse; + } else { + _invalid('$path.direction must be forward or reverse'); + } + final reverseOfValue = input['reverseOf']; + final reverseOf = + reverseOfValue == null ? null : _expectIdentifier(reverseOfValue, '$path.reverseOf'); + return GraphTransitionReversible( + unitId: unitId, + frameCount: frameCount, + direction: direction, + reverseOf: reverseOf, + ); + } + _invalid('$path.kind must be locked or reversible'); +} + +void _validateEdgeReferencesAndGeometry( + GraphEdgeDefinition edge, + Map statesById, + Map> portsByState, + Map> directEdgesByState, + Map> eventEdgesByState, + Map completionEdgesByState, +) { + final sourceLookup = statesById[edge.from]; + final targetLookup = statesById[edge.to]; + if (sourceLookup == null) { + _invalid('${_edgePath(edge)}.from does not reference a state'); + } + if (targetLookup == null) { + _invalid('${_edgePath(edge)}.to does not reference a state'); + } + final source = sourceLookup; + final target = targetLookup; + + final direct = _getOrCreate(directEdgesByState, edge.from); + final duplicateDirect = direct[edge.to]; + if (duplicateDirect != null) { + _invalid( + '${_edgePath(edge)} duplicates direct route ${_quote(duplicateDirect.id)} from ${_quote(edge.from)} to ${_quote(edge.to)}', + ); + } + direct[edge.to] = edge; + + final trigger = edge.trigger; + if (trigger is GraphEdgeTriggerEvent) { + final events = _getOrCreate(eventEdgesByState, edge.from); + final duplicateEvent = events[trigger.name]; + if (duplicateEvent != null) { + _invalid( + '${_edgePath(edge)} duplicates event ${_quote(trigger.name)} from ${_quote(edge.from)}', + ); + } + events[trigger.name] = edge; + } else if (trigger is GraphEdgeTriggerCompletion) { + if (source.body.kind == GraphBodyKind.loop) { + _invalid('${_edgePath(edge)} completion trigger cannot originate from a loop'); + } + final duplicateCompletion = completionEdgesByState[edge.from]; + if (duplicateCompletion != null) { + _invalid( + '${_edgePath(edge)} duplicates completion route ${_quote(duplicateCompletion.id)} from ${_quote(edge.from)}', + ); + } + completionEdgesByState[edge.from] = edge; + } + + final targetPorts = portsByState[target.id]; + if (targetPorts?.containsKey(edge.start.targetPort) != true) { + _invalid( + '${_edgePath(edge)} target port ${_quote(edge.start.targetPort)} does not exist on ${_quote(target.id)}', + ); + } + + final start = edge.start; + if (start is GraphStartPolicyPortal) { + final sourcePorts = portsByState[source.id]; + final port = sourcePorts?[start.sourcePort]; + if (port == null) { + _invalid( + '${_edgePath(edge)} source port ${_quote(start.sourcePort)} does not exist on ${_quote(source.id)}', + ); + } + if (source.body.kind != GraphBodyKind.loop && + port.portalFrames.last != source.body.frameCount - 1) { + _invalid('${_edgePath(edge)} finite/held source port must include the held final frame'); + } + final minimum = greatestPortalWaitFrames(source.body, start.sourcePort); + if (start.maxWaitFrames < minimum) { + _invalid( + '${_edgePath(edge)} maxWaitFrames ${start.maxWaitFrames} is below the geometric minimum $minimum', + ); + } + } else if (start is GraphStartPolicyFinish) { + if (source.body.kind == GraphBodyKind.loop) { + _invalid('${_edgePath(edge)} finish cannot originate from a loop'); + } + final minimum = greatestFinishWaitFrames(source.body); + if (start.maxWaitFrames < minimum) { + _invalid( + '${_edgePath(edge)} maxWaitFrames ${start.maxWaitFrames} is below the finish minimum $minimum', + ); + } + } else { + if (edge.transition != null) { + _invalid('${_edgePath(edge)} cut cannot own a transition unit'); + } + if (edge.continuity != GraphContinuity.cut) { + _invalid('${_edgePath(edge)} cut must declare continuity cut'); + } + } + + if (start is! GraphStartPolicyCut && edge.continuity == GraphContinuity.cut) { + _invalid('${_edgePath(edge)} continuity cut requires start policy cut'); + } + final transition = edge.transition; + if (edge.continuity == GraphContinuity.exactReverse && + (transition is! GraphTransitionReversible || transition.reverseOf == null)) { + _invalid( + '${_edgePath(edge)} exact-reverse requires a reversible transition with reverseOf', + ); + } +} + +Map _validateReversiblePairs( + List edges, + Map edgesById, +) { + final groups = >{}; + for (final edge in edges) { + final transition = edge.transition; + if (transition is! GraphTransitionReversible) continue; + final group = groups[transition.unitId]; + if (group == null) { + groups[transition.unitId] = [edge]; + } else { + group.add(edge); + } + } + + final inverseEdgesById = {}; + for (final entry in groups.entries) { + final unitId = entry.key; + final group = entry.value; + if (group.length != 2) { + _invalid('reversible unit ${_quote(unitId)} must be used by exactly two inverse edges'); + } + final first = group[0]; + final second = group[1]; + final firstTransition = first.transition; + final secondTransition = second.transition; + if (firstTransition is! GraphTransitionReversible || + secondTransition is! GraphTransitionReversible) { + _invalid('reversible unit ${_quote(unitId)} has an invalid inverse pair'); + } + if (first.from != second.to || first.to != second.from) { + _invalid('reversible unit ${_quote(unitId)} must reverse its endpoints'); + } + if (firstTransition.frameCount != secondTransition.frameCount) { + _invalid('reversible unit ${_quote(unitId)} must use one frame count'); + } + if (firstTransition.direction == secondTransition.direction) { + _invalid('reversible unit ${_quote(unitId)} must use opposite directions'); + } + + final declaring = [first, second].where((candidate) { + final t = candidate.transition; + return t is GraphTransitionReversible && t.reverseOf != null; + }).toList(); + if (declaring.length != 1) { + _invalid( + 'reversible unit ${_quote(unitId)} must have exactly one inverse edge with reverseOf', + ); + } + final inverse = declaring[0]; + final inverseTransition = inverse.transition; + if (inverseTransition is! GraphTransitionReversible) { + _invalid('reversible unit ${_quote(unitId)} has no inverse declaration'); + } + final base = identical(inverse, first) ? second : first; + if (inverseTransition.reverseOf != base.id) { + _invalid( + '${_edgePath(inverse)}.transition.reverseOf must reference ${_quote(base.id)}', + ); + } + if (edgesById[inverseTransition.reverseOf] != base) { + _invalid('${_edgePath(inverse)}.transition.reverseOf is invalid'); + } + if (inverse.continuity != GraphContinuity.exactReverse) { + _invalid('${_edgePath(inverse)} must declare continuity exact-reverse'); + } + if (base.continuity == GraphContinuity.exactReverse) { + _invalid('${_edgePath(base)} cannot declare exact-reverse without reverseOf'); + } + inverseEdgesById[first.id] = second; + inverseEdgesById[second.id] = first; + } + return inverseEdgesById; +} + +void _validateImmediateCompletionCycles( + Map completionEdgesByState, + Map statesById, +) { + final immediate = {}; + for (final entry in completionEdgesByState.entries) { + final source = statesById[entry.key]; + if (source != null && + _isImmediateCompletionSource(source) && + entry.value.transition == null) { + immediate[entry.key] = entry.value.to; + } + } + + for (final start in immediate.keys) { + final path = {}; + GraphStateId? cursor = start; + while (cursor != null) { + if (path.contains(cursor)) { + _invalid('completion routes contain an immediate cycle at ${_quote(cursor)}'); + } + path.add(cursor); + cursor = immediate[cursor]; + } + } +} + +bool _isImmediateCompletionSource(GraphStateDefinition state) { + return state.body.kind == GraphBodyKind.held || + (state.body.kind == GraphBodyKind.finite && state.body.frameCount == 1); +} + +Map _expectRecord(Object? value, String path) { + if (value is! Map) { + _invalid('$path must be an object'); + } + return Map.from(value); +} + +List _expectArray(Object? value, String path) { + if (value is! List) { + _invalid('$path must be an array'); + } + return value; +} + +String _expectIdentifier(Object? value, String path) { + if (value is! String || !graphIdentifierPattern.hasMatch(value)) { + _invalid('$path must match ${graphIdentifierPattern.pattern}'); + } + return value; +} + +int _expectPositiveSafeInteger(Object? value, String path) { + final asInt = _asInteger(value); + if (asInt == null || asInt <= 0) { + _invalid('$path must be a positive safe integer'); + } + return asInt; +} + +int _expectNonNegativeSafeInteger(Object? value, String path) { + final asInt = _asInteger(value); + if (asInt == null || asInt < 0) { + _invalid('$path must be a nonnegative safe integer'); + } + return asInt; +} + +/// Accepts a Dart `int` directly, or a whole-valued `double` (as produced by +/// some JSON decoders for integral literals), mirroring how the TypeScript +/// original treats any `Number.isSafeInteger` value the same regardless of +/// whether the source was authored as `4` or `4.0`. +int? _asInteger(Object? value) { + if (value is int) return value; + if (value is double && value.isFinite && value == value.truncateToDouble()) { + return value.toInt(); + } + return null; +} + +void _addUnique(Set values, String value, String path, String label) { + if (values.contains(value)) { + _invalid('$path duplicates $label ${_quote(value)}'); + } + values.add(value); +} + +void _reserveUnit(Set reservedUnitIds, String unitId, String path) { + if (reservedUnitIds.contains(unitId)) { + _invalid('$path duplicates unit ID ${_quote(unitId)}'); + } + reservedUnitIds.add(unitId); +} + +Map _getOrCreate( + Map> map, + K key, +) { + final current = map[key]; + if (current != null) return current; + final created = {}; + map[key] = created; + return created; +} + +String _edgePath(GraphEdgeDefinition edge) => 'edge ${_quote(edge.id)}'; + +String _quote(String value) => jsonEncode(value); + +List _cloneRings( + Object? value, + Map statesById, +) { + if (value == null) return const []; + final inputs = _expectArray(value, 'rings'); + if (inputs.length > GraphLimits.maxRings) { + _invalid('rings must contain at most ${GraphLimits.maxRings} entries'); + } + final rings = [ + for (var index = 0; index < inputs.length; index += 1) + _cloneRing(inputs[index], index, statesById), + ]; + for (var index = 1; index < rings.length; index += 1) { + if (rings[index - 1].id.compareTo(rings[index].id) >= 0) { + _invalid('rings must be sorted and unique by id'); + } + } + return rings; +} + +GraphRingDefinition _cloneRing( + Object? value, + int index, + Map statesById, +) { + final path = 'rings[$index]'; + final input = _expectRecord(value, path); + final id = _expectIdentifier(input['id'], '$path.id'); + final cyclic = input['cyclic']; + if (cyclic is! bool) { + _invalid('ring ${_quote(id)} cyclic must be a boolean'); + } + final tieBreakRaw = input['tieBreak']; + if (tieBreakRaw != 'forward' && tieBreakRaw != 'backward') { + _invalid('ring ${_quote(id)} tieBreak must be forward or backward'); + } + final tieBreak = tieBreakRaw == 'forward' + ? GraphRingTieBreak.forward + : GraphRingTieBreak.backward; + final stateInputs = _expectArray(input['states'], '$path.states'); + if (stateInputs.length > GraphLimits.maxRingStates) { + _invalid( + 'ring ${_quote(id)} must contain at most ${GraphLimits.maxRingStates} states', + ); + } + final seen = {}; + final states = [ + for (var stateIndex = 0; stateIndex < stateInputs.length; stateIndex += 1) + () { + final stateId = _expectIdentifier( + stateInputs[stateIndex], + '$path.states[$stateIndex]', + ); + if (seen.contains(stateId)) { + _invalid('ring ${_quote(id)} duplicates state ${_quote(stateId)}'); + } + seen.add(stateId); + if (!statesById.containsKey(stateId)) { + _invalid( + 'ring ${_quote(id)} references unknown state ${_quote(stateId)}', + ); + } + return stateId; + }(), + ]; + if (states.length < 2) { + _invalid('ring ${_quote(id)} must contain at least 2 states'); + } + if (cyclic && states.length < 3) { + _invalid('cyclic ring ${_quote(id)} must contain at least 3 states'); + } + final maxChainedSteps = + _expectPositiveSafeInteger(input['maxChainedSteps'], '$path.maxChainedSteps'); + if (maxChainedSteps > GraphLimits.maxChainedSteps) { + _invalid( + 'ring ${_quote(id)} maxChainedSteps must be at most ${GraphLimits.maxChainedSteps}', + ); + } + return GraphRingDefinition( + id: id, + states: states, + cyclic: cyclic, + tieBreak: tieBreak, + maxChainedSteps: maxChainedSteps, + ); +} + +Map> _indexRingsByState( + List rings, +) { + final byState = >{}; + for (final ring in rings) { + for (final state in ring.states) { + (byState[state] ??= []).add(ring); + } + } + return byState; +} + +void _validateRingStepOwnership( + List rings, + Map> directEdgesByState, +) { + final owners = {}; + for (final ring in rings) { + for (final pair in _ringNeighbourPairs(ring)) { + final key = '${pair.$1}\u0000${pair.$2}'; + final owner = owners[key]; + if (owner != null) { + _invalid( + 'rings ${_quote(owner.ring)} and ${_quote(ring.id)} both step from ' + '${_quote(pair.$1)} to ${_quote(pair.$2)}', + ); + } + owners[key] = (ring: ring.id, from: pair.$1, to: pair.$2); + } + } + for (final owner in owners.values) { + final edge = directEdgesByState[owner.from]?[owner.to]; + if (edge?.ring != null && edge!.ring != owner.ring) { + _invalid( + 'edge ${_quote(edge.id)} declares ring ${_quote(edge.ring!)} but steps ' + 'inside ring ${_quote(owner.ring)}', + ); + } + } +} + +Iterable<(GraphStateId, GraphStateId)> _ringNeighbourPairs(GraphRingDefinition ring) sync* { + final states = ring.states; + for (var i = 0; i < states.length - 1; i += 1) { + yield (states[i], states[i + 1]); + yield (states[i + 1], states[i]); + } + if (ring.cyclic && states.length >= 2) { + yield (states.last, states.first); + yield (states.first, states.last); + } +} + +void _validateTurnEdge( + GraphEdgeDefinition edge, + Map ringsById, +) { + if (edge.ring == null && edge.step == null) return; + if (edge.ring == null || edge.step == null) { + _invalid('edge ${_quote(edge.id)} turn membership requires both ring and step'); + } + final ring = ringsById[edge.ring]; + if (ring == null) { + _invalid( + 'edge ${_quote(edge.id)} references unknown ring ${_quote(edge.ring!)}', + ); + } +} + +Never _invalid(String message) => throw MotionGraphValidationError(message); diff --git a/flutter/packages/aval_graph/pubspec.yaml b/flutter/packages/aval_graph/pubspec.yaml new file mode 100644 index 0000000..1500a77 --- /dev/null +++ b/flutter/packages/aval_graph/pubspec.yaml @@ -0,0 +1,15 @@ +name: aval_graph +description: > + Deterministic state graph for AVAL assets. Pure-Dart port of + @pixel-point/aval-graph with full behavioral parity. +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.5.0 + +dependencies: {} + +dev_dependencies: + test: ^1.25.0 + lints: ^4.0.0 diff --git a/flutter/packages/aval_graph/test/engine_failure_retention_test.dart b/flutter/packages/aval_graph/test/engine_failure_retention_test.dart new file mode 100644 index 0000000..49f3b7e --- /dev/null +++ b/flutter/packages/aval_graph/test/engine_failure_retention_test.dart @@ -0,0 +1,145 @@ +// Ported from packages/graph/test/engine-failure-retention.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +void main() { + group('MotionGraphEngine failed presentation retention', () { + test('restores the host\'s last drawn state when a committed cut cannot recover', () { + final engine = MotionGraphEngine(); + engine.install({ + 'initialState': 'idle', + 'states': [_state('idle'), _state('hover')], + 'edges': [ + { + 'id': 'idle-hover', + 'from': 'idle', + 'to': 'hover', + 'start': {'type': 'cut', 'targetPort': 'default', 'maxWaitFrames': 1}, + 'continuity': 'cut', + }, + ], + }); + engine.beginAnimated(); + engine.request('hover'); + final committed = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + expect(committed.snapshot.visualState, 'hover'); + + final failed = engine.failStatic( + 'recovery failed', + const MotionGraphStaticFailureOptions(retainedVisualState: 'idle'), + ); + expect(failed.presentation, const GraphPresentationStatic(state: 'idle')); + expect(failed.snapshot.readiness, MotionGraphReadiness.error); + expect(failed.snapshot.phase, MotionGraphPhase.error); + expect(failed.snapshot.requestedState, 'hover'); + expect(failed.snapshot.visualState, 'idle'); + expect(failed.snapshot.isTransitioning, false); + + final failedSnapshot = engine.snapshot(); + final failedTrace = engine.getTrace(); + expect( + () => engine.resumeAnimated(), + throwsA(isA().having((e) => e.message, 'message', contains('requires phase static'))), + ); + expect(engine.snapshot(), failedSnapshot); + expect(engine.getTrace(), failedTrace); + expect( + () => engine.failStatic( + 'again', + const MotionGraphStaticFailureOptions(retainedVisualState: 'missing'), + ), + throwsA(isA().having((e) => e.message, 'message', contains('retained visual state'))), + ); + }); + + test('recovers from the pixels actually retained after a superseded failed cut', () { + final engine = MotionGraphEngine(); + engine.install({ + 'initialState': 'idle', + 'states': [_state('idle'), _state('hover')], + 'edges': [_cut('idle-hover', 'idle', 'hover'), _cut('hover-idle', 'hover', 'idle')], + }); + engine.beginAnimated(); + engine.request('hover'); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + final latest = engine.request('idle'); + expect(latest.accepted, true); + + final recovered = engine.recoverStatic( + 'animation-failure', + const MotionGraphRecoveryOptions(retainedVisualState: 'idle'), + ); + + expect(recovered.presentation, const GraphPresentationStatic(state: 'idle')); + expect(recovered.snapshot.readiness, MotionGraphReadiness.static); + expect(recovered.snapshot.requestedState, 'idle'); + expect(recovered.snapshot.visualState, 'idle'); + expect(recovered.snapshot.isTransitioning, false); + expect(recovered.effects.map((e) => e.runtimeType), [ + MotionGraphEffectReadinessChange, + MotionGraphEffectFallback, + MotionGraphEffectSettle, + ]); + + final resumed = engine.resumeAnimated(); + expect(resumed.operation, MotionGraphOperation.resumeAnimated); + expect( + resumed.presentation, + const GraphPresentationBody(state: 'idle', unitId: 'idle-body', frameIndex: 0), + ); + expect(resumed.snapshot.readiness, MotionGraphReadiness.animated); + expect(resumed.snapshot.phase, MotionGraphPhase.stable); + expect(resumed.snapshot.requestedState, 'idle'); + expect(resumed.snapshot.contentOrdinal, recovered.snapshot.contentOrdinal); + expect(resumed.snapshot.inputSequence, recovered.snapshot.inputSequence); + expect(resumed.snapshot.inputsSinceTick, recovered.snapshot.inputsSinceTick); + expect(resumed.effects, [ + const MotionGraphEffectReadinessChange(from: MotionGraphReadiness.static, to: MotionGraphReadiness.animated), + ]); + }); + + test('does not resume or clear a disposed terminal graph', () { + final engine = MotionGraphEngine(); + engine.install({ + 'initialState': 'idle', + 'states': [_state('idle')], + 'edges': [], + }); + engine.beginStatic('reduced-motion'); + engine.dispose(); + final snapshot = engine.snapshot(); + final trace = engine.getTrace(); + + expect( + () => engine.resumeAnimated(), + throwsA(isA().having((e) => e.message, 'message', contains('requires phase static'))), + ); + expect(engine.snapshot(), snapshot); + expect(engine.getTrace(), trace); + }); + }); +} + +Map _cut(String id, String from, String to) { + return { + 'id': id, + 'from': from, + 'to': to, + 'start': {'type': 'cut', 'targetPort': 'default', 'maxWaitFrames': 1}, + 'continuity': 'cut', + }; +} + +Map _state(String id) { + return { + 'id': id, + 'body': { + 'unitId': '$id-body', + 'kind': 'loop', + 'frameCount': 2, + 'ports': [ + {'id': 'default', 'entryFrame': 0, 'portalFrames': const [0, 1]}, + ], + }, + }; +} diff --git a/flutter/packages/aval_graph/test/engine_fuzz_test.dart b/flutter/packages/aval_graph/test/engine_fuzz_test.dart new file mode 100644 index 0000000..aa886b4 --- /dev/null +++ b/flutter/packages/aval_graph/test/engine_fuzz_test.dart @@ -0,0 +1,659 @@ +// Ported from packages/graph/test/engine-fuzz.test.ts +// +// The TypeScript original imports a shared `mutationSeeds` helper from +// `tests/mutation/seed-profile.ts`, a repo-wide utility outside the graph +// package used by several packages' fuzz suites to read a committed seed +// profile from `AVL_MUTATION_SEEDS`. This Dart package is self-contained +// (test/*.dart within its own directory), so the same small, dependency-free +// logic is reproduced locally rather than reaching outside the package. +import 'dart:io' show Platform; + +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_graph/src/validate.dart'; +import 'package:test/test.dart'; + +const int _generatedTicks = 2500; +const int _drainTicks = 80; + +void main() { + final seeds = _mutationSeeds(const [1, 0x5eedc0de, 0xc0ffee, 0xffffffff]); + + group('MotionGraphEngine seeded properties', () { + for (final seed in seeds) { + test('replays seed 0x${seed.toRadixString(16)} deterministically', () { + final tape = _createTape(seed); + final first = _replayTape(tape, seed); + final second = _replayTape(tape, seed); + + expect(second.results, first.results); + expect(second.finalSnapshot, first.finalSnapshot); + expect(second.trace, first.trace); + + expect(first.finalSnapshot.phase, MotionGraphPhase.stable); + expect(first.finalSnapshot.visualState, first.finalSnapshot.requestedState); + expect(first.finalSnapshot.pendingRequestCount, 0); + expect(first.issuedRequestIds, isNotEmpty); + expect(first.settledRequestIds, first.issuedRequestIds); + + expect(first.trace, hasLength(GraphLimits.maxTraceRecords)); + expect( + first.trace.first.index, + first.results.length - GraphLimits.maxTraceRecords + 1, + ); + expect(first.trace.last.index, first.results.length); + }); + } + }); +} + +sealed class _TapeOperation { + const _TapeOperation(); +} + +class _RequestOp extends _TapeOperation { + const _RequestOp(this.target); + final String target; + + @override + String toString() => 'request($target)'; +} + +class _SendOp extends _TapeOperation { + const _SendOp(this.event); + final String event; + + @override + String toString() => 'send($event)'; +} + +class _TickOp extends _TapeOperation { + const _TickOp(this.routeReady); + final bool routeReady; + + @override + String toString() => 'tick(routeReady: $routeReady)'; +} + +class _Replay { + const _Replay({ + required this.results, + required this.finalSnapshot, + required this.trace, + required this.issuedRequestIds, + required this.settledRequestIds, + }); + + final List results; + final MotionGraphSnapshot finalSnapshot; + final List trace; + final Set issuedRequestIds; + final Set settledRequestIds; +} + +final ValidatedGraphIndexes _fuzzIndexes = + getValidatedGraphIndexes(validateMotionGraphDefinition(_fuzzGraph())); + +_Replay _replayTape(List<_TapeOperation> tape, int seed) { + final engine = MotionGraphEngine(); + final results = []; + final issuedRequestIds = {}; + final settledRequestIds = {}; + var nextRequestId = 1; + var nextContentOrdinal = BigInt.zero; + var inputsSinceTick = 0; + + final installed = engine.install(_fuzzGraph()); + // Installation establishes the initial visual state; it is not a runtime + // visual-state transition and therefore has no visualstatechange effect. + _assertResultProperties(installed, installed.snapshot, seed, -2); + results.add(installed); + var previous = installed.snapshot; + + final animated = engine.beginAnimated(); + _assertResultProperties(animated, previous, seed, -1); + results.add(animated); + previous = animated.snapshot; + + final reduced = engine.recoverStatic('seeded-resume-model'); + _assertResultProperties(reduced, previous, seed, -0.75); + results.add(reduced); + previous = reduced.snapshot; + + final resumed = engine.resumeAnimated(); + _assertResultProperties(resumed, previous, seed, -0.5); + final resumedPresentation = resumed.presentation; + _invariant( + resumed.operation == MotionGraphOperation.resumeAnimated && + resumed.effects.length == 1 && + resumed.effects[0] is MotionGraphEffectReadinessChange && + resumedPresentation is GraphPresentationIntro && + resumedPresentation.state == resumed.snapshot.visualState && + resumedPresentation.frameIndex == 0 && + resumed.snapshot.initialUnitPending, + seed, + -0.5, + 'static resume did not restart the unfinished intro exactly', + ); + results.add(resumed); + previous = resumed.snapshot; + + for (var index = 0; index < tape.length; index += 1) { + final operation = tape[index]; + final MotionGraphResult result; + + if (operation is _RequestOp) { + inputsSinceTick += 1; + result = engine.request(operation.target); + _invariant( + result.requestId == nextRequestId, + seed, + index, + 'request ID ${result.requestId} is not $nextRequestId', + ); + issuedRequestIds.add(nextRequestId); + nextRequestId += 1; + + if (inputsSinceTick > GraphLimits.maxInputsPerTick) { + _invariant( + result.accepted == false, + seed, + index, + 'request beyond the per-tick input cap was accepted', + ); + _invariant( + result.effects.any((effect) => + effect is MotionGraphEffectSettle && + effect.outcome is GraphSettlementReject && + (effect.outcome as GraphSettlementReject).error == + GraphSettlementError.inputOverflowError), + seed, + index, + 'overflowed request did not receive InputOverflowError', + ); + } + } else if (operation is _SendOp) { + inputsSinceTick += 1; + result = engine.send(operation.event); + if (inputsSinceTick > GraphLimits.maxInputsPerTick) { + _invariant( + result.accepted == false, + seed, + index, + 'event beyond the per-tick input cap was accepted', + ); + } + } else { + final tickOp = operation as _TickOp; + try { + result = engine.tick( + MotionGraphTickOptions(contentOrdinal: nextContentOrdinal, routeReady: tickOp.routeReady), + ); + } catch (error) { + final start = index - 8 < 0 ? 0 : index - 8; + final recentOperations = tape.sublist(start, index + 1); + final recentStart = results.length > 10 ? results.length - 10 : 0; + final recentResults = results.sublist(recentStart).map(_summarizeResult).toList(); + throw StateError( + 'seed=0x${seed.toRadixString(16)} operation=$index recentOperations=$recentOperations ' + 'recentResults=$recentResults (cause: $error)', + ); + } + nextContentOrdinal += BigInt.one; + } + + _assertResultProperties(result, previous, seed, index); + _collectSettlements(result.effects, issuedRequestIds, settledRequestIds, seed, index); + + final expectedInputs = operation is _TickOp + ? 0 + : (inputsSinceTick < GraphLimits.maxInputsPerTick ? inputsSinceTick : GraphLimits.maxInputsPerTick); + _invariant( + result.snapshot.inputsSinceTick == expectedInputs, + seed, + index, + 'inputsSinceTick is ${result.snapshot.inputsSinceTick}, expected $expectedInputs', + ); + + if (operation is _TickOp) { + inputsSinceTick = 0; + _invariant( + result.snapshot.contentOrdinal == nextContentOrdinal - BigInt.one, + seed, + index, + 'tick did not consume exactly one content ordinal', + ); + } else { + _invariant( + result.presentation == previous.presentation, + seed, + index, + 'an input operation changed the presented frame', + ); + } + + _invariant( + result.snapshot.pendingRequestCount == issuedRequestIds.length - settledRequestIds.length, + seed, + index, + 'pending request count ${result.snapshot.pendingRequestCount} diverged from unsettled IDs ' + 'after operation $operation', + ); + + results.add(result); + previous = result.snapshot; + } + + return _Replay( + results: List.unmodifiable(results), + finalSnapshot: engine.snapshot(), + trace: engine.getTrace(), + issuedRequestIds: issuedRequestIds, + settledRequestIds: settledRequestIds, + ); +} + +Map _summarizeResult(MotionGraphResult result) { + return { + 'operation': result.operation.name, + 'accepted': result.accepted, + 'joined': result.joined, + 'requestId': result.requestId, + 'effects': result.effects.map((e) => e.runtimeType.toString()).toList(), + 'phase': result.snapshot.phase.name, + 'requested': result.snapshot.requestedState, + 'visual': result.snapshot.visualState, + 'prospective': result.snapshot.prospectiveState, + 'pending': result.snapshot.pendingRequestCount, + 'pendingEdge': result.snapshot.pendingEdgeId, + 'activeEdge': result.snapshot.activeEdgeId, + 'followOn': result.snapshot.followOnEdgeId, + }; +} + +void _assertResultProperties( + MotionGraphResult result, + MotionGraphSnapshot previous, + int seed, + num operationIndex, +) { + // The TypeScript original also asserts `Object.isFrozen(...)` on the + // result, snapshot, effects, and presentation. Every corresponding Dart + // type here is immutable by construction (final fields, no setters), so + // there is no runtime "frozen" check to make. + _invariant( + result.presentation == result.snapshot.presentation, + seed, + operationIndex, + 'result and snapshot expose different presentations', + ); + + final presentation = result.presentation; + if (presentation != null) { + _assertPresentationBounds(presentation, seed, operationIndex); + } + + final visualEffects = result.effects.whereType().toList(); + final visualChanged = previous.visualState != result.snapshot.visualState; + _invariant( + visualEffects.length == (visualChanged ? 1 : 0), + seed, + operationIndex, + 'visual state change does not match its effect count', + ); + + if (visualChanged) { + final effect = visualEffects[0]; + _invariant( + effect.from == previous.visualState && effect.to == result.snapshot.visualState, + seed, + operationIndex, + 'visualstatechange effect has inconsistent endpoints', + ); + _invariant( + _isCommittedPresentation(result.presentation, result.snapshot.visualState), + seed, + operationIndex, + 'visual state changed without presenting the target entry', + ); + } + + String? stableState; + if (presentation is GraphPresentationBody) { + stableState = presentation.state; + } else if (presentation is GraphPresentationIntro) { + stableState = presentation.state; + } else if (presentation is GraphPresentationStatic) { + stableState = presentation.state; + } + if (stableState != null) { + _invariant( + stableState == result.snapshot.visualState, + seed, + operationIndex, + 'stable presentation does not represent visualState', + ); + } + + for (final effect in result.effects.whereType()) { + _invariant( + effect.to == result.snapshot.visualState && + _isCommittedPresentation(result.presentation, effect.to), + seed, + operationIndex, + 'transition ended without its target entry presentation', + ); + } +} + +void _assertPresentationBounds(GraphPresentation presentation, int seed, num operationIndex) { + if (presentation is GraphPresentationStatic) { + final state = _fuzzIndexes.statesById[presentation.state]; + _invariant(state != null, seed, operationIndex, 'static presentation references an unknown state'); + return; + } + + if (presentation is GraphPresentationIntro) { + final initial = _fuzzIndexes.statesById[presentation.state]?.initialUnit; + _invariant( + initial != null && + initial.unitId == presentation.unitId && + presentation.frameIndex >= 0 && + presentation.frameIndex < initial.frameCount, + seed, + operationIndex, + 'intro presentation is outside its unit', + ); + return; + } + + if (presentation is GraphPresentationBody) { + final body = _fuzzIndexes.statesById[presentation.state]?.body; + _invariant( + body != null && + body.unitId == presentation.unitId && + presentation.frameIndex >= 0 && + presentation.frameIndex < body.frameCount, + seed, + operationIndex, + 'body presentation is outside its unit', + ); + return; + } + + if (presentation is GraphPresentationLocked) { + final transition = _fuzzIndexes.edgesById[presentation.edgeId]?.transition; + _invariant( + transition is GraphTransitionLocked && + transition.unitId == presentation.unitId && + presentation.frameIndex >= 0 && + presentation.frameIndex < transition.frameCount, + seed, + operationIndex, + 'transition presentation is outside its unit', + ); + return; + } + + final reversible = presentation as GraphPresentationReversible; + final transition = _fuzzIndexes.edgesById[reversible.edgeId]?.transition; + _invariant( + transition is GraphTransitionReversible && + transition.unitId == reversible.unitId && + reversible.frameIndex >= 0 && + reversible.frameIndex < transition.frameCount, + seed, + operationIndex, + 'transition presentation is outside its unit', + ); + _invariant( + transition is GraphTransitionReversible && transition.direction == reversible.direction, + seed, + operationIndex, + 'reversible presentation has the wrong direction', + ); +} + +void _collectSettlements( + List effects, + Set issued, + Set settled, + int seed, + num operationIndex, +) { + for (final effect in effects) { + if (effect is! MotionGraphEffectSettle) continue; + final unique = effect.requestIds.toSet(); + _invariant( + unique.length == effect.requestIds.length, + seed, + operationIndex, + 'one settlement contains a duplicate request ID', + ); + for (var index = 1; index < effect.requestIds.length; index += 1) { + _invariant( + effect.requestIds[index - 1] < effect.requestIds[index], + seed, + operationIndex, + 'settlement request IDs are not in request order', + ); + } + for (final requestId in effect.requestIds) { + _invariant(issued.contains(requestId), seed, operationIndex, 'settled unknown request $requestId'); + _invariant( + !settled.contains(requestId), + seed, + operationIndex, + 'request $requestId settled more than once', + ); + settled.add(requestId); + } + } +} + +bool _isCommittedPresentation(GraphPresentation? presentation, String? target) { + if (presentation is GraphPresentationStatic) return presentation.state == target; + return presentation is GraphPresentationBody && + presentation.state == target && + presentation.frameIndex == 0; +} + +List<_TapeOperation> _createTape(int seed) { + final random = _mulberry32(seed); + final tape = <_TapeOperation>[]; + const targets = ['idle', 'hovered', 'success', 'missing']; + const events = ['hover.on', 'hover.off', 'complete', 'reset', 'unknown']; + + for (var tick = 0; tick < _generatedTicks; tick += 1) { + final inputCount = tick % 211 == 0 ? 40 : (random() * 5).floor(); + for (var input = 0; input < inputCount; input += 1) { + if (random() < 0.72) { + tape.add(_RequestOp(targets[(random() * targets.length).floor()])); + } else { + tape.add(_SendOp(events[(random() * events.length).floor()])); + } + } + tape.add(_TickOp(tick % 7 == 0 || random() >= 0.2)); + } + + for (var tick = 0; tick < _drainTicks; tick += 1) { + tape.add(const _TickOp(true)); + } + return List.unmodifiable(tape); +} + +/// Deterministic PRNG (mulberry32), used only to generate this test's +/// pseudo-random operation tape. Bit-for-bit parity with the TypeScript +/// original's JS-`number`-based implementation is not required: nothing +/// compares a Dart-generated tape against a JS-generated one, only against +/// itself (replayed twice), so this only needs to be internally +/// deterministic, which 64-bit Dart `int` arithmetic (masked to 32 bits at +/// each step) provides. +double Function() _mulberry32(int seed) { + var state = seed & 0xFFFFFFFF; + return () { + state = (state + 0x6d2b79f5) & 0xFFFFFFFF; + var value = state; + value = ((value ^ (value >> 15)) * (value | 1)) & 0xFFFFFFFF; + value = (value + (((value ^ (value >> 7)) * (value | 61)) & 0xFFFFFFFF)) & 0xFFFFFFFF; + return ((value ^ (value >> 14)) & 0xFFFFFFFF) / 4294967296.0; + }; +} + +void _invariant(bool condition, int seed, num operationIndex, String message) { + if (!condition) { + throw StateError('seed=0x${seed.toRadixString(16)} operation=$operationIndex: $message'); + } +} + +const int _maxProfileSeeds = 64; +const int _uint32Max = 0xffffffff; + +/// Resolves the committed mutation profile, mirroring +/// `tests/mutation/seed-profile.ts`'s `mutationSeeds` (read in full earlier +/// this session): individual fuzz files use their historical seeds by +/// default, but a `AVL_MUTATION_SEEDS` environment variable (as set by a +/// matrix runner) overrides them. +List _mutationSeeds(List fallback) { + final encoded = Platform.environment['AVL_MUTATION_SEEDS']; + if (encoded == null) return _freezeValidatedSeeds(fallback, 'fallback'); + if (encoded.isEmpty || encoded.length > 1024) { + throw StateError('AVL_MUTATION_SEEDS has an invalid encoded length'); + } + final fields = encoded.split(','); + if (fields.length > _maxProfileSeeds) { + throw StateError('AVL_MUTATION_SEEDS exceeds $_maxProfileSeeds seeds'); + } + final seeds = []; + for (final field in fields) { + if (!RegExp(r'^(?:0|[1-9][0-9]*)$').hasMatch(field)) { + throw StateError('AVL_MUTATION_SEEDS contains a non-canonical uint32: $field'); + } + seeds.add(int.parse(field)); + } + return _freezeValidatedSeeds(seeds, 'AVL_MUTATION_SEEDS'); +} + +List _freezeValidatedSeeds(List seeds, String source) { + if (seeds.isEmpty || seeds.length > _maxProfileSeeds) { + throw StateError('$source must contain 1 through $_maxProfileSeeds seeds'); + } + final unique = {}; + for (final seed in seeds) { + if (seed < 0 || seed > _uint32Max) { + throw StateError('$source contains an invalid uint32 seed: $seed'); + } + if (!unique.add(seed)) { + throw StateError('$source contains duplicate seed $seed'); + } + } + return List.unmodifiable(seeds); +} + +Map _fuzzGraph() { + return { + 'initialState': 'idle', + 'states': [ + { + 'id': 'idle', + 'initialUnit': {'unitId': 'intro-unit', 'frameCount': 2}, + 'body': { + 'unitId': 'idle-body', + 'kind': 'loop', + 'frameCount': 5, + 'ports': [ + {'id': 'main', 'entryFrame': 0, 'portalFrames': const [1, 4]}, + ], + }, + }, + { + 'id': 'hovered', + 'body': { + 'unitId': 'hovered-body', + 'kind': 'loop', + 'frameCount': 4, + 'ports': [ + {'id': 'main', 'entryFrame': 0, 'portalFrames': const [0, 2]}, + ], + }, + }, + { + 'id': 'success', + 'body': { + 'unitId': 'success-body', + 'kind': 'finite', + 'frameCount': 3, + 'ports': [ + {'id': 'main', 'entryFrame': 0, 'portalFrames': const [2]}, + ], + }, + }, + ], + 'edges': [ + { + 'id': 'idle-hovered', + 'from': 'idle', + 'to': 'hovered', + 'trigger': {'type': 'event', 'name': 'hover.on'}, + 'start': {'type': 'portal', 'sourcePort': 'main', 'targetPort': 'main', 'maxWaitFrames': 5}, + 'transition': { + 'kind': 'reversible', + 'unitId': 'hover-shift', + 'frameCount': 4, + 'direction': 'forward', + }, + 'continuity': 'exact-authored', + }, + { + 'id': 'hovered-idle', + 'from': 'hovered', + 'to': 'idle', + 'trigger': {'type': 'event', 'name': 'hover.off'}, + 'start': {'type': 'portal', 'sourcePort': 'main', 'targetPort': 'main', 'maxWaitFrames': 4}, + 'transition': { + 'kind': 'reversible', + 'unitId': 'hover-shift', + 'frameCount': 4, + 'direction': 'reverse', + 'reverseOf': 'idle-hovered', + }, + 'continuity': 'exact-reverse', + }, + { + 'id': 'idle-success', + 'from': 'idle', + 'to': 'success', + 'trigger': {'type': 'event', 'name': 'complete'}, + 'start': {'type': 'portal', 'sourcePort': 'main', 'targetPort': 'main', 'maxWaitFrames': 5}, + 'transition': {'kind': 'locked', 'unitId': 'idle-success-bridge', 'frameCount': 2}, + 'continuity': 'exact-authored', + }, + { + 'id': 'hovered-success', + 'from': 'hovered', + 'to': 'success', + 'trigger': {'type': 'event', 'name': 'complete'}, + 'start': {'type': 'portal', 'sourcePort': 'main', 'targetPort': 'main', 'maxWaitFrames': 4}, + 'transition': {'kind': 'locked', 'unitId': 'hovered-success-bridge', 'frameCount': 3}, + 'continuity': 'exact-authored', + }, + { + 'id': 'success-idle', + 'from': 'success', + 'to': 'idle', + 'trigger': {'type': 'event', 'name': 'reset'}, + 'start': {'type': 'finish', 'targetPort': 'main', 'maxWaitFrames': 2}, + 'transition': {'kind': 'locked', 'unitId': 'success-idle-bridge', 'frameCount': 2}, + 'continuity': 'exact-authored', + }, + { + 'id': 'success-hovered', + 'from': 'success', + 'to': 'hovered', + 'trigger': {'type': 'event', 'name': 'hover.on'}, + 'start': {'type': 'finish', 'targetPort': 'main', 'maxWaitFrames': 2}, + 'transition': {'kind': 'locked', 'unitId': 'success-hovered-bridge', 'frameCount': 1}, + 'continuity': 'exact-authored', + }, + ], + }; +} diff --git a/flutter/packages/aval_graph/test/engine_golden_test.dart b/flutter/packages/aval_graph/test/engine_golden_test.dart new file mode 100644 index 0000000..d2ea89e --- /dev/null +++ b/flutter/packages/aval_graph/test/engine_golden_test.dart @@ -0,0 +1,695 @@ +// Ported from packages/graph/test/engine-golden.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +void main() { + group('MotionGraphEngine golden lifecycle traces', () { + test('installs the initial static frame and resolves a stable no-op without state events', () { + final engine = MotionGraphEngine(); + final install = engine.install(_graph()); + + expect(install.presentation, const GraphPresentationStatic(state: 'idle')); + expect(install.effects, [_readiness(MotionGraphReadiness.unready, MotionGraphReadiness.preparing)]); + expect(install.snapshot.readiness, MotionGraphReadiness.preparing); + expect(install.snapshot.phase, MotionGraphPhase.preparing); + expect(install.snapshot.requestedState, 'idle'); + expect(install.snapshot.visualState, 'idle'); + expect(install.snapshot.isTransitioning, false); + expect(install.snapshot.contentOrdinal, isNull); + + final animated = engine.beginAnimated(); + expect(animated.presentation, _bodyFrame('idle', 0)); + expect(animated.effects, [_readiness(MotionGraphReadiness.preparing, MotionGraphReadiness.animated)]); + expect(animated.snapshot.readiness, MotionGraphReadiness.animated); + expect(animated.snapshot.phase, MotionGraphPhase.stable); + expect(animated.snapshot.requestedState, 'idle'); + expect(animated.snapshot.visualState, 'idle'); + expect(animated.snapshot.isTransitioning, false); + + final noop = engine.request('idle'); + expect(noop.accepted, true); + expect(noop.joined, false); + expect(noop.sequence, 1); + expect(noop.requestId, 1); + expect(noop.presentation, _bodyFrame('idle', 0)); + expect(noop.effects, [ + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.stableNoop)), + ]); + expect(noop.snapshot.phase, MotionGraphPhase.stable); + expect(noop.snapshot.requestedState, 'idle'); + expect(noop.snapshot.visualState, 'idle'); + expect(noop.snapshot.isTransitioning, false); + expect(noop.snapshot.pendingRequestCount, 0); + }); + + test('rejects a request before metadata with one settlement and no presentation', () { + final engine = MotionGraphEngine(); + final result = engine.request('hover'); + + expect(result.accepted, false); + expect(result.joined, false); + expect(result.sequence, 1); + expect(result.requestId, 1); + expect(result.presentation, isNull); + expect(result.effects, [ + _settle([1], const GraphSettlementReject(GraphSettlementError.notReadyError)), + ]); + expect(result.snapshot.readiness, MotionGraphReadiness.unready); + expect(result.snapshot.phase, MotionGraphPhase.unready); + expect(result.snapshot.requestedState, isNull); + expect(result.snapshot.visualState, isNull); + expect(result.snapshot.isTransitioning, false); + }); + + test('uses a later loop portal when the first portal is not route-ready', () { + final engine = _animatedEngine(_graph(sourceKind: 'loop', sourcePortals: const [1, 3], startType: 'portal', startMaxWaitFrames: 1)); + + final request = engine.request('hover'); + expect(request.presentation, _bodyFrame('idle', 0)); + expect(request.effects, [_requested('idle', 'hover', 1)]); + expect(request.snapshot.phase, MotionGraphPhase.waiting); + expect(request.snapshot.requestedState, 'hover'); + expect(request.snapshot.visualState, 'idle'); + expect(request.snapshot.prospectiveState, 'hover'); + expect(request.snapshot.pendingEdgeId, 'idle-to-hover'); + expect(request.snapshot.activeEdgeId, isNull); + expect(request.snapshot.isTransitioning, true); + expect(request.snapshot.pendingRequestCount, 1); + + final atFirstPortal = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + expect(atFirstPortal.presentation, _bodyFrame('idle', 1)); + expect(atFirstPortal.effects, isEmpty); + + final skipFirstPortal = engine.tick( + MotionGraphTickOptions(contentOrdinal: BigInt.from(1), routeReady: false), + ); + expect(skipFirstPortal.presentation, _bodyFrame('idle', 2)); + expect(skipFirstPortal.effects, isEmpty); + + final atLaterPortal = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))); + expect(atLaterPortal.presentation, _bodyFrame('idle', 3)); + expect(atLaterPortal.effects, isEmpty); + + final commit = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3))); + expect(commit.presentation, _bodyFrame('hover', 0)); + expect(commit.effects, [ + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + expect(commit.snapshot.phase, MotionGraphPhase.stable); + expect(commit.snapshot.requestedState, 'hover'); + expect(commit.snapshot.visualState, 'hover'); + expect(commit.snapshot.prospectiveState, 'hover'); + expect(commit.snapshot.pendingEdgeId, isNull); + expect(commit.snapshot.activeEdgeId, isNull); + expect(commit.snapshot.isTransitioning, false); + expect(commit.snapshot.routeOperationsLastTick, 1); + expect(commit.snapshot.pendingRequestCount, 0); + }); + + test('commits a transitionless portal directly from the displayed portal to target frame zero', () { + final engine = _animatedEngine(_graph()); + engine.request('hover'); + + final result = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + expect(result.presentation, _bodyFrame('hover', 0)); + expect(result.effects, [ + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + }); + + test('commits a cut on the next tick even when routeReady is false', () { + final engine = _animatedEngine(_graph(startType: 'cut', startMaxWaitFrames: 1)); + engine.request('hover'); + + final result = engine.tick( + MotionGraphTickOptions(contentOrdinal: BigInt.from(0), routeReady: false), + ); + expect(result.presentation, _bodyFrame('hover', 0)); + expect(result.effects, [ + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + expect(result.snapshot.routeOperationsLastTick, 1); + }); + + test('searches finite portals forward, never wraps, and holds the final portal until ready', () { + final engine = _animatedEngine(_graph(sourceKind: 'finite', sourcePortals: const [1, 3], startType: 'portal', startMaxWaitFrames: 1)); + engine.request('hover'); + + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, + _bodyFrame('idle', 1), + ); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1), routeReady: false)).presentation, + _bodyFrame('idle', 2), + ); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))).presentation, + _bodyFrame('idle', 3), + ); + + final held = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3), routeReady: false)); + expect(held.presentation, _bodyFrame('idle', 3)); + expect(held.effects, isEmpty); + expect(held.snapshot.phase, MotionGraphPhase.waiting); + expect(held.snapshot.visualState, 'idle'); + expect(held.snapshot.requestedState, 'hover'); + expect(held.snapshot.isTransitioning, true); + + final commit = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(4))); + expect(commit.presentation, _bodyFrame('hover', 0)); + expect(commit.effects, [ + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + }); + + test('finishes a finite body exactly once and waits at its held final frame', () { + final engine = _animatedEngine(_graph(sourceKind: 'finite', sourcePortals: const [0, 3], startType: 'finish', startMaxWaitFrames: 3)); + engine.request('hover'); + + for (var ordinal = 0; ordinal < 3; ordinal += 1) { + final tick = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(ordinal))); + expect(tick.presentation, _bodyFrame('idle', ordinal + 1)); + expect(tick.effects, isEmpty); + } + final held = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3), routeReady: false)); + expect(held.presentation, _bodyFrame('idle', 3)); + expect(held.effects, isEmpty); + + final commit = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(4))); + expect(commit.presentation, _bodyFrame('hover', 0)); + expect(commit.effects, [ + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + }); + + test('runs an explicit completion cut even when route readiness is false', () { + final definition = _graph(sourceKind: 'held', sourcePortals: const [0], startType: 'cut', startMaxWaitFrames: 1); + final edges = definition['edges']! as List; + final baseEdge = edges[0]! as Map; + final engine = _animatedEngine({ + ...definition, + 'edges': [ + {...baseEdge, 'trigger': {'type': 'completion'}}, + ], + }); + + final completed = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0), routeReady: false)); + expect(completed.presentation, _bodyFrame('hover', 0)); + expect(completed.effects, [ + _requested('idle', 'hover', 1), + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + ]); + expect(completed.snapshot.phase, MotionGraphPhase.stable); + expect(completed.snapshot.requestedState, 'hover'); + expect(completed.snapshot.visualState, 'hover'); + expect(completed.snapshot.routeOperationsLastTick, 1); + }); + + test('previews a completion-triggered tick exactly without committing it', () { + final definition = _graph(sourceKind: 'held', sourcePortals: const [0], startType: 'cut', startMaxWaitFrames: 1); + final edges = definition['edges']! as List; + final baseEdge = edges[0]! as Map; + final engine = _animatedEngine({ + ...definition, + 'edges': [ + {...baseEdge, 'trigger': {'type': 'completion'}}, + ], + }); + final beforeSnapshot = engine.snapshot(); + final beforeTrace = engine.getTrace(); + + final preview = engine.previewTick( + MotionGraphTickOptions(contentOrdinal: BigInt.from(0), routeReady: false), + ); + + expect(preview.snapshot.phase, MotionGraphPhase.stable); + expect(preview.snapshot.visualState, 'hover'); + expect(preview.snapshot.requestedState, 'hover'); + expect(engine.snapshot(), beforeSnapshot); + expect(engine.getTrace(), beforeTrace); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0), routeReady: false)), + preview, + ); + }); + + test('previews stable ticks exactly without advancing the graph journal', () { + final engine = _animatedEngine(_graph()); + final beforeSnapshot = engine.snapshot(); + final beforeTrace = engine.getTrace(); + + final preview = engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + + expect(preview.snapshot.phase, MotionGraphPhase.stable); + expect(preview.snapshot.visualState, 'idle'); + expect(preview.snapshot.contentOrdinal, BigInt.from(0)); + expect(engine.snapshot(), beforeSnapshot); + expect(engine.getTrace(), beforeTrace); + expect(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))), preview); + }); + + test('restores pending requests, counters, routes, and trace across repeated previews', () { + final engine = _animatedEngine(_graph()); + final first = engine.request('hover'); + final duplicate = engine.request('hover'); + final beforeSnapshot = engine.snapshot(); + final beforeTrace = engine.getTrace(); + + final firstPreview = engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + final secondPreview = engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + + expect(firstPreview, secondPreview); + expect( + firstPreview.effects, + contains(_settle( + [first.requestId!, duplicate.requestId!], + const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted), + )), + ); + expect(engine.snapshot(), beforeSnapshot); + expect(engine.snapshot().phase, MotionGraphPhase.waiting); + expect(engine.snapshot().pendingEdgeId, 'idle-to-hover'); + expect(engine.snapshot().pendingRequestCount, 2); + expect(engine.snapshot().inputSequence, 2); + expect(engine.snapshot().inputsSinceTick, 2); + expect(engine.snapshot().contentOrdinal, isNull); + expect(engine.getTrace(), beforeTrace); + + expect(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))), firstPreview); + expect(engine.snapshot().inputsSinceTick, 0); + final third = engine.request('hover'); + expect(third.requestId, 3); + expect(third.sequence, 3); + }); + + test('restores the graph when preview evaluation throws after tick admission', () { + final engine = _animatedEngine(_graph()); + engine.request('hover'); + final beforeSnapshot = engine.snapshot(); + final beforeTrace = engine.getTrace(); + + // Unlike the TypeScript original (which simulates a throwing + // `routeReady` getter — a JS accessor-property quirk with no Dart + // analog), this exercises the same "previewTick must roll back even + // when tick() throws deep inside its own pipeline" invariant via a + // genuinely non-consecutive content ordinal, which throws from + // `OperationJournal.beginTick` partway through `tick()`. + expect( + () => engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(5))), + throwsA(isA()), + ); + expect(engine.snapshot(), beforeSnapshot); + expect(engine.getTrace(), beforeTrace); + final result = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + expect(result.snapshot.phase, MotionGraphPhase.stable); + expect(result.snapshot.contentOrdinal, BigInt.from(0)); + }); + + test('does not invent an implicit completion route for a finite body', () { + final engine = _animatedEngine(_graph(sourceKind: 'finite', sourcePortals: const [0, 3], startType: 'finish', startMaxWaitFrames: 3)); + + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, + _bodyFrame('idle', 1), + ); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))).presentation, + _bodyFrame('idle', 2), + ); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))).presentation, + _bodyFrame('idle', 3), + ); + final held = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3))); + expect(held.presentation, _bodyFrame('idle', 3)); + expect(held.effects, isEmpty); + expect(held.snapshot.phase, MotionGraphPhase.stable); + expect(held.snapshot.requestedState, 'idle'); + expect(held.snapshot.visualState, 'idle'); + }); + + test('keeps a held body on frame zero until a finish route becomes ready', () { + final engine = _animatedEngine(_graph(sourceKind: 'held', sourcePortals: const [0], startType: 'finish', startMaxWaitFrames: 0)); + engine.request('hover'); + + final held = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0), routeReady: false)); + expect(held.presentation, _bodyFrame('idle', 0)); + expect(held.effects, isEmpty); + expect(held.snapshot.phase, MotionGraphPhase.waiting); + + final commit = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + expect(commit.presentation, _bodyFrame('hover', 0)); + expect(commit.effects, [ + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + }); + + test('plays an intro without transition effects and joins body frame zero', () { + final engine = MotionGraphEngine(); + engine.install(_graph(introFrames: 2)); + final begin = engine.beginAnimated(); + expect(begin.presentation, _introFrame(0)); + expect(begin.effects, [_readiness(MotionGraphReadiness.preparing, MotionGraphReadiness.animated)]); + expect(begin.snapshot.phase, MotionGraphPhase.intro); + expect(begin.snapshot.visualState, 'idle'); + expect(begin.snapshot.requestedState, 'idle'); + expect(begin.snapshot.isTransitioning, false); + + final secondIntroFrame = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + expect(secondIntroFrame.presentation, _introFrame(1)); + expect(secondIntroFrame.effects, isEmpty); + + final bodyJoin = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + expect(bodyJoin.presentation, _bodyFrame('idle', 0)); + expect(bodyJoin.effects, isEmpty); + expect(bodyJoin.snapshot.phase, MotionGraphPhase.stable); + expect(bodyJoin.snapshot.visualState, 'idle'); + expect(bodyJoin.snapshot.requestedState, 'idle'); + expect(bodyJoin.snapshot.isTransitioning, false); + }); + + test('plays the intro before a request accepted during preparation', () { + final engine = MotionGraphEngine(); + engine.install(_graph(introFrames: 2)); + final request = engine.request('hover'); + expect(request.effects, [_requested('idle', 'hover', 1)]); + expect(request.snapshot.phase, MotionGraphPhase.preparing); + + final begin = engine.beginAnimated(); + expect(begin.presentation, _introFrame(0)); + expect(begin.effects, [_readiness(MotionGraphReadiness.preparing, MotionGraphReadiness.animated)]); + expect(begin.snapshot.phase, MotionGraphPhase.intro); + expect(begin.snapshot.requestedState, 'hover'); + expect(begin.snapshot.visualState, 'idle'); + expect(begin.snapshot.isTransitioning, true); + + expect(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, _introFrame(1)); + final join = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + expect(join.presentation, _bodyFrame('idle', 0)); + expect(join.effects, isEmpty); + expect(join.snapshot.phase, MotionGraphPhase.waiting); + + final commit = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))); + expect(commit.presentation, _bodyFrame('hover', 0)); + expect(commit.effects, [ + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + }); + + test('locks an accepted route behind a playing intro and draws body zero first', () { + final engine = MotionGraphEngine(); + engine.install(_graph(introFrames: 2)); + engine.beginAnimated(); + + final request = engine.request('hover'); + expect(request.effects, [_requested('idle', 'hover', 1)]); + expect(request.snapshot.phase, MotionGraphPhase.intro); + expect(request.snapshot.requestedState, 'hover'); + expect(request.snapshot.visualState, 'idle'); + expect(request.snapshot.isTransitioning, true); + + expect(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, _introFrame(1)); + final join = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + expect(join.presentation, _bodyFrame('idle', 0)); + expect(join.effects, isEmpty); + expect(join.snapshot.phase, MotionGraphPhase.waiting); + + final commit = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))); + expect(commit.presentation, _bodyFrame('hover', 0)); + expect(commit.effects, [ + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + }); + + test('treats the initial state as a semantic no-op while its intro continues', () { + final engine = MotionGraphEngine(); + engine.install(_graph(introFrames: 2)); + engine.beginAnimated(); + + final noop = engine.request('idle'); + expect(noop.effects, [ + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.stableNoop)), + ]); + expect(noop.snapshot.phase, MotionGraphPhase.intro); + expect(noop.snapshot.requestedState, 'idle'); + expect(noop.snapshot.visualState, 'idle'); + expect(noop.snapshot.isTransitioning, false); + expect(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, _introFrame(1)); + }); + + test('begins static mode by committing the newest prepared target in normative order', () { + final engine = MotionGraphEngine(); + engine.install(_graph()); + engine.request('hover'); + + final result = engine.beginStatic('codec-unsupported'); + expect(result.presentation, const GraphPresentationStatic(state: 'hover')); + expect(result.effects, [ + _readiness(MotionGraphReadiness.preparing, MotionGraphReadiness.static, reason: 'codec-unsupported'), + _fallback('codec-unsupported'), + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.staticRecovery)), + ]); + expect(result.snapshot.readiness, MotionGraphReadiness.static); + expect(result.snapshot.phase, MotionGraphPhase.static); + expect(result.snapshot.requestedState, 'hover'); + expect(result.snapshot.visualState, 'hover'); + expect(result.snapshot.isTransitioning, false); + expect(result.snapshot.pendingRequestCount, 0); + }); + + test('uses direct-edge validation but ignores portal timing for later static requests', () { + final engine = MotionGraphEngine(); + engine.install(_graph(sourcePortals: const [1, 3], startType: 'portal', startMaxWaitFrames: 1)); + final begin = engine.beginStatic('reduced-motion'); + expect(begin.presentation, const GraphPresentationStatic(state: 'idle')); + expect(begin.effects, [ + _readiness(MotionGraphReadiness.preparing, MotionGraphReadiness.static, reason: 'reduced-motion'), + _fallback('reduced-motion'), + ]); + + final request = engine.request('hover'); + expect(request.presentation, const GraphPresentationStatic(state: 'hover')); + expect(request.effects, [ + _requested('idle', 'hover', 1), + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted)), + ]); + expect(request.snapshot.readiness, MotionGraphReadiness.static); + expect(request.snapshot.phase, MotionGraphPhase.static); + expect(request.snapshot.requestedState, 'hover'); + expect(request.snapshot.visualState, 'hover'); + expect(request.snapshot.isTransitioning, false); + }); + + test('recovers pending animation to the requested static state before settling', () { + final engine = _animatedEngine(_graph()); + engine.request('hover'); + + final recovery = engine.recoverStatic('decode-failure'); + expect(recovery.presentation, const GraphPresentationStatic(state: 'hover')); + expect(recovery.effects, [ + _readiness(MotionGraphReadiness.animated, MotionGraphReadiness.static, reason: 'decode-failure'), + _fallback('decode-failure'), + _transitionStart('idle-to-hover', 'idle', 'hover', 1), + _visual('idle', 'hover'), + _transitionEnd('idle-to-hover', 'idle', 'hover'), + _settle([1], const GraphSettlementResolve(GraphSettlementResolveReason.staticRecovery)), + ]); + expect(recovery.snapshot.readiness, MotionGraphReadiness.static); + expect(recovery.snapshot.phase, MotionGraphPhase.static); + expect(recovery.snapshot.requestedState, 'hover'); + expect(recovery.snapshot.visualState, 'hover'); + expect(recovery.snapshot.isTransitioning, false); + expect(recovery.snapshot.pendingRequestCount, 0); + }); + + test('rejects the surviving request when the required static frame cannot be installed', () { + final engine = _animatedEngine(_graph()); + engine.request('hover'); + + final failure = engine.failStatic('png-invalid'); + expect(failure.presentation, _bodyFrame('idle', 0)); + expect(failure.effects, [ + _readiness(MotionGraphReadiness.animated, MotionGraphReadiness.error, reason: 'png-invalid'), + _settle([1], const GraphSettlementReject(GraphSettlementError.playbackFallbackError)), + ]); + expect(failure.snapshot.readiness, MotionGraphReadiness.error); + expect(failure.snapshot.phase, MotionGraphPhase.error); + expect(failure.snapshot.requestedState, 'hover'); + expect(failure.snapshot.visualState, 'idle'); + expect(failure.snapshot.isTransitioning, false); + expect(failure.snapshot.pendingRequestCount, 0); + }); + + test('disposes idempotently, aborts pending requests, and remains terminal', () { + final engine = _animatedEngine(_graph()); + final pending = engine.request('hover'); + + final disposed = engine.dispose(); + expect(disposed.presentation, isNull); + expect(disposed.effects, [ + _settle([pending.requestId!], const GraphSettlementReject(GraphSettlementError.abortError)), + _readiness(MotionGraphReadiness.animated, MotionGraphReadiness.disposed), + ]); + expect(disposed.snapshot.readiness, MotionGraphReadiness.disposed); + expect(disposed.snapshot.phase, MotionGraphPhase.disposed); + expect(disposed.snapshot.pendingRequestCount, 0); + + expect(engine.dispose().effects, isEmpty); + expect( + () => engine.failStatic(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('disposed graph cannot fail static'), + )), + ); + expect(engine.snapshot().readiness, MotionGraphReadiness.disposed); + expect(engine.snapshot().phase, MotionGraphPhase.disposed); + }); + }); +} + +Map _graph({ + String sourceKind = 'loop', + List sourcePortals = const [0, 2], + int? introFrames, + String startType = 'portal', + int startMaxWaitFrames = 1, +}) { + final sourceFrameCount = sourceKind == 'held' ? 1 : 4; + final idle = { + 'id': 'idle', + 'body': { + 'unitId': 'idle-body', + 'kind': sourceKind, + 'frameCount': sourceFrameCount, + 'ports': [ + {'id': 'handoff', 'entryFrame': 0, 'portalFrames': sourcePortals}, + ], + }, + if (introFrames != null) 'initialUnit': {'unitId': 'idle-intro', 'frameCount': introFrames}, + }; + final hover = { + 'id': 'hover', + 'body': { + 'unitId': 'hover-body', + 'kind': 'loop', + 'frameCount': 4, + 'ports': [ + {'id': 'handoff', 'entryFrame': 0, 'portalFrames': const [0, 2]}, + ], + }, + }; + final Map start; + final Map edge; + if (startType == 'portal') { + start = { + 'type': 'portal', + 'sourcePort': 'handoff', + 'targetPort': 'handoff', + 'maxWaitFrames': startMaxWaitFrames, + }; + edge = { + 'id': 'idle-to-hover', + 'from': 'idle', + 'to': 'hover', + 'start': start, + 'continuity': 'exact-authored', + }; + } else if (startType == 'finish') { + start = {'type': 'finish', 'targetPort': 'handoff', 'maxWaitFrames': startMaxWaitFrames}; + edge = { + 'id': 'idle-to-hover', + 'from': 'idle', + 'to': 'hover', + 'start': start, + 'continuity': 'exact-authored', + }; + } else { + start = {'type': 'cut', 'targetPort': 'handoff', 'maxWaitFrames': 1}; + edge = { + 'id': 'idle-to-hover', + 'from': 'idle', + 'to': 'hover', + 'start': start, + 'continuity': 'cut', + }; + } + + return { + 'initialState': 'idle', + 'states': [idle, hover], + 'edges': [edge], + }; +} + +MotionGraphEngine _animatedEngine(Map definition) { + final engine = MotionGraphEngine(); + engine.install(definition); + engine.beginAnimated(); + return engine; +} + +GraphPresentationBody _bodyFrame(String state, int frameIndex) => + GraphPresentationBody(state: state, unitId: '$state-body', frameIndex: frameIndex); + +GraphPresentationIntro _introFrame(int frameIndex) => + GraphPresentationIntro(state: 'idle', unitId: 'idle-intro', frameIndex: frameIndex); + +MotionGraphEffectReadinessChange _readiness( + MotionGraphReadiness from, + MotionGraphReadiness to, { + String? reason, +}) { + return MotionGraphEffectReadinessChange(from: from, to: to, reason: reason); +} + +MotionGraphEffectFallback _fallback(String reason) => MotionGraphEffectFallback(reason: reason); + +MotionGraphEffectRequestedStateChange _requested(String from, String to, int sequence) => + MotionGraphEffectRequestedStateChange(from: from, to: to, sequence: sequence); + +MotionGraphEffectVisualStateChange _visual(String from, String to) => + MotionGraphEffectVisualStateChange(from: from, to: to); + +MotionGraphEffectTransitionStart _transitionStart(String edgeId, String from, String to, int sequence) => + MotionGraphEffectTransitionStart(edgeId: edgeId, from: from, to: to, sequence: sequence); + +MotionGraphEffectTransitionEnd _transitionEnd(String edgeId, String from, String to) => + MotionGraphEffectTransitionEnd(edgeId: edgeId, from: from, to: to); + +MotionGraphEffectSettle _settle(List requestIds, GraphSettlement outcome) => + MotionGraphEffectSettle(requestIds: requestIds, outcome: outcome); diff --git a/flutter/packages/aval_graph/test/engine_resume_test.dart b/flutter/packages/aval_graph/test/engine_resume_test.dart new file mode 100644 index 0000000..84d2344 --- /dev/null +++ b/flutter/packages/aval_graph/test/engine_resume_test.dart @@ -0,0 +1,220 @@ +// Ported from packages/graph/test/engine-resume.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +void main() { + group('MotionGraphEngine animated reentry', () { + test('plays the intro when first animated activation follows static preparation', () { + final engine = MotionGraphEngine(); + engine.install(_graph('loop', introFrames: 2)); + final reduced = engine.beginStatic('reduced-motion'); + + final resumed = engine.resumeAnimated(); + + expect(resumed.operation, MotionGraphOperation.resumeAnimated); + expect(resumed.presentation, _introFrame(0)); + expect(resumed.effects, [_readiness(MotionGraphReadiness.static, MotionGraphReadiness.animated)]); + expect(resumed.snapshot.readiness, MotionGraphReadiness.animated); + expect(resumed.snapshot.phase, MotionGraphPhase.intro); + expect(resumed.snapshot.presentation, _introFrame(0)); + expect(resumed.snapshot.requestedState, reduced.snapshot.requestedState); + expect(resumed.snapshot.visualState, reduced.snapshot.visualState); + }); + + test('does not replay an intro that already reached its body', () { + final engine = MotionGraphEngine(); + engine.install(_graph('loop', introFrames: 2)); + engine.beginAnimated(); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + engine.recoverStatic('visibility-hidden'); + + final resumed = engine.resumeAnimated(); + + expect(resumed.operation, MotionGraphOperation.resumeAnimated); + expect(resumed.presentation, _bodyFrame('idle', 0)); + expect(resumed.snapshot.readiness, MotionGraphReadiness.animated); + expect(resumed.snapshot.phase, MotionGraphPhase.stable); + expect(resumed.snapshot.presentation, _bodyFrame('idle', 0)); + expect(resumed.snapshot.initialUnitPending, false); + expect(resumed.snapshot.contentOrdinal, BigInt.from(1)); + }); + + test('restarts an intro suspended before it reaches the body', () { + final engine = MotionGraphEngine(); + engine.install(_graph('loop', introFrames: 3)); + engine.beginAnimated(); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + final mid = engine.snapshot(); + expect(mid.phase, MotionGraphPhase.intro); + expect(mid.initialUnitPending, true); + expect(mid.presentation, _introFrame(1)); + engine.recoverStatic('visibility-hidden'); + + final resumed = engine.resumeAnimated(); + + expect(resumed.operation, MotionGraphOperation.resumeAnimated); + expect(resumed.presentation, _introFrame(0)); + expect(resumed.snapshot.phase, MotionGraphPhase.intro); + expect(resumed.snapshot.initialUnitPending, true); + expect(resumed.snapshot.presentation, _introFrame(0)); + }); + + test('rolls back intro consumption when the body join is only previewed', () { + final engine = MotionGraphEngine(); + engine.install(_graph('loop', introFrames: 2)); + engine.beginAnimated(); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + final before = engine.snapshot(); + + final preview = engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + + expect(preview.presentation, _bodyFrame('idle', 0)); + expect(preview.snapshot.phase, MotionGraphPhase.stable); + expect(preview.snapshot.initialUnitPending, false); + expect(engine.snapshot(), before); + expect(before.phase, MotionGraphPhase.intro); + expect(before.initialUnitPending, true); + expect(before.presentation, _introFrame(1)); + expect(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))), preview); + }); + + test('does not replay an intro after an explicit noninitial static commit', () { + final engine = MotionGraphEngine(); + engine.install(_graph('loop', introFrames: 3)); + engine.beginStatic('reduced-motion'); + engine.request('hover'); + expect(engine.snapshot().initialUnitPending, false); + engine.request('idle'); + + final resumed = engine.resumeAnimated(); + + expect(resumed.presentation, _bodyFrame('idle', 0)); + expect(resumed.snapshot.phase, MotionGraphPhase.stable); + expect(resumed.snapshot.initialUnitPending, false); + expect(resumed.snapshot.presentation, _bodyFrame('idle', 0)); + }); + + for (final bodyKind in const ['loop', 'finite', 'held']) { + test('resumes a noninitial $bodyKind state at body frame zero', () { + final engine = MotionGraphEngine(); + engine.install(_graph(bodyKind)); + engine.request('hover'); + final reduced = engine.beginStatic('reduced-motion'); + expect(reduced.snapshot.requestedState, 'hover'); + expect(reduced.snapshot.visualState, 'hover'); + expect(reduced.snapshot.pendingRequestCount, 0); + expect(reduced.snapshot.pendingEdgeId, isNull); + expect(reduced.snapshot.activeEdgeId, isNull); + expect(reduced.snapshot.followOnEdgeId, isNull); + + final resumed = engine.resumeAnimated(); + + expect(resumed.operation, MotionGraphOperation.resumeAnimated); + expect(resumed.presentation, _bodyFrame('hover', 0)); + expect(resumed.effects, [_readiness(MotionGraphReadiness.static, MotionGraphReadiness.animated)]); + expect(resumed.snapshot.readiness, MotionGraphReadiness.animated); + expect(resumed.snapshot.phase, MotionGraphPhase.stable); + expect(resumed.snapshot.requestedState, 'hover'); + expect(resumed.snapshot.visualState, 'hover'); + expect(resumed.snapshot.prospectiveState, 'hover'); + expect(resumed.snapshot.isTransitioning, false); + expect(resumed.snapshot.pendingRequestCount, 0); + expect(resumed.snapshot.inputSequence, 1); + expect(resumed.snapshot.inputsSinceTick, 1); + expect(resumed.snapshot.contentOrdinal, isNull); + }); + } + + test('rejects resume outside static phase without mutating state', () { + final unready = MotionGraphEngine(); + expect( + () => unready.resumeAnimated(), + throwsA(isA().having((e) => e.message, 'message', contains('requires graph metadata'))), + ); + + final engine = MotionGraphEngine(); + engine.install(_graph('loop')); + final preparing = engine.snapshot(); + final preparingTrace = engine.getTrace(); + expect( + () => engine.resumeAnimated(), + throwsA(isA().having((e) => e.message, 'message', contains('requires phase static'))), + ); + expect(engine.snapshot(), preparing); + expect(engine.getTrace(), preparingTrace); + + engine.beginStatic('reduced-motion'); + engine.resumeAnimated(); + final animated = engine.snapshot(); + final animatedTrace = engine.getTrace(); + expect( + () => engine.resumeAnimated(), + throwsA(isA().having((e) => e.message, 'message', contains('requires phase static'))), + ); + expect(engine.snapshot(), animated); + expect(engine.getTrace(), animatedTrace); + }); + }); +} + +Map _graph(String hoverKind, {int? introFrames}) { + final hoverFrames = hoverKind == 'held' ? 1 : 4; + return { + 'initialState': 'idle', + 'states': [ + { + 'id': 'idle', + 'body': { + 'unitId': 'idle-body', + 'kind': 'loop', + 'frameCount': 4, + 'ports': [ + {'id': 'handoff', 'entryFrame': 0, 'portalFrames': const [0, 2]}, + ], + }, + if (introFrames != null) 'initialUnit': {'unitId': 'idle-intro', 'frameCount': introFrames}, + }, + { + 'id': 'hover', + 'body': { + 'unitId': 'hover-body', + 'kind': hoverKind, + 'frameCount': hoverFrames, + 'ports': [ + { + 'id': 'handoff', + 'entryFrame': 0, + 'portalFrames': hoverKind == 'held' ? const [0] : [0, hoverFrames - 1], + }, + ], + }, + }, + ], + 'edges': [ + { + 'id': 'idle-to-hover', + 'from': 'idle', + 'to': 'hover', + 'start': {'type': 'cut', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'continuity': 'cut', + }, + { + 'id': 'hover-to-idle', + 'from': 'hover', + 'to': 'idle', + 'start': {'type': 'cut', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'continuity': 'cut', + }, + ], + }; +} + +GraphPresentationBody _bodyFrame(String state, int frameIndex) => + GraphPresentationBody(state: state, unitId: '$state-body', frameIndex: frameIndex); + +GraphPresentationIntro _introFrame(int frameIndex) => + GraphPresentationIntro(state: 'idle', unitId: 'idle-intro', frameIndex: frameIndex); + +MotionGraphEffectReadinessChange _readiness(MotionGraphReadiness from, MotionGraphReadiness to) => + MotionGraphEffectReadinessChange(from: from, to: to); diff --git a/flutter/packages/aval_graph/test/engine_smoke_test.dart b/flutter/packages/aval_graph/test/engine_smoke_test.dart new file mode 100644 index 0000000..1e1e022 --- /dev/null +++ b/flutter/packages/aval_graph/test/engine_smoke_test.dart @@ -0,0 +1,161 @@ +// Ported from packages/graph/test/engine-smoke.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +void main() { + group('MotionGraphEngine smoke', () { + test('presents a portal bridge and commits only at target body zero', () { + final engine = _preparedHoverEngine(); + final requested = engine.request('hovered'); + expect(requested.effects.map((e) => e.runtimeType), [MotionGraphEffectRequestedStateChange]); + + expect(_show(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0)))), 'body:idle:1'); + expect(_show(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1)))), 'body:idle:2'); + expect( + _show(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2)))), + 'reversible:idle-to-hover:0:forward', + ); + expect( + _show(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3)))), + 'reversible:idle-to-hover:1:forward', + ); + expect( + _show(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(4)))), + 'reversible:idle-to-hover:2:forward', + ); + expect( + _show(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(5)))), + 'reversible:idle-to-hover:3:forward', + ); + final committed = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(6))); + expect(_show(committed), 'body:hovered:0'); + expect(committed.snapshot.phase, MotionGraphPhase.stable); + expect(committed.snapshot.requestedState, 'hovered'); + expect(committed.snapshot.visualState, 'hovered'); + expect(committed.snapshot.isTransitioning, false); + expect(committed.effects.map((e) => e.runtimeType), [ + MotionGraphEffectVisualStateChange, + MotionGraphEffectTransitionEnd, + MotionGraphEffectSettle, + ]); + }); + + test('reverses to the adjacent cached frame on the next tick', () { + final engine = _preparedHoverEngine(); + engine.request('hovered'); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3))); + + final inverse = engine.request('idle'); + expect(inverse.snapshot.requestedState, 'idle'); + expect(inverse.snapshot.visualState, 'idle'); + expect(inverse.snapshot.isTransitioning, true); + expect(inverse.effects.map((e) => e.runtimeType), [ + MotionGraphEffectRequestedStateChange, + MotionGraphEffectSettle, + ]); + + final adjacent = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(4))); + expect(_show(adjacent), 'reversible:hover-to-idle:0:reverse'); + expect(adjacent.effects.map((e) => e.runtimeType), [MotionGraphEffectTransitionStart]); + final returned = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(5))); + expect(_show(returned), 'body:idle:0'); + expect(returned.effects.map((e) => e.runtimeType), [ + MotionGraphEffectTransitionEnd, + MotionGraphEffectSettle, + ]); + }); + }); +} + +MotionGraphEngine _preparedHoverEngine() { + final engine = MotionGraphEngine(); + engine.install(_hoverGraph()); + engine.beginAnimated(); + return engine; +} + +Map _hoverGraph() { + return { + 'initialState': 'idle', + 'states': [ + { + 'id': 'idle', + 'body': { + 'unitId': 'idle-body', + 'kind': 'loop', + 'frameCount': 4, + 'ports': [ + {'id': 'neutral', 'entryFrame': 0, 'portalFrames': [2]}, + ], + }, + }, + { + 'id': 'hovered', + 'body': { + 'unitId': 'hover-body', + 'kind': 'loop', + 'frameCount': 3, + 'ports': [ + {'id': 'neutral', 'entryFrame': 0, 'portalFrames': [1]}, + ], + }, + }, + ], + 'edges': [ + { + 'id': 'idle-to-hover', + 'from': 'idle', + 'to': 'hovered', + 'trigger': {'type': 'event', 'name': 'hover.enter'}, + 'start': { + 'type': 'portal', + 'sourcePort': 'neutral', + 'targetPort': 'neutral', + 'maxWaitFrames': 3, + }, + 'transition': { + 'kind': 'reversible', + 'unitId': 'hover-clip', + 'frameCount': 4, + 'direction': 'forward', + }, + 'continuity': 'exact-authored', + }, + { + 'id': 'hover-to-idle', + 'from': 'hovered', + 'to': 'idle', + 'trigger': {'type': 'event', 'name': 'hover.leave'}, + 'start': { + 'type': 'portal', + 'sourcePort': 'neutral', + 'targetPort': 'neutral', + 'maxWaitFrames': 2, + }, + 'transition': { + 'kind': 'reversible', + 'unitId': 'hover-clip', + 'frameCount': 4, + 'direction': 'reverse', + 'reverseOf': 'idle-to-hover', + }, + 'continuity': 'exact-reverse', + }, + ], + }; +} + +String _show(MotionGraphResult result) { + final presentation = result.presentation; + if (presentation == null) return 'none'; + if (presentation is GraphPresentationBody) { + return 'body:${presentation.state}:${presentation.frameIndex}'; + } + if (presentation is GraphPresentationReversible) { + return 'reversible:${presentation.edgeId}:${presentation.frameIndex}:${presentation.direction.name}'; + } + return presentation.runtimeType.toString(); +} diff --git a/flutter/packages/aval_graph/test/engine_transitions_test.dart b/flutter/packages/aval_graph/test/engine_transitions_test.dart new file mode 100644 index 0000000..a5f1ee4 --- /dev/null +++ b/flutter/packages/aval_graph/test/engine_transitions_test.dart @@ -0,0 +1,562 @@ +// Ported from packages/graph/test/engine-transitions.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +void main() { + group('MotionGraphEngine transition routing', () { + test('reverses an active resident clip to the adjacent frame on the next tick', () { + final engine = _animatedEngine(_reversibleGraph()); + + final forward = engine.request('hover'); + expect(forward.accepted, true); + expect(forward.joined, false); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, + _reversiblePresentation('idle-to-hover', 0, TransitionDirection.forward), + ); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))).presentation, + _reversiblePresentation('idle-to-hover', 1, TransitionDirection.forward), + ); + + final inverse = engine.request('idle'); + expect(inverse.accepted, true); + expect(inverse.joined, false); + expect(inverse.snapshot.phase, MotionGraphPhase.reversible); + expect(inverse.snapshot.requestedState, 'idle'); + expect(inverse.snapshot.visualState, 'idle'); + expect(inverse.snapshot.prospectiveState, 'idle'); + + final reversed = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))); + expect(reversed.presentation, _reversiblePresentation('hover-to-idle', 0, TransitionDirection.reverse)); + expect( + reversed.effects, + contains(MotionGraphEffectTransitionStart( + edgeId: 'hover-to-idle', + from: 'hover', + to: 'idle', + sequence: inverse.sequence!, + )), + ); + }); + + test('previews a reversible tick exactly without advancing its active route', () { + final engine = _animatedEngine(_reversibleGraph()); + engine.request('hover'); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + final beforeSnapshot = engine.snapshot(); + final beforeTrace = engine.getTrace(); + + final firstPreview = engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + final secondPreview = engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + + expect(firstPreview, secondPreview); + expect( + firstPreview.presentation, + _reversiblePresentation('idle-to-hover', 1, TransitionDirection.forward), + ); + expect(engine.snapshot(), beforeSnapshot); + expect(engine.getTrace(), beforeTrace); + expect(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))), firstPreview); + }); + + test('reverses an active reverse clip forward to the adjacent frame', () { + final engine = _animatedEngine(_reversibleGraph()); + engine.request('hover'); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3))); + + engine.request('idle'); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(4))).presentation, + _reversiblePresentation('hover-to-idle', 2, TransitionDirection.reverse), + ); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(5))).presentation, + _reversiblePresentation('hover-to-idle', 1, TransitionDirection.reverse), + ); + + final forward = engine.request('hover'); + final adjacent = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(6))); + expect(adjacent.presentation, _reversiblePresentation('idle-to-hover', 2, TransitionDirection.forward)); + expect( + adjacent.effects, + contains(MotionGraphEffectTransitionStart( + edgeId: 'idle-to-hover', + from: 'idle', + to: 'hover', + sequence: forward.sequence!, + )), + ); + }); + + test('cancels a portal-waiting edge when its source state is requested', () { + final engine = _animatedEngine(_reversibleGraph()); + final pending = engine.request('hover'); + + expect(pending.snapshot.phase, MotionGraphPhase.waiting); + expect(pending.snapshot.pendingEdgeId, 'idle-to-hover'); + expect(pending.snapshot.requestedState, 'hover'); + + final cancelled = engine.request('idle'); + expect(cancelled.accepted, true); + expect(cancelled.joined, false); + expect(cancelled.snapshot.phase, MotionGraphPhase.stable); + expect(cancelled.snapshot.pendingEdgeId, isNull); + expect(cancelled.snapshot.requestedState, 'idle'); + expect(cancelled.snapshot.visualState, 'idle'); + expect(cancelled.snapshot.isTransitioning, false); + expect(_settleEffects(cancelled), [ + _settle([pending.requestId!], reject: GraphSettlementError.abortError), + _settle([cancelled.requestId!], resolve: GraphSettlementResolveReason.stableNoop), + ]); + + final next = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + expect(next.presentation, _bodyFrame('idle', 1)); + expect(_effectTypes(next), isNot(contains(MotionGraphEffectTransitionStart))); + }); + + test('does not clear a mismatched pending route or request on invalid resume', () { + final engine = _animatedEngine(_reversibleGraph()); + engine.request('hover'); + final waitingSnapshot = engine.snapshot(); + final waitingTrace = engine.getTrace(); + + expect( + () => engine.resumeAnimated(), + throwsA(isA().having((e) => e.message, 'message', contains('requires phase static'))), + ); + expect(engine.snapshot(), waitingSnapshot); + expect(engine.getTrace(), waitingTrace); + expect(engine.snapshot().phase, MotionGraphPhase.waiting); + expect(engine.snapshot().requestedState, 'hover'); + expect(engine.snapshot().visualState, 'idle'); + expect(engine.snapshot().pendingEdgeId, 'idle-to-hover'); + expect(engine.snapshot().pendingRequestCount, 1); + + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + final activeSnapshot = engine.snapshot(); + final activeTrace = engine.getTrace(); + expect( + () => engine.resumeAnimated(), + throwsA(isA().having((e) => e.message, 'message', contains('requires phase static'))), + ); + expect(engine.snapshot(), activeSnapshot); + expect(engine.getTrace(), activeTrace); + expect(engine.snapshot().phase, MotionGraphPhase.reversible); + expect(engine.snapshot().activeEdgeId, 'idle-to-hover'); + expect(engine.snapshot().pendingRequestCount, 1); + }); + + test('uses the pending edge\'s inverse event before normal visual-state lookup', () { + final engine = _animatedEngine(_reversibleGraph()); + + final beforeQuery = engine.snapshot(); + final traceBeforeQuery = engine.getTrace(); + expect(engine.canSend('hover.enter'), true); + expect(engine.canSend('unknown.event'), false); + expect(engine.snapshot(), beforeQuery); + expect(engine.getTrace(), traceBeforeQuery); + + expect(engine.send('hover.enter').accepted, true); + final cancelled = engine.send('hover.leave'); + + expect(cancelled.accepted, true); + expect(cancelled.snapshot.phase, MotionGraphPhase.stable); + expect(cancelled.snapshot.pendingEdgeId, isNull); + expect(cancelled.snapshot.requestedState, 'idle'); + expect(cancelled.snapshot.visualState, 'idle'); + expect(cancelled.snapshot.isTransitioning, false); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, + _bodyFrame('idle', 1), + ); + expect( + engine.getTrace().any((r) => r.result.presentation is GraphPresentationReversible), + false, + ); + }); + + test('lets a source request cancel an event-owned route during preparation', () { + final engine = MotionGraphEngine(); + engine.install(_reversibleGraph()); + expect(engine.send('hover.enter').accepted, true); + + final source = engine.request('idle'); + expect(source.snapshot.phase, MotionGraphPhase.preparing); + expect(source.snapshot.requestedState, 'idle'); + expect(source.snapshot.visualState, 'idle'); + expect(source.snapshot.pendingEdgeId, isNull); + expect(source.snapshot.isTransitioning, false); + expect(_settleEffects(source), [ + _settle([source.requestId!], resolve: GraphSettlementResolveReason.stableNoop), + ]); + expect(engine.beginAnimated().snapshot.phase, MotionGraphPhase.stable); + }); + + test('lets an inverse event cancel a pending route while the intro continues', () { + final definition = _reversibleGraph(); + final states = definition['states']! as List; + final initial = states[0]! as Map; + final engine = MotionGraphEngine(); + engine.install({ + ...definition, + 'states': [ + {...initial, 'initialUnit': {'unitId': 'idle-intro', 'frameCount': 2}}, + ...states.skip(1), + ], + }); + engine.beginAnimated(); + + expect(engine.send('hover.enter').accepted, true); + final cancelled = engine.send('hover.leave'); + expect(cancelled.accepted, true); + expect(cancelled.snapshot.phase, MotionGraphPhase.intro); + expect(cancelled.snapshot.requestedState, 'idle'); + expect(cancelled.snapshot.visualState, 'idle'); + expect(cancelled.snapshot.pendingEdgeId, isNull); + expect(cancelled.snapshot.isTransitioning, false); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, + const GraphPresentationIntro(state: 'idle', unitId: 'idle-intro', frameIndex: 1), + ); + }); + + test('converges a same-tick mixed burst to its newest valid intent', () { + final engine = _animatedEngine(_reversibleGraph()); + + final first = engine.request('hover'); + final inverseEvent = engine.send('hover.leave'); + final latest = engine.request('hover'); + + expect([first.sequence, inverseEvent.sequence, latest.sequence], [1, 2, 3]); + expect(first.accepted, true); + expect(inverseEvent.accepted, true); + expect(latest.accepted, true); + expect(latest.snapshot.phase, MotionGraphPhase.waiting); + expect(latest.snapshot.requestedState, 'hover'); + expect(latest.snapshot.prospectiveState, 'hover'); + expect(latest.snapshot.pendingEdgeId, 'idle-to-hover'); + expect(latest.snapshot.inputsSinceTick, 3); + + final tick = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + expect(tick.presentation, _reversiblePresentation('idle-to-hover', 0, TransitionDirection.forward)); + expect(tick.snapshot.phase, MotionGraphPhase.reversible); + expect(tick.snapshot.requestedState, 'hover'); + expect(tick.snapshot.inputsSinceTick, 0); + }); + + test('joins duplicate requests and supersedes the whole group in request order', () { + final engine = _animatedEngine(_reversibleGraph(includeIdleError: true)); + + final first = engine.request('hover'); + final duplicate = engine.request('hover'); + expect(first.accepted, true); + expect(first.joined, false); + expect(first.requestId, 1); + expect(duplicate.accepted, true); + expect(duplicate.joined, true); + expect(duplicate.requestId, 2); + expect(duplicate.snapshot.pendingRequestCount, 2); + + final replacement = engine.request('error'); + expect(replacement.accepted, true); + expect(replacement.joined, false); + expect(replacement.requestId, 3); + expect( + _settleEffects(replacement), + [_settle([first.requestId!, duplicate.requestId!], reject: GraphSettlementError.abortError)], + ); + expect(replacement.snapshot.phase, MotionGraphPhase.waiting); + expect(replacement.snapshot.requestedState, 'error'); + expect(replacement.snapshot.prospectiveState, 'error'); + expect(replacement.snapshot.pendingEdgeId, 'idle-to-error'); + expect(replacement.snapshot.pendingRequestCount, 1); + }); + + test('retains a valid reversible follow-on and rejects an invalid route without mutation', () { + final engine = _animatedEngine(_reversibleGraph(includeFollowOn: true)); + final initial = engine.request('hover'); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + + final followOn = engine.request('success'); + expect(followOn.accepted, true); + expect(followOn.joined, false); + expect(followOn.snapshot.phase, MotionGraphPhase.reversible); + expect(followOn.snapshot.requestedState, 'success'); + expect(followOn.snapshot.prospectiveState, 'success'); + expect(followOn.snapshot.activeEdgeId, 'idle-to-hover'); + expect(followOn.snapshot.followOnEdgeId, 'hover-to-success'); + expect(_settleEffects(followOn), [_settle([initial.requestId!], reject: GraphSettlementError.abortError)]); + + final beforeInvalid = followOn.snapshot; + final invalid = engine.request('error'); + expect(invalid.accepted, false); + expect(invalid.joined, false); + expect(_settleEffects(invalid), [_settle([invalid.requestId!], reject: GraphSettlementError.routeError)]); + expect(invalid.snapshot.phase, beforeInvalid.phase); + expect(invalid.snapshot.requestedState, beforeInvalid.requestedState); + expect(invalid.snapshot.prospectiveState, beforeInvalid.prospectiveState); + expect(invalid.snapshot.activeEdgeId, beforeInvalid.activeEdgeId); + expect(invalid.snapshot.followOnEdgeId, beforeInvalid.followOnEdgeId); + expect(invalid.snapshot.pendingRequestCount, beforeInvalid.pendingRequestCount); + + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))).presentation, + _reversiblePresentation('idle-to-hover', 2, TransitionDirection.forward), + ); + final intermediate = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3))); + expect(intermediate.presentation, _bodyFrame('hover', 0)); + expect(intermediate.snapshot.phase, MotionGraphPhase.waiting); + expect(intermediate.snapshot.visualState, 'hover'); + expect(intermediate.snapshot.requestedState, 'success'); + expect(intermediate.snapshot.pendingEdgeId, 'hover-to-success'); + + final committed = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(4))); + expect(committed.presentation, _bodyFrame('success', 0)); + expect(committed.snapshot.phase, MotionGraphPhase.stable); + expect(committed.snapshot.visualState, 'success'); + expect(committed.snapshot.requestedState, 'success'); + expect(committed.snapshot.isTransitioning, false); + expect( + _settleEffects(committed), + [_settle([followOn.requestId!], resolve: GraphSettlementResolveReason.targetCommitted)], + ); + }); + + test('lets a repeated inverse event cancel a queued follow-on', () { + final engine = _animatedEngine(_reversibleGraph(includeIdleError: true)); + engine.request('hover'); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + + expect(engine.send('hover.leave').accepted, true); + final followOn = engine.request('error'); + expect(followOn.snapshot.requestedState, 'error'); + expect(followOn.snapshot.activeEdgeId, 'idle-to-hover'); + expect(followOn.snapshot.followOnEdgeId, 'idle-to-error'); + expect(followOn.snapshot.prospectiveState, 'error'); + + final reiteratedInverse = engine.send('hover.leave'); + expect(reiteratedInverse.accepted, true); + expect(reiteratedInverse.snapshot.requestedState, 'idle'); + expect(reiteratedInverse.snapshot.activeEdgeId, 'idle-to-hover'); + expect(reiteratedInverse.snapshot.followOnEdgeId, isNull); + expect(reiteratedInverse.snapshot.prospectiveState, 'idle'); + expect( + _settleEffects(reiteratedInverse), + [_settle([followOn.requestId!], reject: GraphSettlementError.abortError)], + ); + }); + + test('finishes every locked bridge frame before routing its latest valid follow-on', () { + final engine = _animatedEngine(_lockedFollowOnGraph()); + final loading = engine.request('loading'); + + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))).presentation, + _lockedPresentation('idle-to-loading', 0), + ); + final success = engine.request('success'); + expect(success.accepted, true); + expect(success.joined, false); + expect(success.snapshot.phase, MotionGraphPhase.locked); + expect(success.snapshot.requestedState, 'success'); + expect(success.snapshot.prospectiveState, 'success'); + expect(success.snapshot.activeEdgeId, 'idle-to-loading'); + expect(success.snapshot.followOnEdgeId, 'loading-to-success'); + expect( + _settleEffects(success), + [_settle([loading.requestId!], reject: GraphSettlementError.abortError)], + ); + + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))).presentation, + _lockedPresentation('idle-to-loading', 1), + ); + expect( + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(2))).presentation, + _lockedPresentation('idle-to-loading', 2), + ); + + final intermediate = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(3))); + expect(intermediate.presentation, _bodyFrame('loading', 0)); + expect(intermediate.snapshot.phase, MotionGraphPhase.waiting); + expect(intermediate.snapshot.visualState, 'loading'); + expect(intermediate.snapshot.requestedState, 'success'); + expect(intermediate.snapshot.pendingEdgeId, 'loading-to-success'); + expect(_effectTypes(intermediate), [MotionGraphEffectVisualStateChange, MotionGraphEffectTransitionEnd]); + + final committed = engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(4))); + expect(committed.presentation, _bodyFrame('success', 0)); + expect(committed.snapshot.phase, MotionGraphPhase.stable); + expect(committed.snapshot.visualState, 'success'); + expect(committed.snapshot.requestedState, 'success'); + expect(committed.snapshot.isTransitioning, false); + expect(_effectTypes(committed), [ + MotionGraphEffectTransitionStart, + MotionGraphEffectVisualStateChange, + MotionGraphEffectTransitionEnd, + MotionGraphEffectSettle, + ]); + expect( + _settleEffects(committed), + [_settle([success.requestId!], resolve: GraphSettlementResolveReason.targetCommitted)], + ); + }); + + test('previews a locked tick exactly without advancing its active route', () { + final engine = _animatedEngine(_lockedFollowOnGraph()); + engine.request('loading'); + engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(0))); + final beforeSnapshot = engine.snapshot(); + final beforeTrace = engine.getTrace(); + + final firstPreview = engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + final secondPreview = engine.previewTick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))); + + expect(firstPreview, secondPreview); + expect(firstPreview.presentation, _lockedPresentation('idle-to-loading', 1)); + expect(engine.snapshot(), beforeSnapshot); + expect(engine.getTrace(), beforeTrace); + expect(engine.tick(MotionGraphTickOptions(contentOrdinal: BigInt.from(1))), firstPreview); + }); + }); +} + +MotionGraphEngine _animatedEngine(Map definition) { + final engine = MotionGraphEngine(); + engine.install(definition); + engine.beginAnimated(); + return engine; +} + +Map _reversibleGraph({bool includeFollowOn = false, bool includeIdleError = false}) { + final states = [_state('idle'), _state('hover')]; + final edges = [ + _reversibleEdge('idle-to-hover', 'idle', 'hover', TransitionDirection.forward, 'hover.enter'), + _reversibleEdge('hover-to-idle', 'hover', 'idle', TransitionDirection.reverse, 'hover.leave', + reverseOf: 'idle-to-hover'), + ]; + + if (includeFollowOn) { + states.add(_state('success')); + states.add(_state('error')); + edges.add(_cutEdge('hover-to-success', 'hover', 'success')); + } else if (includeIdleError) { + states.add(_state('error')); + edges.add(_cutEdge('idle-to-error', 'idle', 'error')); + } + + return {'initialState': 'idle', 'states': states, 'edges': edges}; +} + +Map _lockedFollowOnGraph() { + return { + 'initialState': 'idle', + 'states': [_state('idle'), _state('loading'), _state('success')], + 'edges': [ + { + ..._portalEdge('idle-to-loading', 'idle', 'loading'), + 'transition': {'kind': 'locked', 'unitId': 'loading-bridge', 'frameCount': 3}, + }, + _cutEdge('loading-to-success', 'loading', 'success'), + ], + }; +} + +Map _state(String id) { + return { + 'id': id, + 'body': { + 'unitId': '$id-body', + 'kind': 'loop', + 'frameCount': 4, + 'ports': [ + {'id': 'handoff', 'entryFrame': 0, 'portalFrames': const [0, 2]}, + ], + }, + }; +} + +Map _portalEdge(String id, String from, String to) { + return { + 'id': id, + 'from': from, + 'to': to, + 'start': {'type': 'portal', 'sourcePort': 'handoff', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'continuity': 'exact-authored', + }; +} + +Map _reversibleEdge( + String id, + String from, + String to, + TransitionDirection direction, + String event, { + String? reverseOf, +}) { + final transition = { + 'kind': 'reversible', + 'unitId': 'hover-clip', + 'frameCount': 3, + 'direction': direction.name, + }; + if (reverseOf != null) transition['reverseOf'] = reverseOf; + return { + ..._portalEdge(id, from, to), + 'trigger': {'type': 'event', 'name': event}, + 'transition': transition, + 'continuity': reverseOf == null ? 'exact-authored' : 'exact-reverse', + }; +} + +Map _cutEdge(String id, String from, String to) { + return { + 'id': id, + 'from': from, + 'to': to, + 'start': {'type': 'cut', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'continuity': 'cut', + }; +} + +GraphPresentationReversible _reversiblePresentation( + String edgeId, + int frameIndex, + TransitionDirection direction, +) { + return GraphPresentationReversible( + edgeId: edgeId, + unitId: 'hover-clip', + frameIndex: frameIndex, + direction: direction, + ); +} + +GraphPresentationLocked _lockedPresentation(String edgeId, int frameIndex) { + return GraphPresentationLocked(edgeId: edgeId, unitId: 'loading-bridge', frameIndex: frameIndex); +} + +GraphPresentationBody _bodyFrame(String state, int frameIndex) => + GraphPresentationBody(state: state, unitId: '$state-body', frameIndex: frameIndex); + +List _effectTypes(MotionGraphResult result) => result.effects.map((e) => e.runtimeType).toList(); + +List _settleEffects(MotionGraphResult result) => + result.effects.whereType().toList(); + +MotionGraphEffectSettle _settle( + List requestIds, { + GraphSettlementResolveReason? resolve, + GraphSettlementError? reject, +}) { + final outcome = resolve != null + ? GraphSettlementResolve(resolve) + : GraphSettlementReject(reject!); + return MotionGraphEffectSettle(requestIds: requestIds, outcome: outcome); +} diff --git a/flutter/packages/aval_graph/test/intent_router_test.dart b/flutter/packages/aval_graph/test/intent_router_test.dart new file mode 100644 index 0000000..44d9f4f --- /dev/null +++ b/flutter/packages/aval_graph/test/intent_router_test.dart @@ -0,0 +1,446 @@ +// Ported from packages/graph/test/intent-router.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_graph/src/intent_router.dart'; +import 'package:aval_graph/src/route_plan.dart'; +import 'package:aval_graph/src/validate.dart'; +import 'package:test/test.dart'; + +void main() { + final indexes = getValidatedGraphIndexes(validateMotionGraphDefinition(_graph())); + + group('planStateIntent', () { + test('plans standalone no-ops for settled visual-state requests', () { + for (final phase in [ + MotionGraphPhase.preparing, + MotionGraphPhase.intro, + MotionGraphPhase.stable, + MotionGraphPhase.static, + ]) { + expect(planStateIntent(_context(indexes, phase), 'idle'), const StateIntentPlanStandaloneNoop()); + } + }); + + test('cancels preparation and intro routes selected by requests or events', () { + final pending = _edge(indexes, 'idle-hover'); + + for (final phase in [MotionGraphPhase.preparing, MotionGraphPhase.intro]) { + expect( + planStateIntent(_context(indexes, phase, pending: pending, hasPendingRequests: true), 'idle'), + const StateIntentPlanCancelBeforeStable(), + ); + // An event-owned route has no request group but is still older intent. + expect( + planStateIntent(_context(indexes, phase, pending: pending), 'idle'), + const StateIntentPlanCancelBeforeStable(), + ); + } + }); + + test('replaces valid pending routes before and after readiness', () { + for (final phase in [MotionGraphPhase.preparing, MotionGraphPhase.intro, MotionGraphPhase.stable]) { + expect( + planStateIntent(_context(indexes, phase), 'hover'), + StateIntentPlanReplacePending(_edge(indexes, 'idle-hover')), + ); + } + + expect( + planStateIntent( + _context(indexes, MotionGraphPhase.waiting, pending: _edge(indexes, 'idle-hover')), + 'loading', + ), + StateIntentPlanReplacePending(_edge(indexes, 'idle-loading')), + ); + }); + + test('joins, cancels, or rejects a waiting route without ambiguity', () { + final waiting = _context(indexes, MotionGraphPhase.waiting, pending: _edge(indexes, 'idle-hover')); + + expect(planStateIntent(waiting, 'hover'), const StateIntentPlanJoinPending()); + expect(planStateIntent(waiting, 'idle'), const StateIntentPlanCancelPending()); + expect(planStateIntent(waiting, 'success'), const StateIntentPlanReject()); + }); + + test('commits only direct edges in static mode', () { + expect( + planStateIntent(_context(indexes, MotionGraphPhase.static), 'error'), + StateIntentPlanStaticCommit(_edge(indexes, 'idle-error')), + ); + expect( + planStateIntent(_context(indexes, MotionGraphPhase.static), 'success'), + const StateIntentPlanReject(), + ); + }); + + test('treats locked and reversible active targets symmetrically', () { + final cases = [ + ( + phase: MotionGraphPhase.locked, + active: _edge(indexes, 'idle-loading'), + target: 'loading', + followOn: _edge(indexes, 'loading-success'), + ), + ( + phase: MotionGraphPhase.reversible, + active: _edge(indexes, 'idle-hover'), + target: 'hover', + followOn: _edge(indexes, 'hover-success'), + ), + ]; + + for (final c in cases) { + expect( + planStateIntent(_context(indexes, c.phase, active: c.active), c.target), + const StateIntentPlanContinueActiveTarget(), + ); + expect( + planStateIntent(_context(indexes, c.phase, active: c.active), 'success'), + StateIntentPlanQueueFollowOn(c.followOn), + ); + } + }); + + test('queues an authored inverse only for a reversible transition', () { + expect( + planStateIntent( + _context(indexes, MotionGraphPhase.reversible, active: _edge(indexes, 'idle-hover')), + 'idle', + ), + StateIntentPlanQueueReversal(_edge(indexes, 'hover-idle')), + ); + + expect( + planStateIntent( + _context(indexes, MotionGraphPhase.locked, active: _edge(indexes, 'idle-loading')), + 'idle', + ), + const StateIntentPlanReject(), + ); + }); + + test('routes from a queued reversal target and never from an old follow-on', () { + final reversing = _context( + indexes, + MotionGraphPhase.reversible, + active: _edge(indexes, 'idle-hover'), + reversal: _edge(indexes, 'hover-idle'), + followOn: _edge(indexes, 'idle-error'), + ); + + expect(planStateIntent(reversing, 'hover'), const StateIntentPlanContinueActiveTarget()); + expect(planStateIntent(reversing, 'idle'), const StateIntentPlanContinueReversalTarget()); + expect( + planStateIntent(reversing, 'error'), + StateIntentPlanQueueFollowOn(_edge(indexes, 'idle-error')), + ); + + // success is reachable from the old active target, not the effective + // reversal target, so it is not a valid direct follow-on. + expect(planStateIntent(reversing, 'success'), const StateIntentPlanReject()); + }); + + test('rejects invalid direct follow-ons in both transition phases', () { + expect( + planStateIntent( + _context(indexes, MotionGraphPhase.reversible, active: _edge(indexes, 'idle-hover')), + 'error', + ), + const StateIntentPlanReject(), + ); + expect( + planStateIntent( + _context(indexes, MotionGraphPhase.locked, active: _edge(indexes, 'idle-loading')), + 'error', + ), + const StateIntentPlanReject(), + ); + }); + }); + + group('planEventIntent', () { + test('replaces valid event routes in stable, preparing, and intro phases', () { + for (final phase in [MotionGraphPhase.stable, MotionGraphPhase.preparing, MotionGraphPhase.intro]) { + expect( + planEventIntent(_context(indexes, phase), 'hover.enter'), + EventIntentPlanReplacePending(_edge(indexes, 'idle-hover')), + ); + } + }); + + test('keeps a duplicate preparation or intro event as an accepted no-op', () { + for (final phase in [MotionGraphPhase.preparing, MotionGraphPhase.intro]) { + expect( + planEventIntent(_context(indexes, phase, pending: _edge(indexes, 'idle-hover')), 'hover.enter'), + const EventIntentPlanAcceptNoop(), + ); + } + }); + + test('cancels a waiting route through its inverse before visual lookup', () { + final waiting = _context(indexes, MotionGraphPhase.waiting, pending: _edge(indexes, 'idle-hover')); + + expect( + planEventIntent(waiting, 'hover.leave'), + EventIntentPlanCancelPending(_edge(indexes, 'hover-idle')), + ); + expect(planEventIntent(waiting, 'hover.enter'), const EventIntentPlanAcceptNoop()); + expect( + planEventIntent(waiting, 'load'), + EventIntentPlanReplacePending(_edge(indexes, 'idle-loading')), + ); + }); + + test('cancels a preparation or intro route through its inverse event', () { + for (final phase in [MotionGraphPhase.preparing, MotionGraphPhase.intro]) { + expect( + planEventIntent(_context(indexes, phase, pending: _edge(indexes, 'idle-hover')), 'hover.leave'), + EventIntentPlanCancelPending(_edge(indexes, 'hover-idle')), + ); + } + }); + + test('commits a direct event immediately in static mode', () { + expect( + planEventIntent(_context(indexes, MotionGraphPhase.static), 'idle.error'), + EventIntentPlanStaticCommit(_edge(indexes, 'idle-error')), + ); + }); + + test('treats locked and reversible event routing symmetrically', () { + final cases = [ + ( + phase: MotionGraphPhase.locked, + active: _edge(indexes, 'idle-loading'), + activeEvent: 'load', + followOn: _edge(indexes, 'loading-success'), + followOnEvent: 'loading.success', + ), + ( + phase: MotionGraphPhase.reversible, + active: _edge(indexes, 'idle-hover'), + activeEvent: 'hover.enter', + followOn: _edge(indexes, 'hover-success'), + followOnEvent: 'hover.success', + ), + ]; + + for (final c in cases) { + final active = _context(indexes, c.phase, active: c.active); + expect(planEventIntent(active, c.activeEvent), const EventIntentPlanAcceptNoop()); + expect( + planEventIntent(active, c.followOnEvent), + EventIntentPlanQueueFollowOn(c.followOn), + ); + + final queued = _context(indexes, c.phase, active: c.active, followOn: c.followOn); + expect(planEventIntent(queued, c.followOnEvent), const EventIntentPlanAcceptNoop()); + expect( + planEventIntent(queued, c.activeEvent), + EventIntentPlanContinueActiveTarget(c.active), + ); + } + }); + + test('queues an inverse event only while the reversible edge is active', () { + expect( + planEventIntent( + _context(indexes, MotionGraphPhase.reversible, active: _edge(indexes, 'idle-hover')), + 'hover.leave', + ), + EventIntentPlanQueueReversal(_edge(indexes, 'hover-idle')), + ); + + expect( + planEventIntent( + _context(indexes, MotionGraphPhase.locked, active: _edge(indexes, 'idle-loading')), + 'hover.leave', + ), + const EventIntentPlanReject(), + ); + }); + + test('uses active and effective targets when reversal and follow-on are queued', () { + final reversing = _context( + indexes, + MotionGraphPhase.reversible, + active: _edge(indexes, 'idle-hover'), + reversal: _edge(indexes, 'hover-idle'), + followOn: _edge(indexes, 'idle-error'), + ); + + // Reiterating the queued inverse is actionable because it cancels the + // follow-on when the engine applies this plan. + expect( + planEventIntent(reversing, 'hover.leave'), + EventIntentPlanQueueReversal(_edge(indexes, 'hover-idle')), + ); + // The active edge's own trigger remains reachable and cancels both queues. + expect( + planEventIntent(reversing, 'hover.enter'), + EventIntentPlanContinueActiveTarget(_edge(indexes, 'idle-hover')), + ); + expect(planEventIntent(reversing, 'idle.error'), const EventIntentPlanAcceptNoop()); + + // Event lookup must not extend from either the old active target or the + // queued follow-on target, which would create an invalid multi-hop route. + expect(planEventIntent(reversing, 'hover.success'), const EventIntentPlanReject()); + expect(planEventIntent(reversing, 'error.done'), const EventIntentPlanReject()); + }); + + test('rejects missing events', () { + expect(planEventIntent(_context(indexes, MotionGraphPhase.stable), 'missing'), const EventIntentPlanReject()); + }); + + test('throws on structurally impossible waiting and active phases', () { + expect( + () => planEventIntent(_context(indexes, MotionGraphPhase.waiting), 'hover.enter'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('graph invariant missing waiting pending edge'), + )), + ); + expect( + () => planStateIntent(_context(indexes, MotionGraphPhase.reversible), 'hover'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('graph invariant missing active transition edge'), + )), + ); + }); + }); +} + +class _TestRouteView implements RoutePlanView { + _TestRouteView({this.pending, this.active, this.reversal, this.followOn}); + + @override + final SequencedEdge? pending; + @override + final SequencedEdge? active; + @override + final SequencedEdge? reversal; + @override + final SequencedEdge? followOn; + + @override + SequencedEdge? recoveryCandidate() => followOn ?? reversal ?? active ?? pending; + + @override + GraphStateId? prospectiveState(GraphStateId? visualState) => + followOn?.edge.to ?? reversal?.edge.to ?? active?.edge.to ?? pending?.edge.to ?? visualState; + + @override + bool hasRoute() => pending != null || active != null || reversal != null || followOn != null; +} + +IntentContext _context( + ValidatedGraphIndexes indexes, + MotionGraphPhase phase, { + GraphEdgeDefinition? pending, + GraphEdgeDefinition? active, + GraphEdgeDefinition? followOn, + GraphEdgeDefinition? reversal, + bool hasPendingRequests = false, +}) { + return IntentContext( + phase: phase, + visualState: 'idle', + routes: _TestRouteView( + pending: pending == null ? null : SequencedEdge(edge: pending, sequence: 1), + active: active == null ? null : SequencedEdge(edge: active, sequence: 2), + reversal: reversal == null ? null : SequencedEdge(edge: reversal, sequence: 3), + followOn: followOn == null ? null : SequencedEdge(edge: followOn, sequence: 4), + ), + indexes: indexes, + hasPendingRequests: hasPendingRequests, + ); +} + +GraphEdgeDefinition _edge(ValidatedGraphIndexes indexes, String id) { + final found = indexes.edgesById[id]; + if (found == null) throw StateError('missing fixture edge $id'); + return found; +} + +Map _graph() { + return { + 'initialState': 'idle', + 'states': [ + for (final id in ['idle', 'hover', 'loading', 'success', 'error', 'done']) _state(id), + ], + 'edges': [ + _reversibleEdge('idle-hover', 'idle', 'hover', 'forward', 'hover.enter'), + _reversibleEdge('hover-idle', 'hover', 'idle', 'reverse', 'hover.leave', reverseOf: 'idle-hover'), + _lockedEdge('idle-loading', 'idle', 'loading', 'load'), + _cutEdge('idle-error', 'idle', 'error', 'idle.error'), + _cutEdge('hover-success', 'hover', 'success', 'hover.success'), + _cutEdge('loading-success', 'loading', 'success', 'loading.success'), + _cutEdge('error-done', 'error', 'done', 'error.done'), + ], + }; +} + +Map _state(String id) { + return { + 'id': id, + 'body': { + 'unitId': '$id-body', + 'kind': 'loop', + 'frameCount': 2, + 'ports': [ + {'id': 'handoff', 'entryFrame': 0, 'portalFrames': [0, 1]}, + ], + }, + }; +} + +Map _reversibleEdge( + String id, + String from, + String to, + String direction, + String event, { + String? reverseOf, +}) { + final transition = { + 'kind': 'reversible', + 'unitId': 'hover-motion', + 'frameCount': 3, + 'direction': direction, + }; + if (reverseOf != null) transition['reverseOf'] = reverseOf; + return { + 'id': id, + 'from': from, + 'to': to, + 'trigger': {'type': 'event', 'name': event}, + 'start': {'type': 'portal', 'sourcePort': 'handoff', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'transition': transition, + 'continuity': reverseOf == null ? 'exact-authored' : 'exact-reverse', + }; +} + +Map _lockedEdge(String id, String from, String to, String event) { + return { + 'id': id, + 'from': from, + 'to': to, + 'trigger': {'type': 'event', 'name': event}, + 'start': {'type': 'portal', 'sourcePort': 'handoff', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'transition': {'kind': 'locked', 'unitId': 'loading-motion', 'frameCount': 2}, + 'continuity': 'exact-authored', + }; +} + +Map _cutEdge(String id, String from, String to, String event) { + return { + 'id': id, + 'from': from, + 'to': to, + 'trigger': {'type': 'event', 'name': event}, + 'start': {'type': 'cut', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'continuity': 'cut', + }; +} diff --git a/flutter/packages/aval_graph/test/operation_journal_test.dart b/flutter/packages/aval_graph/test/operation_journal_test.dart new file mode 100644 index 0000000..a233c53 --- /dev/null +++ b/flutter/packages/aval_graph/test/operation_journal_test.dart @@ -0,0 +1,154 @@ +// Ported from packages/graph/test/operation-journal.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_graph/src/operation_journal.dart'; +import 'package:test/test.dart'; + +void main() { + group('OperationJournal', () { + test('admits the bounded input window while every input consumes a sequence', () { + final journal = OperationJournal(); + + for (var index = 1; index <= GraphLimits.maxInputsPerTick; index += 1) { + final admission = journal.beginInput(); + expect(admission.sequence, index); + expect(admission.withinLimit, true); + } + + final overflow1 = journal.beginInput(); + expect(overflow1.sequence, 33); + expect(overflow1.withinLimit, false); + final overflow2 = journal.beginInput(); + expect(overflow2.sequence, 34); + expect(overflow2.withinLimit, false); + expect(journal.inputsSinceTick, GraphLimits.maxInputsPerTick); + expect(journal.allocateInternalSequence(), 35); + expect(journal.inputSequence, 35); + expect(journal.inputsSinceTick, GraphLimits.maxInputsPerTick); + }); + + test('validates consecutive ordinals without resetting inputs on failed work', () { + final journal = OperationJournal(); + journal.beginInput(); + journal.beginTick(BigInt.zero); + journal.incrementRouteOperations(); + + expect( + () => journal.beginTick(BigInt.two), + throwsA(isA() + .having((e) => e.code, 'code', MotionGraphErrorCode.nonConsecutiveTick) + .having((e) => e.message, 'message', 'content ordinal must be 1')), + ); + expect(journal.contentOrdinal, BigInt.zero); + expect(journal.inputsSinceTick, 1); + expect(journal.routeOperationsLastTick, 1); + + journal.beginTick(BigInt.one); + expect(journal.routeOperationsLastTick, 0); + expect(journal.inputsSinceTick, 1); + journal.beginInput(); + expect(journal.inputsSinceTick, 2); + + journal.completeTick(); + expect(journal.inputsSinceTick, 0); + expect(journal.contentOrdinal, BigInt.one); + }); + + test('enforces the exact per-tick route-operation cap and error', () { + final journal = OperationJournal(); + journal.beginTick(BigInt.zero); + + for (var count = 0; count < GraphLimits.maxRoutingOperationsPerTick; count += 1) { + journal.incrementRouteOperations(); + } + expect(journal.routeOperationsLastTick, GraphLimits.maxRoutingOperationsPerTick); + + expect( + () => journal.incrementRouteOperations(), + throwsA(isA() + .having((e) => e.code, 'code', MotionGraphErrorCode.graphValidation) + .having( + (e) => e.message, + 'message', + 'graph exceeded the per-tick routing-operation bound', + )), + ); + expect(journal.routeOperationsLastTick, GraphLimits.maxRoutingOperationsPerTick + 1); + + journal.beginTick(BigInt.one); + expect(journal.routeOperationsLastTick, 0); + }); + + test('records the completed presentation and snapshot in a frozen result', () { + final journal = OperationJournal(); + const presentation = GraphPresentationBody(state: 'idle', unitId: 'idle-loop', frameIndex: 3); + final snapshot = _frozenSnapshot(presentation); + const effect = MotionGraphEffectRequestedStateChange(from: 'idle', to: 'hovered', sequence: 1); + + final result = journal.record(CompletedOperation( + operation: MotionGraphOperation.request, + presentation: presentation, + effects: const [effect], + snapshot: snapshot, + metadata: const OperationResultMetadata( + accepted: true, + joined: false, + sequence: 1, + requestId: 1, + ), + )); + + expect(result.operation, MotionGraphOperation.request); + expect(result.accepted, true); + expect(result.joined, false); + expect(result.sequence, 1); + expect(result.requestId, 1); + expect(result.presentation, same(presentation)); + expect(result.effects, const [effect]); + expect(result.snapshot, same(snapshot)); + expect(journal.getTrace(), [MotionGraphTraceRecord(index: 1, result: result)]); + }); + + test('retains only the newest trace window with absolute indices', () { + final journal = OperationJournal(); + final snapshot = _frozenSnapshot(null); + final total = GraphLimits.maxTraceRecords + 4; + + for (var index = 0; index < total; index += 1) { + journal.record(CompletedOperation( + operation: MotionGraphOperation.tick, + effects: const [], + presentation: null, + snapshot: snapshot, + )); + } + + final trace = journal.getTrace(); + expect(trace, hasLength(GraphLimits.maxTraceRecords)); + expect(trace.first.index, 5); + expect(trace.last.index, total); + expect(() => trace.add(trace.first), throwsUnsupportedError); + }); + }); +} + +MotionGraphSnapshot _frozenSnapshot(GraphPresentation? presentation) { + return MotionGraphSnapshot( + readiness: MotionGraphReadiness.animated, + phase: MotionGraphPhase.stable, + initialUnitPending: false, + requestedState: 'idle', + visualState: 'idle', + prospectiveState: 'idle', + isTransitioning: false, + presentation: presentation, + pendingEdgeId: null, + activeEdgeId: null, + followOnEdgeId: null, + direction: null, + contentOrdinal: null, + inputSequence: 0, + pendingRequestCount: 0, + inputsSinceTick: 0, + routeOperationsLastTick: 0, + ); +} diff --git a/flutter/packages/aval_graph/test/portal_search_test.dart b/flutter/packages/aval_graph/test/portal_search_test.dart new file mode 100644 index 0000000..267a1f1 --- /dev/null +++ b/flutter/packages/aval_graph/test/portal_search_test.dart @@ -0,0 +1,248 @@ +// Ported from packages/graph/test/portal-search.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +void main() { + group('body frame geometry', () { + test('advances and wraps a loop, including a one-frame loop', () { + final body = _loop(6, [0, 3]); + expect( + nextBodyFrame(body, 2), + const BodyFrameStep(frameIndex: 3, didAdvance: true, wrapped: false, isHeld: false), + ); + expect( + nextBodyFrame(body, 5), + const BodyFrameStep(frameIndex: 0, didAdvance: true, wrapped: true, isHeld: false), + ); + expect( + nextBodyFrame(_loop(1, [0]), 0), + const BodyFrameStep(frameIndex: 0, didAdvance: true, wrapped: true, isHeld: false), + ); + }); + + test('advances a finite body once and then holds its final frame', () { + final body = _finite(4, [3]); + expect( + nextBodyFrame(body, 2), + const BodyFrameStep(frameIndex: 3, didAdvance: true, wrapped: false, isHeld: false), + ); + expect( + nextBodyFrame(body, 3), + const BodyFrameStep(frameIndex: 3, didAdvance: false, wrapped: false, isHeld: true), + ); + }); + + test('never advances a held body', () { + expect( + nextBodyFrame(_held(), 0), + const BodyFrameStep(frameIndex: 0, didAdvance: false, wrapped: false, isHeld: true), + ); + }); + }); + + group('portal geometry', () { + test('treats a currently displayed portal as distance zero', () { + final result = findNextPortalBoundary(_loop(12, [0, 4, 9]), 'handoff', 4); + expect( + result, + const BodyBoundarySearch(boundaryFrame: 4, waitFrames: 0, eligibleNow: true, wraps: false), + ); + }); + + test('finds the next loop portal without wrapping', () { + expect( + findNextPortalBoundary(_loop(12, [0, 4, 9]), 'handoff', 5), + const BodyBoundarySearch(boundaryFrame: 9, waitFrames: 4, eligibleNow: false, wraps: false), + ); + }); + + test('searches a looping body circularly', () { + expect( + findNextPortalBoundary(_loop(12, [0, 4, 9]), 'handoff', 10), + const BodyBoundarySearch(boundaryFrame: 0, waitFrames: 2, eligibleNow: false, wraps: true), + ); + }); + + test('computes loop worst-case wait from portal gaps', () { + expect(greatestPortalWaitFrames(_loop(12, [0, 4, 9]), 'handoff'), 4); + expect(greatestPortalWaitFrames(_loop(12, [3]), 'handoff'), 11); + expect(greatestPortalWaitFrames(_loop(1, [0]), 'handoff'), 0); + }); + + test('searches finite bodies only forward', () { + final body = _finite(10, [2, 6, 9]); + expect( + findNextPortalBoundary(body, 'handoff', 3), + const BodyBoundarySearch(boundaryFrame: 6, waitFrames: 3, eligibleNow: false, wraps: false), + ); + expect( + findNextPortalBoundary(body, 'handoff', 9), + const BodyBoundarySearch(boundaryFrame: 9, waitFrames: 0, eligibleNow: true, wraps: false), + ); + expect(greatestPortalWaitFrames(body, 'handoff'), 3); + }); + + test('uses frame zero immediately for a valid held port', () { + expect( + findNextPortalBoundary(_held(), 'handoff', 0), + const BodyBoundarySearch(boundaryFrame: 0, waitFrames: 0, eligibleNow: true, wraps: false), + ); + expect(greatestPortalWaitFrames(_held(), 'handoff'), 0); + }); + }); + + group('finish geometry', () { + test('waits through the remaining finite frames and then remains eligible', () { + final body = _finite(7, [6]); + expect( + findFinishBoundary(body, 2), + const BodyBoundarySearch(boundaryFrame: 6, waitFrames: 4, eligibleNow: false, wraps: false), + ); + expect( + findFinishBoundary(body, 6), + const BodyBoundarySearch(boundaryFrame: 6, waitFrames: 0, eligibleNow: true, wraps: false), + ); + expect(greatestFinishWaitFrames(body), 6); + }); + + test('makes a held body immediately finish-eligible', () { + expect( + findFinishBoundary(_held(), 0), + const BodyBoundarySearch(boundaryFrame: 0, waitFrames: 0, eligibleNow: true, wraps: false), + ); + expect(greatestFinishWaitFrames(_held()), 0); + }); + + test('rejects finish geometry for an infinite loop', () { + expect( + () => findFinishBoundary(_loop(4, [0]), 0), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('cannot use a finish boundary'), + )), + ); + expect( + () => greatestFinishWaitFrames(_loop(4, [0])), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('cannot use a finish boundary'), + )), + ); + }); + }); + + group('geometry validation', () { + test('rejects invalid current body frames', () { + final body = _loop(4, [0]); + for (final frame in [-1, 4]) { + expect( + () => nextBodyFrame(body, frame), + throwsA(isA().having((e) => e.message, 'message', contains('out of range'))), + ); + expect( + () => findNextPortalBoundary(body, 'handoff', frame), + throwsA(isA().having((e) => e.message, 'message', contains('out of range'))), + ); + } + }); + + test('rejects a missing or duplicate named port', () { + final body = _loop(4, [0]); + expect( + () => findNextPortalBoundary(body, 'missing', 0), + throwsA(isA().having((e) => e.message, 'message', contains('has no port missing'))), + ); + + final duplicate = GraphBodyDefinition( + unitId: body.unitId, + kind: body.kind, + frameCount: body.frameCount, + ports: [body.ports[0], body.ports[0]], + ); + expect( + () => greatestPortalWaitFrames(duplicate, 'handoff'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('duplicate port handoff'), + )), + ); + }); + + test('rejects empty, unsorted, duplicate, and out-of-range portal frames', () { + for (final portalFrames in [[], [2, 1], [1, 1], [-1], [4]]) { + final body = _loop(4, portalFrames); + expect( + () => greatestPortalWaitFrames(body, 'handoff'), + throwsA(isA()), + ); + } + }); + + test('rejects a finite departure port that omits the held final frame', () { + final body = _finite(6, [1, 4]); + expect( + () => findNextPortalBoundary(body, 'handoff', 5), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('must include the final frame'), + )), + ); + expect( + () => greatestPortalWaitFrames(body, 'handoff'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('must include the final frame'), + )), + ); + }); + + test('rejects malformed body geometry', () { + final zeroFrame = _loop(1, [0]); + expect( + () => nextBodyFrame( + GraphBodyDefinition(unitId: zeroFrame.unitId, kind: zeroFrame.kind, frameCount: 0, ports: zeroFrame.ports), + 0, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('positive safe integer'), + )), + ); + final held = _held(); + expect( + () => nextBodyFrame( + GraphBodyDefinition(unitId: held.unitId, kind: held.kind, frameCount: 2, ports: held.ports), + 0, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('exactly one frame'), + )), + ); + }); + }); +} + +GraphBodyDefinition _loop(int frameCount, List portalFrames) => + _body(GraphBodyKind.loop, frameCount, portalFrames); + +GraphBodyDefinition _finite(int frameCount, List portalFrames) => + _body(GraphBodyKind.finite, frameCount, portalFrames); + +GraphBodyDefinition _held() => _body(GraphBodyKind.held, 1, [0]); + +GraphBodyDefinition _body(GraphBodyKind kind, int frameCount, List portalFrames) { + return GraphBodyDefinition( + unitId: '${kind.name}-body', + kind: kind, + frameCount: frameCount, + ports: [GraphPortDefinition(id: 'handoff', portalFrames: portalFrames)], + ); +} diff --git a/flutter/packages/aval_graph/test/request_ledger_test.dart b/flutter/packages/aval_graph/test/request_ledger_test.dart new file mode 100644 index 0000000..b6bb576 --- /dev/null +++ b/flutter/packages/aval_graph/test/request_ledger_test.dart @@ -0,0 +1,138 @@ +// Ported from packages/graph/test/request-ledger.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_graph/src/request_ledger.dart'; +import 'package:test/test.dart'; + +void main() { + group('RequestLedger', () { + test('allocates monotonically increasing request IDs', () { + final ledger = RequestLedger(); + + expect(ledger.request('hovered').requestId, 1); + expect(ledger.request('hovered').requestId, 2); + expect( + ledger.settleNew(const GraphSettlementReject(GraphSettlementError.routeError)).requestId, + 3, + ); + expect(ledger.request('idle').requestId, 4); + }); + + test('joins duplicate requests into the surviving destination group', () { + final ledger = RequestLedger(); + + final first = ledger.request('hovered'); + final second = ledger.request('hovered'); + + expect(first.requestId, 1); + expect(first.target, 'hovered'); + expect(first.joined, false); + expect(first.superseded, isNull); + + expect(second.requestId, 2); + expect(second.target, 'hovered'); + expect(second.joined, true); + expect(second.superseded, isNull); + + expect(ledger.pendingRequestCount, 2); + expect(ledger.pendingTarget, 'hovered'); + + final settled = ledger.settlePending( + const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted), + ); + expect( + settled, + MotionGraphEffectSettle( + requestIds: const [1, 2], + outcome: const GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted), + ), + ); + expect(ledger.pendingRequestCount, 0); + expect(ledger.pendingTarget, isNull); + }); + + test('supersedes a whole group once and rejects it in request order', () { + final ledger = RequestLedger(); + ledger.request('success'); + ledger.request('success'); + + final replacement = ledger.request('error'); + + expect(replacement.requestId, 3); + expect(replacement.target, 'error'); + expect(replacement.joined, false); + expect( + replacement.superseded, + MotionGraphEffectSettle( + requestIds: const [1, 2], + outcome: const GraphSettlementReject(GraphSettlementError.abortError), + ), + ); + expect(ledger.pendingRequestCount, 1); + expect(ledger.pendingTarget, 'error'); + + final duplicate = ledger.request('error'); + expect(duplicate.joined, true); + expect(duplicate.superseded, isNull); + expect(ledger.pendingRequestCount, 2); + + expect( + ledger.settlePending( + const GraphSettlementReject(GraphSettlementError.playbackFallbackError), + )! + .requestIds, + const [3, 4], + ); + expect( + ledger.settlePending(const GraphSettlementReject(GraphSettlementError.abortError)), + isNull, + ); + }); + + test('settles standalone requests without disturbing a pending group', () { + final ledger = RequestLedger(); + ledger.request('hovered'); + + final invalid = ledger.settleNew(const GraphSettlementReject(GraphSettlementError.routeError)); + + expect(invalid.requestId, 2); + expect( + invalid.effect, + MotionGraphEffectSettle( + requestIds: const [2], + outcome: const GraphSettlementReject(GraphSettlementError.routeError), + ), + ); + expect(ledger.pendingRequestCount, 1); + expect(ledger.pendingTarget, 'hovered'); + }); + + test('every returned settlement carries an independent, unmodifiable request-ID list', () { + final ledger = RequestLedger(); + ledger.request('hovered'); + final superseding = ledger.request('idle'); + final resolved = + ledger.settlePending(const GraphSettlementResolve(GraphSettlementResolveReason.stableNoop)); + final standalone = + ledger.settleNew(const GraphSettlementReject(GraphSettlementError.notReadyError)); + + expect(superseding.superseded!.requestIds, const [1]); + expect(resolved!.requestIds, const [2]); + expect(standalone.effect.requestIds, const [3]); + + expect( + () => resolved.requestIds.add(99), + throwsUnsupportedError, + ); + }); + + test('stores the exact settlement outcome value passed in', () { + final ledger = RequestLedger(); + ledger.request('hovered'); + const outcome = GraphSettlementResolve(GraphSettlementResolveReason.targetCommitted); + + final effect = ledger.settlePending(outcome); + + expect(effect!.outcome, same(outcome)); + }); + }); +} diff --git a/flutter/packages/aval_graph/test/ring_plan_test.dart b/flutter/packages/aval_graph/test/ring_plan_test.dart new file mode 100644 index 0000000..63faf38 --- /dev/null +++ b/flutter/packages/aval_graph/test/ring_plan_test.dart @@ -0,0 +1,181 @@ +import 'package:aval_graph/aval_graph.dart'; +import 'package:test/test.dart'; + +const facings = [ + 'walk_n', + 'walk_ne', + 'walk_e', + 'walk_se', + 'walk_s', + 'walk_sw', + 'walk_w', + 'walk_nw', +]; + +GraphRingDefinition facingRing({ + bool cyclic = true, + GraphRingTieBreak tieBreak = GraphRingTieBreak.forward, + int maxChainedSteps = 4, +}) { + return GraphRingDefinition( + id: 'facing.walk', + states: facings, + cyclic: cyclic, + tieBreak: tieBreak, + maxChainedSteps: maxChainedSteps, + ); +} + +void main() { + group('planRingArc', () { + test('chooses the shorter arc and reports landing states', () { + final ring = facingRing(); + expect( + planRingArc(ring, 'walk_n', 'walk_e'), + RingArc( + direction: GraphRingTieBreak.forward, + states: const ['walk_ne', 'walk_e'], + ), + ); + expect( + planRingArc(ring, 'walk_nw', 'walk_ne'), + RingArc( + direction: GraphRingTieBreak.forward, + states: const ['walk_n', 'walk_ne'], + ), + ); + expect( + planRingArc(ring, 'walk_n', 'walk_w'), + RingArc( + direction: GraphRingTieBreak.backward, + states: const ['walk_nw', 'walk_w'], + ), + ); + }); + + test('resolves half turn via tieBreak', () { + final forward = facingRing(); + final backward = facingRing(tieBreak: GraphRingTieBreak.backward); + expect( + planRingArc(forward, 'walk_n', 'walk_s')!.direction, + GraphRingTieBreak.forward, + ); + expect( + planRingArc(forward, 'walk_n', 'walk_s')!.states, + const ['walk_ne', 'walk_e', 'walk_se', 'walk_s'], + ); + expect( + planRingArc(backward, 'walk_n', 'walk_s')!.direction, + GraphRingTieBreak.backward, + ); + expect( + planRingArc(backward, 'walk_n', 'walk_s')!.states, + const ['walk_nw', 'walk_w', 'walk_sw', 'walk_s'], + ); + }); + + test('never wraps a non-cyclic ring', () { + final line = facingRing(cyclic: false); + expect( + planRingArc(line, 'walk_nw', 'walk_ne')!.states, + const [ + 'walk_w', + 'walk_sw', + 'walk_s', + 'walk_se', + 'walk_e', + 'walk_ne', + ], + ); + expect(planRingArc(line, 'walk_n', 'walk_n'), isNull); + expect(planRingArc(line, 'walk_n', 'sit'), isNull); + }); + }); + + group('MotionGraphEngine.planFor rings', () { + test('plans multi-step arc without advancing the graph', () { + final engine = _animatedFacingEngine(); + final before = engine.snapshot(); + expect(engine.planFor('walk_e'), const ['walk_ne', 'walk_e']); + expect(engine.planFor('walk_ne'), const ['walk_ne']); + expect(engine.planFor('walk_n'), isEmpty); + expect(engine.planFor('unknown'), isNull); + expect(engine.snapshot().visualState, before.visualState); + expect(engine.snapshot().requestedState, before.requestedState); + }); + + test('refuses arcs longer than maxChainedSteps', () { + final engine = _animatedFacingEngine(maxChainedSteps: 2); + expect(engine.planFor('walk_s'), isNull); + final refused = engine.request('walk_s'); + expect(refused.accepted, isFalse); + }); + }); +} + +MotionGraphEngine _animatedFacingEngine({int maxChainedSteps = 4}) { + final states = >[ + for (final id in facings) + { + 'id': id, + 'body': { + 'unitId': '$id.body', + 'kind': 'loop', + 'frameCount': 8, + 'ports': [ + { + 'id': 'default', + 'entryFrame': 0, + 'portalFrames': [0, 4], + } + ], + }, + }, + ]; + final edges = >[]; + for (var i = 0; i < facings.length; i += 1) { + final from = facings[i]; + final toFwd = facings[(i + 1) % facings.length]; + final toBack = facings[(i - 1 + facings.length) % facings.length]; + for (final entry in [ + (toFwd, 1), + (toBack, -1), + ]) { + final to = entry.$1; + final step = entry.$2; + final short = from.replaceFirst('walk_', ''); + final shortTo = to.replaceFirst('walk_', ''); + edges.add({ + 'id': 'facing.walk.$short.$shortTo', + 'from': from, + 'to': to, + 'start': { + 'type': 'cut', + 'targetPort': 'default', + 'maxWaitFrames': 1, + }, + 'continuity': 'cut', + 'ring': 'facing.walk', + 'step': step, + }); + } + } + final definition = { + 'initialState': 'walk_n', + 'states': states, + 'edges': edges, + 'rings': [ + { + 'id': 'facing.walk', + 'states': facings, + 'cyclic': true, + 'tieBreak': 'forward', + 'maxChainedSteps': maxChainedSteps, + } + ], + }; + final engine = MotionGraphEngine(); + engine.install(definition); + engine.beginAnimated(); + return engine; +} diff --git a/flutter/packages/aval_graph/test/route_plan_test.dart b/flutter/packages/aval_graph/test/route_plan_test.dart new file mode 100644 index 0000000..16e20fa --- /dev/null +++ b/flutter/packages/aval_graph/test/route_plan_test.dart @@ -0,0 +1,228 @@ +// Ported from packages/graph/test/route-plan.test.ts +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_graph/src/route_plan.dart'; +import 'package:test/test.dart'; + +void main() { + group('RoutePlan', () { + test('keeps a pending edge and sequence in one immutable value', () { + final plan = RoutePlan(); + + final pending = plan.replacePending(edgeAB, 17); + + expect(pending.edge, same(edgeAB)); + expect(pending.sequence, 17); + expect(plan.pending, same(pending)); + expect(plan.prospectiveState('a'), 'b'); + expect(plan.hasRoute(), true); + }); + + test('cancels a pending route without changing its returned value', () { + final plan = RoutePlan(); + final pending = plan.replacePending(edgeAB, 2); + + expect(plan.cancelPending(), same(pending)); + expect(plan.cancelPending(), isNull); + expect(plan.prospectiveState('a'), 'a'); + expect(plan.hasRoute(), false); + }); + + test('activates a matching pending route without rebuilding its atomic ref', () { + final plan = RoutePlan(); + final pending = plan.replacePending(edgeAB, 3); + + final active = plan.activate(edgeAB, 3); + + expect(active, same(pending)); + expect(plan.pending, isNull); + expect(plan.active, same(active)); + expect(plan.recoveryCandidate(), same(active)); + }); + + test('activates a completion route directly when no edge was pending', () { + final plan = RoutePlan(); + + final active = plan.activate(edgeAB, 4); + + expect(active.edge, same(edgeAB)); + expect(active.sequence, 4); + expect(plan.active, same(active)); + expect(plan.pending, isNull); + }); + + test('queues one follow-on from the effective active target', () { + final plan = _activePlan(); + + final first = plan.queueFollowOn(edgeBC, 5); + final replacement = plan.queueFollowOn(edgeBD, 6); + + expect(first, isNot(same(replacement))); + expect(plan.followOn, same(replacement)); + expect(plan.prospectiveState('a'), 'd'); + expect(plan.clearFollowOn(), same(replacement)); + expect(plan.followOn, isNull); + }); + + test('queues a reversal atomically and discards an older follow-on', () { + final plan = _activePlan(); + plan.queueFollowOn(edgeBC, 5); + + final reversal = plan.queueReversal(edgeBA, 6); + + expect(plan.followOn, isNull); + expect(plan.reversal, same(reversal)); + expect(plan.prospectiveState('a'), 'a'); + expect(plan.recoveryCandidate(), same(reversal)); + }); + + test('allows a continuation after a queued reversal and preserves it on activation', () { + final plan = _activePlan(); + final reversal = plan.queueReversal(edgeBA, 7); + final followOn = plan.queueFollowOn(edgeAC, 8); + + expect(plan.prospectiveState('a'), 'c'); + expect(plan.recoveryCandidate(), same(followOn)); + + expect(plan.activateReversal(), same(reversal)); + expect(plan.active, same(reversal)); + expect(plan.reversal, isNull); + expect(plan.followOn, same(followOn)); + }); + + test('promotes a follow-on to pending when the active edge completes', () { + final plan = _activePlan(); + final followOn = plan.queueFollowOn(edgeBC, 9); + + final completion = plan.completeActive(); + + expect(completion.completed.edge, same(edgeAB)); + expect(completion.completed.sequence, 1); + expect(completion.promoted, same(followOn)); + expect(plan.active, isNull); + expect(plan.followOn, isNull); + expect(plan.pending, same(followOn)); + }); + + test('clears a queued reversal when an active edge completes', () { + final plan = _activePlan(); + plan.queueReversal(edgeBA, 10); + + final completion = plan.completeActive(); + + expect(completion.promoted, isNull); + expect(plan.active, isNull); + expect(plan.reversal, isNull); + expect(plan.hasRoute(), false); + }); + + test('uses follow-on, reversal, active, and pending recovery priority', () { + final plan = _activePlan(); + final active = plan.active; + final reversal = plan.queueReversal(edgeBA, 11); + final followOn = plan.queueFollowOn(edgeAC, 12); + + expect(plan.recoveryCandidate(), same(followOn)); + plan.clearFollowOn(); + expect(plan.recoveryCandidate(), same(reversal)); + plan.clearReversal(); + expect(plan.recoveryCandidate(), same(active)); + plan.completeActive(); + expect(plan.recoveryCandidate(), isNull); + + final pending = plan.replacePending(edgeAB, 13); + expect(plan.recoveryCandidate(), same(pending)); + }); + + test('exposes structural read-only slots to pure route consumers', () { + final plan = _activePlan(); + final RoutePlanView view = plan; + + expect(view.prospectiveState('a'), 'b'); + expect(view.recoveryCandidate()?.edge.id, 'a-to-b'); + }); + + test('clears every slot without retaining stale sequences', () { + final plan = _activePlan(); + plan.queueReversal(edgeBA, 14); + plan.queueFollowOn(edgeAC, 15); + + plan.clear(); + + expect(plan.pending, isNull); + expect(plan.active, isNull); + expect(plan.followOn, isNull); + expect(plan.reversal, isNull); + expect(plan.hasRoute(), false); + expect(plan.prospectiveState(null), isNull); + }); + + test('rejects cross-slot topology mistakes', () { + final pendingPlan = RoutePlan(); + pendingPlan.replacePending(edgeAB, 1); + expect( + () => pendingPlan.activate(edgeAB, 2), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('activated route does not match the pending route'), + )), + ); + + final active = _activePlan(); + expect( + () => active.replacePending(edgeAB, 2), + throwsA(isA().having((e) => e.message, 'message', contains('active route must complete'))), + ); + expect( + () => active.queueFollowOn(edgeAC, 2), + throwsA(isA().having((e) => e.message, 'message', contains('follow-on source must match'))), + ); + expect( + () => active.queueReversal(edgeCA, 2), + throwsA(isA().having((e) => e.message, 'message', contains('reversal must invert'))), + ); + }); + + test('rejects missing slots and invalid route sequences', () { + final plan = RoutePlan(); + + expect( + () => plan.activateReversal(), + throwsA(isA().having((e) => e.message, 'message', contains('no active route'))), + ); + expect( + () => plan.completeActive(), + throwsA(isA().having((e) => e.message, 'message', contains('no active route'))), + ); + // TypeScript additionally asserts `replacePending(EDGE_AB, Number.MAX_VALUE)` + // throws RangeError. Dart's `int` sequence parameter has no + // non-integer/unsafe-magnitude representation (unlike JS `number`), so + // that specific overflow path is unreachable here; the negative-sequence + // guard below is the only reachable RangeError trigger. + expect(() => plan.replacePending(edgeAB, -1), throwsA(isA())); + }); + }); +} + +RoutePlan _activePlan() { + final plan = RoutePlan(); + plan.activate(edgeAB, 1); + return plan; +} + +GraphEdgeDefinition _edge(String id, String from, String to) { + return GraphEdgeDefinition( + id: id, + from: from, + to: to, + start: const GraphStartPolicyCut(targetPort: 'entry'), + continuity: GraphContinuity.cut, + ); +} + +final edgeAB = _edge('a-to-b', 'a', 'b'); +final edgeBA = _edge('b-to-a', 'b', 'a'); +final edgeBC = _edge('b-to-c', 'b', 'c'); +final edgeBD = _edge('b-to-d', 'b', 'd'); +final edgeAC = _edge('a-to-c', 'a', 'c'); +final edgeCA = _edge('c-to-a', 'c', 'a'); diff --git a/flutter/packages/aval_graph/test/validate_test.dart b/flutter/packages/aval_graph/test/validate_test.dart new file mode 100644 index 0000000..9e0fcb7 --- /dev/null +++ b/flutter/packages/aval_graph/test/validate_test.dart @@ -0,0 +1,651 @@ +// Ported from packages/graph/test/validate.test.ts +// +// The TypeScript original constructs plain object/array literals — some +// deliberately malformed (sparse arrays, wrong-typed fields) — because +// validate.ts treats its input as fully untrusted at runtime regardless of +// its nominal `MotionGraphDefinition` parameter type. This port mirrors that +// intent literally: every fixture here is a `Map` / +// `List` tree (the natural Dart shape for untrusted/JSON-like data) +// rather than the package's own typed model classes, and +// `validateMotionGraphDefinition` is exercised exactly as it is meant to be +// used at a real trust boundary (e.g. a JSON graph asset loaded at runtime). +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_graph/src/validate.dart' show getValidatedGraphIndexes; +import 'package:test/test.dart'; + +void main() { + group('validateMotionGraphDefinition', () { + test('returns a detached definition with independently queryable indexes', () { + final input = _reversibleGraph(); + final validated = validateMotionGraphDefinition(input); + final indexes = getValidatedGraphIndexes(validated); + + expect(validated.definition.states[0].id, 'idle'); + expect(indexes.statesById['idle'], same(validated.definition.states[0])); + expect(indexes.edgesById['idle-to-hover'], same(validated.definition.edges[0])); + expect(indexes.portsByState['idle']!['handoff']!.entryFrame, 0); + expect(indexes.directEdgesByState['idle']!['hover']!.id, 'idle-to-hover'); + expect(indexes.eventEdgesByState['hover']!['hover.leave']!.id, 'hover-to-idle'); + expect(indexes.inverseEdgesById['idle-to-hover']!.id, 'hover-to-idle'); + expect(indexes.inverseEdgesById['hover-to-idle']!.id, 'idle-to-hover'); + + // Mutating the untrusted input after validation must not affect the + // already-produced validated graph (structural detachment). + (input['states']! as List)[0] = _state('changed', 'loop'); + expect(validated.definition.states[0].id, 'idle'); + }); + + test('rejects a ValidatedMotionGraph instance not produced by this validator', () { + final fake = ValidatedMotionGraph( + MotionGraphDefinition( + initialState: 'idle', + states: [ + GraphStateDefinition( + id: 'idle', + body: GraphBodyDefinition( + unitId: 'idle-body', + kind: GraphBodyKind.loop, + frameCount: 1, + ports: [GraphPortDefinition(id: 'handoff', portalFrames: const [0])], + ), + ), + ], + edges: const [], + ), + ); + expect(() => getValidatedGraphIndexes(fake), throwsA(isA())); + }); + + test('enforces state and edge count limits', () { + final empty = _simpleGraph(); + empty['states'] = []; + _expectInvalid(empty, RegExp('states must contain between 1 and 32')); + + final tooManyStates = _simpleGraph(); + tooManyStates['states'] = [ + for (var index = 0; index < GraphLimits.maxStates + 1; index += 1) + _state('state-$index', 'held'), + ]; + tooManyStates['initialState'] = 'state-0'; + _expectInvalid(tooManyStates, RegExp('states must contain between 1 and 32')); + + final tooManyEdges = _simpleGraph(); + tooManyEdges['edges'] = [ + for (var index = 0; index < GraphLimits.maxEdges + 1; index += 1) + { + 'id': 'edge-$index', + 'from': 'idle', + 'to': 'hover', + 'start': {'type': 'cut', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'continuity': 'cut', + }, + ]; + _expectInvalid(tooManyEdges, RegExp('edges must contain at most 64')); + }); + + test('rejects malformed array entries with a stable validation error', () { + final sparseStates = _simpleGraph(); + sparseStates['states'] = [null]; + _expectInvalid(sparseStates, RegExp(r'states\[0\] must be an object')); + + final sparseEdges = _simpleGraph(); + sparseEdges['edges'] = [null]; + _expectInvalid(sparseEdges, RegExp(r'edges\[0\] must be an object')); + + final sparsePorts = _simpleGraph(); + final states = sparsePorts['states']! as List; + final state0 = Map.from(states[0]! as Map); + final body0 = Map.from(state0['body']! as Map); + body0['ports'] = [null]; + state0['body'] = body0; + states[0] = state0; + _expectInvalid(sparsePorts, RegExp(r'states\[0\]\.body\.ports\[0\] must be an object')); + }); + + test('validates IDs, initial ownership, frames, and state-level uniqueness', () { + final invalidId = _simpleGraph(); + final statesA = invalidId['states']! as List; + statesA[0] = {...(statesA[0]! as Map), 'id': 'Idle'}; + _expectInvalid(invalidId, RegExp('must match')); + + final missingInitial = _simpleGraph(); + missingInitial['initialState'] = 'missing'; + _expectInvalid(missingInitial, RegExp('does not reference a state')); + + final duplicateState = _simpleGraph(); + final statesB = duplicateState['states']! as List; + statesB[1] = {...(statesB[1]! as Map), 'id': 'idle'}; + _expectInvalid(duplicateState, RegExp('duplicates state ID')); + + final duplicateUnit = _simpleGraph(); + final statesC = duplicateUnit['states']! as List; + final state1 = Map.from(statesC[1]! as Map); + state1['body'] = {...(state1['body']! as Map), 'unitId': 'idle-body'}; + statesC[1] = state1; + _expectInvalid(duplicateUnit, RegExp('duplicates unit ID')); + + final heldLength = _simpleGraph(); + final statesD = heldLength['states']! as List; + final state0d = Map.from(statesD[0]! as Map); + state0d['body'] = {...(state0d['body']! as Map), 'kind': 'held', 'frameCount': 2}; + statesD[0] = state0d; + _expectInvalid(heldLength, RegExp('must be 1 for a held body')); + + final nonInitialIntro = _simpleGraph(); + final statesE = nonInitialIntro['states']! as List; + statesE[1] = { + ...(statesE[1]! as Map), + 'initialUnit': {'unitId': 'wrong-intro', 'frameCount': 2}, + }; + _expectInvalid(nonInitialIntro, RegExp('allowed only on the initial state')); + + final duplicateIntroUnit = _simpleGraph(); + final statesF = duplicateIntroUnit['states']! as List; + statesF[0] = { + ...(statesF[0]! as Map), + 'initialUnit': {'unitId': 'idle-body', 'frameCount': 2}, + }; + _expectInvalid(duplicateIntroUnit, RegExp('duplicates unit ID')); + }); + + test('validates port counts, identities, entry frames, and portal frames', () { + final tooMany = _simpleGraph(); + final statesA = tooMany['states']! as List; + final state0a = Map.from(statesA[0]! as Map); + state0a['body'] = { + ...(state0a['body']! as Map), + 'ports': [ + for (var index = 0; index < GraphLimits.maxPortsPerBody + 1; index += 1) + {'id': 'port-$index', 'entryFrame': 0, 'portalFrames': [0]}, + ], + }; + statesA[0] = state0a; + _expectInvalid(tooMany, RegExp('ports must contain at most 16')); + + final duplicate = _simpleGraph(); + final statesB = duplicate['states']! as List; + final state0b = Map.from(statesB[0]! as Map); + state0b['body'] = {...(state0b['body']! as Map), 'ports': [_port(), _port()]}; + statesB[0] = state0b; + _expectInvalid(duplicate, RegExp('duplicates port ID')); + + final wrongEntry = _simpleGraph(); + final statesC = wrongEntry['states']! as List; + final state0c = Map.from(statesC[0]! as Map); + state0c['body'] = { + ...(state0c['body']! as Map), + 'ports': [{..._port(), 'entryFrame': 1}], + }; + statesC[0] = state0c; + _expectInvalid(wrongEntry, RegExp('entryFrame must be 0')); + + final empty = _simpleGraph(); + final statesD = empty['states']! as List; + statesD[0] = _withPortalFrames(statesD[0]! as Map, const []); + _expectInvalid(empty, RegExp('must contain at least one frame')); + + final unsorted = _simpleGraph(); + final statesE = unsorted['states']! as List; + statesE[0] = _withPortalFrames(statesE[0]! as Map, const [2, 1]); + _expectInvalid(unsorted, RegExp('sorted and unique')); + + final duplicateFrame = _simpleGraph(); + final statesF = duplicateFrame['states']! as List; + statesF[0] = _withPortalFrames(statesF[0]! as Map, const [0, 0]); + _expectInvalid(duplicateFrame, RegExp('sorted and unique')); + + final outside = _simpleGraph(); + final statesG = outside['states']! as List; + statesG[0] = _withPortalFrames(statesG[0]! as Map, const [4]); + _expectInvalid(outside, RegExp('must be less than frameCount')); + }); + + test('rejects missing references and ambiguous direct, event, and completion routes', () { + final missingSource = _simpleGraph(); + missingSource['edges'] = [_cutEdge('missing-source', 'missing', 'hover')]; + _expectInvalid(missingSource, RegExp('from does not reference a state')); + + final missingTarget = _simpleGraph(); + missingTarget['edges'] = [_cutEdge('missing-target', 'idle', 'missing')]; + _expectInvalid(missingTarget, RegExp('to does not reference a state')); + + final self = _simpleGraph(); + self['edges'] = [_cutEdge('self', 'idle', 'idle')]; + _expectInvalid(self, RegExp('must connect distinct states')); + + final direct = _simpleGraph(); + direct['edges'] = [_cutEdge('first', 'idle', 'hover'), _cutEdge('second', 'idle', 'hover')]; + _expectInvalid(direct, RegExp('duplicates direct route')); + + final event = _threeStateGraph(); + event['edges'] = [ + _eventCutEdge('first', 'idle', 'hover', 'activate'), + _eventCutEdge('second', 'idle', 'error', 'activate'), + ]; + _expectInvalid(event, RegExp('duplicates event')); + + final completion = _threeStateGraph('finite'); + final completionStates = completion['states']! as List; + completionStates[0] = _withPortalFrames(completionStates[0]! as Map, const [0, 3]); + completion['edges'] = [ + _completionFinishEdge('first', 'idle', 'hover', 3), + _completionFinishEdge('second', 'idle', 'error', 3), + ]; + _expectInvalid(completion, RegExp('duplicates completion route')); + + final loopCompletion = _simpleGraph(); + loopCompletion['edges'] = [_completionFinishEdge('complete', 'idle', 'hover', 3)]; + _expectInvalid(loopCompletion, RegExp('completion trigger cannot originate from a loop')); + }); + + test('enforces source and target ports and loop portal wait geometry', () { + final missingSourcePort = _simpleGraph(); + missingSourcePort['edges'] = [ + { + ..._portalEdge('edge', 'idle', 'hover', 1), + 'start': {'type': 'portal', 'sourcePort': 'missing', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + }, + ]; + _expectInvalid(missingSourcePort, RegExp('source port .* does not exist')); + + final missingTargetPort = _simpleGraph(); + missingTargetPort['edges'] = [ + { + ..._cutEdge('edge', 'idle', 'hover'), + 'start': {'type': 'cut', 'targetPort': 'missing', 'maxWaitFrames': 1}, + }, + ]; + _expectInvalid(missingTargetPort, RegExp('target port .* does not exist')); + + final loopGeometry = _simpleGraph(); + final loopStates = loopGeometry['states']! as List; + loopStates[0] = _withPortalFrames(loopStates[0]! as Map, const [0, 2]); + loopGeometry['edges'] = [_portalEdge('edge', 'idle', 'hover', 0)]; + _expectInvalid(loopGeometry, RegExp('geometric minimum 1')); + + final loopValid = _simpleGraph(); + final loopValidStates = loopValid['states']! as List; + loopValidStates[0] = _withPortalFrames(loopValidStates[0]! as Map, const [0, 2]); + loopValid['edges'] = [_portalEdge('edge', 'idle', 'hover', 1)]; + expect(() => validateMotionGraphDefinition(loopValid), returnsNormally); + }); + + test('computes loop portal geometry correctly at very large frame counts', () { + final graph = _simpleGraph(); + final states = graph['states']! as List; + final state0 = Map.from(states[0]! as Map); + state0['body'] = { + ...(state0['body']! as Map), + 'frameCount': 9007199254740991, + 'ports': [ + {'id': 'handoff', 'entryFrame': 0, 'portalFrames': [2]}, + ], + }; + states[0] = state0; + graph['edges'] = [_portalEdge('idle-to-hover', 'idle', 'hover', 9007199254740991 - 2)]; + _expectInvalid(graph, RegExp('below the geometric minimum 9007199254740990')); + }); + + test('enforces finite portal and finish geometry without wrapping', () { + final missingHeldPortal = _simpleGraph('finite'); + final s1 = missingHeldPortal['states']! as List; + s1[0] = _withPortalFrames(s1[0]! as Map, const [0, 2]); + missingHeldPortal['edges'] = [_portalEdge('edge', 'idle', 'hover', 1)]; + _expectInvalid(missingHeldPortal, RegExp('must include the held final frame')); + + final finiteWait = _simpleGraph('finite'); + final s2 = finiteWait['states']! as List; + s2[0] = _withPortalFrames(s2[0]! as Map, const [2, 3]); + finiteWait['edges'] = [_portalEdge('edge', 'idle', 'hover', 1)]; + _expectInvalid(finiteWait, RegExp('geometric minimum 2')); + + final finishLoop = _simpleGraph(); + finishLoop['edges'] = [_finishEdge('edge', 'idle', 'hover', 3)]; + _expectInvalid(finishLoop, RegExp('finish cannot originate from a loop')); + + final finishWait = _simpleGraph('finite'); + finishWait['edges'] = [_finishEdge('edge', 'idle', 'hover', 2)]; + _expectInvalid(finishWait, RegExp('finish minimum 3')); + + final held = _simpleGraph('held'); + held['edges'] = [_finishEdge('edge', 'idle', 'hover', 0)]; + expect(() => validateMotionGraphDefinition(held), returnsNormally); + }); + + test('enforces cut and continuity invariants', () { + final cutBridge = _simpleGraph(); + cutBridge['edges'] = [ + { + ..._cutEdge('edge', 'idle', 'hover'), + 'transition': {'kind': 'locked', 'unitId': 'bridge', 'frameCount': 2}, + }, + ]; + _expectInvalid(cutBridge, RegExp('cut cannot own a transition unit')); + + final wrongCutContinuity = _simpleGraph(); + wrongCutContinuity['edges'] = [ + {..._cutEdge('edge', 'idle', 'hover'), 'continuity': 'exact-authored'}, + ]; + _expectInvalid(wrongCutContinuity, RegExp('must declare continuity cut')); + + final cutContinuityOnPortal = _simpleGraph(); + cutContinuityOnPortal['edges'] = [ + {..._portalEdge('edge', 'idle', 'hover', 3), 'continuity': 'cut'}, + ]; + _expectInvalid(cutContinuityOnPortal, RegExp('requires start policy cut')); + + final wrongCutWait = _simpleGraph(); + wrongCutWait['edges'] = [ + { + 'id': 'edge', + 'from': 'idle', + 'to': 'hover', + 'start': {'type': 'cut', 'targetPort': 'handoff', 'maxWaitFrames': 2}, + 'continuity': 'cut', + }, + ]; + _expectInvalid(wrongCutWait, RegExp('must be 1 for a cut')); + }); + + test('validates complete reversible pairs', () { + final unpaired = _reversibleGraph(); + (unpaired['edges']! as List).removeLast(); + _expectInvalid(unpaired, RegExp('must be used by exactly two inverse edges')); + + final sameDirection = _reversibleGraph(); + final edgesA = sameDirection['edges']! as List; + edgesA[1] = _replaceReversible(edgesA[1]! as Map, + direction: 'forward', reverseOf: 'idle-to-hover'); + _expectInvalid(sameDirection, RegExp('must use opposite directions')); + + final wrongEndpoints = _threeStateGraph(); + final baseEdges = (_reversibleGraph()['edges']! as List).cast>(); + wrongEndpoints['edges'] = [ + for (var i = 0; i < baseEdges.length; i += 1) + i == 1 ? {...baseEdges[i], 'from': 'error'} : baseEdges[i], + ]; + _expectInvalid(wrongEndpoints, RegExp('must reverse its endpoints')); + + final wrongCount = _reversibleGraph(); + final edgesB = wrongCount['edges']! as List; + edgesB[1] = _replaceReversible(edgesB[1]! as Map, + frameCount: 4, direction: 'reverse', reverseOf: 'idle-to-hover'); + _expectInvalid(wrongCount, RegExp('must use one frame count')); + + final noDeclaration = _reversibleGraph(); + final edgesC = noDeclaration['edges']! as List; + edgesC[1] = { + ..._replaceReversible(edgesC[1]! as Map, direction: 'reverse'), + 'continuity': 'exact-authored', + }; + _expectInvalid(noDeclaration, RegExp('exactly one inverse edge with reverseOf')); + + final twoDeclarations = _reversibleGraph(); + final edgesD = twoDeclarations['edges']! as List; + edgesD[0] = _replaceReversible(edgesD[0]! as Map, + direction: 'forward', reverseOf: 'hover-to-idle'); + _expectInvalid(twoDeclarations, RegExp('exactly one inverse edge with reverseOf')); + + final wrongReference = _reversibleGraph(); + final edgesE = wrongReference['edges']! as List; + edgesE[1] = _replaceReversible(edgesE[1]! as Map, + direction: 'reverse', reverseOf: 'hover-to-idle'); + _expectInvalid(wrongReference, RegExp('must reference "idle-to-hover"')); + + final wrongContinuity = _reversibleGraph(); + final edgesF = wrongContinuity['edges']! as List; + edgesF[1] = {...(edgesF[1]! as Map), 'continuity': 'exact-authored'}; + _expectInvalid(wrongContinuity, RegExp('must declare continuity exact-reverse')); + + final formerlyTooLong = _reversibleGraph(); + final edgesG = formerlyTooLong['edges']! as List; + edgesG[0] = _replaceReversible(edgesG[0]! as Map, frameCount: 25, direction: 'forward'); + edgesG[1] = _replaceReversible(edgesG[1]! as Map, + frameCount: 25, direction: 'reverse', reverseOf: 'idle-to-hover'); + expect(() => validateMotionGraphDefinition(formerlyTooLong), returnsNormally); + }); + + test('prevents illegal animation-unit aliases', () { + final bodyCollision = _reversibleGraph(); + final edgesA = bodyCollision['edges']! as List; + edgesA[0] = _replaceReversible(edgesA[0]! as Map, unitId: 'idle-body', direction: 'forward'); + _expectInvalid(bodyCollision, RegExp('already used by a body or initial unit')); + + final lockedCollision = _threeStateGraph(); + lockedCollision['edges'] = [ + _lockedEdge('first', 'idle', 'hover', 'shared'), + _lockedEdge('second', 'hover', 'error', 'shared'), + ]; + _expectInvalid(lockedCollision, RegExp('already used by another transition')); + + final mixedCollision = _threeStateGraph(); + mixedCollision['edges'] = [ + _lockedEdge('locked', 'idle', 'error', 'shared'), + _reversibleEdge('forward', 'idle', 'hover', 'forward', 'shared'), + _reversibleEdge('reverse', 'hover', 'idle', 'reverse', 'shared', reverseOf: 'forward'), + ]; + _expectInvalid(mixedCollision, RegExp('already used by a locked transition')); + }); + + test('rejects immediate transitionless completion cycles between held states', () { + final graph = _simpleGraph('held'); + final states = graph['states']! as List; + states[1] = _state('hover', 'held'); + graph['edges'] = [ + _completionFinishEdge('idle-complete', 'idle', 'hover', 0), + _completionFinishEdge('hover-complete', 'hover', 'idle', 0), + ]; + _expectInvalid(graph, RegExp('immediate cycle')); + }); + + test('rejects immediate completion cycles between one-frame finite states', () { + final graph = _simpleGraph('finite'); + graph['states'] = [ + for (final id in ['idle', 'hover']) + { + ..._state(id, 'finite'), + 'body': { + ...(_state(id, 'finite')['body']! as Map), + 'frameCount': 1, + 'ports': [ + {'id': 'handoff', 'entryFrame': 0, 'portalFrames': [0]}, + ], + }, + }, + ]; + graph['edges'] = [ + _completionFinishEdge('idle-complete', 'idle', 'hover', 0), + _completionFinishEdge('hover-complete', 'hover', 'idle', 0), + ]; + _expectInvalid(graph, RegExp('immediate cycle')); + }); + + test('indexes one valid completion edge per finite source', () { + final graph = _simpleGraph('finite'); + graph['edges'] = [_completionFinishEdge('idle-complete', 'idle', 'hover', 3)]; + final validated = validateMotionGraphDefinition(graph); + expect(getValidatedGraphIndexes(validated).completionEdgesByState['idle']!.id, 'idle-complete'); + }); + }); +} + +Map _simpleGraph([String initialKind = 'loop']) { + return { + 'initialState': 'idle', + 'states': [_state('idle', initialKind), _state('hover', 'loop')], + 'edges': [], + }; +} + +Map _threeStateGraph([String initialKind = 'loop']) { + final graph = _simpleGraph(initialKind); + (graph['states']! as List).add(_state('error', 'held')); + return graph; +} + +Map _reversibleGraph() { + final graph = _simpleGraph(); + graph['edges'] = [ + { + ..._portalEdge('idle-to-hover', 'idle', 'hover', 1), + 'trigger': {'type': 'event', 'name': 'hover.enter'}, + 'transition': { + 'kind': 'reversible', + 'unitId': 'hover-clip', + 'frameCount': 3, + 'direction': 'forward', + }, + }, + { + ..._portalEdge('hover-to-idle', 'hover', 'idle', 1), + 'trigger': {'type': 'event', 'name': 'hover.leave'}, + 'transition': { + 'kind': 'reversible', + 'unitId': 'hover-clip', + 'frameCount': 3, + 'direction': 'reverse', + 'reverseOf': 'idle-to-hover', + }, + 'continuity': 'exact-reverse', + }, + ]; + return graph; +} + +Map _state(String id, String kind) { + final frameCount = kind == 'held' ? 1 : 4; + final List portalFrames; + if (kind == 'loop') { + portalFrames = const [0, 2]; + } else if (kind == 'finite') { + portalFrames = [0, frameCount - 1]; + } else { + portalFrames = const [0]; + } + return { + 'id': id, + 'body': { + 'unitId': '$id-body', + 'kind': kind, + 'frameCount': frameCount, + 'ports': [ + {'id': 'handoff', 'entryFrame': 0, 'portalFrames': portalFrames}, + ], + }, + }; +} + +Map _port() => {'id': 'handoff', 'entryFrame': 0, 'portalFrames': const [0]}; + +Map _withPortalFrames(Map state, List portalFrames) { + final body = Map.from(state['body']! as Map); + final ports = List.from(body['ports']! as List); + final port = Map.from(ports[0]! as Map); + port['portalFrames'] = portalFrames; + ports[0] = port; + body['ports'] = ports; + return {...state, 'body': body}; +} + +Map _portalEdge(String id, String from, String to, int maxWaitFrames) { + return { + 'id': id, + 'from': from, + 'to': to, + 'start': { + 'type': 'portal', + 'sourcePort': 'handoff', + 'targetPort': 'handoff', + 'maxWaitFrames': maxWaitFrames, + }, + 'continuity': 'exact-authored', + }; +} + +Map _finishEdge(String id, String from, String to, int maxWaitFrames) { + return { + 'id': id, + 'from': from, + 'to': to, + 'start': {'type': 'finish', 'targetPort': 'handoff', 'maxWaitFrames': maxWaitFrames}, + 'continuity': 'exact-authored', + }; +} + +Map _cutEdge(String id, String from, String to) { + return { + 'id': id, + 'from': from, + 'to': to, + 'start': {'type': 'cut', 'targetPort': 'handoff', 'maxWaitFrames': 1}, + 'continuity': 'cut', + }; +} + +Map _eventCutEdge(String id, String from, String to, String name) { + return { + ..._cutEdge(id, from, to), + 'trigger': {'type': 'event', 'name': name}, + }; +} + +Map _completionFinishEdge(String id, String from, String to, int maxWaitFrames) { + return { + ..._finishEdge(id, from, to, maxWaitFrames), + 'trigger': {'type': 'completion'}, + }; +} + +Map _lockedEdge(String id, String from, String to, String unitId) { + return { + ..._portalEdge(id, from, to, 1), + 'transition': {'kind': 'locked', 'unitId': unitId, 'frameCount': 2}, + }; +} + +Map _reversibleEdge( + String id, + String from, + String to, + String direction, + String unitId, { + String? reverseOf, +}) { + final transition = { + 'kind': 'reversible', + 'unitId': unitId, + 'frameCount': 3, + 'direction': direction, + }; + if (reverseOf != null) transition['reverseOf'] = reverseOf; + return { + ..._portalEdge(id, from, to, 1), + 'transition': transition, + 'continuity': reverseOf == null ? 'exact-authored' : 'exact-reverse', + }; +} + +Map _replaceReversible( + Map edge, { + String? unitId, + int? frameCount, + required String direction, + String? reverseOf, +}) { + final current = edge['transition']! as Map; + final transition = { + 'kind': 'reversible', + 'unitId': unitId ?? current['unitId'], + 'frameCount': frameCount ?? current['frameCount'], + 'direction': direction, + }; + if (reverseOf != null) transition['reverseOf'] = reverseOf; + return {...edge, 'transition': transition}; +} + +void _expectInvalid(Map graph, Pattern message) { + expect( + () => validateMotionGraphDefinition(graph), + throwsA(isA().having((e) => e.message, 'message', matches(message))), + ); +} diff --git a/flutter/packages/aval_player/analysis_options.yaml b/flutter/packages/aval_player/analysis_options.yaml new file mode 100644 index 0000000..a3e657b --- /dev/null +++ b/flutter/packages/aval_player/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + todo: ignore diff --git a/flutter/packages/aval_player/lib/aval_player.dart b/flutter/packages/aval_player/lib/aval_player.dart new file mode 100644 index 0000000..c16e67e --- /dev/null +++ b/flutter/packages/aval_player/lib/aval_player.dart @@ -0,0 +1,39 @@ +/// Public entry point for `aval_player` — the pure-Dart port of the AVAL web +/// player's runtime scheduling core. +/// +/// This surface currently covers the Phase 2 foundation modules: the rational +/// clock, decode timeline, edge-lead formula, submission horizon, path +/// sequence builder, and the shared path-scheduler types (with the frozen +/// decoder-worker / runtime-model / worker-sample contracts they reference). +/// The path scheduler itself and the remaining runtime modules are ported in +/// later phases. +library aval_player; + +export 'src/asset_catalog.dart'; +export 'src/asset_catalog_index.dart'; +export 'src/borrowed_avc_inspection.dart'; +export 'src/decode_timeline.dart'; +export 'src/decoder_worker/client_support.dart'; +export 'src/decoder_worker/protocol.dart'; +export 'src/edge_lead.dart'; +export 'src/errors.dart'; +export 'src/model.dart'; +export 'src/path_scheduler.dart'; +export 'src/path_scheduler_cursor_ledger.dart'; +export 'src/path_scheduler_generation.dart'; +export 'src/path_scheduler_identity.dart'; +export 'src/path_scheduler_model.dart'; +export 'src/path_scheduler_output.dart'; +export 'src/path_scheduler_pump.dart'; +export 'src/path_scheduler_reservation.dart'; +export 'src/path_scheduler_resident_runway.dart'; +export 'src/path_scheduler_route.dart'; +export 'src/path_scheduler_trace.dart'; +export 'src/path_scheduler_validation.dart'; +export 'src/path_sequence.dart'; +export 'src/platform.dart'; +export 'src/presentation_ring.dart'; +export 'src/rational_time.dart'; +export 'src/submission_horizon.dart'; +export 'src/verified_blob_store.dart'; +export 'src/worker_samples.dart'; diff --git a/flutter/packages/aval_player/lib/src/asset_catalog.dart b/flutter/packages/aval_player/lib/src/asset_catalog.dart new file mode 100644 index 0000000..7afd0b0 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/asset_catalog.dart @@ -0,0 +1,756 @@ +/// One immutable metadata catalog over owned or digest-verified bytes. +/// +/// Direct port of `packages/player-web/src/runtime/asset-catalog.ts`. +/// +/// Judgment calls (with TS anchors): +/// - The two `unique symbol` methods `RUNTIME_CATALOG_COMPLETE_SOURCE` +/// (asset-catalog.ts:90,190) and `RUNTIME_CATALOG_AVC_INSPECTION` +/// (asset-catalog.ts:220) have no Dart analog; they become the ordinary +/// library-visible methods [RuntimeAssetCatalog.adoptCompleteSourceInternal] +/// and [RuntimeAssetCatalog.inspectAvcRenditionInternal]. +/// - TS constructor overloading (`Uint8Array | CatalogInstallation`, +/// asset-catalog.ts:128-131) becomes the public generative constructor +/// [RuntimeAssetCatalog] (owned bytes) plus the private +/// `RuntimeAssetCatalog._` (installation), which +/// [createMetadataRuntimeAssetCatalog] uses. `isCatalogInstallation` and the +/// symbol brand are therefore unnecessary and omitted; the installation and +/// payload-authority helper types are library-private (`_CatalogInstallation`, +/// `_CatalogPayloadAuthority`) since TS never exports them. +/// - `CatalogPayloadAuthority` (a frozen closure object, asset-catalog.ts:75) +/// becomes an abstract class with two concrete implementations. +/// - `copySample` returns a Dart `ByteBuffer` (the shape [DecoderWorkerSample] +/// consumes) in place of the TS `ArrayBuffer`. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart' + show + AvcConstrainedBaselineProfile, + AvcRenditionInspection, + ByteRange, + CompiledManifestV01, + EdgeV01, + FormatError, + ParsedFrontIndex, + RenditionV01, + StateV01, + UnitV01, + ValidatedAssetLayout, + formatDefaultBudgets, + validateCompleteAsset; +import 'package:aval_graph/aval_graph.dart' show ValidatedMotionGraph; + +import 'asset_catalog_index.dart' + show + CatalogMapBuildInput, + CatalogMaps, + RuntimeCatalogIdIndex, + RuntimeCatalogPortIndex, + RuntimeCatalogRecordIndex, + buildCatalogMaps, + checkedCatalogRangeEnd, + createCatalogIdIndex, + createCatalogPortIndex, + createCatalogRecordIndex, + runtimeUnitBlobKey; +import 'borrowed_avc_inspection.dart' + show + BorrowedAvcAccessUnitPlan, + BorrowedAvcRenditionPlan, + BorrowedAvcUnitPlan, + inspectBorrowedAvcRendition; +import 'errors.dart' + show + RuntimeFailureCode, + RuntimeFailureContext, + RuntimePlaybackError, + normalizeRuntimeFailure; +import 'model.dart' + show + RuntimeAssetResidencySnapshot, + RuntimeBlobResidencySnapshot, + RuntimeBlobResidencyState, + RuntimeTransportMode; +import 'verified_blob_store.dart' + show VerifiedBlobDescriptor, VerifiedBlobStore, VerifiedBlobStoreSnapshot; +import 'worker_samples.dart' show WorkerSampleCatalog; + +/// Payload ownership of a catalog installation. +enum _PayloadOwnership { none, verified, persistent } + +/// Input to [createMetadataRuntimeAssetCatalog]. +class MetadataRuntimeAssetCatalogInput { + const MetadataRuntimeAssetCatalogInput({ + required this.frontIndex, + required this.declaredFileLength, + required this.mode, + required this.blobStore, + }); + + final ParsedFrontIndex frontIndex; + final int declaredFileLength; + final RuntimeTransportMode mode; + final VerifiedBlobStore blobStore; +} + +/// Byte-free residency accounting shared by both payload authorities. +class _CatalogPayloadSnapshot { + const _CatalogPayloadSnapshot({ + required this.generation, + required this.verifiedBytes, + required this.persistentBytes, + required this.unitBlobs, + }); + + final int generation; + final int verifiedBytes; + final int persistentBytes; + final RuntimeBlobResidencySnapshot unitBlobs; +} + +/// Backing-byte authority abstraction (owned vs. verified). +abstract class _CatalogPayloadAuthority { + RuntimeBlobResidencyState state(String key); + Uint8List copyRange(String key, int relativeOffset, int byteLength); + AvcRenditionInspection inspectAvcRendition(BorrowedAvcRenditionPlan plan); + _CatalogPayloadSnapshot snapshot(); + void dispose(); +} + +class _CatalogInstallation { + const _CatalogInstallation({ + required this.frontIndex, + required this.declaredFileLength, + required this.mode, + required this.metadataBytes, + required this.baseOwnedBytes, + required this.payloadOwnership, + required this.payloads, + required this.completeLayout, + }); + + final ParsedFrontIndex frontIndex; + final int declaredFileLength; + final RuntimeTransportMode mode; + final int metadataBytes; + final int baseOwnedBytes; + final _PayloadOwnership payloadOwnership; + final _CatalogPayloadAuthority payloads; + final ValidatedAssetLayout? completeLayout; +} + +/// One immutable metadata catalog over either completely owned bytes or sparse +/// digest-verified blob residency. Both installation paths share every lookup +/// and downstream copy method. +class RuntimeAssetCatalog implements WorkerSampleCatalog { + RuntimeAssetCatalog(Uint8List callerBytes) + : this._(_installOwnedBytes(callerBytes)); + + RuntimeAssetCatalog._(_CatalogInstallation installed) + : _frontIndex = installed.frontIndex, + _layout = installed.completeLayout, + _declaredFileLength = installed.declaredFileLength, + _mode = installed.mode, + _metadataBytes = installed.metadataBytes, + _baseOwnedBytes = installed.baseOwnedBytes, + _payloadOwnership = installed.payloadOwnership, + _payloads = installed.payloads { + _maps = buildCatalogMaps(CatalogMapBuildInput( + frontIndex: installed.frontIndex, + declaredFileLength: installed.declaredFileLength, + )); + + renditions = createCatalogIdIndex( + 'rendition', + () => _requireMaps().renditions, + (rendition) => RuntimeFailureContext(rendition: rendition), + ); + units = createCatalogIdIndex( + 'unit', + () => _requireMaps().units, + (unit) => RuntimeFailureContext(unit: unit), + ); + states = createCatalogIdIndex( + 'state', + () => _requireMaps().states, + (state) => RuntimeFailureContext(state: state), + ); + edges = createCatalogIdIndex( + 'edge', + () => _requireMaps().edges, + (edge) => RuntimeFailureContext(edge: edge), + ); + ports = createCatalogPortIndex(() => _requireMaps().ports); + records = createCatalogRecordIndex(() => _requireMaps().records); + } + + @override + late final RuntimeCatalogIdIndex renditions; + @override + late final RuntimeCatalogIdIndex units; + late final RuntimeCatalogIdIndex states; + late final RuntimeCatalogIdIndex edges; + late final RuntimeCatalogPortIndex ports; + @override + late final RuntimeCatalogRecordIndex records; + + final int _declaredFileLength; + RuntimeTransportMode _mode; + final int _metadataBytes; + int _baseOwnedBytes; + _PayloadOwnership _payloadOwnership; + final _CatalogPayloadAuthority _payloads; + bool _disposed = false; + ParsedFrontIndex? _frontIndex; + ValidatedAssetLayout? _layout; + CatalogMaps? _maps; + + bool get disposed => _disposed; + + /// Current retained source ownership plus verified payload copies. + int get ownedByteLength { + if (_disposed) return 0; + if (_payloadOwnership == _PayloadOwnership.none) return _baseOwnedBytes; + final payloads = _payloads.snapshot(); + return _checkedOwnedByteSum( + _baseOwnedBytes, + _payloadOwnership == _PayloadOwnership.verified + ? payloads.verifiedBytes + : payloads.persistentBytes, + ); + } + + /// Switch sparse accounting after an entity-safe full replacement + /// (`RUNTIME_CATALOG_COMPLETE_SOURCE`, asset-catalog.ts:190). + void adoptCompleteSourceInternal() { + _throwIfDisposed(); + _mode = RuntimeTransportMode.full; + _baseOwnedBytes = _declaredFileLength; + _payloadOwnership = _PayloadOwnership.persistent; + } + + ValidatedAssetLayout get layout { + _throwIfDisposed(); + final existing = _layout; + if (existing != null) return existing; + final frontIndex = _requireFrontIndex(); + final built = ValidatedAssetLayout( + frontIndex: frontIndex, + fileRange: ByteRange(offset: 0, length: _declaredFileLength), + ); + _layout = built; + return built; + } + + CompiledManifestV01 get manifest => _requireFrontIndex().manifest; + + ValidatedMotionGraph get graph => _requireFrontIndex().graph; + + /// Byte-free synchronous inspection over private payload backing + /// (`RUNTIME_CATALOG_AVC_INSPECTION`, asset-catalog.ts:220). + AvcRenditionInspection inspectAvcRenditionInternal( + String rendition, + AvcConstrainedBaselineProfile profile, + ) { + return _inspectAvcRendition(rendition, profile); + } + + /// A fresh exact-length buffer that the caller charges and transfers. + @override + ByteBuffer copySample(String rendition, String unit, int localFrame) { + final entry = records.require(rendition, unit, localFrame); + final blobKey = _requireCatalogBlobKey(entry.blobKey); + final relativeRange = _requireCatalogRelativeRange(entry.relativeRange); + _requireVerifiedBlob( + blobKey, + RuntimeFailureContext( + rendition: rendition, + unit: unit, + localFrame: localFrame, + ), + ); + return _payloads + .copyRange(blobKey, relativeRange.offset, relativeRange.length) + .buffer; + } + + RuntimeAssetResidencySnapshot residencySnapshot() { + final payloads = _payloads.snapshot(); + return RuntimeAssetResidencySnapshot( + generation: payloads.generation, + mode: _mode, + declaredFileBytes: _declaredFileLength, + metadataBytes: _disposed ? 0 : _metadataBytes, + verifiedPayloadBytes: _disposed ? 0 : payloads.verifiedBytes, + unitBlobs: payloads.unitBlobs, + ); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _payloads.dispose(); + _frontIndex = null; + _layout = null; + final maps = _maps; + _maps = null; + if (maps != null) { + maps.renditions.clear(); + maps.units.clear(); + maps.states.clear(); + maps.edges.clear(); + maps.ports.clear(); + maps.records.clear(); + } + } + + AvcRenditionInspection _inspectAvcRendition( + String rendition, + AvcConstrainedBaselineProfile profile, + ) { + _throwIfDisposed(); + final unitPlans = manifest.units.map((unit) { + return BorrowedAvcUnitPlan( + id: unit.id, + accessUnits: List.generate( + unit.frameCount, + (localFrame) { + final entry = records.require(rendition, unit.id, localFrame); + final blobKey = _requireCatalogBlobKey(entry.blobKey); + final relativeRange = + _requireCatalogRelativeRange(entry.relativeRange); + _requireVerifiedBlob( + blobKey, + RuntimeFailureContext( + rendition: rendition, + unit: unit.id, + localFrame: localFrame, + ), + ); + return BorrowedAvcAccessUnitPlan( + blobKey: blobKey, + relativeOffset: relativeRange.offset, + byteLength: relativeRange.length, + key: entry.record.key, + ); + }, + ), + ); + }).toList(); + return _payloads.inspectAvcRendition( + BorrowedAvcRenditionPlan(profile: profile, units: unitPlans), + ); + } + + void _requireVerifiedBlob(String key, RuntimeFailureContext context) { + final state = _payloadState(key); + if (state != RuntimeBlobResidencyState.verified) { + throw _catalogError( + RuntimeFailureCode.loadFailure, + 'asset catalog blob is not verified', + RuntimeFailureContext( + rendition: context.rendition, + unit: context.unit, + localFrame: context.localFrame, + policyPhase: state.wireValue, + ), + ); + } + } + + RuntimeBlobResidencyState _payloadState(String key) { + _throwIfDisposed(); + try { + return _payloads.state(key); + } on RuntimePlaybackError { + rethrow; + } catch (_) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'asset catalog blob key is invalid', + ); + } + } + + ParsedFrontIndex _requireFrontIndex() { + final frontIndex = _frontIndex; + if (frontIndex == null) throw _disposedCatalogError(); + return frontIndex; + } + + CatalogMaps _requireMaps() { + final maps = _maps; + if (maps == null) throw _disposedCatalogError(); + return maps; + } + + void _throwIfDisposed() { + if (_disposed) throw _disposedCatalogError(); + } +} + +RuntimeAssetCatalog installRuntimeAssetCatalog(Uint8List bytes) { + return RuntimeAssetCatalog(bytes); +} + +RuntimeAssetCatalog createMetadataRuntimeAssetCatalog( + MetadataRuntimeAssetCatalogInput input, +) { + return RuntimeAssetCatalog._(_installMetadata(input)); +} + +/// Account a retained complete source exactly once. +void adoptRuntimeCatalogCompleteSource(RuntimeAssetCatalog catalog) { + catalog.adoptCompleteSourceInternal(); +} + +List createRuntimeCatalogBlobDescriptors( + ParsedFrontIndex frontIndex, +) { + final descriptors = []; + for (final blob in frontIndex.unitBlobs) { + checkedCatalogRangeEnd( + blob.offset, + blob.length, + frontIndex.header.declaredFileLength, + ); + descriptors.add(VerifiedBlobDescriptor( + key: runtimeUnitBlobKey(blob.rendition, blob.unit), + kind: 'unit', + byteLength: blob.length, + )); + } + return descriptors; +} + +_CatalogInstallation _installOwnedBytes(Uint8List callerBytes) { + if (callerBytes.lengthInBytes > formatDefaultBudgets.maxFileBytes) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'asset catalog input exceeds the complete-file limit', + ); + } + + Uint8List bytes; + try { + bytes = Uint8List(callerBytes.lengthInBytes); + bytes.setAll(0, callerBytes); + } catch (_) { + throw _catalogError( + RuntimeFailureCode.resourceRejection, + 'asset catalog owned-byte allocation failed', + ); + } + + ValidatedAssetLayout layout; + try { + layout = validateCompleteAsset(bytes: bytes); + } catch (error) { + throw _normalizeInstallError(error); + } + return _CatalogInstallation( + frontIndex: layout.frontIndex, + declaredFileLength: bytes.lengthInBytes, + mode: RuntimeTransportMode.full, + metadataBytes: layout.frontIndex.frontIndexRange.length, + baseOwnedBytes: bytes.lengthInBytes, + payloadOwnership: _PayloadOwnership.none, + payloads: _createOwnedPayloadAuthority(bytes, layout.frontIndex), + completeLayout: layout, + ); +} + +_CatalogInstallation _installMetadata(MetadataRuntimeAssetCatalogInput input) { + final frontIndex = input.frontIndex; + final declared = input.declaredFileLength; + if (declared < 1 || + declared > formatDefaultBudgets.maxFileBytes || + frontIndex.header.declaredFileLength != declared || + frontIndex.frontIndexRange.offset != 0 || + frontIndex.frontIndexRange.length < 1 || + frontIndex.frontIndexRange.length > declared) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'metadata catalog declared geometry is invalid', + ); + } + final descriptors = createRuntimeCatalogBlobDescriptors(frontIndex); + final snapshot = input.blobStore.snapshot(); + if (snapshot.disposed || + snapshot.unitBlobs.total != frontIndex.unitBlobs.length) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'verified blob store descriptors do not match metadata', + ); + } + try { + for (final descriptor in descriptors) { + input.blobStore.state(descriptor.key); + } + } catch (_) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'verified blob store key mapping does not match metadata', + ); + } + return _CatalogInstallation( + frontIndex: frontIndex, + declaredFileLength: declared, + mode: input.mode, + metadataBytes: frontIndex.frontIndexRange.length, + baseOwnedBytes: input.mode == RuntimeTransportMode.full + ? declared + : frontIndex.frontIndexRange.length, + payloadOwnership: input.mode == RuntimeTransportMode.range + ? _PayloadOwnership.verified + : _PayloadOwnership.persistent, + payloads: _VerifiedPayloadAuthority(input.blobStore), + completeLayout: null, + ); +} + +_CatalogPayloadAuthority _createOwnedPayloadAuthority( + Uint8List initialBytes, + ParsedFrontIndex frontIndex, +) { + final ranges = {}; + for (final blob in frontIndex.unitBlobs) { + ranges[runtimeUnitBlobKey(blob.rendition, blob.unit)] = + ByteRange(offset: blob.offset, length: blob.length); + } + return _OwnedPayloadAuthority( + initialBytes, + ranges, + frontIndex.unitBlobs.map((blob) => blob.length).toList(), + ); +} + +class _OwnedPayloadAuthority extends _CatalogPayloadAuthority { + _OwnedPayloadAuthority(this._bytes, this._ranges, this._blobLengths); + + Uint8List? _bytes; + final Map _ranges; + final List _blobLengths; + + @override + RuntimeBlobResidencyState state(String key) { + if (!_ranges.containsKey(key)) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'owned blob key is unavailable', + ); + } + return _bytes == null + ? RuntimeBlobResidencyState.absent + : RuntimeBlobResidencyState.verified; + } + + @override + Uint8List copyRange(String key, int relativeOffset, int byteLength) { + final range = _requireOwnedRange(_ranges, key); + return _copyOwnedBytes(_bytes, range, relativeOffset, byteLength); + } + + @override + AvcRenditionInspection inspectAvcRendition(BorrowedAvcRenditionPlan plan) { + return _inspectBorrowedOwnedAvcRendition(plan, _ranges, _bytes); + } + + @override + _CatalogPayloadSnapshot snapshot() { + final disposed = _bytes == null; + final unitBlobs = _summarizeOwnedBlobs(_blobLengths, disposed); + return _CatalogPayloadSnapshot( + generation: 0, + verifiedBytes: disposed ? 0 : unitBlobs.verifiedBytes, + persistentBytes: 0, + unitBlobs: unitBlobs, + ); + } + + @override + void dispose() { + _bytes = null; + _ranges.clear(); + } +} + +class _VerifiedPayloadAuthority extends _CatalogPayloadAuthority { + _VerifiedPayloadAuthority(this._store); + + final VerifiedBlobStore _store; + + @override + RuntimeBlobResidencyState state(String key) => _store.state(key); + + @override + Uint8List copyRange(String key, int relativeOffset, int byteLength) => + _store.copyRange(key, relativeOffset, byteLength); + + @override + AvcRenditionInspection inspectAvcRendition(BorrowedAvcRenditionPlan plan) => + _store.inspectAvcRendition(plan); + + @override + _CatalogPayloadSnapshot snapshot() { + final VerifiedBlobStoreSnapshot value = _store.snapshot(); + return _CatalogPayloadSnapshot( + generation: value.generation, + verifiedBytes: value.verifiedBytes, + persistentBytes: value.persistentBytes, + unitBlobs: value.unitBlobs, + ); + } + + @override + void dispose() { + // Fire-and-forget the store's async disposal (asset-catalog.ts:519-521). + _store.dispose(); + } +} + +AvcRenditionInspection _inspectBorrowedOwnedAvcRendition( + BorrowedAvcRenditionPlan plan, + Map ranges, + Uint8List? bytes, +) { + if (bytes == null) throw _disposedCatalogError(); + return inspectBorrowedAvcRendition(plan, (key, relativeOffset, byteLength) { + final range = _requireOwnedRange(ranges, key); + if (relativeOffset < 0 || + byteLength < 1 || + relativeOffset > range.length || + byteLength > range.length - relativeOffset) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'owned blob borrow range is invalid', + ); + } + final absoluteOffset = range.offset + relativeOffset; + final end = + checkedCatalogRangeEnd(absoluteOffset, byteLength, bytes.lengthInBytes); + return Uint8List.sublistView(bytes, absoluteOffset, end); + }); +} + +RuntimeBlobResidencySnapshot _summarizeOwnedBlobs( + List lengths, + bool disposed, +) { + final verifiedBytes = + disposed ? 0 : lengths.fold(0, (total, length) => total + length); + return RuntimeBlobResidencySnapshot( + total: lengths.length, + absent: disposed ? lengths.length : 0, + loading: 0, + verified: disposed ? 0 : lengths.length, + verifiedBytes: verifiedBytes, + ); +} + +ByteRange _requireOwnedRange(Map ranges, String key) { + final range = ranges[key]; + if (range == null) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'owned blob key is unavailable', + ); + } + return range; +} + +Uint8List _copyOwnedBytes( + Uint8List? bytes, + ByteRange range, + int relativeOffset, + int byteLength, +) { + if (bytes == null) throw _disposedCatalogError(); + if (relativeOffset < 0 || + byteLength < 1 || + relativeOffset > range.length || + byteLength > range.length - relativeOffset) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'owned blob copy range is invalid', + ); + } + final absoluteOffset = range.offset + relativeOffset; + final end = + checkedCatalogRangeEnd(absoluteOffset, byteLength, bytes.lengthInBytes); + Uint8List copy; + try { + copy = Uint8List(byteLength); + } catch (_) { + throw _catalogError( + RuntimeFailureCode.resourceRejection, + 'asset catalog byte-copy allocation failed', + ); + } + copy.setRange(0, byteLength, bytes, absoluteOffset); + assert(end == absoluteOffset + byteLength); + return copy; +} + +String _requireCatalogBlobKey(String? value) { + if (value == null || value.isEmpty) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'asset catalog blob key is missing', + ); + } + return value; +} + +ByteRange _requireCatalogRelativeRange(ByteRange? value) { + if (value == null) { + throw _catalogError( + RuntimeFailureCode.invalidAsset, + 'asset catalog sample range is missing', + ); + } + return value; +} + +int _checkedOwnedByteSum(int metadataBytes, int payloadBytes) { + final total = metadataBytes + payloadBytes; + if (total > formatDefaultBudgets.maxFileBytes) { + throw _catalogError( + RuntimeFailureCode.resourceRejection, + 'asset catalog owned byte total is invalid', + ); + } + return total; +} + +RuntimePlaybackError _normalizeInstallError(Object error) { + if (error is RuntimePlaybackError) return error; + if (error is FormatError) { + return RuntimePlaybackError(normalizeRuntimeFailure( + RuntimeFailureCode.invalidAsset, + null, + RuntimeFailureContext( + sourceCode: error.code.name, + sourcePath: error.path, + offset: error.offset, + ), + )); + } + return _catalogError( + RuntimeFailureCode.invalidAsset, + 'complete asset validation failed', + ); +} + +RuntimePlaybackError _disposedCatalogError() { + return _catalogError(RuntimeFailureCode.disposed, 'asset catalog is disposed'); +} + +RuntimePlaybackError _catalogError( + RuntimeFailureCode code, + String message, [ + RuntimeFailureContext context = const RuntimeFailureContext(), +]) { + return RuntimePlaybackError(normalizeRuntimeFailure(code, message, context)); +} diff --git a/flutter/packages/aval_player/lib/src/asset_catalog_index.dart b/flutter/packages/aval_player/lib/src/asset_catalog_index.dart new file mode 100644 index 0000000..ebbcea8 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/asset_catalog_index.dart @@ -0,0 +1,420 @@ +/// Immutable catalog lookup maps and indexes over a parsed front index. +/// +/// Direct port of `packages/player-web/src/runtime/asset-catalog-index.ts`. +/// TS `ReadonlyMap` → Dart `Map` (returned views are copied via +/// `List.unmodifiable`); the `Pick`-narrowed index interfaces map onto full +/// abstract interfaces. `frontIndex.header?.declaredFileLength` becomes a plain +/// field read because [FormatHeader] is non-nullable in the Dart format port. +/// The generic `indexById` becomes an id-extractor +/// argument because Dart has no structural "has an `id`" bound. +library; + +import 'package:aval_format/aval_format.dart' + show + AccessUnitRecord, + BodyUnitV01, + ByteRange, + EdgeV01, + ParsedFrontIndex, + PortV01, + RenditionV01, + StateV01, + UnitBlobRange, + UnitV01; + +import 'errors.dart' + show + RuntimeFailureCode, + RuntimeFailureContext, + RuntimePlaybackError, + normalizeRuntimeFailure; + +/// A require/get lookup index keyed by entity id. +abstract interface class RuntimeCatalogIdIndex { + int get size; + TValue? get(String id); + TValue require(String id); + List keys(); + List values(); +} + +/// One body port plus its owning unit id. +class RuntimeCatalogPortEntry { + const RuntimeCatalogPortEntry({required this.unit, required this.port}); + + final String unit; + final PortV01 port; +} + +/// A require/get lookup index of body ports keyed by `unit/port`. +abstract interface class RuntimeCatalogPortIndex { + int get size; + RuntimeCatalogPortEntry? get(String unit, String port); + RuntimeCatalogPortEntry require(String unit, String port); + List values(); +} + +/// One access unit's byte geometry and identity. +class RuntimeCatalogAccessUnit { + const RuntimeCatalogAccessUnit({ + required this.rendition, + required this.unit, + required this.localFrame, + required this.ordinal, + required this.record, + required this.range, + this.blobKey, + this.blobRange, + this.relativeRange, + }); + + final String rendition; + final String unit; + final int localFrame; + final int ordinal; + final AccessUnitRecord record; + final String? blobKey; + final UnitBlobRange? blobRange; + final ByteRange? relativeRange; + final ByteRange range; +} + +/// A require/get lookup index of access units keyed by `rendition/unit/frame`. +abstract interface class RuntimeCatalogRecordIndex { + int get size; + RuntimeCatalogAccessUnit? get(String rendition, String unit, int localFrame); + RuntimeCatalogAccessUnit require( + String rendition, + String unit, + int localFrame, + ); + List values(); +} + +/// Input to [buildCatalogMaps]. +class CatalogMapBuildInput { + const CatalogMapBuildInput({ + required this.frontIndex, + required this.declaredFileLength, + }); + + final ParsedFrontIndex frontIndex; + final int declaredFileLength; +} + +/// The complete set of built catalog lookup maps. +class CatalogMaps { + const CatalogMaps({ + required this.renditions, + required this.units, + required this.states, + required this.edges, + required this.ports, + required this.records, + }); + + final Map renditions; + final Map units; + final Map states; + final Map edges; + final Map ports; + final Map records; +} + +CatalogMaps buildCatalogMaps(CatalogMapBuildInput input) { + final frontIndex = input.frontIndex; + final byteLength = input.declaredFileLength; + if (byteLength < 1 || frontIndex.header.declaredFileLength != byteLength) { + throw _indexError('asset catalog front-index geometry is invalid'); + } + final manifest = frontIndex.manifest; + final renditions = _indexById( + manifest.renditions, + (value) => value.id, + 'rendition', + ); + final units = _indexById( + manifest.units, + (value) => value.id, + 'unit', + ); + final states = _indexById( + manifest.states, + (value) => value.id, + 'state', + ); + final edges = _indexById( + manifest.edges, + (value) => value.id, + 'edge', + ); + final ports = {}; + final records = {}; + final unitBlobs = _indexUnitBlobs(frontIndex, byteLength); + + for (final unit in manifest.units) { + if (unit.kind != 'body') continue; + for (final port in (unit as BodyUnitV01).ports) { + _insertUnique( + ports, + _portIdentity(unit.id, port.id), + RuntimeCatalogPortEntry(unit: unit.id, port: port), + 'validated asset contains a duplicate body port', + ); + } + } + + for (var ordinal = 0; ordinal < frontIndex.records.length; ordinal += 1) { + final record = frontIndex.records[ordinal]; + if (record.renditionIndex < 0 || + record.renditionIndex >= manifest.renditions.length || + record.unitIndex < 0 || + record.unitIndex >= manifest.units.length) { + throw _indexError('validated asset record relation is missing'); + } + final rendition = manifest.renditions[record.renditionIndex]; + final unit = manifest.units[record.unitIndex]; + checkedCatalogRangeEnd( + record.payloadOffset, + record.payloadLength, + byteLength, + ); + final blob = unitBlobs[_unitBlobIdentity(rendition.id, unit.id)]; + if (blob == null) { + throw _indexError('validated asset record has no containing unit blob'); + } + final blobEnd = checkedCatalogRangeEnd(blob.offset, blob.length, byteLength); + final recordEnd = record.payloadOffset + record.payloadLength; + if (ordinal < blob.sampleStart || + ordinal >= blob.sampleStart + blob.sampleCount || + record.payloadOffset < blob.offset || + recordEnd > blobEnd) { + throw _indexError('validated asset record exceeds its unit blob'); + } + final range = ByteRange( + offset: record.payloadOffset, + length: record.payloadLength, + ); + final relativeRange = ByteRange( + offset: record.payloadOffset - blob.offset, + length: record.payloadLength, + ); + _insertUnique( + records, + _recordIdentity(rendition.id, unit.id, record.frameIndex), + RuntimeCatalogAccessUnit( + rendition: rendition.id, + unit: unit.id, + localFrame: record.frameIndex, + ordinal: ordinal, + record: record, + blobKey: runtimeUnitBlobKey(rendition.id, unit.id), + blobRange: blob, + relativeRange: relativeRange, + range: range, + ), + 'validated asset contains a duplicate access-unit identity', + ); + } + + return CatalogMaps( + renditions: renditions, + units: units, + states: states, + edges: edges, + ports: ports, + records: records, + ); +} + +String runtimeUnitBlobKey(String rendition, String unit) => + 'unit:$rendition:$unit'; + +Map _indexUnitBlobs( + ParsedFrontIndex frontIndex, + int declaredFileLength, +) { + final result = {}; + for (final blob in frontIndex.unitBlobs) { + checkedCatalogRangeEnd(blob.offset, blob.length, declaredFileLength); + if (blob.sampleStart < 0 || + blob.sampleCount < 1 || + blob.sampleStart > frontIndex.records.length || + blob.sampleCount > frontIndex.records.length - blob.sampleStart) { + throw _indexError('validated unit blob sample span is invalid'); + } + _insertUnique( + result, + _unitBlobIdentity(blob.rendition, blob.unit), + blob, + 'validated asset contains a duplicate unit blob', + ); + } + return result; +} + +RuntimeCatalogIdIndex createCatalogIdIndex( + String label, + Map Function() map, + RuntimeFailureContext Function(String id) context, +) { + return _CatalogIdIndex(label, map, context); +} + +RuntimeCatalogPortIndex createCatalogPortIndex( + Map Function() map, +) { + return _CatalogPortIndex(map); +} + +RuntimeCatalogRecordIndex createCatalogRecordIndex( + Map Function() map, +) { + return _CatalogRecordIndex(map); +} + +int checkedCatalogRangeEnd(int offset, int length, int limit) { + if (offset < 0 || length < 1 || offset > limit || length > limit - offset) { + throw _indexError('validated asset byte range is unavailable'); + } + return offset + length; +} + +class _CatalogIdIndex implements RuntimeCatalogIdIndex { + _CatalogIdIndex(this._label, this._map, this._context); + + final String _label; + final Map Function() _map; + final RuntimeFailureContext Function(String id) _context; + + @override + int get size => _map().length; + + @override + TValue? get(String id) => _map()[id]; + + @override + TValue require(String id) { + final value = _map()[id]; + if (value == null) { + throw _indexError('asset catalog $_label lookup failed', _context(id)); + } + return value; + } + + @override + List keys() => List.unmodifiable(_map().keys); + + @override + List values() => List.unmodifiable(_map().values); +} + +class _CatalogPortIndex implements RuntimeCatalogPortIndex { + _CatalogPortIndex(this._map); + + final Map Function() _map; + + @override + int get size => _map().length; + + @override + RuntimeCatalogPortEntry? get(String unit, String port) => + _map()[_portIdentity(unit, port)]; + + @override + RuntimeCatalogPortEntry require(String unit, String port) { + final value = _map()[_portIdentity(unit, port)]; + if (value == null) { + throw _indexError( + 'asset catalog port lookup failed', + RuntimeFailureContext(unit: unit, path: port), + ); + } + return value; + } + + @override + List values() => + List.unmodifiable(_map().values); +} + +class _CatalogRecordIndex implements RuntimeCatalogRecordIndex { + _CatalogRecordIndex(this._map); + + final Map Function() _map; + + @override + int get size => _map().length; + + @override + RuntimeCatalogAccessUnit? get(String rendition, String unit, int localFrame) => + _map()[_recordIdentity(rendition, unit, localFrame)]; + + @override + RuntimeCatalogAccessUnit require( + String rendition, + String unit, + int localFrame, + ) { + final value = _map()[_recordIdentity(rendition, unit, localFrame)]; + if (value == null) { + throw _indexError( + 'asset catalog access-unit lookup failed', + RuntimeFailureContext( + rendition: rendition, + unit: unit, + localFrame: localFrame, + ), + ); + } + return value; + } + + @override + List values() => + List.unmodifiable(_map().values); +} + +Map _indexById( + List values, + String Function(TValue) idOf, + String label, +) { + final map = {}; + for (final value in values) { + _insertUnique( + map, + idOf(value), + value, + 'validated asset contains a duplicate $label', + ); + } + return map; +} + +void _insertUnique( + Map map, + String key, + TValue value, + String message, +) { + if (map.containsKey(key)) throw _indexError(message); + map[key] = value; +} + +String _portIdentity(String unit, String port) => '$unit/$port'; + +String _unitBlobIdentity(String rendition, String unit) => + runtimeUnitBlobKey(rendition, unit); + +String _recordIdentity(String rendition, String unit, int localFrame) => + '$rendition/$unit/$localFrame'; + +RuntimePlaybackError _indexError( + String message, [ + RuntimeFailureContext context = const RuntimeFailureContext(), +]) { + return RuntimePlaybackError( + normalizeRuntimeFailure(RuntimeFailureCode.invalidAsset, message, context), + ); +} diff --git a/flutter/packages/aval_player/lib/src/borrowed_avc_inspection.dart b/flutter/packages/aval_player/lib/src/borrowed_avc_inspection.dart new file mode 100644 index 0000000..73abd76 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/borrowed_avc_inspection.dart @@ -0,0 +1,98 @@ +/// Byte-free AVC rendition inspection over borrowed catalog views. +/// +/// Direct port of `packages/player-web/src/runtime/borrowed-avc-inspection.ts`. +/// The TS `RUNTIME_CATALOG_AVC_INSPECTION` `unique symbol` (a hidden method key +/// on `RuntimeAssetCatalog`) has no Dart analog; the internal inspection entry +/// point is instead the ordinary method +/// `RuntimeAssetCatalog.inspectAvcRenditionInternal` (asset_catalog.dart), and +/// [inspectRuntimeCatalogAvcRendition] calls it directly +/// (borrowed-avc-inspection.ts:37-43). +library; + +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart' + show + AvcAccessUnitInput, + AvcConstrainedBaselineProfile, + AvcRenditionInspection, + AvcRenditionInspectionInput, + AvcUnitInput, + inspectAvcAnnexBRendition; + +import 'asset_catalog.dart' show RuntimeAssetCatalog; + +/// One access unit's borrow plan. +class BorrowedAvcAccessUnitPlan { + const BorrowedAvcAccessUnitPlan({ + required this.blobKey, + required this.relativeOffset, + required this.byteLength, + required this.key, + }); + + final String blobKey; + final int relativeOffset; + final int byteLength; + final bool key; +} + +/// One unit's ordered access-unit borrow plan. +class BorrowedAvcUnitPlan { + const BorrowedAvcUnitPlan({required this.id, required this.accessUnits}); + + final String id; + final List accessUnits; +} + +/// A rendition's complete borrow plan plus its constrained-baseline profile. +class BorrowedAvcRenditionPlan { + const BorrowedAvcRenditionPlan({required this.profile, required this.units}); + + final AvcConstrainedBaselineProfile profile; + final List units; +} + +/// Synchronously borrows exactly [byteLength] bytes at [relativeOffset] within +/// the blob keyed [key]. The returned view never escapes the inspection call. +typedef BorrowVerifiedRange = Uint8List Function( + String key, + int relativeOffset, + int byteLength, +); + +/// Returns only a byte-free immutable inspection result. +AvcRenditionInspection inspectRuntimeCatalogAvcRendition( + RuntimeAssetCatalog catalog, + String rendition, + AvcConstrainedBaselineProfile profile, +) { + return catalog.inspectAvcRenditionInternal(rendition, profile); +} + +/// The trusted format inspector consumes borrowed views synchronously and +/// returns a byte-free scalar summary. The borrow function never escapes. +AvcRenditionInspection inspectBorrowedAvcRendition( + BorrowedAvcRenditionPlan plan, + BorrowVerifiedRange borrow, +) { + final units = plan.units.map((unit) { + return AvcUnitInput( + id: unit.id, + accessUnits: unit.accessUnits.map((accessUnit) { + final bytes = borrow( + accessUnit.blobKey, + accessUnit.relativeOffset, + accessUnit.byteLength, + ); + if (bytes.length != accessUnit.byteLength) { + throw ArgumentError('borrowed AVC access unit is malformed'); + } + return AvcAccessUnitInput(bytes: bytes, key: accessUnit.key); + }).toList(), + ); + }).toList(); + return inspectAvcAnnexBRendition( + AvcRenditionInspectionInput(profile: plan.profile, units: units), + ); +} diff --git a/flutter/packages/aval_player/lib/src/decode_timeline.dart b/flutter/packages/aval_player/lib/src/decode_timeline.dart new file mode 100644 index 0000000..ba90be3 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/decode_timeline.dart @@ -0,0 +1,413 @@ +/// The decoder session's global clock and generation-local unit identity. +/// +/// Direct port of `packages/player-web/src/runtime/decode-timeline.ts`. +/// TypeScript `number` counters become Dart `int`; the ordinal-successor bound +/// is computed with `BigInt` exactly where the TS source used `bigint`, to keep +/// the safe-integer overflow checks precise. `Object.freeze` on the returned +/// samples/snapshot becomes `List.unmodifiable` plus immutable value classes. +/// +/// Note on `validatePositiveSafeInteger` (decode-timeline.ts:287): unlike the +/// simplified sign-only check used in `rational_time.dart`, this module keeps +/// the upper `Number.MAX_SAFE_INTEGER` guard, because `allocateUnitOccurrence` +/// materializes one frame request per `unitFrameCount` — an unbounded count +/// must be rejected before it is expanded into an array. +library; + +import 'rational_time.dart'; + +const int _maxUnitIdLength = 128; + +/// Immutable clock/occurrence fields before record type and bytes are attached. +class DecodeSampleMetadata { + const DecodeSampleMetadata({ + required this.generation, + required this.ordinal, + required this.unitId, + required this.unitInstance, + required this.unitFrame, + required this.unitFrameCount, + required this.timestamp, + required this.duration, + }); + + final int generation; + final int ordinal; + final String unitId; + final int unitInstance; + final int unitFrame; + final int unitFrameCount; + final int timestamp; + final int duration; + + @override + bool operator ==(Object other) => + other is DecodeSampleMetadata && + other.generation == generation && + other.ordinal == ordinal && + other.unitId == unitId && + other.unitInstance == unitInstance && + other.unitFrame == unitFrame && + other.unitFrameCount == unitFrameCount && + other.timestamp == timestamp && + other.duration == duration; + + @override + int get hashCode => Object.hash( + generation, + ordinal, + unitId, + unitInstance, + unitFrame, + unitFrameCount, + timestamp, + duration, + ); + + @override + String toString() => + 'DecodeSampleMetadata(generation: $generation, ordinal: $ordinal, ' + 'unitId: $unitId, unitInstance: $unitInstance, unitFrame: $unitFrame, ' + 'unitFrameCount: $unitFrameCount, timestamp: $timestamp, ' + 'duration: $duration)'; +} + +/// One complete independently-decodable occurrence request. +class DecodeUnitOccurrence { + const DecodeUnitOccurrence({ + required this.unitId, + required this.unitFrameCount, + }); + + final String unitId; + final int unitFrameCount; +} + +/// One frame's identity within a planned occurrence batch. +class DecodeTimelineFrameRequest { + const DecodeTimelineFrameRequest({ + required this.unitId, + required this.unitFrame, + required this.unitFrameCount, + }); + + final String unitId; + final int unitFrame; + final int unitFrameCount; +} + +/// A staged, atomically-committable metadata batch. +/// +/// The returned [commit] rejects if another timeline operation ran first. +abstract interface class DecodeTimelineBatchPlan { + int get generation; + List get samples; + List commit(); +} + +/// Immutable snapshot of the timeline's observable counters. +class DecodeTimelineSnapshot { + const DecodeTimelineSnapshot({ + required this.frameRate, + required this.activeGeneration, + required this.nextOrdinal, + required this.nextUnitInstance, + }); + + final RationalFrameRate frameRate; + final int? activeGeneration; + final int nextOrdinal; + final int nextUnitInstance; + + @override + bool operator ==(Object other) => + other is DecodeTimelineSnapshot && + other.frameRate == frameRate && + other.activeGeneration == activeGeneration && + other.nextOrdinal == nextOrdinal && + other.nextUnitInstance == nextUnitInstance; + + @override + int get hashCode => + Object.hash(frameRate, activeGeneration, nextOrdinal, nextUnitInstance); + + @override + String toString() => + 'DecodeTimelineSnapshot(frameRate: $frameRate, ' + 'activeGeneration: $activeGeneration, nextOrdinal: $nextOrdinal, ' + 'nextUnitInstance: $nextUnitInstance)'; +} + +class _ActiveOccurrence { + _ActiveOccurrence({ + required this.unitId, + required this.unitFrameCount, + required this.unitInstance, + required this.nextUnitFrame, + }); + + final String unitId; + final int unitFrameCount; + final int unitInstance; + final int nextUnitFrame; +} + +/// Owns the decoder session's global clock and generation-local unit identity. +/// It neither owns bytes nor submits work. +class DecodeTimeline { + DecodeTimeline(RationalFrameRate frameRate) + : _frameRate = RationalFrameRate( + numerator: frameRate.numerator, + denominator: frameRate.denominator, + ) { + validateFrameRate(frameRate); + } + + final RationalFrameRate _frameRate; + int? _activeGeneration; + int _nextOrdinal = 0; + int _nextUnitInstance = 0; + _ActiveOccurrence? _activeOccurrence; + int _revision = 0; + + /// Activates the next positive generation without resetting decode time. + int activateNextGeneration() { + if (_activeGeneration == maxSafeInteger) { + throw RangeError('decode generation exceeds the safe-integer range'); + } + + final generation = (_activeGeneration ?? 0) + 1; + _activeGeneration = generation; + _nextUnitInstance = 0; + _activeOccurrence = null; + _revision += 1; + return generation; + } + + /// Atomically assigns one complete independently-decodable occurrence. + /// Failure leaves every timeline counter unchanged. + List allocateUnitOccurrence( + String unitId, + int unitFrameCount, + ) { + return allocateUnitOccurrences([ + DecodeUnitOccurrence(unitId: unitId, unitFrameCount: unitFrameCount), + ]); + } + + /// Assigns one or more complete occurrences in one atomic timeline step. + List allocateUnitOccurrences( + List occurrences, + ) { + if (_activeGeneration == null) { + throw RangeError( + 'decode timeline requires an active generation before an occurrence', + ); + } + if (occurrences.isEmpty) { + throw RangeError('decode timeline requires at least one occurrence'); + } + + final frames = []; + for (final occurrence in occurrences) { + _validateUnitId(occurrence.unitId); + _validatePositiveSafeInteger( + occurrence.unitFrameCount, + 'unit frame count', + ); + for (var unitFrame = 0; + unitFrame < occurrence.unitFrameCount; + unitFrame += 1) { + frames.add(DecodeTimelineFrameRequest( + unitId: occurrence.unitId, + unitFrame: unitFrame, + unitFrameCount: occurrence.unitFrameCount, + )); + } + } + + return planSampleBatch(frames).commit(); + } + + /// Builds immutable metadata without mutating counters. The returned commit + /// is atomic and rejects if another operation changed the timeline first. + DecodeTimelineBatchPlan planSampleBatch( + List frames, + ) { + final generation = _activeGeneration; + if (generation == null) { + throw RangeError( + 'decode timeline requires an active generation before an occurrence', + ); + } + if (frames.isEmpty) { + throw RangeError('decode timeline batch must contain a frame'); + } + final finalOrdinal = + BigInt.from(_nextOrdinal) + BigInt.from(frames.length) - BigInt.one; + if (finalOrdinal >= BigInt.from(maxSafeInteger)) { + throw RangeError('decode ordinal leaves no safe successor'); + } + + final revision = _revision; + var nextUnitInstance = _nextUnitInstance; + _ActiveOccurrence? activeOccurrence = _activeOccurrence == null + ? null + : _ActiveOccurrence( + unitId: _activeOccurrence!.unitId, + unitFrameCount: _activeOccurrence!.unitFrameCount, + unitInstance: _activeOccurrence!.unitInstance, + nextUnitFrame: _activeOccurrence!.nextUnitFrame, + ); + var ordinal = _nextOrdinal; + var timestamp = timestampForFrame(ordinal, _frameRate); + final samples = []; + + for (final frame in frames) { + _validateFrameRequest(frame); + int unitInstance; + if (activeOccurrence == null) { + if (frame.unitFrame != 0) { + throw RangeError( + 'every decode unit occurrence must begin at frame zero', + ); + } + if (nextUnitInstance >= maxSafeInteger) { + throw RangeError('unit instance leaves no safe successor'); + } + unitInstance = nextUnitInstance; + nextUnitInstance += 1; + activeOccurrence = frame.unitFrameCount == 1 + ? null + : _ActiveOccurrence( + unitId: frame.unitId, + unitFrameCount: frame.unitFrameCount, + unitInstance: unitInstance, + nextUnitFrame: 1, + ); + } else { + if (frame.unitId != activeOccurrence.unitId || + frame.unitFrameCount != activeOccurrence.unitFrameCount || + frame.unitFrame != activeOccurrence.nextUnitFrame) { + throw RangeError( + 'decode unit occurrence frames must remain complete and contiguous', + ); + } + unitInstance = activeOccurrence.unitInstance; + final nextUnitFrame = frame.unitFrame + 1; + activeOccurrence = nextUnitFrame == frame.unitFrameCount + ? null + : _ActiveOccurrence( + unitId: activeOccurrence.unitId, + unitFrameCount: activeOccurrence.unitFrameCount, + unitInstance: activeOccurrence.unitInstance, + nextUnitFrame: nextUnitFrame, + ); + } + + final nextTimestamp = timestampForFrame(ordinal + 1, _frameRate); + final duration = nextTimestamp - timestamp; + if (duration <= 0 || timestamp > maxSafeInteger - duration) { + throw RangeError( + 'decode timestamp duration must be positive and remain in the ' + 'safe-integer range', + ); + } + samples.add(DecodeSampleMetadata( + generation: generation, + ordinal: ordinal, + unitId: frame.unitId, + unitInstance: unitInstance, + unitFrame: frame.unitFrame, + unitFrameCount: frame.unitFrameCount, + timestamp: timestamp, + duration: duration, + )); + ordinal += 1; + timestamp = nextTimestamp; + } + + final immutableSamples = List.unmodifiable(samples); + return _DecodeTimelineBatchPlan( + timeline: this, + generation: generation, + samples: immutableSamples, + revision: revision, + committedOrdinal: ordinal, + committedNextUnitInstance: nextUnitInstance, + committedActiveOccurrence: activeOccurrence, + ); + } + + DecodeTimelineSnapshot snapshot() { + return DecodeTimelineSnapshot( + frameRate: _frameRate, + activeGeneration: _activeGeneration, + nextOrdinal: _nextOrdinal, + nextUnitInstance: _nextUnitInstance, + ); + } +} + +class _DecodeTimelineBatchPlan implements DecodeTimelineBatchPlan { + _DecodeTimelineBatchPlan({ + required this.timeline, + required this.generation, + required this.samples, + required this.revision, + required this.committedOrdinal, + required this.committedNextUnitInstance, + required this.committedActiveOccurrence, + }); + + final DecodeTimeline timeline; + + @override + final int generation; + + @override + final List samples; + + final int revision; + final int committedOrdinal; + final int committedNextUnitInstance; + final _ActiveOccurrence? committedActiveOccurrence; + + bool _committed = false; + + @override + List commit() { + if (_committed) { + throw RangeError('decode timeline batch was already committed'); + } + if (timeline._revision != revision || + timeline._activeGeneration != generation) { + throw RangeError('decode timeline batch plan became stale'); + } + timeline._nextOrdinal = committedOrdinal; + timeline._nextUnitInstance = committedNextUnitInstance; + timeline._activeOccurrence = committedActiveOccurrence; + timeline._revision += 1; + _committed = true; + return samples; + } +} + +void _validateUnitId(String unitId) { + if (unitId.isEmpty || unitId.length > _maxUnitIdLength) { + throw RangeError('unit ID length must be between 1 and $_maxUnitIdLength'); + } +} + +void _validateFrameRequest(DecodeTimelineFrameRequest frame) { + _validateUnitId(frame.unitId); + _validatePositiveSafeInteger(frame.unitFrameCount, 'unit frame count'); + if (frame.unitFrame < 0 || frame.unitFrame >= frame.unitFrameCount) { + throw RangeError('unit frame must be within the unit frame count'); + } +} + +void _validatePositiveSafeInteger(int value, String label) { + if (value <= 0 || value > maxSafeInteger) { + throw RangeError('$label must be a positive safe integer'); + } +} diff --git a/flutter/packages/aval_player/lib/src/decoder_worker/client_support.dart b/flutter/packages/aval_player/lib/src/decoder_worker/client_support.dart new file mode 100644 index 0000000..15da5b9 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/decoder_worker/client_support.dart @@ -0,0 +1,69 @@ +/// Decoder-worker client contracts referenced by the path scheduler. +/// +/// **Partial port** of `packages/player-web/src/decoder-worker/client-support.ts`. +/// Only [DecoderWorkerWaitOptions] and [ManagedDecoderWorkerFrame] — the two +/// shapes the path-scheduler family references — are ported here as frozen +/// types. The client runtime, its error classes, and the configure/wait +/// validation helpers are a later phase's responsibility and will extend this +/// file. +/// +/// `AbortSignal` and `VideoFrame` map onto the platform seams in +/// `../platform.dart`. `outputCallbackMicroseconds?` becomes a nullable `int`. +library; + +import '../platform.dart'; + +/// Options for awaiting decoded frames. +class DecoderWorkerWaitOptions { + const DecoderWorkerWaitOptions({this.signal, this.timeoutMs}); + + final AbortSignal? signal; + final int? timeoutMs; +} + +/// Base for the decoder-worker error taxonomy, carrying the JS `Error.name` +/// the path-scheduler's failure classification reads +/// (`path-scheduler.ts:794`). Partial: only [DecoderWorkerWatchdogError] — the +/// one the scheduler and its tests observe — is ported from +/// `decoder-worker/client-support.ts:45-83`; the remaining error classes remain +/// a later phase's responsibility. +abstract class DecoderWorkerError implements Exception { + DecoderWorkerError(this.message); + + final String message; + + /// Mirrors JS `Error.name`. + String get name; + + @override + String toString() => '$name: $message'; +} + +/// Raised when the decode client's frame watchdog fires +/// (`client-support.ts:78`). +class DecoderWorkerWatchdogError extends DecoderWorkerError { + DecoderWorkerWatchdogError(super.message); + + @override + String get name => 'DecoderWorkerWatchdogError'; +} + +/// A decoded frame the client hands to the presentation ring. +/// +/// The concrete implementation (the TS `ManagedDecoderWorkerFrameImpl`) is +/// owned by the decode client; the scheduler only consumes this interface. +abstract interface class ManagedDecoderWorkerFrame { + VideoFrame get frame; + int get frameId; + int get generation; + int get ordinal; + String get unitId; + int get unitInstance; + int get unitFrame; + int get timestamp; + int get duration; + int? get outputCallbackMicroseconds; + int get decodedBytes; + bool get closed; + void close(); +} diff --git a/flutter/packages/aval_player/lib/src/decoder_worker/protocol.dart b/flutter/packages/aval_player/lib/src/decoder_worker/protocol.dart new file mode 100644 index 0000000..956df79 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/decoder_worker/protocol.dart @@ -0,0 +1,144 @@ +/// Decoder-worker protocol data types referenced by the path scheduler. +/// +/// **Partial port** of `packages/player-web/src/decoder-worker/protocol.ts`. +/// Only the three data shapes the path-scheduler family needs — +/// [DecoderWorkerLimits], [DecoderWorkerSample], [DecoderWorkerMetrics] — plus +/// the [EncodedVideoChunkType] discriminant are ported here as frozen types. +/// The command/event message unions, the message-port interfaces, and the +/// worker runtime are a later phase's responsibility and will extend this file. +/// +/// The TypeScript `ArrayBuffer` (transferred access-unit bytes) becomes a Dart +/// `ByteBuffer`; `EncodedVideoChunkType` (the browser `"key" | "delta"` union) +/// becomes an enum with the wire values preserved. +library; + +import 'dart:typed_data'; + +import '../rational_time.dart' show maxSafeInteger; + +/// Absolute decoder-worker ceilings (`DECODER_WORKER_HARD_LIMITS`, +/// decoder-worker/protocol.ts). Ported as static consts because the TS source +/// is a frozen literal object; [maxSampleBytes]/[maxDecodedBytes] are +/// `Number.MAX_SAFE_INTEGER`, i.e. [maxSafeInteger] here. +abstract final class DecoderWorkerHardLimits { + static const int maxDecodeQueueSize = 12; + static const int maxPendingSamples = 24; + static const int maxOutstandingFrames = 12; + static const int maxSampleBytes = maxSafeInteger; + static const int maxDecodedBytes = maxSafeInteger; +} + +/// Access-unit chunk classification (`EncodedVideoChunkType`). +enum EncodedVideoChunkType { + key('key'), + delta('delta'); + + const EncodedVideoChunkType(this.wireValue); + + final String wireValue; +} + +/// Backpressure/queue-depth limits configured for the decoder. +class DecoderWorkerLimits { + const DecoderWorkerLimits({ + required this.maxDecodeQueueSize, + required this.maxPendingSamples, + required this.maxOutstandingFrames, + required this.maxDecodedBytes, + }); + + /// Maximum native decoder input queue depth. + final int maxDecodeQueueSize; + + /// Maximum accepted samples waiting to enter WebCodecs. + final int maxPendingSamples; + + /// Combined submitted-output and transferred-frame credit ceiling. + final int maxOutstandingFrames; + + /// Logical RGBA bytes leased to the main thread at once. + final int maxDecodedBytes; +} + +/// One owned access unit. +/// +/// Posting a submit command transfers [data]; callers must not retain or mutate +/// that buffer afterward. +class DecoderWorkerSample { + const DecoderWorkerSample({ + required this.ordinal, + required this.unitId, + required this.unitInstance, + required this.unitFrame, + required this.unitFrameCount, + required this.type, + required this.timestamp, + required this.duration, + required this.data, + }); + + final int ordinal; + final String unitId; + final int unitInstance; + final int unitFrame; + final int unitFrameCount; + final EncodedVideoChunkType type; + final int timestamp; + final int duration; + final ByteBuffer data; +} + +/// Observable decoder counters. +class DecoderWorkerMetrics { + const DecoderWorkerMetrics({ + required this.configureCalls, + required this.resetCalls, + required this.flushCalls, + required this.boundaryFlushCalls, + required this.acceptedSamples, + required this.submittedChunks, + required this.outputFrames, + required this.deliveredFrames, + required this.releasedFrames, + required this.staleFrames, + required this.closedFrames, + required this.pendingSamples, + required this.submittedFrames, + required this.leasedFrames, + required this.leasedDecodedBytes, + required this.decodeQueueSize, + required this.activeGeneration, + required this.nextSubmissionOrdinal, + required this.nextOutputOrdinal, + required this.errors, + required this.disposed, + }); + + final int configureCalls; + + /// Always `0`. + final int resetCalls; + + /// Always `0`. + final int flushCalls; + + /// Always `0`. + final int boundaryFlushCalls; + final int acceptedSamples; + final int submittedChunks; + final int outputFrames; + final int deliveredFrames; + final int releasedFrames; + final int staleFrames; + final int closedFrames; + final int pendingSamples; + final int submittedFrames; + final int leasedFrames; + final int leasedDecodedBytes; + final int decodeQueueSize; + final int? activeGeneration; + final int nextSubmissionOrdinal; + final int nextOutputOrdinal; + final int errors; + final bool disposed; +} diff --git a/flutter/packages/aval_player/lib/src/edge_lead.dart b/flutter/packages/aval_player/lib/src/edge_lead.dart new file mode 100644 index 0000000..1f62c0d --- /dev/null +++ b/flutter/packages/aval_player/lib/src/edge_lead.dart @@ -0,0 +1,149 @@ +/// The M5.5 locked/transitionless consecutive-lead formula. +/// +/// Direct port of `packages/player-web/src/runtime/edge-lead.ts`. TypeScript +/// `number` counters become Dart `int`. The `"bridge" | "target-body"` string +/// union becomes the [EdgeLeadFirstPresentation] enum with wire values kept +/// exactly. `Number.MAX_SAFE_INTEGER` is kept as the literal JavaScript bound +/// for parity (see [maxSafeInteger] in `rational_time.dart`). +library; + +import 'presentation_ring.dart'; +import 'rational_time.dart' show maxSafeInteger; + +/// Which presentation an edge lead begins with. +enum EdgeLeadFirstPresentation { + bridge('bridge'), + targetBody('target-body'); + + const EdgeLeadFirstPresentation(this.wireValue); + + final String wireValue; +} + +/// Inputs required to derive the required consecutive lead for an edge. +class RequiredEdgeLeadInput { + const RequiredEdgeLeadInput({ + required this.transitionFrames, + required this.ringCapacity, + }); + + /// Zero for a transitionless edge; otherwise the complete locked bridge. + final int transitionFrames; + final int ringCapacity; +} + +/// [RequiredEdgeLeadInput] plus the measured available consecutive frames. +class EdgeLeadInput extends RequiredEdgeLeadInput { + const EdgeLeadInput({ + required super.transitionFrames, + required super.ringCapacity, + required this.availableConsecutiveFrames, + }); + + final int availableConsecutiveFrames; +} + +/// The resolved edge-lead plan. +class EdgeLeadPlan { + const EdgeLeadPlan({ + required this.transitionFrames, + required this.targetEntryOffset, + required this.firstPresentation, + required this.requiredConsecutiveFrames, + required this.availableConsecutiveFrames, + required this.missingConsecutiveFrames, + required this.ready, + }); + + final int transitionFrames; + + /// Number of presentations before target body frame zero. + final int targetEntryOffset; + final EdgeLeadFirstPresentation firstPresentation; + final int requiredConsecutiveFrames; + final int availableConsecutiveFrames; + final int missingConsecutiveFrames; + final bool ready; + + @override + bool operator ==(Object other) => + other is EdgeLeadPlan && + other.transitionFrames == transitionFrames && + other.targetEntryOffset == targetEntryOffset && + other.firstPresentation == firstPresentation && + other.requiredConsecutiveFrames == requiredConsecutiveFrames && + other.availableConsecutiveFrames == availableConsecutiveFrames && + other.missingConsecutiveFrames == missingConsecutiveFrames && + other.ready == ready; + + @override + int get hashCode => Object.hash( + transitionFrames, + targetEntryOffset, + firstPresentation, + requiredConsecutiveFrames, + availableConsecutiveFrames, + missingConsecutiveFrames, + ready, + ); + + @override + String toString() => + 'EdgeLeadPlan(transitionFrames: $transitionFrames, ' + 'targetEntryOffset: $targetEntryOffset, ' + 'firstPresentation: $firstPresentation, ' + 'requiredConsecutiveFrames: $requiredConsecutiveFrames, ' + 'availableConsecutiveFrames: $availableConsecutiveFrames, ' + 'missingConsecutiveFrames: $missingConsecutiveFrames, ready: $ready)'; +} + +/// Sole owner of the M5.5 locked/transitionless consecutive-lead formula. +int calculateRequiredEdgeLeadFrames(RequiredEdgeLeadInput input) { + validatePresentationRingCapacity(input.ringCapacity); + _validateNonNegativeSafeInteger( + input.transitionFrames, + 'transition frame count', + ); + if (input.transitionFrames >= maxSafeInteger) { + throw RangeError('transition frame count leaves no safe successor'); + } + + final sequenceThroughTargetEntry = input.transitionFrames + 1; + return sequenceThroughTargetEntry <= input.ringCapacity + ? (sequenceThroughTargetEntry > 2 ? sequenceThroughTargetEntry : 2) + : input.ringCapacity; +} + +EdgeLeadPlan planEdgeLead(EdgeLeadInput input) { + final requiredConsecutiveFrames = calculateRequiredEdgeLeadFrames(input); + _validateNonNegativeSafeInteger( + input.availableConsecutiveFrames, + 'available consecutive frame count', + ); + if (input.availableConsecutiveFrames > input.ringCapacity) { + throw RangeError( + 'available consecutive frame count exceeds the presentation ring', + ); + } + + final rawMissing = + requiredConsecutiveFrames - input.availableConsecutiveFrames; + final missingConsecutiveFrames = rawMissing > 0 ? rawMissing : 0; + return EdgeLeadPlan( + transitionFrames: input.transitionFrames, + targetEntryOffset: input.transitionFrames, + firstPresentation: input.transitionFrames == 0 + ? EdgeLeadFirstPresentation.targetBody + : EdgeLeadFirstPresentation.bridge, + requiredConsecutiveFrames: requiredConsecutiveFrames, + availableConsecutiveFrames: input.availableConsecutiveFrames, + missingConsecutiveFrames: missingConsecutiveFrames, + ready: missingConsecutiveFrames == 0, + ); +} + +void _validateNonNegativeSafeInteger(int value, String label) { + if (value < 0) { + throw RangeError('$label must be a non-negative safe integer'); + } +} diff --git a/flutter/packages/aval_player/lib/src/errors.dart b/flutter/packages/aval_player/lib/src/errors.dart new file mode 100644 index 0000000..7bff2d9 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/errors.dart @@ -0,0 +1,273 @@ +/// Bounded, sanitized runtime failure taxonomy. +/// +/// Direct port of `packages/player-web/src/runtime/errors.ts`. The TypeScript +/// string-literal union `RuntimeFailureCode` becomes an enum whose [wireValue] +/// carries the exact literal; the frozen `RuntimeFailureContext` object becomes +/// an immutable value class. `Object.freeze`/`Object.defineProperties` on the +/// thrown error have no Dart analog and are dropped — [RuntimePlaybackError] is +/// simply an immutable [Exception]. The JS `Reflect`/hostile-accessor defenses +/// in `messageFrom` collapse to a plain type check, because Dart cannot invoke +/// a getter it did not declare. +library; + +/// Maximum UTF-16 code units retained from a runtime failure message. +const int maxRuntimeFailureMessageLength = 512; + +/// Maximum UTF-16 code units retained from one structured diagnostic string. +const int maxRuntimeDiagnosticTextLength = 128; + +/// The closed set of runtime failure codes (`RUNTIME_FAILURE_CODES`). +enum RuntimeFailureCode { + invalidAsset('invalid-asset'), + loadFailure('load-failure'), + rangeResponseInvalid('range-response-invalid'), + entityChanged('entity-changed'), + integrityMismatch('integrity-mismatch'), + unsupportedProfile('unsupported-profile'), + resourceRejection('resource-rejection'), + readinessFailure('readiness-failure'), + workerDecodeFailure('worker-decode-failure'), + rendererFailure('renderer-failure'), + contextLoss('context-loss'), + watchdogTimeout('watchdog-timeout'), + underflow('underflow'), + abort('abort'), + disposed('disposed'); + + const RuntimeFailureCode(this.wireValue); + + final String wireValue; +} + +const Map _defaultFailureMessages = { + RuntimeFailureCode.invalidAsset: 'installed animation asset is invalid', + RuntimeFailureCode.loadFailure: 'animation asset loading failed', + RuntimeFailureCode.rangeResponseInvalid: 'animation range response is invalid', + RuntimeFailureCode.entityChanged: + 'animation asset entity changed during loading', + RuntimeFailureCode.integrityMismatch: + 'animation asset integrity did not match', + RuntimeFailureCode.unsupportedProfile: 'AVC animation profile is unsupported', + RuntimeFailureCode.resourceRejection: 'animation resource budget was rejected', + RuntimeFailureCode.readinessFailure: 'animation readiness failed', + RuntimeFailureCode.workerDecodeFailure: 'animation decoder worker failed', + RuntimeFailureCode.rendererFailure: 'animation renderer failed', + RuntimeFailureCode.contextLoss: 'animation rendering context was lost', + RuntimeFailureCode.watchdogTimeout: 'animation watchdog expired', + RuntimeFailureCode.underflow: 'animation presentation underflowed', + RuntimeFailureCode.abort: 'animation operation was aborted', + RuntimeFailureCode.disposed: 'animation player is disposed', +}; + +/// IDs and counters stay structured so diagnostics never interpolate untrusted +/// asset data into a message. +class RuntimeFailureContext { + const RuntimeFailureContext({ + this.rendition, + this.profile, + this.codec, + this.unit, + this.state, + this.edge, + this.path, + this.operation, + this.sourceCode, + this.sourcePath, + this.alphaStatistic, + this.policyPhase, + this.lifecyclePhase, + this.offset, + this.width, + this.height, + this.generation, + this.ordinal, + this.localFrame, + this.rank, + this.requestOrdinal, + this.httpStatus, + this.expectedBytes, + this.observedBytes, + this.declaredTotalBytes, + this.playerBytes, + this.pageBytes, + }); + + final String? rendition; + final String? profile; + final String? codec; + final String? unit; + final String? state; + final String? edge; + final String? path; + final String? operation; + final String? sourceCode; + final String? sourcePath; + final String? alphaStatistic; + final String? policyPhase; + final String? lifecyclePhase; + final int? offset; + final int? width; + final int? height; + final int? generation; + final int? ordinal; + final int? localFrame; + final int? rank; + final int? requestOrdinal; + final int? httpStatus; + final int? expectedBytes; + final int? observedBytes; + final int? declaredTotalBytes; + final int? playerBytes; + final int? pageBytes; + + bool get isEmpty => + rendition == null && + profile == null && + codec == null && + unit == null && + state == null && + edge == null && + path == null && + operation == null && + sourceCode == null && + sourcePath == null && + alphaStatistic == null && + policyPhase == null && + lifecyclePhase == null && + offset == null && + width == null && + height == null && + generation == null && + ordinal == null && + localFrame == null && + rank == null && + requestOrdinal == null && + httpStatus == null && + expectedBytes == null && + observedBytes == null && + declaredTotalBytes == null && + playerBytes == null && + pageBytes == null; +} + +/// A normalized, bounded runtime failure value. +class RuntimeFailure { + const RuntimeFailure({ + required this.code, + required this.message, + required this.context, + }); + + final RuntimeFailureCode code; + final String message; + final RuntimeFailureContext context; +} + +/// A stable thrown form of a normalized runtime failure. +class RuntimePlaybackError implements Exception { + RuntimePlaybackError(this.failure); + + final RuntimeFailure failure; + + RuntimeFailureCode get code => failure.code; + + String get name => 'RuntimePlaybackError'; + + @override + String toString() => 'RuntimePlaybackError: ${failure.message}'; +} + +bool isRuntimePlaybackError(Object? error) => error is RuntimePlaybackError; + +/// Convert an unknown boundary failure into a bounded immutable value. +RuntimeFailure normalizeRuntimeFailure( + RuntimeFailureCode code, [ + Object? cause, + RuntimeFailureContext context = const RuntimeFailureContext(), +]) { + if (cause is RuntimePlaybackError && cause.code == code && context.isEmpty) { + return cause.failure; + } + + final message = _boundedMessage( + _messageFrom(cause), + _defaultFailureMessages[code]!, + ); + return RuntimeFailure( + code: code, + message: message, + context: _normalizeContext(context), + ); +} + +String? _messageFrom(Object? cause) { + if (cause is String) return cause; + if (cause is RuntimePlaybackError) return cause.failure.message; + if (cause is Error || cause is Exception) { + // Dart cannot read a hostile accessor-backed `message`; the only structured + // message an unknown thrown value exposes safely is its toString(). + final text = cause.toString(); + return text.isEmpty ? null : text; + } + return null; +} + +String _boundedMessage(String? candidate, String fallback) { + final source = + candidate != null && candidate.isNotEmpty ? candidate : fallback; + return _truncateUtf16(source, maxRuntimeFailureMessageLength); +} + +RuntimeFailureContext _normalizeContext(RuntimeFailureContext context) { + String? text(String? value) { + if (value != null && value.isNotEmpty) { + return _truncateUtf16(value, maxRuntimeDiagnosticTextLength); + } + return null; + } + + int? integer(int? value) { + if (value != null && value >= 0) return value; + return null; + } + + return RuntimeFailureContext( + rendition: text(context.rendition), + profile: text(context.profile), + codec: text(context.codec), + unit: text(context.unit), + state: text(context.state), + edge: text(context.edge), + path: text(context.path), + operation: text(context.operation), + sourceCode: text(context.sourceCode), + sourcePath: text(context.sourcePath), + alphaStatistic: text(context.alphaStatistic), + policyPhase: text(context.policyPhase), + lifecyclePhase: text(context.lifecyclePhase), + offset: integer(context.offset), + width: integer(context.width), + height: integer(context.height), + generation: integer(context.generation), + ordinal: integer(context.ordinal), + localFrame: integer(context.localFrame), + rank: integer(context.rank), + requestOrdinal: integer(context.requestOrdinal), + httpStatus: integer(context.httpStatus), + expectedBytes: integer(context.expectedBytes), + observedBytes: integer(context.observedBytes), + declaredTotalBytes: integer(context.declaredTotalBytes), + playerBytes: integer(context.playerBytes), + pageBytes: integer(context.pageBytes), + ); +} + +String _truncateUtf16(String value, int maximum) { + if (value.length <= maximum) return value; + var result = value.substring(0, maximum); + final last = result.codeUnitAt(result.length - 1); + if (last >= 0xd800 && last <= 0xdbff) { + result = result.substring(0, result.length - 1); + } + return result; +} diff --git a/flutter/packages/aval_player/lib/src/model.dart b/flutter/packages/aval_player/lib/src/model.dart new file mode 100644 index 0000000..4215c1f --- /dev/null +++ b/flutter/packages/aval_player/lib/src/model.dart @@ -0,0 +1,238 @@ +/// Runtime presentation/scheduler data types referenced by the path scheduler. +/// +/// **Partial port** of `packages/player-web/src/runtime/model.ts`. Only the +/// shapes the path-scheduler family references — [RuntimeFrameKey], +/// [RuntimeMediaPresentation], [RuntimeMediaCursor], [RuntimeSchedulerSnapshot] +/// — are ported here as frozen types. The rest of the runtime model (readiness, +/// failures, trace records, candidate reports, `summarizeStaticReason`, ...) is +/// a later phase's responsibility and will extend this file. +/// +/// The `RuntimeMediaPresentation` discriminated union becomes a sealed-class +/// hierarchy. The `graphKind: Exclude` +/// field becomes the [RuntimeMediaGraphKind] enum; the `drawSource` string +/// unions become the [RuntimeMediaDrawSource] enum (and the fixed +/// `"fallback"` literal on the static variant). +library; + +/// Maximum retained bounded-trace records (`RUNTIME_TRACE_CAPACITY`, +/// model.ts:387). +const int runtimeTraceCapacity = 512; + +/// Stable authored identity. Equal pixels never merge different keys. +class RuntimeFrameKey { + const RuntimeFrameKey({ + required this.rendition, + required this.unit, + required this.localFrame, + }); + + final String rendition; + final String unit; + final int localFrame; + + @override + bool operator ==(Object other) => + other is RuntimeFrameKey && + other.rendition == rendition && + other.unit == unit && + other.localFrame == localFrame; + + @override + int get hashCode => Object.hash(rendition, unit, localFrame); + + @override + String toString() => + 'RuntimeFrameKey(rendition: $rendition, unit: $unit, ' + 'localFrame: $localFrame)'; +} + +/// The non-static graph presentation kinds a media frame can carry +/// (`Exclude`). +enum RuntimeMediaGraphKind { + intro('intro'), + body('body'), + locked('locked'), + reversible('reversible'); + + const RuntimeMediaGraphKind(this.wireValue); + + final String wireValue; +} + +/// Where a media frame's pixels are drawn from. +enum RuntimeMediaDrawSource { + resident('resident'), + streaming('streaming'); + + const RuntimeMediaDrawSource(this.wireValue); + + final String wireValue; +} + +/// What the runtime is presenting right now. +sealed class RuntimeMediaPresentation { + const RuntimeMediaPresentation(); + + String get kind; +} + +/// A static (fallback poster) presentation. +class RuntimeMediaPresentationStatic extends RuntimeMediaPresentation { + const RuntimeMediaPresentationStatic({required this.state}); + + final String state; + + /// Always `"fallback"`. + final String drawSource = 'fallback'; + + @override + String get kind => 'static'; +} + +/// A decoded-frame presentation. +class RuntimeMediaPresentationFrame extends RuntimeMediaPresentation { + const RuntimeMediaPresentationFrame({ + required this.graphKind, + required this.state, + required this.edge, + required this.path, + required this.frame, + required this.drawSource, + required this.generation, + required this.unitInstance, + required this.decodeOrdinal, + required this.timestamp, + required this.intendedPresentationOrdinal, + }); + + final RuntimeMediaGraphKind graphKind; + final String? state; + final String? edge; + final String path; + final RuntimeFrameKey frame; + final RuntimeMediaDrawSource drawSource; + final int generation; + final int unitInstance; + final int decodeOrdinal; + final int timestamp; + final BigInt intendedPresentationOrdinal; + + @override + String get kind => 'frame'; +} + +/// A cursor into a rendition/unit/frame the runtime tracks. +class RuntimeMediaCursor { + const RuntimeMediaCursor({ + required this.path, + required this.unit, + required this.unitInstance, + required this.localFrame, + }); + + final String path; + final String unit; + final int unitInstance; + final int localFrame; + + @override + bool operator ==(Object other) => + other is RuntimeMediaCursor && + other.path == path && + other.unit == unit && + other.unitInstance == unitInstance && + other.localFrame == localFrame; + + @override + int get hashCode => Object.hash(path, unit, unitInstance, localFrame); + + @override + String toString() => + 'RuntimeMediaCursor(path: $path, unit: $unit, ' + 'unitInstance: $unitInstance, localFrame: $localFrame)'; +} + +/// Observable scheduler cursors and ring occupancy. +class RuntimeSchedulerSnapshot { + const RuntimeSchedulerSnapshot({ + required this.generation, + required this.activePath, + required this.sourceCursor, + required this.submittedCursor, + required this.decodedCursor, + required this.displayedCursor, + required this.ringSize, + required this.ringCapacity, + required this.smoothSession, + }); + + final int? generation; + final String? activePath; + final RuntimeMediaCursor? sourceCursor; + final RuntimeMediaCursor? submittedCursor; + final RuntimeMediaCursor? decodedCursor; + final RuntimeMediaCursor? displayedCursor; + final int ringSize; + final int ringCapacity; + final bool smoothSession; +} + +/// Transport mode of a catalog's byte residency (`RuntimeTransportMode`, +/// model.ts:119). `range` = sparse digest-verified blobs; `full` = complete +/// owned/persistent bytes. +enum RuntimeTransportMode { + range('range'), + full('full'); + + const RuntimeTransportMode(this.wireValue); + + final String wireValue; +} + +/// Residency state of one unit blob (`RuntimeBlobResidencyState`, model.ts:149). +enum RuntimeBlobResidencyState { + absent('absent'), + loading('loading'), + verified('verified'); + + const RuntimeBlobResidencyState(this.wireValue); + + final String wireValue; +} + +/// Aggregate unit-blob residency counts (`RuntimeBlobResidencySnapshot`). +class RuntimeBlobResidencySnapshot { + const RuntimeBlobResidencySnapshot({ + required this.total, + required this.absent, + required this.loading, + required this.verified, + required this.verifiedBytes, + }); + + final int total; + final int absent; + final int loading; + final int verified; + final int verifiedBytes; +} + +/// Sanitized catalog observation: it deliberately carries no URL or ETag +/// (`RuntimeAssetResidencySnapshot`, model.ts:161). +class RuntimeAssetResidencySnapshot { + const RuntimeAssetResidencySnapshot({ + required this.generation, + required this.mode, + required this.declaredFileBytes, + required this.metadataBytes, + required this.verifiedPayloadBytes, + required this.unitBlobs, + }); + + final int generation; + final RuntimeTransportMode mode; + final int declaredFileBytes; + final int metadataBytes; + final int verifiedPayloadBytes; + final RuntimeBlobResidencySnapshot unitBlobs; +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler.dart b/flutter/packages/aval_player/lib/src/path_scheduler.dart new file mode 100644 index 0000000..656505c --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler.dart @@ -0,0 +1,876 @@ +/// One sequential decoder path: graph-selected edge in, frame plans out. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler.ts`. TS +/// `Promise` → `Future`; discriminated-union `kind` checks map onto Dart +/// `is`/pattern matches over the ported sealed classes; `bigint` → `BigInt`. +/// The failure classifier reads `DecoderWorkerError.name` for the only named +/// error the tests observe (the watchdog); any other error yields +/// `"unknown-failure"` — the JS `error instanceof Error ? error.name` fallback +/// has no exact Dart analog for core errors (`RangeError` etc.), but the trace +/// *operation* it produces (`failure`) is identical, and only the watchdog case +/// is asserted (path-scheduler.ts:794). +library; + +import 'package:aval_graph/aval_graph.dart'; + +import 'decoder_worker/client_support.dart' show DecoderWorkerError; +import 'decoder_worker/protocol.dart'; +import 'edge_lead.dart'; +import 'model.dart'; +import 'path_scheduler_cursor_ledger.dart'; +import 'path_scheduler_generation.dart'; +import 'path_scheduler_model.dart'; +import 'path_scheduler_output.dart'; +import 'path_scheduler_pump.dart'; +import 'path_scheduler_reservation.dart'; +import 'path_scheduler_resident_runway.dart'; +import 'path_scheduler_route.dart'; +import 'path_scheduler_trace.dart'; +import 'path_scheduler_validation.dart'; +import 'path_sequence.dart'; +import 'platform.dart'; +import 'presentation_ring.dart' show validatePresentationRingCapacity; +import 'rational_time.dart' show maxSafeInteger; +import 'submission_horizon.dart'; +import 'worker_samples.dart' show WorkerSampleFactory; + +/// An in-flight replacement's identity plus the worker acknowledgement to await. +class _PathSchedulerReplacementActivation { + const _PathSchedulerReplacementActivation({ + required this.retiredGeneration, + required this.generation, + required this.serial, + required this.activation, + }); + + final int retiredGeneration; + final int generation; + final int serial; + final Future activation; +} + +/// Owns one sequential decoder path. Graph routing and future settlement stay +/// outside this class; callers supply the already-selected edge. +class PathScheduler { + PathScheduler(PathSchedulerOptions options) + : _samples = options.samples, + _worker = options.worker, + _ringCapacity = options.ringCapacity, + _limits = options.limits, + _maxBatchSamples = _resolveMaxBatchSamples(options) { + validatePresentationRingCapacity(options.ringCapacity); + validateSchedulerLimits(options.limits); + validateSchedulerId(options.rendition, 'scheduler rendition'); + _output = PathSchedulerOutput(PathSchedulerOutputOptions( + worker: options.worker, + rendition: options.rendition, + ringCapacity: options.ringCapacity, + clock: options.clock, + onTrace: (operation, output, reason) { + _trace(operation, output, reason); + }, + )); + _generationOwner = PathSchedulerGeneration(PathSchedulerGenerationOptions( + timeline: options.timeline, + worker: options.worker, + output: _output, + )); + _residentRunwayOwner = + PathSchedulerResidentRunwayOwner(PathSchedulerResidentRunwayOwnerOptions( + rendition: options.rendition, + generation: _generationOwner, + output: _output, + route: _routeOwner, + cursors: _cursorLedger, + reservation: _reservationOwner, + )); + } + + static int _resolveMaxBatchSamples(PathSchedulerOptions options) { + final maxBatchSamples = options.maxBatchSamples ?? + (options.limits.maxPendingSamples < options.limits.maxOutstandingFrames + ? options.limits.maxPendingSamples + : options.limits.maxOutstandingFrames); + if (maxBatchSamples < 1 || + maxBatchSamples > maxSafeInteger || + maxBatchSamples > options.limits.maxPendingSamples || + maxBatchSamples > options.limits.maxOutstandingFrames) { + throw RangeError('path scheduler batch limit is invalid'); + } + return maxBatchSamples; + } + + final WorkerSampleFactory _samples; + final PathSchedulerWorkerAdapter _worker; + final int _ringCapacity; + final DecoderWorkerLimits _limits; + final int _maxBatchSamples; + final PathSchedulerTraceLog _traceLog = PathSchedulerTraceLog(); + late final PathSchedulerOutput _output; + late final PathSchedulerGeneration _generationOwner; + final PathSchedulerRoute _routeOwner = PathSchedulerRoute(); + final PathSchedulerCursorLedger _cursorLedger = PathSchedulerCursorLedger(); + final PathSchedulerReservationOwner _reservationOwner = + PathSchedulerReservationOwner(); + late final PathSchedulerResidentRunwayOwner _residentRunwayOwner; + + PathSchedulerStatus _status = PathSchedulerStatus.idle; + bool _smoothSession = true; + PathSequenceState? _build; + int _replacementSerial = 0; + + // Resident recovery is a target path without a graph-owned streaming edge. + ResidentPathTarget? _residentTarget; + + Future startBody(StartScheduledBodyInput input) async { + _requireStatus(PathSchedulerStatus.idle); + validateSchedulerId(input.state, 'source state'); + validateSchedulerId(input.path, 'scheduler path'); + validateScheduledBody(input.body); + final firstPresentationOrdinal = + input.firstPresentationOrdinal ?? BigInt.zero; + if (firstPresentationOrdinal < BigInt.zero) { + throw RangeError('first presentation ordinal must be non-negative'); + } + + _build = _cursorLedger.startSource( + state: input.state, + body: input.body, + outgoingStarts: input.outgoingStarts, + firstPresentationOrdinal: firstPresentationOrdinal, + ); + try { + await _generationOwner.start(input.path); + _status = PathSchedulerStatus.active; + _trace(PathSchedulerTraceOperation.activate, null, null); + } catch (error) { + _status = PathSchedulerStatus.error; + _smoothSession = false; + rethrow; + } + } + + Future prepareRoute( + PrepareScheduledRouteInput input, + ) async { + _requireActive(); + if (_cursorLedger.displayedSource == null || + _cursorLedger.sourceBody == null) { + throw RangeError('a source frame must be displayed before routing'); + } + if (input.edge.transition is GraphTransitionReversible) { + throw RangeError( + 'resident reversible motion is not a streaming path segment', + ); + } + validateScheduledBody(input.targetBody); + validateSchedulerId(input.targetState, 'target state'); + if (_routeOwner.committed) { + throw RangeError('a committed path cannot be replaced'); + } + + if (_routeOwner.current?.edge.id == input.edge.id) { + final current = routeDecision(); + if (current == null) { + throw StateError('pending route decision disappeared'); + } + return current; + } + if (_routeOwner.current != null) { + await _restartForReplacement( + input.replacementPath ?? input.edge.id, + input.signal, + input.preserveReservedSource == true, + ); + } + + final decision = _calculateRouteDecision(input.edge); + if (decision is SubmissionHorizonRejectReadiness) { + return decision; + } + if (decision is SubmissionHorizonRestartGeneration) { + return decision; + } + final boundary = _decisionBoundary(decision); + _routeOwner.prepare( + edge: input.edge, + targetState: input.targetState, + targetBody: input.targetBody, + boundary: boundary, + ); + final build = _requireBuild(); + if (build.phase == PathSequencePhase.done) { + build.phase = PathSequencePhase.source; + build.sourceNext = null; + } + build.sourceStop = + SourceBodyCursor(occurrence: boundary.occurrence, frame: boundary.frame); + _trace(PathSchedulerTraceOperation.routeSelect, null, input.edge.id); + return decision; + } + + /// Cancels only an uncommitted route and retains the displayed source. + Future cancelPreparedRoute( + String replacementPath, [ + AbortSignal? signal, + bool preserveReservedSource = false, + ]) async { + _requireActive(); + if (_routeOwner.current == null) return; + if (_routeOwner.committed) { + throw RangeError('a committed path cannot be cancelled'); + } + await _restartForReplacement( + replacementPath, + signal, + preserveReservedSource, + ); + } + + /// Adopts a resident body pixel only after its successful draw barrier. + Future adoptResidentBodyCheckpoint({ + required String state, + required GraphBodyDefinition body, + required List outgoingStarts, + required int frame, + required int unitInstance, + required BigInt presentationOrdinal, + required String path, + AbortSignal? signal, + }) async { + _requireActive(); + validateSchedulerId(state, 'resident checkpoint state'); + validateScheduledBody(body); + if (frame < 0 || + frame > maxSafeInteger || + frame >= body.frameCount || + presentationOrdinal < BigInt.zero) { + throw RangeError('resident body checkpoint is invalid'); + } + final replacement = _beginReplacementGeneration(path, signal); + await _settleReplacementGeneration(replacement, signal); + _routeOwner.clear(); + _residentTarget = null; + _build = _cursorLedger.replaceSource( + PathSchedulerSourceReplacementResidentCheckpoint( + state: state, + body: body, + outgoingStarts: outgoingStarts, + frame: frame, + unitInstance: unitInstance, + presentationOrdinal: presentationOrdinal, + path: path, + ), + ); + } + + SubmissionHorizonDecision? routeDecision() { + _requireActive(); + final route = _routeOwner.current; + if (route == null) return null; + final decision = _calculateRouteDecision(route.edge); + final boundary = _routeOwner.reconcileBoundary( + decision, + _requireBuild().edgeSubmissionStarted, + ); + if (boundary != null) { + _requireBuild().sourceStop = SourceBodyCursor( + occurrence: boundary.occurrence, + frame: boundary.frame, + ); + } + return decision; + } + + void commitPreparedRoute() { + _requireActive(); + final decision = routeDecision(); + if (decision is! SubmissionHorizonCommitEdge) { + throw RangeError('route cannot commit without its exact prepared lead'); + } + _routeOwner.commit(); + _trace( + PathSchedulerTraceOperation.routeCommit, + null, + _routeOwner.pendingEdge, + ); + } + + /// Reserves exact generation and resident metadata without replacing the + /// currently visible source. The returned token is the sole commit key. + PathSchedulerResidentRunwayTransaction stageResidentRunway( + StartResidentRunwayInput input, + ) { + _requireActive(); + return _residentRunwayOwner.stage(input); + } + + /// Installs a staged runway synchronously at the draw barrier. Only the + /// worker acknowledgement remains asynchronous and is returned to the lane. + PathSchedulerWorkerActivation commitResidentRunway( + PathSchedulerResidentRunwayTransaction transaction, [ + CommitResidentRunwayOptions options = const CommitResidentRunwayOptions(), + ]) { + _requireActive(); + final committed = _residentRunwayOwner.commit(transaction, options); + _build = committed.build; + _residentTarget = committed.residentTarget; + if (committed.firstPresented != null) { + _trace( + PathSchedulerTraceOperation.residentPresent, + null, + null, + committed.firstPresented, + ); + } + _trace( + PathSchedulerTraceOperation.generationRetire, + null, + committed.retiredGeneration.toString(), + ); + _trace(PathSchedulerTraceOperation.activate, null, null); + return committed.activateWorker; + } + + /// Invalidates only the matching uncommitted transaction. + bool rollbackResidentRunway( + PathSchedulerResidentRunwayTransaction transaction, + ) { + return _residentRunwayOwner.rollback(transaction); + } + + Future startResidentRunway(StartResidentRunwayInput input) async { + final transaction = stageResidentRunway(input); + final activateWorker = commitResidentRunway(transaction); + await abortablePathSchedulerActivation(activateWorker(), input.signal); + } + + Future pump([ + PathSchedulerPumpOptions options = const PathSchedulerPumpOptions(), + ]) async { + _requireActive(); + try { + return await pumpPathScheduler(PumpPathSchedulerInput( + options: options, + ringCapacity: _ringCapacity, + limits: _limits, + maxBatchSamples: _maxBatchSamples, + worker: _worker, + samples: _samples, + output: _output, + build: _requireBuild(), + buildFrame: (state) => buildNextPathFrame( + state, + PathSequenceContext( + sourceState: _cursorLedger.sourceState, + sourceBody: _cursorLedger.sourceBody, + route: _routeOwner.current, + residentTarget: _residentTarget, + canSubmitSource: (cursor) => + _sourceWithinUnresolvedHorizon(cursor), + ), + ), + commitBuild: (state) { + _build = state; + }, + recordSubmitted: (outputs) => _recordSubmitted(outputs), + onDrain: (report) => _recordDrain(report), + )); + } catch (error) { + final signal = options.signal; + if (signal != null && signal.aborted) { + throw signal.reason ?? + DOMException('the operation was aborted', 'AbortError'); + } + await _fail(error); + rethrow; + } + } + + PathSchedulerTakeResult takeNext() { + final result = reserveNext(); + if (result is PathSchedulerTakeFrame) { + commitPreparedPresentation(result.media); + } else if (result is PathSchedulerTakeResident) { + commitPreparedPresentation(result.media); + } + return result; + } + + /// Removes one ready frame from the ring without claiming it was drawn. + PathSchedulerTakeResult reserveNext([bool allowPreparedRoute = false]) { + _requireActive(); + _reservationOwner.requireEmpty(); + final resident = _output.takeResident(); + if (resident != null) { + _reservationOwner.reserve(PathSchedulerPresentationReservation( + media: resident, + output: null, + commitRoute: false, + )); + return PathSchedulerTakeResident(media: resident); + } + + return _reserveNextStreaming(allowPreparedRoute); + } + + /// Reserves the decoded continuation behind a resident runway without + /// consuming its presentation queue. Resident coordinators own those pixels. + PathSchedulerTakeResult takeStreamingContinuation() { + _requireActive(); + _reservationOwner.requireEmpty(); + return _reserveNextStreaming(false); + } + + /// Commits the sole reserved frame only after its successful draw barrier. + void commitPreparedPresentation(RuntimeMediaPresentationFrame media) { + _requireActive(); + final reserved = _reservationOwner.consume(media); + if (reserved.commitRoute) { + _routeOwner.commit(); + _trace( + PathSchedulerTraceOperation.routeCommit, + null, + _routeOwner.pendingEdge, + ); + } + if (reserved.output == null) { + _cursorLedger.recordResidentDisplayed(media); + _trace(PathSchedulerTraceOperation.residentPresent, null, null, media); + } else { + _recordDisplayed(reserved.output!, media); + _trace(PathSchedulerTraceOperation.present, reserved.output, null, media); + } + } + + /// Consumes matching resident metadata drawn by a persistent cache owner. + void commitResidentPresentation(RuntimeMediaPresentationFrame media) { + _requireActive(); + final resident = _output.takeResident(); + if (resident == null || !sameSchedulerMediaIdentity(resident, media)) { + throw RangeError('scheduler resident presentation diverged'); + } + _cursorLedger.recordResidentDisplayed(media); + _trace(PathSchedulerTraceOperation.residentPresent, null, null, media); + } + + /// Atomically adopts a completed target as the next routable source. + void promoteTargetToSource({ + required String state, + required GraphBodyDefinition body, + required List outgoingStarts, + }) { + _requireActive(); + _reservationOwner.requireEmpty(); + final routeTarget = _routeOwner.current; + final targetState = + routeTarget?.targetState ?? _residentTarget?.targetState; + final targetBody = routeTarget?.targetBody ?? _residentTarget?.targetBody; + if (targetState != state || + targetBody?.unitId != body.unitId || + _cursorLedger.displayedTarget == null) { + throw RangeError('scheduler target cannot be promoted to this source'); + } + _output.promoteTargetToSource(state, body); + promoteTargetSequenceToSource(_requireBuild(), body); + _cursorLedger.promoteTargetToSource( + state: state, + body: body, + outgoingStarts: outgoingStarts, + ); + _residentTarget = null; + _routeOwner.clear(); + } + + void discardPreparedPresentation() { + _reservationOwner.discard(); + } + + /// Records a held-body repeat that reuses the last uploaded pixels. + void commitHeldPresentation(BigInt ordinal) { + _requireActive(); + _reservationOwner.requireEmpty(); + _cursorLedger.recordHeld(ordinal); + _requireBuild().nextPresentationOrdinal = ordinal + BigInt.one; + } + + PathSchedulerTakeResult _reserveNextStreaming(bool allowPreparedRoute) { + final next = _output.peekRingOutput(); + if (next != null) { + var commitRoute = false; + if (next.plan.purpose != PathSchedulerFramePurpose.source && + _routeOwner.current != null && + !_routeOwner.committed) { + if (!allowPreparedRoute || + routeDecision() is! SubmissionHorizonCommitEdge) { + return const PathSchedulerTakeRouteBlocked(); + } + commitRoute = true; + } + if (next.plan.purpose == PathSchedulerFramePurpose.bridge && + !_lockedBridgeLeadReady()) { + return _underflow(); + } + final result = _output.takeRingOutput(); + if (result is PathSchedulerRingTakeUnderflow) { + return _underflow(); + } + final frameResult = result as PathSchedulerRingTakeFrame; + final media = _output.mediaFor(frameResult.output); + _reservationOwner.reserve(PathSchedulerPresentationReservation( + media: media, + output: frameResult.output, + commitRoute: commitRoute, + )); + return PathSchedulerTakeFrame( + purpose: next.plan.purpose, + media: media, + frame: frameResult.frame, + ); + } + + if (_output.hasExpected() || _buildHasMoreFrames()) { + return _underflow(); + } + return const PathSchedulerTakeHeld(); + } + + PathSchedulerSnapshot snapshot() { + final cursors = _cursorLedger.snapshot(); + final sourceCursor = cursors.sourceCursor; + return PathSchedulerSnapshot( + generation: _generationOwner.current, + activePath: _generationOwner.path, + sourceCursor: sourceCursor == null + ? null + : RuntimeMediaCursor( + path: _generationOwner.path ?? '', + unit: sourceCursor.unit, + unitInstance: sourceCursor.unitInstance, + localFrame: sourceCursor.localFrame, + ), + submittedCursor: cursors.submittedCursor, + decodedCursor: cursors.decodedCursor, + displayedCursor: cursors.displayedCursor, + ringSize: _output.ringSize, + ringCapacity: _ringCapacity, + smoothSession: _smoothSession, + status: _status, + pendingEdge: _routeOwner.pendingEdge, + expectedOutputs: _output.expectedCount, + residentFrames: _output.residentCount, + discardedDependencyFrames: _output.discardedDependencyFrames, + staleFrames: _output.staleFrames, + nextDecodeOrdinal: _generationOwner.nextDecodeOrdinal, + submittedSource: cursors.submittedSource, + displayedSource: cursors.displayedSource, + unresolvedMaximumSubmitted: _unresolvedMaximumSubmitted(), + ); + } + + List trace() { + return _traceLog.snapshot(); + } + + Future dispose() async { + if (_status == PathSchedulerStatus.disposed) return; + _residentRunwayOwner.clear(); + _reservationOwner.discard(); + _output.dispose(); + await _generationOwner.dispose(); + _status = PathSchedulerStatus.disposed; + _trace(PathSchedulerTraceOperation.dispose, null, null); + } + + void _recordSubmitted(List outputs) { + _cursorLedger.recordSubmitted(outputs, _requirePath()); + for (final output in outputs) { + _trace(PathSchedulerTraceOperation.submit, output, null); + } + } + + void _recordDrain(PathSchedulerOutputDrainReport report) { + _cursorLedger.recordDrain(report); + } + + SubmissionHorizonDecision _calculateRouteDecision(GraphEdgeDefinition edge) { + final body = _cursorLedger.sourceBody; + final displayed = _cursorLedger.displayedSource; + if (body == null || displayed == null) { + throw RangeError('route decision requires a displayed source cursor'); + } + return _routeOwner.decide( + edge, + PathSchedulerRouteDecisionInput( + body: body, + displayed: displayed, + submitted: _cursorLedger.submittedSource ?? displayed, + ringCapacity: _ringCapacity, + availableConsecutiveEdgeFrames: _availableEdgeLead(), + ), + ); + } + + int _availableEdgeLead() { + return _output.availableEdgeLead(); + } + + bool _lockedBridgeLeadReady() { + final transition = _routeOwner.current?.edge.transition; + if (transition is! GraphTransitionLocked) return true; + return planEdgeLead(EdgeLeadInput( + transitionFrames: transition.frameCount, + ringCapacity: _ringCapacity, + availableConsecutiveFrames: _availableEdgeLead(), + )).ready; + } + + void _recordDisplayed( + PathSchedulerExpectedOutput output, + RuntimeMediaPresentationFrame media, + ) { + if (_cursorLedger.recordDisplayed(output, media)) { + _routeOwner.noteDisplayedSource(); + } + } + + Future _restartForReplacement( + String path, [ + AbortSignal? signal, + bool preserveReservedSource = false, + ]) async { + final body = _cursorLedger.sourceBody; + final displayed = _cursorLedger.displayedSource; + if (body == null || displayed == null) { + throw RangeError('route replacement requires a displayed source'); + } + final reserved = + preserveReservedSource ? _reservationOwner.current : null; + final reservedSource = reserved?.output?.plan.sourceCursor; + if (preserveReservedSource && + (reserved == null || + reserved.output?.plan.purpose != + PathSchedulerFramePurpose.source || + reservedSource == null)) { + throw RangeError( + 'route replacement can preserve only a source reservation', + ); + } + final checkpoint = reservedSource ?? displayed; + final replacement = _beginReplacementGeneration( + path, + signal, + preserveReservedSource, + ); + _routeOwner.clear(); + final firstPresentationOrdinal = reserved == null + ? (_cursorLedger.lastDisplayedOrdinal ?? -BigInt.one) + BigInt.one + : reserved.media.intendedPresentationOrdinal + BigInt.one; + _build = _cursorLedger.replaceSource( + PathSchedulerSourceReplacementRouteRestart( + checkpoint: checkpoint, + firstPresentationOrdinal: firstPresentationOrdinal, + ), + ); + await _settleReplacementGeneration(replacement, signal); + } + + _PathSchedulerReplacementActivation _beginReplacementGeneration( + String path, [ + AbortSignal? signal, + bool preserveReservation = false, + ]) { + if (signal?.aborted == true) { + throw signal!.reason ?? + DOMException('the operation was aborted', 'AbortError'); + } + if (_residentRunwayOwner.locked) { + throw RangeError( + 'path scheduler generation is locked by a staged resident runway', + ); + } + validateSchedulerId(path, 'replacement path'); + final oldGeneration = _requireGeneration(); + if (!preserveReservation) _reservationOwner.discard(); + final serial = checkedPathSchedulerSerial(_replacementSerial); + _replacementSerial = serial; + final committed = _generationOwner.commitReplacement( + _generationOwner.planReplacement(path), + ); + return _PathSchedulerReplacementActivation( + retiredGeneration: oldGeneration, + generation: committed.generation, + serial: serial, + activation: committed.activateWorker(), + ); + } + + Future _settleReplacementGeneration( + _PathSchedulerReplacementActivation replacement, [ + AbortSignal? signal, + ]) async { + await abortablePathSchedulerActivation(replacement.activation, signal); + if (replacement.serial != _replacementSerial || + replacement.generation != _generationOwner.current) { + throw DOMException( + 'path scheduler activation was superseded', + 'AbortError', + ); + } + _trace( + PathSchedulerTraceOperation.generationRetire, + null, + replacement.retiredGeneration.toString(), + ); + _trace(PathSchedulerTraceOperation.activate, null, null); + } + + Future _fail(Object error) async { + if (_status != PathSchedulerStatus.active) return; + _smoothSession = false; + _status = PathSchedulerStatus.error; + try { + _output.clear(); + } catch (_) { + // Preserve the initiating failure; managed handles are close-once. + } + try { + await _generationOwner.abortActive(); + } catch (_) { + // Preserve the initiating failure. + } + final failureName = + error is DecoderWorkerError ? error.name : 'unknown-failure'; + _trace( + failureName.contains('Watchdog') + ? PathSchedulerTraceOperation.watchdog + : PathSchedulerTraceOperation.failure, + null, + failureName, + ); + } + + PathSchedulerTakeResult _underflow() { + _smoothSession = false; + _trace( + PathSchedulerTraceOperation.underflow, + _output.peekRingOutput(), + null, + ); + return const PathSchedulerTakeUnderflow(); + } + + bool _sourceWithinUnresolvedHorizon(SourceBodyCursor proposed) { + final body = _cursorLedger.sourceBody; + final outgoingStarts = _cursorLedger.outgoingStarts; + if (outgoingStarts.isEmpty || body == null) { + return true; + } + final displayed = _cursorLedger.displayedSource ?? + SourceBodyCursor(occurrence: BigInt.zero, frame: 0); + final result = planUnresolvedSubmissionHorizon( + UnresolvedSubmissionHorizonInput( + body: body, + displayed: displayed, + submitted: proposed, + outgoingStarts: outgoingStarts, + ringCapacity: _ringCapacity, + ), + ); + return result.submittedWithinHorizon; + } + + SourceBodyCursor? _unresolvedMaximumSubmitted() { + final body = _cursorLedger.sourceBody; + final outgoingStarts = _cursorLedger.outgoingStarts; + if (body == null || + outgoingStarts.isEmpty || + _routeOwner.current != null) { + return null; + } + final displayed = _cursorLedger.displayedSource ?? + SourceBodyCursor(occurrence: BigInt.zero, frame: 0); + final submitted = _cursorLedger.submittedSource ?? displayed; + try { + return planUnresolvedSubmissionHorizon( + UnresolvedSubmissionHorizonInput( + body: body, + displayed: displayed, + submitted: submitted, + outgoingStarts: outgoingStarts, + ringCapacity: _ringCapacity, + ), + ).maximumSubmitted; + } catch (_) { + return null; + } + } + + bool _buildHasMoreFrames() { + final build = _build; + return build != null && build.phase != PathSequencePhase.done; + } + + void _trace( + PathSchedulerTraceOperation operation, + PathSchedulerExpectedOutput? output, + String? reason, [ + RuntimeMediaPresentationFrame? media, + ]) { + _traceLog.append(PathSchedulerTraceInput( + operation: operation, + generation: _generationOwner.current, + path: _generationOwner.path, + unit: output?.sample.unitId ?? media?.frame.unit, + unitInstance: output?.sample.unitInstance ?? media?.unitInstance, + unitFrame: output?.sample.unitFrame ?? media?.frame.localFrame, + decodeOrdinal: output?.sample.ordinal ?? media?.decodeOrdinal, + intendedPresentationOrdinal: output?.plan.intendedPresentationOrdinal ?? + media?.intendedPresentationOrdinal, + ringSize: _output.ringSize, + expectedOutputs: _output.expectedCount, + reason: reason, + )); + } + + void _requireStatus(PathSchedulerStatus expected) { + if (_status != expected) { + throw RangeError('path scheduler must be ${expected.wireValue}'); + } + } + + void _requireActive() { + _requireStatus(PathSchedulerStatus.active); + } + + int _requireGeneration() { + return _generationOwner.requireGeneration(); + } + + String _requirePath() { + return _generationOwner.requirePath(); + } + + PathSequenceState _requireBuild() { + final build = _build; + if (build == null) { + throw RangeError('path scheduler has no active build state'); + } + return build; + } +} + +/// Extracts the source boundary from a decision that is neither a readiness +/// rejection nor a cut restart (both handled by the caller before this runs). +SourceBoundary _decisionBoundary(SubmissionHorizonDecision decision) { + return switch (decision) { + SubmissionHorizonContinueSource(:final boundary) => boundary, + SubmissionHorizonSelectPortal(:final boundary) => boundary, + SubmissionHorizonCommitEdge(:final boundary) => boundary, + SubmissionHorizonWaitHeld(:final boundary) => boundary, + SubmissionHorizonRejectReadiness() => + throw StateError('reject-readiness has no boundary'), + SubmissionHorizonRestartGeneration() => + throw StateError('restart-generation has no boundary'), + }; +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_cursor_ledger.dart b/flutter/packages/aval_player/lib/src/path_scheduler_cursor_ledger.dart new file mode 100644 index 0000000..716c94e --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_cursor_ledger.dart @@ -0,0 +1,352 @@ +/// Canonical source identity and decode/presentation cursor ledger. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-cursor-ledger.ts`. +/// The TS `PathSchedulerSourceReplacement` discriminated union becomes a +/// sealed-class hierarchy; `bigint` ordinals become `BigInt`. The TS `snapshot` +/// record becomes [PathSchedulerCursorLedgerSnapshot]. Defensive cursor clones +/// (`{ ...cursor }`) are pass-throughs — `SourceBodyCursor`/`RuntimeMediaCursor` +/// are immutable value types. +library; + +import 'package:aval_graph/aval_graph.dart'; + +import 'model.dart'; +import 'path_scheduler_identity.dart'; +import 'path_scheduler_output.dart' + show PathSchedulerExpectedOutput, PathSchedulerOutputDrainReport; +import 'path_sequence.dart'; +import 'submission_horizon.dart' show SourceBodyCursor; + +/// How a generation replacement reseeds the source cursor ledger. +sealed class PathSchedulerSourceReplacement { + const PathSchedulerSourceReplacement(); +} + +class PathSchedulerSourceReplacementRouteRestart + extends PathSchedulerSourceReplacement { + const PathSchedulerSourceReplacementRouteRestart({ + required this.checkpoint, + required this.firstPresentationOrdinal, + }); + + final SourceBodyCursor checkpoint; + final BigInt firstPresentationOrdinal; +} + +class PathSchedulerSourceReplacementResidentCheckpoint + extends PathSchedulerSourceReplacement { + const PathSchedulerSourceReplacementResidentCheckpoint({ + required this.state, + required this.body, + required this.outgoingStarts, + required this.frame, + required this.unitInstance, + required this.presentationOrdinal, + required this.path, + }); + + final String state; + final GraphBodyDefinition body; + final List outgoingStarts; + final int frame; + final int unitInstance; + final BigInt presentationOrdinal; + final String path; +} + +class PathSchedulerSourceReplacementResidentRunway + extends PathSchedulerSourceReplacement { + const PathSchedulerSourceReplacementResidentRunway({ + required this.targetState, + required this.targetBody, + required this.runwayFrames, + required this.firstPresentationOrdinal, + }); + + final String targetState; + final GraphBodyDefinition targetBody; + final int runwayFrames; + final BigInt firstPresentationOrdinal; +} + +/// The read-only projection [PathSchedulerCursorLedger.snapshot] returns. +class PathSchedulerCursorLedgerSnapshot { + const PathSchedulerCursorLedgerSnapshot({ + required this.sourceCursor, + required this.submittedCursor, + required this.decodedCursor, + required this.displayedCursor, + required this.submittedSource, + required this.displayedSource, + }); + + final RuntimeMediaCursor? sourceCursor; + final RuntimeMediaCursor? submittedCursor; + final RuntimeMediaCursor? decodedCursor; + final RuntimeMediaCursor? displayedCursor; + final SourceBodyCursor? submittedSource; + final SourceBodyCursor? displayedSource; +} + +/// Canonical source identity and decode/presentation cursor ledger. Every +/// generation replacement resets this state through [replaceSource]. +class PathSchedulerCursorLedger { + String? _sourceState; + GraphBodyDefinition? _sourceBody; + List _outgoingStarts = const []; + SourceBodyCursor? _submittedSource; + SourceBodyCursor? _decodedSource; + SourceBodyCursor? _displayedSource; + SourceBodyCursor? _submittedTarget; + SourceBodyCursor? _decodedTarget; + SourceBodyCursor? _displayedTarget; + RuntimeMediaCursor? _submittedCursor; + RuntimeMediaCursor? _decodedCursor; + RuntimeMediaCursor? _displayedCursor; + BigInt? _lastDisplayedOrdinal; + + String? get sourceState => _sourceState; + + GraphBodyDefinition? get sourceBody => _sourceBody; + + List get outgoingStarts => _outgoingStarts; + + SourceBodyCursor? get submittedSource => _submittedSource; + + SourceBodyCursor? get decodedSource => _decodedSource; + + SourceBodyCursor? get displayedSource => _displayedSource; + + SourceBodyCursor? get submittedTarget => _submittedTarget; + + SourceBodyCursor? get decodedTarget => _decodedTarget; + + SourceBodyCursor? get displayedTarget => _displayedTarget; + + BigInt? get lastDisplayedOrdinal => _lastDisplayedOrdinal; + + PathSequenceState startSource({ + required String state, + required GraphBodyDefinition body, + required List outgoingStarts, + required BigInt firstPresentationOrdinal, + }) { + _sourceState = state; + _sourceBody = body; + _outgoingStarts = List.unmodifiable(outgoingStarts); + return createSourcePathSequence(firstPresentationOrdinal); + } + + PathSequenceState replaceSource(PathSchedulerSourceReplacement input) { + switch (input) { + case PathSchedulerSourceReplacementRouteRestart(): + return _replaceRouteSource(input); + case PathSchedulerSourceReplacementResidentCheckpoint(): + return _replaceResidentCheckpoint(input); + case PathSchedulerSourceReplacementResidentRunway(): + return _replaceResidentRunway(input); + } + } + + void recordSubmitted( + List outputs, + String path, + ) { + for (final output in outputs) { + _submittedCursor = RuntimeMediaCursor( + path: path, + unit: output.sample.unitId, + unitInstance: output.sample.unitInstance, + localFrame: output.sample.unitFrame, + ); + if (output.plan.sourceCursor != null && !output.plan.discard) { + _submittedSource = output.plan.sourceCursor; + } + if (output.plan.targetCursor != null && !output.plan.discard) { + _submittedTarget = output.plan.targetCursor; + } + } + } + + void recordDrain(PathSchedulerOutputDrainReport report) { + if (report.decodedCursor != null) { + _decodedCursor = report.decodedCursor; + } + if (report.decodedSource != null) { + _decodedSource = report.decodedSource; + } + if (report.decodedTarget != null) { + _decodedTarget = report.decodedTarget; + } + } + + /// Returns true when route wait accounting must advance. + bool recordDisplayed( + PathSchedulerExpectedOutput output, + RuntimeMediaPresentationFrame media, + ) { + _displayedCursor = schedulerMediaCursor(media); + _lastDisplayedOrdinal = media.intendedPresentationOrdinal; + var displayedSource = false; + if (output.plan.sourceCursor != null) { + _displayedSource = output.plan.sourceCursor; + displayedSource = true; + } + if (output.plan.targetCursor != null) { + _displayedTarget = output.plan.targetCursor; + } + return displayedSource; + } + + void recordResidentDisplayed(RuntimeMediaPresentationFrame media) { + _lastDisplayedOrdinal = media.intendedPresentationOrdinal; + _displayedCursor = schedulerMediaCursor(media); + } + + void recordHeld(BigInt ordinal) { + if (ordinal < BigInt.zero || _displayedSource == null) { + throw RangeError('scheduler held presentation is invalid'); + } + _lastDisplayedOrdinal = ordinal; + } + + void promoteTargetToSource({ + required String state, + required GraphBodyDefinition body, + required List outgoingStarts, + }) { + final displayed = _displayedTarget; + if (displayed == null) { + throw RangeError('scheduler has no displayed target to promote'); + } + _sourceState = state; + _sourceBody = body; + _outgoingStarts = List.unmodifiable(outgoingStarts); + _submittedSource = _promotedSourceCursor( + _submittedTarget ?? displayed, + body, + ); + _decodedSource = _promotedSourceCursor( + _decodedTarget ?? displayed, + body, + ); + _displayedSource = _promotedSourceCursor(displayed, body); + _submittedTarget = null; + _decodedTarget = null; + _displayedTarget = null; + } + + PathSchedulerCursorLedgerSnapshot snapshot() { + return PathSchedulerCursorLedgerSnapshot( + sourceCursor: _displayedSource == null || _sourceBody == null + ? null + : RuntimeMediaCursor( + path: _displayedCursor?.path ?? '', + unit: _sourceBody!.unitId, + unitInstance: _displayedCursor?.unitInstance ?? 0, + localFrame: _displayedSource!.frame, + ), + submittedCursor: freezeSchedulerCursor(_submittedCursor), + decodedCursor: freezeSchedulerCursor(_decodedCursor), + displayedCursor: freezeSchedulerCursor(_displayedCursor), + submittedSource: freezeSchedulerSourceCursor(_submittedSource), + displayedSource: freezeSchedulerSourceCursor(_displayedSource), + ); + } + + PathSequenceState _replaceRouteSource( + PathSchedulerSourceReplacementRouteRestart input, + ) { + final body = _sourceBody; + if (body == null) { + throw RangeError('route replacement has no source body'); + } + final checkpoint = input.checkpoint; + final next = nextBodyCursor(body, checkpoint); + _submittedSource = checkpoint; + _decodedSource = checkpoint; + _submittedTarget = null; + _decodedTarget = null; + _displayedTarget = null; + _submittedCursor = null; + _decodedCursor = null; + if (next == null) { + final terminal = createSourcePathSequence(input.firstPresentationOrdinal); + terminal.sourceNext = null; + return terminal; + } + return createReplacementPathSequence( + nextSource: next, + firstPresentationOrdinal: input.firstPresentationOrdinal, + ); + } + + PathSequenceState _replaceResidentCheckpoint( + PathSchedulerSourceReplacementResidentCheckpoint input, + ) { + final displayed = + SourceBodyCursor(occurrence: BigInt.zero, frame: input.frame); + final next = nextBodyCursor(input.body, displayed); + _sourceState = input.state; + _sourceBody = input.body; + _outgoingStarts = List.unmodifiable(input.outgoingStarts); + _submittedSource = displayed; + _decodedSource = displayed; + _displayedSource = displayed; + _submittedTarget = null; + _decodedTarget = null; + _displayedTarget = null; + _submittedCursor = null; + _decodedCursor = null; + _displayedCursor = RuntimeMediaCursor( + path: input.path, + unit: input.body.unitId, + unitInstance: input.unitInstance, + localFrame: input.frame, + ); + _lastDisplayedOrdinal = input.presentationOrdinal; + if (next == null) { + final terminal = + createSourcePathSequence(input.presentationOrdinal + BigInt.one); + terminal.sourceNext = null; + return terminal; + } + return createReplacementPathSequence( + nextSource: next, + firstPresentationOrdinal: input.presentationOrdinal + BigInt.one, + ); + } + + PathSequenceState _replaceResidentRunway( + PathSchedulerSourceReplacementResidentRunway input, + ) { + _sourceState = input.targetState; + _sourceBody = input.targetBody; + _outgoingStarts = const []; + _submittedSource = null; + _decodedSource = null; + _displayedSource = null; + _submittedTarget = null; + _decodedTarget = null; + _displayedTarget = null; + _submittedCursor = null; + _decodedCursor = null; + return createResidentContinuationSequence( + runwayFrames: input.runwayFrames, + targetBody: input.targetBody, + firstStreamingPresentationOrdinal: + input.firstPresentationOrdinal + BigInt.from(input.runwayFrames), + ); + } +} + +SourceBodyCursor _promotedSourceCursor( + SourceBodyCursor cursor, + GraphBodyDefinition body, +) { + return SourceBodyCursor( + occurrence: + body.kind == GraphBodyKind.loop ? cursor.occurrence : BigInt.zero, + frame: cursor.frame, + ); +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_generation.dart b/flutter/packages/aval_player/lib/src/path_scheduler_generation.dart new file mode 100644 index 0000000..4e4e77f --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_generation.dart @@ -0,0 +1,210 @@ +/// Decoder generation/path tokens and their worker/ring activation order. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-generation.ts`. +/// TS `Promise` → `Future`; `AbortSignal.reason`/`addEventListener` +/// use the extended platform seam in `platform.dart`. The lazy `activateWorker` +/// closure memoizes exactly as the TS version, converting a synchronous throw +/// into a rejected future (`Promise.reject`). `Number.MAX_SAFE_INTEGER` bounds +/// map onto `maxSafeInteger` from `rational_time.dart`. +library; + +import 'dart:async'; + +import 'decode_timeline.dart'; +import 'path_scheduler_model.dart' + show PathSchedulerWorkerActivation, PathSchedulerWorkerAdapter; +import 'path_scheduler_output.dart' show PathSchedulerOutput; +import 'platform.dart'; +import 'rational_time.dart' show maxSafeInteger; + +/// Construction options for [PathSchedulerGeneration]. +class PathSchedulerGenerationOptions { + const PathSchedulerGenerationOptions({ + required this.timeline, + required this.worker, + required this.output, + }); + + final DecodeTimeline timeline; + final PathSchedulerWorkerAdapter worker; + final PathSchedulerOutput output; +} + +/// A reserved replacement identity: which generation retires, which is next. +class PathSchedulerGenerationPlan { + const PathSchedulerGenerationPlan({ + required this.retiredGeneration, + required this.generation, + required this.path, + }); + + final int retiredGeneration; + final int generation; + final String path; +} + +/// A committed replacement plus the pending worker acknowledgement. +class PathSchedulerGenerationCommit { + const PathSchedulerGenerationCommit({ + required this.retiredGeneration, + required this.generation, + required this.path, + required this.activateWorker, + }); + + final int retiredGeneration; + final int generation; + final String path; + final PathSchedulerWorkerActivation activateWorker; +} + +/// Owns decoder generation/path tokens and their worker/ring activation order. +class PathSchedulerGeneration { + PathSchedulerGeneration(PathSchedulerGenerationOptions options) + : _timeline = options.timeline, + _worker = options.worker, + _output = options.output; + + final DecodeTimeline _timeline; + final PathSchedulerWorkerAdapter _worker; + final PathSchedulerOutput _output; + + int? _generation; + String? _path; + + int? get current => _generation; + + String? get path => _path; + + int get nextDecodeOrdinal => _timeline.snapshot().nextOrdinal; + + Future start(String path) async { + if (_generation != null) { + throw RangeError('path scheduler generation already started'); + } + final generation = _timeline.activateNextGeneration(); + _generation = generation; + _path = path; + _output.start(generation, path); + await _worker.activateGeneration(generation); + return generation; + } + + /// Reserves identity only; the active generation remains unchanged. + PathSchedulerGenerationPlan planReplacement(String path) { + final retiredGeneration = requireGeneration(); + if (retiredGeneration >= maxSafeInteger) { + throw RangeError('decode generation exceeds the safe-integer range'); + } + return PathSchedulerGenerationPlan( + retiredGeneration: retiredGeneration, + generation: retiredGeneration + 1, + path: path, + ); + } + + /// Synchronously installs the exact planned identity, returning only the + /// worker acknowledgement that an external operation lane must await. + PathSchedulerGenerationCommit commitReplacement( + PathSchedulerGenerationPlan plan, + ) { + if (_generation != plan.retiredGeneration) { + throw RangeError('planned path scheduler generation became stale'); + } + final generation = _timeline.activateNextGeneration(); + if (generation != plan.generation) { + throw RangeError('decode timeline diverged from its reserved generation'); + } + _output.activate(generation, plan.path); + _generation = generation; + _path = plan.path; + Future? activation; + Future activateWorker() { + if (activation != null) return activation!; + try { + activation = _worker.activateGeneration(generation); + } catch (error) { + activation = Future.error(error); + } + return activation!; + } + + return PathSchedulerGenerationCommit( + retiredGeneration: plan.retiredGeneration, + generation: generation, + path: plan.path, + activateWorker: activateWorker, + ); + } + + Future abortActive() async { + final generation = _generation; + if (generation != null && _worker.activeGeneration == generation) { + await _worker.abortGeneration(generation); + } + } + + Future dispose() async { + await abortActive(); + _generation = null; + _path = null; + } + + int requireGeneration() { + final generation = _generation; + if (generation == null) { + throw RangeError('path scheduler has no active generation'); + } + return generation; + } + + String requirePath() { + final path = _path; + if (path == null) { + throw RangeError('path scheduler has no active path'); + } + return path; + } +} + +Future abortablePathSchedulerActivation( + Future activation, [ + AbortSignal? signal, +]) { + if (signal == null) return activation; + if (signal.aborted) { + // Swallow the superseded activation's outcome; reject with the reason. + activation.then((_) {}, onError: (_) {}); + return Future.error(signal.reason ?? _defaultAbortReason()); + } + final completer = Completer(); + late void Function() abort; + abort = () { + signal.removeEventListener('abort', abort); + if (!completer.isCompleted) { + completer.completeError(signal.reason ?? _defaultAbortReason()); + } + }; + signal.addEventListener('abort', abort, once: true); + activation.then( + (value) { + signal.removeEventListener('abort', abort); + if (!completer.isCompleted) completer.complete(value); + }, + onError: (Object error) { + signal.removeEventListener('abort', abort); + if (!completer.isCompleted) completer.completeError(error); + }, + ); + return completer.future; +} + +Object _defaultAbortReason() => + DOMException('the operation was aborted', 'AbortError'); + +int checkedPathSchedulerSerial(int value) { + if (value < 0 || value >= maxSafeInteger) { + throw RangeError('scheduler replacement serial exceeded the safe range'); + } + return value + 1; +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_identity.dart b/flutter/packages/aval_player/lib/src/path_scheduler_identity.dart new file mode 100644 index 0000000..2c1b820 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_identity.dart @@ -0,0 +1,28 @@ +/// Cursor identity/freeze helpers for the path scheduler. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-identity.ts`. +/// The TS helpers `Object.freeze` a shallow clone; `RuntimeMediaCursor` and +/// `SourceBodyCursor` are already immutable value types here, so the "freeze" +/// helpers return their argument unchanged (a documented, behavior-identical +/// no-op — no consumer mutates the returned cursor). +library; + +import 'model.dart'; +import 'submission_horizon.dart' show SourceBodyCursor; + +RuntimeMediaCursor schedulerMediaCursor(RuntimeMediaPresentationFrame media) { + return RuntimeMediaCursor( + path: media.path, + unit: media.frame.unit, + unitInstance: media.unitInstance, + localFrame: media.frame.localFrame, + ); +} + +RuntimeMediaCursor? freezeSchedulerCursor(RuntimeMediaCursor? cursor) { + return cursor; +} + +SourceBodyCursor? freezeSchedulerSourceCursor(SourceBodyCursor? cursor) { + return cursor; +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_model.dart b/flutter/packages/aval_player/lib/src/path_scheduler_model.dart new file mode 100644 index 0000000..2f4cc99 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_model.dart @@ -0,0 +1,420 @@ +/// Shared types for the path-scheduler family. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-model.ts`. +/// These are the frozen contracts the follow-on `path-scheduler.ts` port binds +/// against. TypeScript behavioral interfaces (`PathSchedulerWorkerAdapter`, +/// `PathSchedulerClock`) become `abstract interface class`es; data-shape +/// interfaces become immutable classes; discriminated unions +/// (`PathSchedulerTakeResult`) become sealed-class hierarchies carrying the +/// same `kind` wire string. `Promise` becomes `Future`; `bigint` +/// becomes `BigInt`; `number` becomes `int`. +/// +/// Cross-module type references resolve to: `aval_graph` (graph definitions); +/// the partial ports in `decoder_worker/protocol.dart`, +/// `decoder_worker/client_support.dart`, `model.dart`, `worker_samples.dart`; +/// `decode_timeline.dart` and `submission_horizon.dart` from this task; and the +/// `platform.dart` seams (`AbortSignal`). +library; + +import 'package:aval_graph/aval_graph.dart'; + +import 'decode_timeline.dart'; +import 'decoder_worker/client_support.dart'; +import 'decoder_worker/protocol.dart'; +import 'model.dart'; +import 'platform.dart'; +import 'submission_horizon.dart'; +import 'worker_samples.dart'; + +/// Lifecycle status of the path scheduler. +enum PathSchedulerStatus { + idle('idle'), + active('active'), + error('error'), + disposed('disposed'); + + const PathSchedulerStatus(this.wireValue); + + final String wireValue; +} + +/// Which segment of a path a scheduled frame belongs to. +enum PathSchedulerFramePurpose { + source('source'), + bridge('bridge'), + target('target'); + + const PathSchedulerFramePurpose(this.wireValue); + + final String wireValue; +} + +/// Decoder-worker surface required by the active-path scheduler. +abstract interface class PathSchedulerWorkerAdapter { + int? get activeGeneration; + int get queuedFrames; + int get openFrames; + Future activateGeneration(int generation); + Future submit(int generation, List samples); + Future abortGeneration(int generation); + ManagedDecoderWorkerFrame? takeFrame(); + Future waitForFrames([int? minimum, DecoderWorkerWaitOptions? options]); + Future snapshotMetrics(); +} + +/// Monotonic clock the scheduler reads. +abstract interface class PathSchedulerClock { + int now(); +} + +/// Construction options for the path scheduler. +class PathSchedulerOptions { + const PathSchedulerOptions({ + required this.timeline, + required this.samples, + required this.worker, + required this.rendition, + required this.ringCapacity, + required this.limits, + required this.clock, + this.maxBatchSamples, + }); + + final DecodeTimeline timeline; + final WorkerSampleFactory samples; + final PathSchedulerWorkerAdapter worker; + final String rendition; + final int ringCapacity; + final DecoderWorkerLimits limits; + final PathSchedulerClock clock; + final int? maxBatchSamples; +} + +/// Input to begin a scheduled body. +class StartScheduledBodyInput { + const StartScheduledBodyInput({ + required this.state, + required this.body, + required this.outgoingStarts, + required this.path, + this.firstPresentationOrdinal, + }); + + final String state; + final GraphBodyDefinition body; + final List outgoingStarts; + final String path; + final BigInt? firstPresentationOrdinal; +} + +/// Input to prepare a scheduled route. +class PrepareScheduledRouteInput { + const PrepareScheduledRouteInput({ + required this.edge, + required this.targetState, + required this.targetBody, + this.replacementPath, + this.signal, + this.preserveReservedSource, + }); + + final GraphEdgeDefinition edge; + final String targetState; + final GraphBodyDefinition targetBody; + final String? replacementPath; + final AbortSignal? signal; + + /// Keeps an uploaded source reservation across pending-route replacement. + final bool? preserveReservedSource; +} + +/// One resident frame supplied to a resident runway. +class PathSchedulerResidentFrame { + const PathSchedulerResidentFrame({ + required this.frame, + required this.unitInstance, + required this.decodeOrdinal, + required this.timestamp, + }); + + final RuntimeFrameKey frame; + final int unitInstance; + final int decodeOrdinal; + final int timestamp; +} + +/// Input to begin a resident runway. +class StartResidentRunwayInput { + const StartResidentRunwayInput({ + required this.edgeId, + required this.targetState, + required this.targetBody, + required this.frames, + required this.path, + this.signal, + this.firstPresentationOrdinal, + }); + + final String edgeId; + final String targetState; + final GraphBodyDefinition targetBody; + final List frames; + final String path; + final AbortSignal? signal; + final BigInt? firstPresentationOrdinal; +} + +/// Scheduler-issued, identity-stable reservation for one resident runway. +class PathSchedulerResidentRunwayTransaction { + const PathSchedulerResidentRunwayTransaction({ + required this.generation, + required this.path, + required this.edgeId, + required this.targetState, + required this.media, + }); + + final int generation; + final String path; + final String edgeId; + final String targetState; + final List media; +} + +/// Options for committing a resident runway. +class CommitResidentRunwayOptions { + const CommitResidentRunwayOptions({this.alreadyPresented}); + + /// Browser draw-barrier commits frame zero; compatibility activation uses 0. + /// Restricted to `0 | 1` in the TypeScript source. + final int? alreadyPresented; +} + +/// One-shot lazy worker activation; invoke only inside the media lane. +typedef PathSchedulerWorkerActivation = Future Function(); + +/// Options for one pump step. +class PathSchedulerPumpOptions { + const PathSchedulerPumpOptions({ + this.targetRingFrames, + this.signal, + this.timeoutMs, + }); + + final int? targetRingFrames; + final AbortSignal? signal; + final int? timeoutMs; +} + +/// Report from one pump step. +class PathSchedulerPumpReport { + const PathSchedulerPumpReport({ + required this.submittedFrames, + required this.decodedFrames, + required this.discardedFrames, + required this.staleFrames, + required this.waits, + required this.ringSize, + required this.expectedOutputs, + }); + + final int submittedFrames; + final int decodedFrames; + final int discardedFrames; + final int staleFrames; + final int waits; + final int ringSize; + final int expectedOutputs; +} + +/// Result of taking the next presentation from the scheduler. +sealed class PathSchedulerTakeResult { + const PathSchedulerTakeResult(); + + String get kind; +} + +class PathSchedulerTakeFrame extends PathSchedulerTakeResult { + const PathSchedulerTakeFrame({ + required this.purpose, + required this.media, + required this.frame, + }); + + final PathSchedulerFramePurpose purpose; + final RuntimeMediaPresentationFrame media; + final ManagedDecoderWorkerFrame frame; + + @override + String get kind => 'frame'; +} + +class PathSchedulerTakeResident extends PathSchedulerTakeResult { + const PathSchedulerTakeResident({required this.media}); + + final RuntimeMediaPresentationFrame media; + + @override + String get kind => 'resident'; +} + +class PathSchedulerTakeRouteBlocked extends PathSchedulerTakeResult { + const PathSchedulerTakeRouteBlocked(); + + @override + String get kind => 'route-blocked'; +} + +class PathSchedulerTakeUnderflow extends PathSchedulerTakeResult { + const PathSchedulerTakeUnderflow(); + + @override + String get kind => 'underflow'; +} + +class PathSchedulerTakeHeld extends PathSchedulerTakeResult { + const PathSchedulerTakeHeld(); + + @override + String get kind => 'held'; +} + +/// Bounded operation-trace operation kinds. +enum PathSchedulerTraceOperation { + activate('activate'), + submit('submit'), + output('output'), + discardOutput('discard-output'), + staleOutput('stale-output'), + present('present'), + residentPresent('resident-present'), + routeSelect('route-select'), + routeCommit('route-commit'), + generationRetire('generation-retire'), + underflow('underflow'), + watchdog('watchdog'), + failure('failure'), + dispose('dispose'); + + const PathSchedulerTraceOperation(this.wireValue); + + final String wireValue; +} + +/// One retained entry of the scheduler's bounded operation trace. +class PathSchedulerTraceRecord { + const PathSchedulerTraceRecord({ + required this.index, + required this.operation, + required this.generation, + required this.path, + required this.unit, + required this.unitInstance, + required this.unitFrame, + required this.decodeOrdinal, + required this.intendedPresentationOrdinal, + required this.ringSize, + required this.expectedOutputs, + required this.reason, + }); + + final int index; + final PathSchedulerTraceOperation operation; + final int? generation; + final String? path; + final String? unit; + final int? unitInstance; + final int? unitFrame; + final int? decodeOrdinal; + final BigInt? intendedPresentationOrdinal; + final int ringSize; + final int expectedOutputs; + final String? reason; +} + +/// Observable scheduler snapshot, extending the runtime scheduler snapshot. +class PathSchedulerSnapshot extends RuntimeSchedulerSnapshot { + const PathSchedulerSnapshot({ + required super.generation, + required super.activePath, + required super.sourceCursor, + required super.submittedCursor, + required super.decodedCursor, + required super.displayedCursor, + required super.ringSize, + required super.ringCapacity, + required super.smoothSession, + required this.status, + required this.pendingEdge, + required this.expectedOutputs, + required this.residentFrames, + required this.discardedDependencyFrames, + required this.staleFrames, + required this.nextDecodeOrdinal, + required this.submittedSource, + required this.displayedSource, + required this.unresolvedMaximumSubmitted, + }); + + final PathSchedulerStatus status; + final String? pendingEdge; + final int expectedOutputs; + final int residentFrames; + final int discardedDependencyFrames; + final int staleFrames; + final int nextDecodeOrdinal; + final SourceBodyCursor? submittedSource; + final SourceBodyCursor? displayedSource; + final SourceBodyCursor? unresolvedMaximumSubmitted; + + // Value equality (extension over the frozen surface): the ported + // `path-scheduler.test.ts` compares whole snapshots structurally + // (`toEqual(before)`), which the TS `Object.freeze`d plain object supports by + // deep comparison. `RuntimeMediaCursor`/`SourceBodyCursor` already define + // value equality, so a field-wise comparison here is exact. + @override + bool operator ==(Object other) => + other is PathSchedulerSnapshot && + other.generation == generation && + other.activePath == activePath && + other.sourceCursor == sourceCursor && + other.submittedCursor == submittedCursor && + other.decodedCursor == decodedCursor && + other.displayedCursor == displayedCursor && + other.ringSize == ringSize && + other.ringCapacity == ringCapacity && + other.smoothSession == smoothSession && + other.status == status && + other.pendingEdge == pendingEdge && + other.expectedOutputs == expectedOutputs && + other.residentFrames == residentFrames && + other.discardedDependencyFrames == discardedDependencyFrames && + other.staleFrames == staleFrames && + other.nextDecodeOrdinal == nextDecodeOrdinal && + other.submittedSource == submittedSource && + other.displayedSource == displayedSource && + other.unresolvedMaximumSubmitted == unresolvedMaximumSubmitted; + + @override + int get hashCode => Object.hashAll([ + generation, + activePath, + sourceCursor, + submittedCursor, + decodedCursor, + displayedCursor, + ringSize, + ringCapacity, + smoothSession, + status, + pendingEdge, + expectedOutputs, + residentFrames, + discardedDependencyFrames, + staleFrames, + nextDecodeOrdinal, + submittedSource, + displayedSource, + unresolvedMaximumSubmitted, + ]); +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_output.dart b/flutter/packages/aval_player/lib/src/path_scheduler_output.dart new file mode 100644 index 0000000..cb4e634 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_output.dart @@ -0,0 +1,470 @@ +/// Decoder-output expectations, resident frames, and the streaming ring. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-output.ts`. +/// The TS discriminated union `PathSchedulerRingTakeResult` becomes a +/// sealed-class hierarchy; `Object.freeze`d records become immutable classes. +/// The `PathSchedulerOutputTrace` operation string union maps onto the shared +/// [PathSchedulerTraceOperation] enum (only `output`/`discard-output`/ +/// `stale-output` are emitted here). +library; + +import 'package:aval_graph/aval_graph.dart'; + +import 'decoder_worker/client_support.dart'; +import 'decoder_worker/protocol.dart'; +import 'model.dart'; +import 'path_scheduler_model.dart'; +import 'path_sequence.dart'; +import 'presentation_ring.dart'; +import 'submission_horizon.dart' show SourceBodyCursor; + +/// One planned output joined to its submitted sample and ring identity. +class PathSchedulerExpectedOutput { + const PathSchedulerExpectedOutput({ + required this.plan, + required this.sample, + required this.expected, + }); + + final PathFramePlan plan; + final DecoderWorkerSample sample; + final PresentationRingExpectedFrame? expected; +} + +/// The result of draining decoder output into the ring. +class PathSchedulerOutputDrainReport { + const PathSchedulerOutputDrainReport({ + required this.decodedFrames, + required this.discardedFrames, + required this.staleFrames, + required this.decodedCursor, + required this.decodedSource, + required this.decodedTarget, + }); + + final int decodedFrames; + final int discardedFrames; + final int staleFrames; + final RuntimeMediaCursor? decodedCursor; + final SourceBodyCursor? decodedSource; + final SourceBodyCursor? decodedTarget; +} + +/// Callback the output owner uses to trace drained frames. +typedef PathSchedulerOutputTrace = void Function( + PathSchedulerTraceOperation operation, + PathSchedulerExpectedOutput? output, + String? reason, +); + +/// Result of [PathSchedulerOutput.takeRingOutput]. +sealed class PathSchedulerRingTakeResult { + const PathSchedulerRingTakeResult(); + + String get kind; +} + +class PathSchedulerRingTakeUnderflow extends PathSchedulerRingTakeResult { + const PathSchedulerRingTakeUnderflow(); + + @override + String get kind => 'underflow'; +} + +class PathSchedulerRingTakeFrame extends PathSchedulerRingTakeResult { + const PathSchedulerRingTakeFrame({required this.output, required this.frame}); + + final PathSchedulerExpectedOutput output; + final ManagedDecoderWorkerFrame frame; + + @override + String get kind => 'frame'; +} + +/// Construction options for [PathSchedulerOutput]. +class PathSchedulerOutputOptions { + const PathSchedulerOutputOptions({ + required this.worker, + required this.rendition, + required this.ringCapacity, + required this.clock, + required this.onTrace, + }); + + final PathSchedulerWorkerAdapter worker; + final String rendition; + final int ringCapacity; + final PathSchedulerClock clock; + final PathSchedulerOutputTrace onTrace; +} + +/// Owns decoder-output expectations, resident frames, and the streaming ring. +class PathSchedulerOutput { + PathSchedulerOutput(PathSchedulerOutputOptions options) + : _worker = options.worker, + _rendition = options.rendition, + _ringCapacity = options.ringCapacity, + _clock = options.clock, + _onTrace = options.onTrace; + + final PathSchedulerWorkerAdapter _worker; + final String _rendition; + final int _ringCapacity; + final PathSchedulerClock _clock; + final PathSchedulerOutputTrace _onTrace; + final List _expected = + []; + final List _ringPlans = + []; + final List _resident = + []; + + PresentationRing? _ring; + int? _generation; + String? _path; + int _discardedDependencyFrames = 0; + int _staleFrames = 0; + + int get expectedCount => _expected.length; + + int get residentCount => _resident.length; + + int get discardedDependencyFrames => _discardedDependencyFrames; + + int get staleFrames => _staleFrames; + + int get ringSize => _ring?.snapshot().size ?? 0; + + void start(int generation, String path) { + if (_ring != null) { + throw RangeError('path scheduler output already has a ring'); + } + _generation = generation; + _path = path; + _ring = PresentationRing(PresentationRingOptions( + capacity: _ringCapacity, + generation: generation, + path: path, + )); + } + + void activate(int generation, String path) { + final ring = _requireRing(); + _clearQueues(); + ring.activatePath(generation: generation, path: path); + _generation = generation; + _path = path; + } + + void clear() { + try { + _ring?.clear(); + } finally { + _clearQueues(); + } + } + + void dispose() { + try { + _ring?.dispose(); + } finally { + _clearQueues(); + } + } + + List schedule( + List plans, + List samples, + ) { + if (plans.length != samples.length || plans.isEmpty) { + throw RangeError('scheduled path output relation is invalid'); + } + final generation = _requireGeneration(); + final path = _requirePath(); + final outputs = []; + for (var index = 0; index < plans.length; index += 1) { + final plan = plans[index]; + final sample = samples[index]; + final expected = plan.discard + ? null + : PresentationRingExpectedFrame( + generation: generation, + path: path, + unitId: sample.unitId, + unitInstance: sample.unitInstance, + unitFrame: sample.unitFrame, + decodeOrdinal: sample.ordinal, + timestamp: sample.timestamp, + duration: sample.duration, + intendedPresentationOrdinal: + plan.intendedPresentationOrdinal ?? BigInt.zero, + ); + outputs.add(PathSchedulerExpectedOutput( + plan: plan, + sample: sample, + expected: expected, + )); + } + _expected.addAll(outputs); + return outputs; + } + + int presentableExpectedCount() { + var count = 0; + for (final output in _expected) { + if (!output.plan.discard) count += 1; + } + return count; + } + + bool hasExpected() { + return _expected.isNotEmpty; + } + + PathSchedulerExpectedOutput? peekRingOutput() { + return _ringPlans.isEmpty ? null : _ringPlans[0]; + } + + int availableEdgeLead() { + final first = _ringPlans.indexWhere( + (output) => output.plan.purpose != PathSchedulerFramePurpose.source, + ); + return first < 0 ? 0 : _ringPlans.length - first; + } + + /// Reclassifies retained decoded target lead as the new stable source ring. + void promoteTargetToSource(String state, GraphBodyDefinition body) { + PathSchedulerExpectedOutput promote(PathSchedulerExpectedOutput output) { + if (output.plan.purpose != PathSchedulerFramePurpose.target) { + return output; + } + final targetCursor = output.plan.targetCursor; + if (targetCursor == null) { + throw RangeError('target output has no promotion cursor'); + } + final cursor = body.kind == GraphBodyKind.loop + ? targetCursor + : SourceBodyCursor(occurrence: BigInt.zero, frame: targetCursor.frame); + return PathSchedulerExpectedOutput( + plan: PathFramePlan( + purpose: PathSchedulerFramePurpose.source, + unitId: output.plan.unitId, + unitFrame: output.plan.unitFrame, + state: state, + edge: null, + graphKind: output.plan.graphKind, + sourceCursor: SourceBodyCursor( + occurrence: cursor.occurrence, + frame: cursor.frame, + ), + targetCursor: null, + discard: output.plan.discard, + intendedPresentationOrdinal: output.plan.intendedPresentationOrdinal, + ), + sample: output.sample, + expected: output.expected, + ); + } + + final promotedExpected = _expected.map(promote).toList(); + _expected + ..clear() + ..addAll(promotedExpected); + final promotedRing = _ringPlans.map(promote).toList(); + _ringPlans + ..clear() + ..addAll(promotedRing); + } + + PathSchedulerRingTakeResult takeRingOutput() { + final output = _ringPlans.isEmpty ? null : _ringPlans[0]; + if (output == null || output.expected == null) { + return const PathSchedulerRingTakeUnderflow(); + } + final result = _requireRing().takeExpected(output.expected!); + if (result is PresentationRingTakeUnderflow) { + return const PathSchedulerRingTakeUnderflow(); + } + _ringPlans.removeAt(0); + return PathSchedulerRingTakeFrame( + output: output, + frame: (result as PresentationRingTakeFrame).entry.frame, + ); + } + + void replaceResident(List media) { + _resident + ..clear() + ..addAll(media); + } + + RuntimeMediaPresentationFrame? takeResident() { + return _resident.isEmpty ? null : _resident.removeAt(0); + } + + PathSchedulerOutputDrainReport drain() { + var decodedFrames = 0; + var discardedFrames = 0; + var staleFrames = 0; + RuntimeMediaCursor? decodedCursor; + SourceBodyCursor? decodedSource; + SourceBodyCursor? decodedTarget; + while (true) { + final frame = _worker.takeFrame(); + if (frame == null) break; + if (frame.generation != _generation) { + frame.close(); + staleFrames += 1; + _staleFrames += 1; + _onTrace( + PathSchedulerTraceOperation.staleOutput, + null, + 'obsolete-generation', + ); + continue; + } + final output = _expected.isEmpty ? null : _expected.removeAt(0); + if (output == null) { + frame.close(); + throw RangeError('worker produced an unplanned path frame'); + } + _validateManagedOutput(frame, output.sample); + decodedFrames += 1; + decodedCursor = RuntimeMediaCursor( + path: _requirePath(), + unit: frame.unitId, + unitInstance: frame.unitInstance, + localFrame: frame.unitFrame, + ); + if (output.plan.sourceCursor != null && !output.plan.discard) { + final cursor = output.plan.sourceCursor!; + decodedSource = + SourceBodyCursor(occurrence: cursor.occurrence, frame: cursor.frame); + } + if (output.plan.targetCursor != null && !output.plan.discard) { + final cursor = output.plan.targetCursor!; + decodedTarget = + SourceBodyCursor(occurrence: cursor.occurrence, frame: cursor.frame); + } + if (output.plan.discard) { + frame.close(); + discardedFrames += 1; + _discardedDependencyFrames += 1; + _onTrace(PathSchedulerTraceOperation.discardOutput, output, null); + continue; + } + if (output.expected == null) { + frame.close(); + throw StateError('presentable output has no ring identity'); + } + final enqueue = _requireRing().enqueue(PresentationRingInsertion( + expected: output.expected!, + frame: frame, + workerOutputTimeMs: _now(), + uploadReadyTimeMs: null, + )); + if (enqueue is PresentationRingEnqueueAccepted) { + _ringPlans.add(output); + } else { + staleFrames += 1; + _staleFrames += 1; + } + _onTrace(PathSchedulerTraceOperation.output, output, null); + } + return PathSchedulerOutputDrainReport( + decodedFrames: decodedFrames, + discardedFrames: discardedFrames, + staleFrames: staleFrames, + decodedCursor: decodedCursor, + decodedSource: decodedSource, + decodedTarget: decodedTarget, + ); + } + + RuntimeMediaPresentationFrame mediaFor(PathSchedulerExpectedOutput output) { + final expected = output.expected; + if (expected == null) throw StateError('media output has no identity'); + return RuntimeMediaPresentationFrame( + graphKind: _graphKindOf(output.plan.graphKind), + state: output.plan.state, + edge: output.plan.edge, + path: expected.path, + frame: RuntimeFrameKey( + rendition: _rendition, + unit: expected.unitId, + localFrame: expected.unitFrame, + ), + drawSource: RuntimeMediaDrawSource.streaming, + generation: expected.generation, + unitInstance: expected.unitInstance, + decodeOrdinal: expected.decodeOrdinal, + timestamp: expected.timestamp, + intendedPresentationOrdinal: expected.intendedPresentationOrdinal, + ); + } + + void _clearQueues() { + _expected.clear(); + _ringPlans.clear(); + _resident.clear(); + } + + int _now() { + final value = _clock.now(); + if (value < 0) { + throw RangeError('scheduler clock must be finite and non-negative'); + } + return value; + } + + PresentationRing _requireRing() { + final ring = _ring; + if (ring == null) { + throw RangeError('path scheduler has no presentation ring'); + } + return ring; + } + + int _requireGeneration() { + final generation = _generation; + if (generation == null) { + throw RangeError('path scheduler output has no active generation'); + } + return generation; + } + + String _requirePath() { + final path = _path; + if (path == null) { + throw RangeError('path scheduler output has no active path'); + } + return path; + } +} + +/// Maps a [PathFrameGraphKind] onto the media [RuntimeMediaGraphKind]. The TS +/// `graphKind` field is carried through verbatim; the sequence builder only ever +/// emits `body` or `locked`. +RuntimeMediaGraphKind _graphKindOf(PathFrameGraphKind kind) { + switch (kind) { + case PathFrameGraphKind.body: + return RuntimeMediaGraphKind.body; + case PathFrameGraphKind.locked: + return RuntimeMediaGraphKind.locked; + } +} + +void _validateManagedOutput( + ManagedDecoderWorkerFrame frame, + DecoderWorkerSample sample, +) { + if (frame.ordinal != sample.ordinal || + frame.unitId != sample.unitId || + frame.unitInstance != sample.unitInstance || + frame.unitFrame != sample.unitFrame || + frame.timestamp != sample.timestamp || + frame.duration != sample.duration) { + frame.close(); + throw RangeError('worker output did not match submitted path identity'); + } +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_pump.dart b/flutter/packages/aval_player/lib/src/path_scheduler_pump.dart new file mode 100644 index 0000000..86ed7e2 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_pump.dart @@ -0,0 +1,209 @@ +/// Bounded credit/request pump loop for the path scheduler. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-pump.ts`. +/// TS `Promise` → `Future`; `Math.min`/`Math.max` map onto `dart:math`. The +/// `Number.isFinite` guard on `timeoutMs` is dropped (Dart `int` is always +/// finite); the `>0`/positivity checks are preserved. `batch.release?.()` +/// becomes an unconditional `batch.release()` — the Dart +/// `DecoderWorkerSampleBatch.release` is non-nullable. +library; + +import 'dart:math' as math; + +import 'decoder_worker/client_support.dart'; +import 'decoder_worker/protocol.dart'; +import 'path_scheduler_model.dart' + show + PathSchedulerPumpOptions, + PathSchedulerPumpReport, + PathSchedulerWorkerAdapter; +import 'path_scheduler_output.dart'; +import 'path_sequence.dart'; +import 'rational_time.dart' show maxSafeInteger; +import 'worker_samples.dart'; + +const int _defaultPumpTimeoutMs = 2000; +const int _maxPumpIterations = 256; + +/// Inputs to [pumpPathScheduler]; the graph routing callbacks stay in +/// `PathScheduler`. +class PumpPathSchedulerInput { + const PumpPathSchedulerInput({ + required this.options, + required this.ringCapacity, + required this.limits, + required this.maxBatchSamples, + required this.worker, + required this.samples, + required this.output, + required this.build, + required this.buildFrame, + required this.commitBuild, + required this.recordSubmitted, + required this.onDrain, + }); + + final PathSchedulerPumpOptions options; + final int ringCapacity; + final DecoderWorkerLimits limits; + final int maxBatchSamples; + final PathSchedulerWorkerAdapter worker; + final WorkerSampleFactory samples; + final PathSchedulerOutput output; + final PathSequenceState build; + final PathFramePlan? Function(PathSequenceState state) buildFrame; + final void Function(PathSequenceState state) commitBuild; + final void Function(List outputs) recordSubmitted; + final void Function(PathSchedulerOutputDrainReport report) onDrain; +} + +/// Bounded credit/request loop; graph routing remains in PathScheduler. +Future pumpPathScheduler( + PumpPathSchedulerInput input, +) async { + final targetRingFrames = input.options.targetRingFrames ?? input.ringCapacity; + if (targetRingFrames < 1 || targetRingFrames > input.ringCapacity) { + throw RangeError('pump target must fit the presentation ring'); + } + final timeoutMs = input.options.timeoutMs ?? _defaultPumpTimeoutMs; + if (timeoutMs <= 0) { + throw RangeError('pump timeout must be finite and positive'); + } + + var submittedFrames = 0; + var decodedFrames = 0; + var discardedFrames = 0; + var staleFrames = 0; + var waits = 0; + var build = input.build; + for (var iteration = 0; iteration < _maxPumpIterations; iteration += 1) { + final drained = input.output.drain(); + input.onDrain(drained); + decodedFrames += drained.decodedFrames; + discardedFrames += drained.discardedFrames; + staleFrames += drained.staleFrames; + + final ringSize = input.output.ringSize; + if (ringSize >= targetRingFrames) { + return _report( + input.output, + submittedFrames: submittedFrames, + decodedFrames: decodedFrames, + discardedFrames: discardedFrames, + staleFrames: staleFrames, + waits: waits, + ); + } + + final metrics = await input.worker.snapshotMetrics(); + final deficit = targetRingFrames - + ringSize - + input.output.presentableExpectedCount(); + final pendingCredit = + math.max(0, input.limits.maxPendingSamples - metrics.pendingSamples); + final outstanding = _checkedAdd( + metrics.submittedFrames, + metrics.leasedFrames, + 'worker outstanding frames', + ); + final outstandingCredit = math.max( + 0, + input.limits.maxOutstandingFrames - outstanding, + ); + final batchLimit = [ + input.maxBatchSamples, + pendingCredit, + outstandingCredit, + math.max(1, deficit), + ].reduce(math.min); + + if (batchLimit > 0 && deficit > 0) { + final draft = clonePathSequenceState(build); + final plans = []; + for (var index = 0; index < batchLimit; index += 1) { + final plan = input.buildFrame(draft); + if (plan == null) break; + plans.add(plan); + } + // A phase-only transition is semantic progress too. Persist terminal + // finite state even when it emits no decoder request, otherwise reserve + // reports an underflow forever instead of a held presentation. + input.commitBuild(draft); + build = draft; + if (plans.isNotEmpty) { + final batch = input.samples.createBatch(CreateWorkerSampleBatchInput( + frames: plans + .map((plan) => WorkerSampleFrameRequest( + unitId: plan.unitId, + unitFrame: plan.unitFrame, + )) + .toList(), + pendingSamples: metrics.pendingSamples, + outstandingFrames: outstanding, + )); + try { + final outputs = input.output.schedule(plans, batch.samples); + input.recordSubmitted(outputs); + submittedFrames += batch.samples.length; + await input.worker.submit(batch.generation, batch.samples); + } finally { + batch.release(); + } + continue; + } + } + + if (input.output.hasExpected()) { + final queuedBefore = input.worker.queuedFrames; + waits += 1; + await input.worker.waitForFrames( + 1, + DecoderWorkerWaitOptions( + signal: input.options.signal, + timeoutMs: timeoutMs, + ), + ); + if (input.worker.queuedFrames <= queuedBefore && + input.worker.queuedFrames == 0) { + throw RangeError('worker frame wait resolved without output'); + } + continue; + } + + return _report( + input.output, + submittedFrames: submittedFrames, + decodedFrames: decodedFrames, + discardedFrames: discardedFrames, + staleFrames: staleFrames, + waits: waits, + ); + } + throw RangeError('path scheduler pump exceeded its bounded iterations'); +} + +PathSchedulerPumpReport _report( + PathSchedulerOutput output, { + required int submittedFrames, + required int decodedFrames, + required int discardedFrames, + required int staleFrames, + required int waits, +}) { + return PathSchedulerPumpReport( + submittedFrames: submittedFrames, + decodedFrames: decodedFrames, + discardedFrames: discardedFrames, + staleFrames: staleFrames, + waits: waits, + ringSize: output.ringSize, + expectedOutputs: output.expectedCount, + ); +} + +int _checkedAdd(int left, int right, String label) { + if (left < 0 || right < 0 || left > maxSafeInteger - right) { + throw RangeError('$label exceeded the safe-integer range'); + } + return left + right; +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_reservation.dart b/flutter/packages/aval_player/lib/src/path_scheduler_reservation.dart new file mode 100644 index 0000000..d6f69ea --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_reservation.dart @@ -0,0 +1,75 @@ +/// The scheduler's sole draw-barrier presentation reservation. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-reservation.ts`. +/// `PathSchedulerFrameMedia` (the TS `Extract`) is [RuntimeMediaPresentationFrame]. `Object.freeze`d +/// reservations are stored directly (the reservation and its media are +/// immutable value objects here). +library; + +import 'model.dart'; +import 'path_scheduler_output.dart' show PathSchedulerExpectedOutput; + +/// The frame-kind media a reservation carries. +typedef PathSchedulerFrameMedia = RuntimeMediaPresentationFrame; + +/// One prepared, not-yet-committed presentation. +class PathSchedulerPresentationReservation { + const PathSchedulerPresentationReservation({ + required this.media, + required this.output, + required this.commitRoute, + }); + + final PathSchedulerFrameMedia media; + final PathSchedulerExpectedOutput? output; + final bool commitRoute; +} + +/// Owns the scheduler's sole draw-barrier presentation reservation. +class PathSchedulerReservationOwner { + PathSchedulerPresentationReservation? _current; + + PathSchedulerPresentationReservation? get current => _current; + + void reserve(PathSchedulerPresentationReservation reservation) { + requireEmpty(); + _current = reservation; + } + + PathSchedulerPresentationReservation consume(PathSchedulerFrameMedia media) { + final current = _current; + if (current == null || !sameSchedulerMediaIdentity(current.media, media)) { + throw RangeError('scheduler presentation reservation diverged'); + } + _current = null; + return current; + } + + void discard() { + _current = null; + } + + void requireEmpty() { + if (_current != null) { + throw RangeError('scheduler already has a prepared presentation'); + } + } +} + +bool sameSchedulerMediaIdentity( + PathSchedulerFrameMedia left, + PathSchedulerFrameMedia right, +) { + return left.graphKind == right.graphKind && + left.state == right.state && + left.edge == right.edge && + left.path == right.path && + left.frame.rendition == right.frame.rendition && + left.frame.unit == right.frame.unit && + left.frame.localFrame == right.frame.localFrame && + left.unitInstance == right.unitInstance && + left.decodeOrdinal == right.decodeOrdinal && + left.timestamp == right.timestamp && + left.intendedPresentationOrdinal == right.intendedPresentationOrdinal; +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_resident_runway.dart b/flutter/packages/aval_player/lib/src/path_scheduler_resident_runway.dart new file mode 100644 index 0000000..f136f22 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_resident_runway.dart @@ -0,0 +1,202 @@ +/// The exclusive staged-runway token and its atomic scheduler install. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-resident-runway.ts`. +/// The TS staged-token identity check (`staged.token !== transaction`) maps onto +/// `!identical(...)`; `bigint` ordinals become `BigInt`; `media.slice(n)` becomes +/// `sublist(n)`. `Object.freeze`d media/tokens are immutable value objects here. +library; + +import 'package:aval_graph/aval_graph.dart'; + +import 'model.dart'; +import 'path_scheduler_cursor_ledger.dart'; +import 'path_scheduler_generation.dart'; +import 'path_scheduler_model.dart'; +import 'path_scheduler_output.dart' show PathSchedulerOutput; +import 'path_scheduler_reservation.dart' show PathSchedulerReservationOwner; +import 'path_scheduler_route.dart' show PathSchedulerRoute; +import 'path_scheduler_validation.dart' show validateResidentRunway; +import 'path_sequence.dart' show PathSequenceState, ResidentPathTarget; + +class _StagedResidentRunway { + const _StagedResidentRunway({ + required this.token, + required this.generation, + required this.targetBody, + required this.firstPresentationOrdinal, + }); + + final PathSchedulerResidentRunwayTransaction token; + final PathSchedulerGenerationPlan generation; + final GraphBodyDefinition targetBody; + final BigInt firstPresentationOrdinal; +} + +/// The result of atomically installing a staged runway. +class PathSchedulerResidentRunwayCommit { + const PathSchedulerResidentRunwayCommit({ + required this.activateWorker, + required this.build, + required this.residentTarget, + required this.retiredGeneration, + required this.firstPresented, + }); + + final PathSchedulerWorkerActivation activateWorker; + final PathSequenceState build; + final ResidentPathTarget residentTarget; + final int retiredGeneration; + final RuntimeMediaPresentationFrame? firstPresented; +} + +/// Construction options for [PathSchedulerResidentRunwayOwner]. +class PathSchedulerResidentRunwayOwnerOptions { + const PathSchedulerResidentRunwayOwnerOptions({ + required this.rendition, + required this.generation, + required this.output, + required this.route, + required this.cursors, + required this.reservation, + }); + + final String rendition; + final PathSchedulerGeneration generation; + final PathSchedulerOutput output; + final PathSchedulerRoute route; + final PathSchedulerCursorLedger cursors; + final PathSchedulerReservationOwner reservation; +} + +/// Owns the exclusive staged-runway token and its atomic scheduler install. +class PathSchedulerResidentRunwayOwner { + PathSchedulerResidentRunwayOwner( + PathSchedulerResidentRunwayOwnerOptions options, + ) : _rendition = options.rendition, + _generation = options.generation, + _output = options.output, + _route = options.route, + _cursors = options.cursors, + _reservation = options.reservation; + + final String _rendition; + final PathSchedulerGeneration _generation; + final PathSchedulerOutput _output; + final PathSchedulerRoute _route; + final PathSchedulerCursorLedger _cursors; + final PathSchedulerReservationOwner _reservation; + _StagedResidentRunway? _current; + + bool get locked => _current != null; + + PathSchedulerResidentRunwayTransaction stage( + StartResidentRunwayInput input, + ) { + if (_current != null) { + throw RangeError('path scheduler already has a staged resident runway'); + } + validateResidentRunway(input, _rendition); + final firstPresentationOrdinal = input.firstPresentationOrdinal ?? + (_cursors.lastDisplayedOrdinal ?? -BigInt.one) + BigInt.one; + if (firstPresentationOrdinal < BigInt.zero) { + throw RangeError('resident runway ordinal must be non-negative'); + } + final generation = _generation.planReplacement(input.path); + final media = []; + for (var index = 0; index < input.frames.length; index += 1) { + final resident = input.frames[index]; + media.add(RuntimeMediaPresentationFrame( + graphKind: RuntimeMediaGraphKind.body, + state: input.targetState, + edge: input.edgeId, + path: input.path, + frame: RuntimeFrameKey( + rendition: resident.frame.rendition, + unit: resident.frame.unit, + localFrame: resident.frame.localFrame, + ), + drawSource: RuntimeMediaDrawSource.resident, + generation: generation.generation, + unitInstance: resident.unitInstance, + decodeOrdinal: resident.decodeOrdinal, + timestamp: resident.timestamp, + intendedPresentationOrdinal: + firstPresentationOrdinal + BigInt.from(index), + )); + } + final token = PathSchedulerResidentRunwayTransaction( + generation: generation.generation, + path: input.path, + edgeId: input.edgeId, + targetState: input.targetState, + media: List.unmodifiable(media), + ); + _current = _StagedResidentRunway( + token: token, + generation: generation, + targetBody: input.targetBody, + firstPresentationOrdinal: firstPresentationOrdinal, + ); + return token; + } + + PathSchedulerResidentRunwayCommit commit( + PathSchedulerResidentRunwayTransaction transaction, [ + CommitResidentRunwayOptions options = const CommitResidentRunwayOptions(), + ]) { + final staged = _current; + if (staged == null || !identical(staged.token, transaction)) { + throw RangeError('resident runway transaction is stale'); + } + final alreadyPresented = options.alreadyPresented ?? 0; + if (alreadyPresented != 0 && alreadyPresented != 1) { + throw RangeError('resident runway presented count must be zero or one'); + } + final committed = _generation.commitReplacement(staged.generation); + _reservation.discard(); + _output.replaceResident(transaction.media.sublist(alreadyPresented)); + _route.activateResident(); + final build = _cursors.replaceSource( + PathSchedulerSourceReplacementResidentRunway( + targetState: transaction.targetState, + targetBody: staged.targetBody, + runwayFrames: transaction.media.length, + firstPresentationOrdinal: staged.firstPresentationOrdinal, + ), + ); + final residentTarget = ResidentPathTarget( + edgeId: transaction.edgeId, + targetState: transaction.targetState, + targetBody: staged.targetBody, + ); + final firstPresented = alreadyPresented == 0 + ? null + : (transaction.media.isEmpty ? null : transaction.media[0]); + if (alreadyPresented == 1) { + if (firstPresented == null) { + throw RangeError('resident runway has no presented frame zero'); + } + _cursors.recordResidentDisplayed(firstPresented); + } + _current = null; + return PathSchedulerResidentRunwayCommit( + activateWorker: committed.activateWorker, + build: build, + residentTarget: residentTarget, + retiredGeneration: committed.retiredGeneration, + firstPresented: firstPresented, + ); + } + + bool rollback(PathSchedulerResidentRunwayTransaction transaction) { + if (_current == null || !identical(_current!.token, transaction)) { + return false; + } + _current = null; + return true; + } + + void clear() { + _current = null; + } +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_route.dart b/flutter/packages/aval_player/lib/src/path_scheduler_route.dart new file mode 100644 index 0000000..86eecd9 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_route.dart @@ -0,0 +1,125 @@ +/// Pending-route identity, boundary reconciliation, and wait accounting. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-route.ts`. +/// `SubmissionHorizonDecision`'s `kind === "select-portal"` discriminant maps +/// onto `decision is SubmissionHorizonSelectPortal`. `ScheduledPathRoute` is +/// reused from `path_sequence.dart`. `Object.freeze`d routes are stored +/// directly (immutable value objects here). +library; + +import 'package:aval_graph/aval_graph.dart'; + +import 'path_sequence.dart' show ScheduledPathRoute; +import 'submission_horizon.dart'; + +/// Inputs for [PathSchedulerRoute.decide]. +class PathSchedulerRouteDecisionInput { + const PathSchedulerRouteDecisionInput({ + required this.body, + required this.displayed, + required this.submitted, + required this.ringCapacity, + required this.availableConsecutiveEdgeFrames, + }); + + final GraphBodyDefinition body; + final SourceBodyCursor displayed; + final SourceBodyCursor submitted; + final int ringCapacity; + final int availableConsecutiveEdgeFrames; +} + +/// Owns pending-route identity, boundary reconciliation, and wait accounting. +class PathSchedulerRoute { + ScheduledPathRoute? _current; + bool _committed = false; + int _elapsedWaitFrames = 0; + + ScheduledPathRoute? get current => _current; + + bool get committed => _committed; + + String? get pendingEdge => _current?.edge.id; + + SubmissionHorizonDecision decide( + GraphEdgeDefinition edge, + PathSchedulerRouteDecisionInput input, + ) { + return planSubmissionHorizon(SubmissionHorizonInput( + body: input.body, + edge: edge, + displayed: input.displayed, + submitted: input.submitted, + ringCapacity: input.ringCapacity, + availableConsecutiveEdgeFrames: input.availableConsecutiveEdgeFrames, + elapsedWaitFrames: _elapsedWaitFrames, + )); + } + + void prepare({ + required GraphEdgeDefinition edge, + required String targetState, + required GraphBodyDefinition targetBody, + required SourceBoundary boundary, + }) { + _current = ScheduledPathRoute( + edge: edge, + targetState: targetState, + targetBody: targetBody, + boundary: boundary, + ); + _committed = false; + _elapsedWaitFrames = 0; + } + + SourceBoundary? reconcileBoundary( + SubmissionHorizonDecision decision, + bool edgeSubmissionStarted, + ) { + final route = _current; + if (route == null || + decision is! SubmissionHorizonSelectPortal || + _sameBoundary(decision.boundary, route.boundary)) { + return null; + } + if (edgeSubmissionStarted) { + throw RangeError( + 'prepared edge lead cannot move after edge submission began', + ); + } + _current = ScheduledPathRoute( + edge: route.edge, + targetState: route.targetState, + targetBody: route.targetBody, + boundary: decision.boundary, + ); + return decision.boundary; + } + + void commit() { + if (_current == null) { + throw RangeError('path scheduler has no route to commit'); + } + _committed = true; + } + + void noteDisplayedSource() { + if (_current != null) _elapsedWaitFrames += 1; + } + + void clear() { + _current = null; + _committed = false; + _elapsedWaitFrames = 0; + } + + void activateResident() { + _current = null; + _committed = true; + _elapsedWaitFrames = 0; + } +} + +bool _sameBoundary(SourceBoundary left, SourceBoundary right) { + return left.occurrence == right.occurrence && left.frame == right.frame; +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_trace.dart b/flutter/packages/aval_player/lib/src/path_scheduler_trace.dart new file mode 100644 index 0000000..cbb185d --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_trace.dart @@ -0,0 +1,74 @@ +/// Bounded scheduler diagnostics log. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-trace.ts`. +/// `PathSchedulerTraceInput` (the TS `Omit`) +/// becomes an explicit immutable class; `Number.MAX_SAFE_INTEGER` maps onto +/// `maxSafeInteger` from `rational_time.dart`. +library; + +import 'model.dart' show runtimeTraceCapacity; +import 'path_scheduler_model.dart'; +import 'rational_time.dart' show maxSafeInteger; + +/// Trace record fields minus the monotonic [PathSchedulerTraceRecord.index]. +class PathSchedulerTraceInput { + const PathSchedulerTraceInput({ + required this.operation, + required this.generation, + required this.path, + required this.unit, + required this.unitInstance, + required this.unitFrame, + required this.decodeOrdinal, + required this.intendedPresentationOrdinal, + required this.ringSize, + required this.expectedOutputs, + required this.reason, + }); + + final PathSchedulerTraceOperation operation; + final int? generation; + final String? path; + final String? unit; + final int? unitInstance; + final int? unitFrame; + final int? decodeOrdinal; + final BigInt? intendedPresentationOrdinal; + final int ringSize; + final int expectedOutputs; + final String? reason; +} + +/// Bounded, immutable scheduler diagnostics with one monotonic record index. +class PathSchedulerTraceLog { + final List _records = []; + int _nextIndex = 0; + + void append(PathSchedulerTraceInput input) { + if (_nextIndex >= maxSafeInteger) { + throw RangeError('path scheduler trace index leaves no safe successor'); + } + _records.add(PathSchedulerTraceRecord( + index: _nextIndex, + operation: input.operation, + generation: input.generation, + path: input.path, + unit: input.unit, + unitInstance: input.unitInstance, + unitFrame: input.unitFrame, + decodeOrdinal: input.decodeOrdinal, + intendedPresentationOrdinal: input.intendedPresentationOrdinal, + ringSize: input.ringSize, + expectedOutputs: input.expectedOutputs, + reason: input.reason, + )); + _nextIndex += 1; + if (_records.length > runtimeTraceCapacity) { + _records.removeAt(0); + } + } + + List snapshot() { + return List.unmodifiable(_records); + } +} diff --git a/flutter/packages/aval_player/lib/src/path_scheduler_validation.dart b/flutter/packages/aval_player/lib/src/path_scheduler_validation.dart new file mode 100644 index 0000000..a625314 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_scheduler_validation.dart @@ -0,0 +1,106 @@ +/// Scheduler input validation. +/// +/// Direct port of `packages/player-web/src/runtime/path-scheduler-validation.ts`. +/// `graphBodyFrameAt` (from `body-frame-semantics.ts`, not yet ported as its own +/// module) is inlined here as [_graphBodyFrameAt] — the only consumer in this +/// task. `Number.isSafeInteger` bounds map onto `maxSafeInteger` from +/// `rational_time.dart`. +library; + +import 'package:aval_graph/aval_graph.dart'; + +import 'decoder_worker/protocol.dart'; +import 'path_scheduler_model.dart' + show PathSchedulerResidentFrame, StartResidentRunwayInput; +import 'rational_time.dart' show maxSafeInteger; + +const int _minResidentRunwayFrames = 6; +const int _maxResidentRunwayFrames = 12; + +void validateScheduledBody(GraphBodyDefinition body) { + validateSchedulerId(body.unitId, 'scheduled body unit'); + if (body.frameCount < 1 || body.frameCount > maxSafeInteger) { + throw RangeError('scheduled body frame count must be positive'); + } + if (body.kind == GraphBodyKind.held && body.frameCount != 1) { + throw RangeError('held scheduled body must contain one frame'); + } +} + +void validateResidentRunway( + StartResidentRunwayInput input, + String rendition, +) { + validateSchedulerId(input.edgeId, 'resident edge'); + validateSchedulerId(input.targetState, 'resident target state'); + validateSchedulerId(input.path, 'resident path'); + validateScheduledBody(input.targetBody); + if (input.frames.length < _minResidentRunwayFrames || + input.frames.length > _maxResidentRunwayFrames) { + throw RangeError('resident runway must contain 6-12 frames'); + } + for (var index = 0; index < input.frames.length; index += 1) { + final frame = input.frames[index]; + _validateResidentFrame(frame); + final expectedLocalFrame = _graphBodyFrameAt(input.targetBody, index); + if (frame.frame.rendition != rendition || + frame.frame.unit != input.targetBody.unitId || + frame.frame.localFrame != expectedLocalFrame) { + throw RangeError( + 'resident runway frame does not match the selected target body', + ); + } + } +} + +void validateSchedulerLimits(DecoderWorkerLimits limits) { + const pairs = [ + 'decode queue', + 'pending samples', + 'outstanding frames', + 'decoded bytes', + ]; + final values = [ + limits.maxDecodeQueueSize, + limits.maxPendingSamples, + limits.maxOutstandingFrames, + limits.maxDecodedBytes, + ]; + for (var index = 0; index < pairs.length; index += 1) { + final value = values[index]; + if (value < 1 || value > maxSafeInteger) { + throw RangeError('${pairs[index]} limit must be a positive safe integer'); + } + } +} + +void validateSchedulerId(String value, String label) { + if (value.isEmpty || value.length > 128) { + throw RangeError('$label length must be 1-128'); + } +} + +void _validateResidentFrame(PathSchedulerResidentFrame frame) { + validateSchedulerId(frame.frame.rendition, 'resident rendition'); + validateSchedulerId(frame.frame.unit, 'resident unit'); + _validateNonNegativeInteger(frame.frame.localFrame, 'resident local frame'); + _validateNonNegativeInteger(frame.unitInstance, 'resident unit instance'); + _validateNonNegativeInteger(frame.decodeOrdinal, 'resident decode ordinal'); + _validateNonNegativeInteger(frame.timestamp, 'resident timestamp'); +} + +void _validateNonNegativeInteger(int value, String label) { + if (value < 0 || value > maxSafeInteger) { + throw RangeError('$label must be a non-negative safe integer'); + } +} + +/// Maps an unbounded logical body offset to its authored local frame +/// (`body-frame-semantics.ts:2`). +int _graphBodyFrameAt(GraphBodyDefinition body, int logicalFrame) { + return body.kind == GraphBodyKind.loop + ? logicalFrame % body.frameCount + : (logicalFrame < body.frameCount - 1 + ? logicalFrame + : body.frameCount - 1); +} diff --git a/flutter/packages/aval_player/lib/src/path_sequence.dart b/flutter/packages/aval_player/lib/src/path_sequence.dart new file mode 100644 index 0000000..2e8baef --- /dev/null +++ b/flutter/packages/aval_player/lib/src/path_sequence.dart @@ -0,0 +1,431 @@ +/// Frame-by-frame path sequence builder shared by the path scheduler. +/// +/// Direct port of `packages/player-web/src/runtime/path-sequence.ts`. +/// TypeScript `bigint` presentation ordinals/occurrences become Dart `BigInt`; +/// `number` frame counters become `int`. The `phase` and `graphKind` string +/// unions become the [PathSequencePhase] / [PathFrameGraphKind] enums. The +/// `GraphTransition.kind === "locked"` discriminant maps onto the `aval_graph` +/// `GraphTransitionLocked` subclass. `Object.freeze` on emitted plans becomes +/// immutable value classes (`SourceBodyCursor` is already immutable, so the +/// TS defensive cursor clone in `freezePathFramePlan` is a pass-through here). +library; + +import 'dart:math' as math; + +import 'package:aval_graph/aval_graph.dart'; + +import 'path_scheduler_model.dart' show PathSchedulerFramePurpose; +import 'submission_horizon.dart' show SourceBodyCursor, SourceBoundary; + +/// Which graph object a planned frame belongs to. +enum PathFrameGraphKind { + body('body'), + locked('locked'); + + const PathFrameGraphKind(this.wireValue); + + final String wireValue; +} + +/// Phase of a [PathSequenceState]. +enum PathSequencePhase { + source, + bridge, + target, + done; +} + +/// A fully-resolved route the sequence departs onto. +class ScheduledPathRoute { + const ScheduledPathRoute({ + required this.edge, + required this.targetState, + required this.targetBody, + required this.boundary, + }); + + final GraphEdgeDefinition edge; + final String targetState; + final GraphBodyDefinition targetBody; + final SourceBoundary boundary; +} + +/// A resident (already-decoded) runway target. +class ResidentPathTarget { + const ResidentPathTarget({ + required this.edgeId, + required this.targetState, + required this.targetBody, + }); + + final String edgeId; + final String targetState; + final GraphBodyDefinition targetBody; +} + +/// Mutable cursor/phase state advanced by [buildNextPathFrame]. +class PathSequenceState { + PathSequenceState({ + required this.phase, + required this.sourceNext, + required this.sourceStop, + required this.sourceDiscardBefore, + required this.bridgeNextFrame, + required this.targetNext, + required this.targetDiscardRemaining, + required this.nextPresentationOrdinal, + required this.edgeSubmissionStarted, + }); + + PathSequencePhase phase; + SourceBodyCursor? sourceNext; + SourceBodyCursor? sourceStop; + SourceBodyCursor? sourceDiscardBefore; + int bridgeNextFrame; + SourceBodyCursor? targetNext; + int targetDiscardRemaining; + BigInt nextPresentationOrdinal; + bool edgeSubmissionStarted; +} + +/// One planned decode frame. +class PathFramePlan { + const PathFramePlan({ + required this.purpose, + required this.unitId, + required this.unitFrame, + required this.state, + required this.edge, + required this.graphKind, + required this.sourceCursor, + required this.targetCursor, + required this.discard, + required this.intendedPresentationOrdinal, + }); + + final PathSchedulerFramePurpose purpose; + final String unitId; + final int unitFrame; + final String? state; + final String? edge; + final PathFrameGraphKind graphKind; + final SourceBodyCursor? sourceCursor; + final SourceBodyCursor? targetCursor; + final bool discard; + final BigInt? intendedPresentationOrdinal; +} + +/// Read-only context for one [buildNextPathFrame] step. +class PathSequenceContext { + const PathSequenceContext({ + required this.sourceState, + required this.sourceBody, + required this.route, + required this.residentTarget, + required this.canSubmitSource, + }); + + final String? sourceState; + final GraphBodyDefinition? sourceBody; + final ScheduledPathRoute? route; + final ResidentPathTarget? residentTarget; + final bool Function(SourceBodyCursor cursor) canSubmitSource; +} + +PathSequenceState createSourcePathSequence(BigInt firstPresentationOrdinal) { + return PathSequenceState( + phase: PathSequencePhase.source, + sourceNext: SourceBodyCursor(occurrence: BigInt.zero, frame: 0), + sourceStop: null, + sourceDiscardBefore: null, + bridgeNextFrame: 0, + targetNext: null, + targetDiscardRemaining: 0, + nextPresentationOrdinal: firstPresentationOrdinal, + edgeSubmissionStarted: false, + ); +} + +PathSequenceState createReplacementPathSequence({ + required SourceBodyCursor nextSource, + required BigInt firstPresentationOrdinal, +}) { + return PathSequenceState( + phase: PathSequencePhase.source, + sourceNext: SourceBodyCursor(occurrence: nextSource.occurrence, frame: 0), + sourceStop: null, + sourceDiscardBefore: SourceBodyCursor( + occurrence: nextSource.occurrence, + frame: nextSource.frame, + ), + bridgeNextFrame: 0, + targetNext: null, + targetDiscardRemaining: 0, + nextPresentationOrdinal: firstPresentationOrdinal, + edgeSubmissionStarted: false, + ); +} + +PathSequenceState createResidentContinuationSequence({ + required int runwayFrames, + required GraphBodyDefinition targetBody, + required BigInt firstStreamingPresentationOrdinal, +}) { + return PathSequenceState( + phase: PathSequencePhase.target, + sourceNext: null, + sourceStop: null, + sourceDiscardBefore: null, + bridgeNextFrame: 0, + targetNext: SourceBodyCursor(occurrence: BigInt.zero, frame: 0), + targetDiscardRemaining: targetBody.kind == GraphBodyKind.loop + ? runwayFrames + : math.min(runwayFrames, targetBody.frameCount - 1), + nextPresentationOrdinal: firstStreamingPresentationOrdinal, + edgeSubmissionStarted: false, + ); +} + +PathSequenceState clonePathSequenceState(PathSequenceState state) { + return PathSequenceState( + phase: state.phase, + sourceNext: _cloneSourceCursor(state.sourceNext), + sourceStop: _cloneSourceCursor(state.sourceStop), + sourceDiscardBefore: _cloneSourceCursor(state.sourceDiscardBefore), + bridgeNextFrame: state.bridgeNextFrame, + targetNext: _cloneSourceCursor(state.targetNext), + targetDiscardRemaining: state.targetDiscardRemaining, + nextPresentationOrdinal: state.nextPresentationOrdinal, + edgeSubmissionStarted: state.edgeSubmissionStarted, + ); +} + +PathFramePlan? buildNextPathFrame( + PathSequenceState state, + PathSequenceContext context, +) { + while (true) { + if (state.phase == PathSequencePhase.source) { + final body = context.sourceBody; + final current = state.sourceNext; + if (body == null) { + state.phase = PathSequencePhase.done; + continue; + } + if (current == null) { + if (context.route != null && state.sourceStop != null) { + _switchToEdge(state, context.route); + } else { + state.phase = PathSequencePhase.done; + } + continue; + } + if (state.sourceStop != null && + _compareSourceCursor(body, current, state.sourceStop!) > 0) { + _switchToEdge(state, context.route); + continue; + } + final discard = state.sourceDiscardBefore != null && + _compareSourceCursor(body, current, state.sourceDiscardBefore!) < 0; + if (context.route == null && + !discard && + !context.canSubmitSource(current)) { + return null; + } + final intended = discard ? null : state.nextPresentationOrdinal; + if (!discard) state.nextPresentationOrdinal += BigInt.one; + final plan = _freezePathFramePlan(PathFramePlan( + purpose: PathSchedulerFramePurpose.source, + unitId: body.unitId, + unitFrame: current.frame, + state: context.sourceState, + edge: null, + graphKind: PathFrameGraphKind.body, + sourceCursor: current, + targetCursor: null, + discard: discard, + intendedPresentationOrdinal: intended, + )); + state.sourceNext = nextBodyCursor(body, current); + return plan; + } + + if (state.phase == PathSequencePhase.bridge) { + final route = context.route; + final transition = route?.edge.transition; + if (route == null || transition is! GraphTransitionLocked) { + state.phase = PathSequencePhase.target; + continue; + } + if (state.bridgeNextFrame >= transition.frameCount) { + state.phase = PathSequencePhase.target; + state.targetNext = SourceBodyCursor(occurrence: BigInt.zero, frame: 0); + continue; + } + final frame = state.bridgeNextFrame; + state.bridgeNextFrame += 1; + final intended = state.nextPresentationOrdinal; + state.nextPresentationOrdinal += BigInt.one; + state.edgeSubmissionStarted = true; + return _freezePathFramePlan(PathFramePlan( + purpose: PathSchedulerFramePurpose.bridge, + unitId: transition.unitId, + unitFrame: frame, + state: null, + edge: route.edge.id, + graphKind: PathFrameGraphKind.locked, + sourceCursor: null, + targetCursor: null, + discard: false, + intendedPresentationOrdinal: intended, + )); + } + + if (state.phase == PathSequencePhase.target) { + final route = context.route; + final ResidentPathTarget? target = route == null + ? context.residentTarget + : ResidentPathTarget( + edgeId: route.edge.id, + targetState: route.targetState, + targetBody: route.targetBody, + ); + final cursor = state.targetNext; + if (target == null || cursor == null) { + state.phase = PathSequencePhase.done; + continue; + } + final discard = state.targetDiscardRemaining > 0; + if (discard) state.targetDiscardRemaining -= 1; + final intended = discard ? null : state.nextPresentationOrdinal; + if (!discard) state.nextPresentationOrdinal += BigInt.one; + final plan = _freezePathFramePlan(PathFramePlan( + purpose: PathSchedulerFramePurpose.target, + unitId: target.targetBody.unitId, + unitFrame: cursor.frame, + state: target.targetState, + edge: target.edgeId, + graphKind: PathFrameGraphKind.body, + sourceCursor: null, + targetCursor: cursor, + discard: discard, + intendedPresentationOrdinal: intended, + )); + state.targetNext = nextBodyCursor(target.targetBody, cursor); + if (state.targetNext == null) { + if (target.targetBody.frameCount == 1) { + state.targetNext = SourceBodyCursor( + occurrence: cursor.occurrence + BigInt.one, + frame: 0, + ); + } else { + state.phase = PathSequencePhase.done; + } + } + return plan; + } + + return null; + } +} + +SourceBodyCursor? nextBodyCursor( + GraphBodyDefinition body, + SourceBodyCursor cursor, +) { + if (body.kind == GraphBodyKind.loop) { + return cursor.frame + 1 < body.frameCount + ? SourceBodyCursor(occurrence: cursor.occurrence, frame: cursor.frame + 1) + : SourceBodyCursor(occurrence: cursor.occurrence + BigInt.one, frame: 0); + } + return cursor.frame + 1 < body.frameCount + ? SourceBodyCursor(occurrence: BigInt.zero, frame: cursor.frame + 1) + : null; +} + +void promoteTargetSequenceToSource( + PathSequenceState state, + GraphBodyDefinition body, +) { + if (state.phase != PathSequencePhase.target && + state.phase != PathSequencePhase.done) { + throw RangeError('only a target sequence can become a source sequence'); + } + // A finite target may already be fully prefetched. Keep a terminal source + // phase so a completion/finish route can still switch at exhaustion. + state.phase = PathSequencePhase.source; + state.sourceNext = body.kind == GraphBodyKind.loop + ? _cloneSourceCursor(state.targetNext) + : body.kind == GraphBodyKind.finite && state.targetNext != null + ? SourceBodyCursor( + occurrence: BigInt.zero, + frame: state.targetNext!.frame, + ) + : null; + state.sourceStop = null; + state.sourceDiscardBefore = null; + state.targetNext = null; + state.targetDiscardRemaining = 0; + state.edgeSubmissionStarted = false; +} + +bool sameSourceCursor(SourceBodyCursor left, SourceBodyCursor right) { + return left.occurrence == right.occurrence && left.frame == right.frame; +} + +void _switchToEdge(PathSequenceState state, ScheduledPathRoute? route) { + final transition = route?.edge.transition; + if (transition is GraphTransitionLocked) { + state.phase = PathSequencePhase.bridge; + state.bridgeNextFrame = 0; + return; + } + state.phase = PathSequencePhase.target; + state.targetNext = SourceBodyCursor(occurrence: BigInt.zero, frame: 0); +} + +int _compareSourceCursor( + GraphBodyDefinition body, + SourceBodyCursor left, + SourceBodyCursor right, +) { + final frameCount = BigInt.from(body.frameCount); + final leftAbsolute = left.occurrence * frameCount + BigInt.from(left.frame); + final rightAbsolute = right.occurrence * frameCount + BigInt.from(right.frame); + return leftAbsolute < rightAbsolute + ? -1 + : leftAbsolute > rightAbsolute + ? 1 + : 0; +} + +SourceBodyCursor? _cloneSourceCursor(SourceBodyCursor? cursor) { + return cursor == null + ? null + : SourceBodyCursor(occurrence: cursor.occurrence, frame: cursor.frame); +} + +PathFramePlan _freezePathFramePlan(PathFramePlan plan) { + return PathFramePlan( + purpose: plan.purpose, + unitId: plan.unitId, + unitFrame: plan.unitFrame, + state: plan.state, + edge: plan.edge, + graphKind: plan.graphKind, + sourceCursor: plan.sourceCursor == null + ? null + : SourceBodyCursor( + occurrence: plan.sourceCursor!.occurrence, + frame: plan.sourceCursor!.frame, + ), + targetCursor: plan.targetCursor == null + ? null + : SourceBodyCursor( + occurrence: plan.targetCursor!.occurrence, + frame: plan.targetCursor!.frame, + ), + discard: plan.discard, + intendedPresentationOrdinal: plan.intendedPresentationOrdinal, + ); +} diff --git a/flutter/packages/aval_player/lib/src/platform.dart b/flutter/packages/aval_player/lib/src/platform.dart new file mode 100644 index 0000000..fc175c3 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/platform.dart @@ -0,0 +1,121 @@ +/// Platform seam types referenced by the pure runtime contracts. +/// +/// These stand in for browser/native objects that the TypeScript sources use +/// directly but that have no pure-Dart equivalent. They are declared here as +/// opaque interfaces so the pure `aval_player` package stays free of `dart:ui` +/// / `dart:html` / FFI dependencies; the concrete implementations live in the +/// platform-bound layer (`aval_flutter` / the decode adapter), per §6 of +/// `flutter/ARCHITECTURE.md`. +library; + +/// Opaque handle to a decoded picture. +/// +/// Mirrors the TypeScript `VideoFrame` DOM type used by +/// `decoder-worker/client-support.ts` and `protocol.ts`. In the port, the +/// decoded-frame buffer is owned by the platform decode backend (browser +/// `VideoFrame`, or native external typed data behind a `NativeFinalizer`), so +/// this package only ever holds it as an opaque reference. +abstract interface class VideoFrame {} + +/// Cancellation seam mirroring the browser `AbortSignal`. +/// +/// The path-scheduler family threads this through worker/decode calls to cancel +/// a superseded generation (§4 of `flutter/ARCHITECTURE.md` — the +/// cancellation token stays in Dart). +/// +/// Extended (vs. the original Phase-2 frozen surface) with `reason` and the +/// `addEventListener`/`removeEventListener` seam that +/// `abortablePathSchedulerActivation` (path-scheduler-generation.ts:145-171) +/// requires. A pure-Dart [AbortController]/[DOMException] implementation is +/// provided below so the headless `aval_player` scheduler — and its tests — can +/// run without a browser or FFI backend. +abstract interface class AbortSignal { + /// Whether the associated operation has already been aborted. + bool get aborted; + + /// The reason the operation was aborted (a [DOMException] by default). + Object? get reason; + + /// Registers [listener] for `type` (only `"abort"` is dispatched). When + /// [once] is true the listener is removed after it first fires. + void addEventListener(String type, void Function() listener, {bool once}); + + /// Removes a previously-registered [listener] for `type`. + void removeEventListener(String type, void Function() listener); +} + +/// A cancellable operation token whose [signal] mirrors the browser +/// `AbortController`. Pure Dart — no platform dependency. +class AbortController { + final _AbortSignal _signal = _AbortSignal(); + + AbortSignal get signal => _signal; + + /// Aborts the associated [signal], defaulting to an `AbortError` + /// [DOMException] when no explicit [reason] is supplied (browser parity). + void abort([Object? reason]) { + _signal._abort( + reason ?? DOMException('signal aborted without an explicit reason', + 'AbortError'), + ); + } +} + +class _AbortSignal implements AbortSignal { + bool _aborted = false; + Object? _reason; + final List<_AbortListener> _listeners = <_AbortListener>[]; + + @override + bool get aborted => _aborted; + + @override + Object? get reason => _reason; + + @override + void addEventListener(String type, void Function() listener, + {bool once = false}) { + if (type != 'abort') return; + _listeners.add(_AbortListener(listener, once)); + } + + @override + void removeEventListener(String type, void Function() listener) { + if (type != 'abort') return; + _listeners.removeWhere((entry) => identical(entry.listener, listener)); + } + + void _abort(Object reason) { + if (_aborted) return; + _aborted = true; + _reason = reason; + for (final entry in List<_AbortListener>.of(_listeners)) { + if (entry.once) { + _listeners.removeWhere((candidate) => identical(candidate, entry)); + } + entry.listener(); + } + } +} + +class _AbortListener { + _AbortListener(this.listener, this.once); + + final void Function() listener; + final bool once; +} + +/// Cancellation-error seam mirroring the browser `DOMException`. +/// +/// The scheduler raises `DOMException("...", "AbortError")` when a generation +/// activation is superseded (path-scheduler.ts:767). Only the `name`/`message` +/// surface the runtime and tests observe is modelled. +class DOMException implements Exception { + DOMException(this.message, this.name); + + final String message; + final String name; + + @override + String toString() => '$name: $message'; +} diff --git a/flutter/packages/aval_player/lib/src/presentation_ring.dart b/flutter/packages/aval_player/lib/src/presentation_ring.dart new file mode 100644 index 0000000..e652cec --- /dev/null +++ b/flutter/packages/aval_player/lib/src/presentation_ring.dart @@ -0,0 +1,588 @@ +/// Presentation-ring capacity bounds, validation, and the bounded FIFO ring. +/// +/// Direct port of `packages/player-web/src/runtime/presentation-ring.ts`. +/// The Phase-2 frozen surface exposed only the capacity constants and +/// [validatePresentationRingCapacity] (the sole part `edge-lead.ts` / +/// `submission-horizon.ts` depend on). This file now also ports the +/// [PresentationRing] itself and its data shapes, because +/// `path-scheduler-output.ts` owns one directly (`new PresentationRing(...)`, +/// `enqueue`/`takeExpected`/`activatePath`/`clear`/`dispose`/`snapshot`). +/// +/// TypeScript `bigint` presentation ordinals become Dart `BigInt`; `number` +/// counters become `int`. Discriminated unions (`PresentationRingEnqueueResult`, +/// `PresentationRingTakeResult`) become sealed-class hierarchies carrying the +/// same `kind` wire string. `Object.freeze`d records become immutable classes. +/// The TS `AggregateError` thrown by `#closeAllEntries` becomes a +/// [StateError] carrying the collected causes (Dart has no `AggregateError`). +library; + +import 'decoder_worker/client_support.dart'; +import 'rational_time.dart' show maxSafeInteger; + +/// Minimum accepted presentation-ring capacity (`MIN_PRESENTATION_RING_CAPACITY`). +const int minPresentationRingCapacity = 6; + +/// Maximum accepted presentation-ring capacity (`MAX_PRESENTATION_RING_CAPACITY`). +const int maxPresentationRingCapacity = 12; + +const int _maxMediaIdLength = 128; + +/// The exact identity a decoded frame must carry to enter/leave the ring. +class PresentationRingExpectedFrame { + const PresentationRingExpectedFrame({ + required this.generation, + required this.path, + required this.unitId, + required this.unitInstance, + required this.unitFrame, + required this.decodeOrdinal, + required this.timestamp, + required this.duration, + required this.intendedPresentationOrdinal, + }); + + final int generation; + final String path; + final String unitId; + final int unitInstance; + final int unitFrame; + final int decodeOrdinal; + final int timestamp; + final int duration; + final BigInt intendedPresentationOrdinal; +} + +/// One decoded frame plus its arrival timing, offered to [PresentationRing.enqueue]. +class PresentationRingInsertion { + const PresentationRingInsertion({ + required this.expected, + required this.frame, + required this.workerOutputTimeMs, + required this.uploadReadyTimeMs, + }); + + final PresentationRingExpectedFrame expected; + final ManagedDecoderWorkerFrame frame; + final int workerOutputTimeMs; + final int? uploadReadyTimeMs; +} + +/// A retained ring entry: the expected identity plus its owned frame. +class PresentationRingEntry extends PresentationRingExpectedFrame { + const PresentationRingEntry({ + required super.generation, + required super.path, + required super.unitId, + required super.unitInstance, + required super.unitFrame, + required super.decodeOrdinal, + required super.timestamp, + required super.duration, + required super.intendedPresentationOrdinal, + required this.frameId, + required this.decodedBytes, + required this.workerOutputTimeMs, + required this.uploadReadyTimeMs, + required this.frame, + }); + + final int frameId; + final int decodedBytes; + final int workerOutputTimeMs; + final int? uploadReadyTimeMs; + final ManagedDecoderWorkerFrame frame; +} + +/// Result of an [PresentationRing.enqueue]. +sealed class PresentationRingEnqueueResult { + const PresentationRingEnqueueResult(); + + String get kind; +} + +class PresentationRingEnqueueAccepted extends PresentationRingEnqueueResult { + const PresentationRingEnqueueAccepted({required this.size}); + + final int size; + + @override + String get kind => 'accepted'; +} + +class PresentationRingEnqueueStale extends PresentationRingEnqueueResult { + const PresentationRingEnqueueStale({ + required this.activeGeneration, + required this.discardedGeneration, + }); + + final int activeGeneration; + final int discardedGeneration; + + @override + String get kind => 'stale'; +} + +/// Result of a [PresentationRing.takeExpected]. +sealed class PresentationRingTakeResult { + const PresentationRingTakeResult(); + + String get kind; +} + +class PresentationRingTakeFrame extends PresentationRingTakeResult { + const PresentationRingTakeFrame({required this.entry}); + + final PresentationRingEntry entry; + + @override + String get kind => 'frame'; +} + +class PresentationRingTakeUnderflow extends PresentationRingTakeResult { + const PresentationRingTakeUnderflow({required this.expected}); + + final PresentationRingExpectedFrame expected; + + @override + String get kind => 'underflow'; +} + +/// One entry of a ring snapshot (identity plus bookkeeping, no owned frame). +class PresentationRingSnapshotEntry extends PresentationRingExpectedFrame { + const PresentationRingSnapshotEntry({ + required super.generation, + required super.path, + required super.unitId, + required super.unitInstance, + required super.unitFrame, + required super.decodeOrdinal, + required super.timestamp, + required super.duration, + required super.intendedPresentationOrdinal, + required this.frameId, + required this.decodedBytes, + required this.workerOutputTimeMs, + required this.uploadReadyTimeMs, + }); + + final int frameId; + final int decodedBytes; + final int workerOutputTimeMs; + final int? uploadReadyTimeMs; +} + +/// Observable ring counters and entries. +class PresentationRingSnapshot { + const PresentationRingSnapshot({ + required this.capacity, + required this.generation, + required this.activePath, + required this.size, + required this.decodedBytes, + required this.underflows, + required this.staleFrames, + required this.closedFrames, + required this.disposed, + required this.entries, + }); + + final int capacity; + final int generation; + final String activePath; + final int size; + final int decodedBytes; + final int underflows; + final int staleFrames; + final int closedFrames; + final bool disposed; + final List entries; +} + +/// Result of [PresentationRing.activatePath]. +class PresentationRingActivateResult { + const PresentationRingActivateResult({ + required this.closedFrames, + required this.generation, + required this.path, + }); + + final int closedFrames; + final int generation; + final String path; +} + +/// Construction options for [PresentationRing]. +class PresentationRingOptions { + const PresentationRingOptions({ + required this.capacity, + required this.generation, + required this.path, + }); + + final int capacity; + final int generation; + final String path; +} + +/// Bounded FIFO owner for one active streaming media path. +class PresentationRing { + PresentationRing(PresentationRingOptions options) + : _capacity = options.capacity, + _generation = options.generation, + _activePath = options.path { + validatePresentationRingCapacity(options.capacity); + _validatePositiveSafeInteger(options.generation, 'ring generation'); + _validateMediaId(options.path, 'ring path'); + } + + final int _capacity; + int _generation; + String _activePath; + final List _entries = []; + int _decodedBytes = 0; + int _underflows = 0; + int _staleFrames = 0; + int _closedFrames = 0; + bool _disposed = false; + + /// Takes ownership of `frame` on every success and failure path. + PresentationRingEnqueueResult enqueue(PresentationRingInsertion insertion) { + final frame = insertion.frame; + try { + _requireUsable(); + _validateExpected(insertion.expected); + _validateTiming( + insertion.workerOutputTimeMs, + insertion.uploadReadyTimeMs, + ); + _validatePositiveSafeInteger(frame.frameId, 'worker frame ID'); + _validatePositiveSafeInteger(frame.decodedBytes, 'decoded frame bytes'); + _validateFrameMatches(frame, insertion.expected); + + if (insertion.expected.generation < _generation) { + _closeOwnedFrame(frame); + _staleFrames += 1; + return PresentationRingEnqueueStale( + activeGeneration: _generation, + discardedGeneration: insertion.expected.generation, + ); + } + if (insertion.expected.generation > _generation) { + throw RangeError( + 'ring output generation is newer than the active generation', + ); + } + if (insertion.expected.path != _activePath) { + throw RangeError('ring output did not target the active media path'); + } + if (frame.closed) { + throw RangeError('ring cannot own an already closed frame'); + } + if (_entries.length >= _capacity) { + throw RangeError('presentation ring capacity is full'); + } + if (_entries.any((entry) => + entry.frameId == frame.frameId || + _sameExpected(entry, insertion.expected))) { + throw RangeError('presentation ring rejected a duplicate identity'); + } + + final tail = _entries.isEmpty ? null : _entries.last; + if (tail != null) { + _validateNextFifoIdentity(tail, insertion.expected); + } + if (_decodedBytes > maxSafeInteger - frame.decodedBytes) { + throw RangeError('presentation ring decoded bytes exceed safe range'); + } + + final entry = PresentationRingEntry( + generation: insertion.expected.generation, + path: insertion.expected.path, + unitId: insertion.expected.unitId, + unitInstance: insertion.expected.unitInstance, + unitFrame: insertion.expected.unitFrame, + decodeOrdinal: insertion.expected.decodeOrdinal, + timestamp: insertion.expected.timestamp, + duration: insertion.expected.duration, + intendedPresentationOrdinal: + insertion.expected.intendedPresentationOrdinal, + frameId: frame.frameId, + decodedBytes: frame.decodedBytes, + workerOutputTimeMs: insertion.workerOutputTimeMs, + uploadReadyTimeMs: insertion.uploadReadyTimeMs, + frame: frame, + ); + _entries.add(entry); + _decodedBytes += frame.decodedBytes; + return PresentationRingEnqueueAccepted(size: _entries.length); + } catch (error) { + _closeOwnedFrame(frame); + rethrow; + } + } + + /// Removes only the exact expected head. A frame result transfers ownership + /// to the renderer; the renderer becomes responsible for its single close. + PresentationRingTakeResult takeExpected( + PresentationRingExpectedFrame expected, + ) { + _requireUsable(); + _validateExpected(expected); + if (expected.generation != _generation || expected.path != _activePath) { + throw RangeError( + 'expected presentation does not target the active ring path', + ); + } + + final head = _entries.isEmpty ? null : _entries.first; + if (head == null) { + _underflows += 1; + return PresentationRingTakeUnderflow(expected: expected); + } + if (!_sameExpected(head, expected)) { + throw RangeError( + 'ring head did not match the expected presentation identity', + ); + } + + _removeHead(head); + if (head.frame.closed) { + throw RangeError('ring-owned frame was already closed before take'); + } + return PresentationRingTakeFrame(entry: head); + } + + /// Retires all old path frames before activating a strictly newer token. + PresentationRingActivateResult activatePath({ + required int generation, + required String path, + }) { + _requireUsable(); + _validatePositiveSafeInteger(generation, 'ring generation'); + _validateMediaId(path, 'ring path'); + + if (generation == _generation && path == _activePath) { + return PresentationRingActivateResult( + closedFrames: 0, + generation: _generation, + path: _activePath, + ); + } + if (generation <= _generation) { + throw RangeError( + 'ring generation must increase before replacing the active path', + ); + } + + final closedFrames = _closeAllEntries(); + _generation = generation; + _activePath = path; + return PresentationRingActivateResult( + closedFrames: closedFrames, + generation: _generation, + path: _activePath, + ); + } + + int clear() { + _requireUsable(); + return _closeAllEntries(); + } + + int dispose() { + if (_disposed) return 0; + _disposed = true; + return _closeAllEntries(); + } + + PresentationRingSnapshot snapshot() { + return PresentationRingSnapshot( + capacity: _capacity, + generation: _generation, + activePath: _activePath, + size: _entries.length, + decodedBytes: _decodedBytes, + underflows: _underflows, + staleFrames: _staleFrames, + closedFrames: _closedFrames, + disposed: _disposed, + entries: List.unmodifiable( + _entries.map((entry) => PresentationRingSnapshotEntry( + generation: entry.generation, + path: entry.path, + unitId: entry.unitId, + unitInstance: entry.unitInstance, + unitFrame: entry.unitFrame, + decodeOrdinal: entry.decodeOrdinal, + timestamp: entry.timestamp, + duration: entry.duration, + intendedPresentationOrdinal: entry.intendedPresentationOrdinal, + frameId: entry.frameId, + decodedBytes: entry.decodedBytes, + workerOutputTimeMs: entry.workerOutputTimeMs, + uploadReadyTimeMs: entry.uploadReadyTimeMs, + )), + ), + ); + } + + void _requireUsable() { + if (_disposed) { + throw RangeError('presentation ring is disposed'); + } + } + + void _removeHead(PresentationRingEntry head) { + _entries.removeAt(0); + _decodedBytes -= head.decodedBytes; + } + + bool _closeOwnedFrame(ManagedDecoderWorkerFrame frame) { + final wasOpen = !frame.closed; + frame.close(); + if (wasOpen) { + _closedFrames += 1; + } + return wasOpen; + } + + int _closeAllEntries() { + final entries = List.of(_entries); + _entries.clear(); + _decodedBytes = 0; + var closedFrames = 0; + final errors = []; + for (final entry in entries) { + try { + if (_closeOwnedFrame(entry.frame)) { + closedFrames += 1; + } + } catch (error) { + errors.add(error); + } + } + if (errors.isNotEmpty) { + throw StateError( + 'presentation ring frame cleanup failed: ${errors.join(', ')}', + ); + } + return closedFrames; + } +} + +/// Rejects any capacity outside the inclusive `6-12` range. +void validatePresentationRingCapacity(int capacity) { + if (capacity < minPresentationRingCapacity || + capacity > maxPresentationRingCapacity) { + throw RangeError( + 'presentation ring capacity must be ' + '$minPresentationRingCapacity-$maxPresentationRingCapacity', + ); + } +} + +void _validateExpected(PresentationRingExpectedFrame expected) { + _validatePositiveSafeInteger(expected.generation, 'frame generation'); + _validateMediaId(expected.path, 'frame path'); + _validateMediaId(expected.unitId, 'frame unit ID'); + _validateNonNegativeSafeInteger(expected.unitInstance, 'unit instance'); + _validateNonNegativeSafeInteger(expected.unitFrame, 'unit frame'); + _validateNonNegativeSafeInteger(expected.decodeOrdinal, 'decode ordinal'); + if (expected.decodeOrdinal >= maxSafeInteger) { + throw RangeError('decode ordinal leaves no safe successor'); + } + _validateNonNegativeSafeInteger(expected.timestamp, 'frame timestamp'); + _validatePositiveSafeInteger(expected.duration, 'frame duration'); + if (expected.timestamp > maxSafeInteger - expected.duration) { + throw RangeError('frame timestamp plus duration exceeds safe range'); + } + if (expected.intendedPresentationOrdinal < BigInt.zero) { + throw RangeError('presentation ordinal must be non-negative'); + } +} + +void _validateFrameMatches( + ManagedDecoderWorkerFrame frame, + PresentationRingExpectedFrame expected, +) { + if (frame.generation != expected.generation || + frame.ordinal != expected.decodeOrdinal || + frame.unitId != expected.unitId || + frame.unitInstance != expected.unitInstance || + frame.unitFrame != expected.unitFrame || + frame.timestamp != expected.timestamp || + frame.duration != expected.duration) { + throw RangeError( + 'managed decoder frame did not match its expected ring identity', + ); + } +} + +void _validateNextFifoIdentity( + PresentationRingExpectedFrame previous, + PresentationRingExpectedFrame next, +) { + if (next.decodeOrdinal != previous.decodeOrdinal + 1 || + next.timestamp != previous.timestamp + previous.duration || + next.intendedPresentationOrdinal != + previous.intendedPresentationOrdinal + BigInt.one) { + throw RangeError('presentation ring rejected noncontiguous FIFO order'); + } + + if (next.unitInstance == previous.unitInstance) { + if (next.unitId != previous.unitId || + next.unitFrame != previous.unitFrame + 1) { + throw RangeError('presentation ring rejected noncontiguous unit order'); + } + return; + } + if (next.unitInstance != previous.unitInstance + 1 || next.unitFrame != 0) { + throw RangeError( + 'presentation ring rejected noncontiguous occurrence order', + ); + } +} + +bool _sameExpected( + PresentationRingExpectedFrame left, + PresentationRingExpectedFrame right, +) { + return left.generation == right.generation && + left.path == right.path && + left.unitId == right.unitId && + left.unitInstance == right.unitInstance && + left.unitFrame == right.unitFrame && + left.decodeOrdinal == right.decodeOrdinal && + left.timestamp == right.timestamp && + left.duration == right.duration && + left.intendedPresentationOrdinal == right.intendedPresentationOrdinal; +} + +void _validateTiming(int workerOutputTimeMs, int? uploadReadyTimeMs) { + if (workerOutputTimeMs < 0) { + throw RangeError('worker output time must be finite and non-negative'); + } + if (uploadReadyTimeMs != null && uploadReadyTimeMs < workerOutputTimeMs) { + throw RangeError( + 'upload-ready time must be null or no earlier than worker output', + ); + } +} + +void _validateMediaId(String value, String label) { + if (value.isEmpty || value.length > _maxMediaIdLength) { + throw RangeError('$label length must be 1-$_maxMediaIdLength'); + } +} + +void _validatePositiveSafeInteger(int value, String label) { + if (value <= 0 || value > maxSafeInteger) { + throw RangeError('$label must be a positive safe integer'); + } +} + +void _validateNonNegativeSafeInteger(int value, String label) { + if (value < 0 || value > maxSafeInteger) { + throw RangeError('$label must be a non-negative safe integer'); + } +} diff --git a/flutter/packages/aval_player/lib/src/rational_time.dart b/flutter/packages/aval_player/lib/src/rational_time.dart new file mode 100644 index 0000000..272054e --- /dev/null +++ b/flutter/packages/aval_player/lib/src/rational_time.dart @@ -0,0 +1,146 @@ +/// Exact rational frame-rate clock shared by presentation and decode time. +/// +/// Direct port of `packages/player-web/src/runtime/rational-time.ts`. The TS +/// `number | bigint` virtual-frame parameter becomes a plain Dart `int`: +/// Dart's `int` is 64-bit on the VM, comfortably beyond JavaScript's +/// `Number.MAX_SAFE_INTEGER`, so no separate bigint-accepting overload is +/// needed the way TypeScript required one. `BigInt` is used internally only +/// where the TS source itself used `bigint`, to keep the microsecond dividend +/// calculation exact (frame counts near the safe-integer ceiling would +/// otherwise overflow even a 64-bit product). `Number.MAX_SAFE_INTEGER` is +/// kept as the exact JavaScript literal for parity, per the convention +/// established by `aval_graph`'s `request_ledger.dart`. +library; + +final BigInt _microsecondsPerSecond = BigInt.from(1000000); +final BigInt _maxFrameRate = BigInt.from(60); + +/// The largest integer JavaScript can represent exactly +/// (`Number.MAX_SAFE_INTEGER`). Kept as the literal TypeScript bound for +/// parity, even though Dart's native `int` safely exceeds it. +const int maxSafeInteger = 9007199254740991; +final BigInt _maxSafeIntegerBig = BigInt.from(maxSafeInteger); + +/// A rational frame rate expressed as an authored numerator/denominator pair. +/// +/// Rates are deliberately not reduced: preserving the authored numerator and +/// denominator keeps the manifest clock explicit. +class RationalFrameRate { + const RationalFrameRate({required this.numerator, required this.denominator}); + + final int numerator; + final int denominator; + + @override + bool operator ==(Object other) => + other is RationalFrameRate && + other.numerator == numerator && + other.denominator == denominator; + + @override + int get hashCode => Object.hash(numerator, denominator); + + @override + String toString() => + 'RationalFrameRate(numerator: $numerator, denominator: $denominator)'; +} + +/// A virtual frame's position within one reusable encoded loop occurrence. +class VirtualFramePosition { + const VirtualFramePosition({ + required this.iteration, + required this.contentFrame, + }); + + final BigInt iteration; + final int contentFrame; + + @override + bool operator ==(Object other) => + other is VirtualFramePosition && + other.iteration == iteration && + other.contentFrame == contentFrame; + + @override + int get hashCode => Object.hash(iteration, contentFrame); + + @override + String toString() => + 'VirtualFramePosition(iteration: $iteration, contentFrame: $contentFrame)'; +} + +/// Validates the exact rational clock shared by presentation and decode time. +/// +/// Integer comparison avoids rounding a rate near the 60 fps ceiling through +/// floating-point arithmetic. +void validateFrameRate(RationalFrameRate rate) { + _validatePositiveSafeInteger(rate.numerator, 'frame-rate numerator'); + _validatePositiveSafeInteger(rate.denominator, 'frame-rate denominator'); + + if (BigInt.from(rate.numerator) > + _maxFrameRate * BigInt.from(rate.denominator)) { + throw RangeError('frame rate must not exceed 60 fps'); + } +} + +/// Maps a non-negative frame ordinal to an integer-microsecond timestamp +/// using exact round-half-up arithmetic. +int timestampForFrame(int virtualFrame, RationalFrameRate rate) { + validateFrameRate(rate); + + final frame = _normalizeVirtualFrame(virtualFrame); + final dividend = + frame * _microsecondsPerSecond * BigInt.from(rate.denominator); + final timestamp = _divideRoundHalfUp(dividend, BigInt.from(rate.numerator)); + + if (timestamp > _maxSafeIntegerBig) { + throw RangeError("frame timestamp exceeds JavaScript's safe-integer range"); + } + + return timestamp.toInt(); +} + +/// Uses adjacent exact timestamps rather than accumulating a rounded duration. +int durationForFrame(int virtualFrame, RationalFrameRate rate) { + final frame = _normalizeVirtualFrame(virtualFrame).toInt(); + final timestamp = timestampForFrame(frame, rate); + final nextTimestamp = timestampForFrame(frame + 1, rate); + + return nextTimestamp - timestamp; +} + +/// Maps the global clock back to one frame of a reusable encoded loop. +VirtualFramePosition splitVirtualFrame(int virtualFrame, int unitFrameCount) { + final frame = _normalizeVirtualFrame(virtualFrame); + _validatePositiveSafeInteger(unitFrameCount, 'unit frame count'); + + final count = BigInt.from(unitFrameCount); + + return VirtualFramePosition( + iteration: frame ~/ count, + contentFrame: (frame.remainder(count)).toInt(), + ); +} + +BigInt _normalizeVirtualFrame(int virtualFrame) { + if (virtualFrame < 0) { + throw RangeError( + 'virtual frame must be a non-negative safe integer or bigint', + ); + } + return BigInt.from(virtualFrame); +} + +void _validatePositiveSafeInteger(int value, String label) { + if (value <= 0) { + throw RangeError('$label must be a positive safe integer'); + } +} + +BigInt _divideRoundHalfUp(BigInt dividend, BigInt divisor) { + final quotient = dividend ~/ divisor; + final remainder = dividend.remainder(divisor); + + return quotient + + (remainder * BigInt.two >= divisor ? BigInt.one : BigInt.zero); +} diff --git a/flutter/packages/aval_player/lib/src/submission_horizon.dart b/flutter/packages/aval_player/lib/src/submission_horizon.dart new file mode 100644 index 0000000..c67edc5 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/submission_horizon.dart @@ -0,0 +1,768 @@ +/// Pure owner of selected-route source horizon and boundary decisions. +/// +/// Direct port of `packages/player-web/src/runtime/submission-horizon.ts`. +/// TypeScript `bigint` occurrence/absolute arithmetic becomes Dart `BigInt`; +/// `number` counters become `int`. The `SourceBoundary.type` string union +/// becomes the [SourceBoundaryType] enum — its wire values are compared +/// lexicographically (via [SourceBoundaryType.wireValue]) exactly where the TS +/// tie-break did `boundary.type < earliest.type`. The +/// `SubmissionHorizonDecision` discriminated union becomes a sealed-class +/// hierarchy, each carrying the same `kind` wire string. +/// +/// The `GraphStartPolicy`/`GraphTransition` discriminants (`type === "cut"` +/// etc.) map onto the `aval_graph` sealed subclasses (`GraphStartPolicyCut`, +/// `GraphTransitionReversible`, ...). `validateBody`'s unreachable +/// "kind is invalid" branch (submission-horizon.ts:495) is dropped: `body.kind` +/// is a closed `GraphBodyKind` enum in the Dart port and cannot be invalid. +library; + +import 'package:aval_graph/aval_graph.dart'; + +import 'edge_lead.dart'; +import 'presentation_ring.dart'; + +/// A cursor into one occurrence of a source body. +class SourceBodyCursor { + const SourceBodyCursor({required this.occurrence, required this.frame}); + + final BigInt occurrence; + final int frame; + + @override + bool operator ==(Object other) => + other is SourceBodyCursor && + other.occurrence == occurrence && + other.frame == frame; + + @override + int get hashCode => Object.hash(occurrence, frame); + + @override + String toString() => + 'SourceBodyCursor(occurrence: $occurrence, frame: $frame)'; +} + +/// The category of a resolved source boundary. +enum SourceBoundaryType { + portal('portal'), + finish('finish'), + cut('cut'); + + const SourceBoundaryType(this.wireValue); + + final String wireValue; +} + +/// A resolved source boundary the selected route may depart at. +class SourceBoundary { + const SourceBoundary({ + required this.type, + required this.occurrence, + required this.frame, + required this.wraps, + }); + + final SourceBoundaryType type; + final BigInt occurrence; + final int frame; + final bool wraps; + + @override + bool operator ==(Object other) => + other is SourceBoundary && + other.type == type && + other.occurrence == occurrence && + other.frame == frame && + other.wraps == wraps; + + @override + int get hashCode => Object.hash(type, occurrence, frame, wraps); + + @override + String toString() => + 'SourceBoundary(type: $type, occurrence: $occurrence, frame: $frame, ' + 'wraps: $wraps)'; +} + +/// Reason a selected-portal decision resolved as it did. +enum SelectPortalReason { + authoredBoundary('authored-boundary'), + submittedHorizon('submitted-horizon'), + leadUnavailable('lead-unavailable'); + + const SelectPortalReason(this.wireValue); + + final String wireValue; +} + +/// Reason a readiness rejection resolved as it did. +enum RejectReadinessReason { + maxWaitExceeded('max-wait-exceeded'), + noReachableBoundary('no-reachable-boundary'); + + const RejectReadinessReason(this.wireValue); + + final String wireValue; +} + +/// Selected-route submission-horizon input. +class SubmissionHorizonInput { + const SubmissionHorizonInput({ + required this.body, + required this.edge, + required this.displayed, + required this.submitted, + required this.ringCapacity, + required this.availableConsecutiveEdgeFrames, + required this.elapsedWaitFrames, + }); + + final GraphBodyDefinition body; + final GraphEdgeDefinition edge; + final SourceBodyCursor displayed; + + /// Furthest source access unit already submitted, inclusive. + final SourceBodyCursor submitted; + final int ringCapacity; + final int availableConsecutiveEdgeFrames; + + /// Content ticks already charged to this request. + final int elapsedWaitFrames; +} + +/// The pure decision returned by [planSubmissionHorizon]. +sealed class SubmissionHorizonDecision { + const SubmissionHorizonDecision(); + + String get kind; +} + +class SubmissionHorizonContinueSource extends SubmissionHorizonDecision { + const SubmissionHorizonContinueSource({ + required this.boundary, + required this.waitFrames, + required this.totalWaitFrames, + required this.lead, + }); + + final SourceBoundary boundary; + final int waitFrames; + final int totalWaitFrames; + final EdgeLeadPlan? lead; + + @override + String get kind => 'continue-source'; +} + +class SubmissionHorizonSelectPortal extends SubmissionHorizonDecision { + const SubmissionHorizonSelectPortal({ + required this.reason, + required this.boundary, + required this.waitFrames, + required this.totalWaitFrames, + required this.lead, + }); + + final SelectPortalReason reason; + final SourceBoundary boundary; + final int waitFrames; + final int totalWaitFrames; + final EdgeLeadPlan? lead; + + @override + String get kind => 'select-portal'; +} + +class SubmissionHorizonWaitHeld extends SubmissionHorizonDecision { + const SubmissionHorizonWaitHeld({ + required this.boundary, + required this.elapsedWaitFrames, + required this.remainingWaitFrames, + required this.lead, + }); + + final SourceBoundary boundary; + final int elapsedWaitFrames; + final int remainingWaitFrames; + final EdgeLeadPlan lead; + + @override + String get kind => 'wait-held'; +} + +class SubmissionHorizonCommitEdge extends SubmissionHorizonDecision { + const SubmissionHorizonCommitEdge({ + required this.boundary, + required this.totalWaitFrames, + required this.lead, + }); + + final SourceBoundary boundary; + final int totalWaitFrames; + final EdgeLeadPlan? lead; + + @override + String get kind => 'commit-edge'; +} + +class SubmissionHorizonRestartGeneration extends SubmissionHorizonDecision { + const SubmissionHorizonRestartGeneration({required this.totalWaitFrames}); + + /// Always `"cut"`. + final String reason = 'cut'; + + /// Always `1`. + final int responseFrames = 1; + final int totalWaitFrames; + + @override + String get kind => 'restart-generation'; +} + +class SubmissionHorizonRejectReadiness extends SubmissionHorizonDecision { + const SubmissionHorizonRejectReadiness({ + required this.reason, + required this.requiredWaitFrames, + required this.maxWaitFrames, + required this.lead, + }); + + final RejectReadinessReason reason; + final BigInt requiredWaitFrames; + final int maxWaitFrames; + final EdgeLeadPlan? lead; + + @override + String get kind => 'reject-readiness'; +} + +/// Unresolved-route submission-horizon input. +class UnresolvedSubmissionHorizonInput { + const UnresolvedSubmissionHorizonInput({ + required this.body, + required this.displayed, + required this.submitted, + required this.outgoingStarts, + required this.ringCapacity, + }); + + final GraphBodyDefinition body; + final SourceBodyCursor displayed; + final SourceBodyCursor submitted; + final List outgoingStarts; + final int ringCapacity; +} + +/// The pure result returned by [planUnresolvedSubmissionHorizon]. +class UnresolvedSubmissionHorizon { + const UnresolvedSubmissionHorizon({ + required this.earliestBoundary, + required this.maximumSubmitted, + required this.submittedWithinHorizon, + required this.framesBeyondEarliestBoundary, + }); + + final SourceBoundary earliestBoundary; + final SourceBodyCursor maximumSubmitted; + final bool submittedWithinHorizon; + final BigInt framesBeyondEarliestBoundary; + + @override + bool operator ==(Object other) => + other is UnresolvedSubmissionHorizon && + other.earliestBoundary == earliestBoundary && + other.maximumSubmitted == maximumSubmitted && + other.submittedWithinHorizon == submittedWithinHorizon && + other.framesBeyondEarliestBoundary == framesBeyondEarliestBoundary; + + @override + int get hashCode => Object.hash( + earliestBoundary, + maximumSubmitted, + submittedWithinHorizon, + framesBeyondEarliestBoundary, + ); + + @override + String toString() => + 'UnresolvedSubmissionHorizon(earliestBoundary: $earliestBoundary, ' + 'maximumSubmitted: $maximumSubmitted, ' + 'submittedWithinHorizon: $submittedWithinHorizon, ' + 'framesBeyondEarliestBoundary: $framesBeyondEarliestBoundary)'; +} + +/// Free-running source submission may pass the earliest unresolved boundary by +/// at most one presentation-ring capacity. +UnresolvedSubmissionHorizon planUnresolvedSubmissionHorizon( + UnresolvedSubmissionHorizonInput input, +) { + validatePresentationRingCapacity(input.ringCapacity); + _validateBody(input.body); + _validateCursor(input.body, input.displayed, 'displayed cursor'); + _validateCursor(input.body, input.submitted, 'submitted cursor'); + final displayedAbsolute = _cursorAbsolute( + input.body, + input.displayed.occurrence, + input.displayed.frame, + ); + final submittedAbsolute = _cursorAbsolute( + input.body, + input.submitted.occurrence, + input.submitted.frame, + ); + if (submittedAbsolute < displayedAbsolute) { + throw RangeError('submitted cursor cannot be behind displayed cursor'); + } + if (input.outgoingStarts.isEmpty) { + throw RangeError('unresolved horizon requires an outgoing start policy'); + } + + SourceBoundary? earliest; + BigInt? earliestAbsolute; + for (final start in input.outgoingStarts) { + final boundary = _boundaryForStart(input.body, input.displayed, start); + final absolute = + _cursorAbsolute(input.body, boundary.occurrence, boundary.frame); + if (earliestAbsolute == null || + absolute < earliestAbsolute || + absolute == earliestAbsolute && + boundary.type.wireValue.compareTo(earliest!.type.wireValue) < 0) { + earliest = boundary; + earliestAbsolute = absolute; + } + } + if (earliest == null || earliestAbsolute == null) { + throw RangeError('unresolved horizon has no reachable boundary'); + } + + var maximumAbsolute = earliestAbsolute + BigInt.from(input.ringCapacity); + if (input.body.kind != GraphBodyKind.loop) { + maximumAbsolute = _minimumBigInt( + maximumAbsolute, + BigInt.from(input.body.frameCount - 1), + ); + } + final framesBeyondEarliestBoundary = submittedAbsolute > earliestAbsolute + ? submittedAbsolute - earliestAbsolute + : BigInt.zero; + return UnresolvedSubmissionHorizon( + earliestBoundary: earliest, + maximumSubmitted: _cursorFromAbsolute(input.body, maximumAbsolute), + submittedWithinHorizon: submittedAbsolute <= maximumAbsolute, + framesBeyondEarliestBoundary: framesBeyondEarliestBoundary, + ); +} + +/// Sole pure owner of selected-route source horizon and boundary decisions. +SubmissionHorizonDecision planSubmissionHorizon(SubmissionHorizonInput input) { + validatePresentationRingCapacity(input.ringCapacity); + _validateBody(input.body); + _validateCursor(input.body, input.displayed, 'displayed cursor'); + _validateCursor(input.body, input.submitted, 'submitted cursor'); + _validateNonNegativeSafeInteger( + input.elapsedWaitFrames, + 'elapsed wait frame count', + ); + _validateNonNegativeSafeInteger( + input.edge.start.maxWaitFrames, + 'edge maxWaitFrames', + ); + _validateNonNegativeSafeInteger( + input.availableConsecutiveEdgeFrames, + 'available consecutive frame count', + ); + if (input.availableConsecutiveEdgeFrames > input.ringCapacity) { + throw RangeError( + 'available consecutive frame count exceeds the presentation ring', + ); + } + + final displayedAbsolute = _cursorAbsolute( + input.body, + input.displayed.occurrence, + input.displayed.frame, + ); + final submittedAbsolute = _cursorAbsolute( + input.body, + input.submitted.occurrence, + input.submitted.frame, + ); + if (submittedAbsolute < displayedAbsolute) { + throw RangeError('submitted cursor cannot be behind displayed cursor'); + } + final maxWaitFrames = input.edge.start.maxWaitFrames; + final elapsed = BigInt.from(input.elapsedWaitFrames); + + if (input.edge.start is GraphStartPolicyCut) { + final totalWait = elapsed + BigInt.one; + if (totalWait > BigInt.from(maxWaitFrames)) { + return _rejectMaxWait(totalWait, maxWaitFrames, null); + } + return SubmissionHorizonRestartGeneration( + totalWaitFrames: totalWait.toInt(), + ); + } + + final lead = _createLeadPlan(input); + if (input.edge.start is GraphStartPolicyFinish) { + return _planFinish( + body: input.body, + displayed: input.displayed, + elapsed: elapsed, + maxWaitFrames: maxWaitFrames, + lead: lead, + ); + } + + final start = input.edge.start as GraphStartPolicyPortal; + return _planPortal( + body: input.body, + sourcePort: start.sourcePort, + displayed: input.displayed, + displayedAbsolute: displayedAbsolute, + submittedAbsolute: submittedAbsolute, + elapsed: elapsed, + maxWaitFrames: maxWaitFrames, + lead: lead, + ); +} + +SubmissionHorizonDecision _planFinish({ + required GraphBodyDefinition body, + required SourceBodyCursor displayed, + required BigInt elapsed, + required int maxWaitFrames, + required EdgeLeadPlan? lead, +}) { + final search = findFinishBoundary(body, displayed.frame); + final boundary = SourceBoundary( + type: SourceBoundaryType.finish, + occurrence: displayed.occurrence, + frame: search.boundaryFrame, + wraps: false, + ); + final wait = BigInt.from(search.waitFrames); + final totalWait = elapsed + wait; + if (totalWait > BigInt.from(maxWaitFrames)) { + return _rejectMaxWait(totalWait, maxWaitFrames, lead); + } + if (wait > BigInt.zero) { + return SubmissionHorizonContinueSource( + boundary: boundary, + waitFrames: wait.toInt(), + totalWaitFrames: totalWait.toInt(), + lead: lead, + ); + } + if (_leadReady(lead)) { + return SubmissionHorizonCommitEdge( + boundary: boundary, + totalWaitFrames: totalWait.toInt(), + lead: lead, + ); + } + if (lead == null) { + throw StateError('resident edge lead invariant failed'); + } + if (elapsed >= BigInt.from(maxWaitFrames)) { + return _rejectMaxWait(elapsed + BigInt.one, maxWaitFrames, lead); + } + return SubmissionHorizonWaitHeld( + boundary: boundary, + elapsedWaitFrames: elapsed.toInt(), + remainingWaitFrames: maxWaitFrames - elapsed.toInt(), + lead: lead, + ); +} + +SubmissionHorizonDecision _planPortal({ + required GraphBodyDefinition body, + required String sourcePort, + required SourceBodyCursor displayed, + required BigInt displayedAbsolute, + required BigInt submittedAbsolute, + required BigInt elapsed, + required int maxWaitFrames, + required EdgeLeadPlan? lead, +}) { + final graphSearch = findNextPortalBoundary(body, sourcePort, displayed.frame); + final graphBoundaryOccurrence = + displayed.occurrence + (graphSearch.wraps ? BigInt.one : BigInt.zero); + final graphBoundaryAbsolute = + graphBoundaryOccurrence * BigInt.from(body.frameCount) + + BigInt.from(graphSearch.boundaryFrame); + + // A reversible transition is already resident (`lead == null`), so source + // frames submitted beyond the visible authored portal can be discarded. + // Streamed transitions still have to select at/after their submitted debt. + final minimumAbsolute = lead == null + ? displayedAbsolute + : _maximumBigInt(displayedAbsolute, submittedAbsolute); + var candidate = _findPortalAtOrAfter( + body, + sourcePort, + minimumAbsolute, + displayed.occurrence, + ); + if (candidate == null) { + return _rejectNoBoundary(maxWaitFrames, lead); + } + + var reason = candidate.absolute > graphBoundaryAbsolute + ? SelectPortalReason.submittedHorizon + : SelectPortalReason.authoredBoundary; + + if (candidate.absolute == displayedAbsolute && _leadReady(lead)) { + if (elapsed > BigInt.from(maxWaitFrames)) { + return _rejectMaxWait(elapsed, maxWaitFrames, lead); + } + return SubmissionHorizonCommitEdge( + boundary: candidate.boundary, + totalWaitFrames: elapsed.toInt(), + lead: lead, + ); + } + + if (candidate.absolute == displayedAbsolute) { + final later = _findPortalAtOrAfter( + body, + sourcePort, + displayedAbsolute + BigInt.one, + displayed.occurrence, + ); + if (later == null) { + if (body.kind != GraphBodyKind.loop && + displayed.frame == body.frameCount - 1 && + lead != null) { + if (elapsed >= BigInt.from(maxWaitFrames)) { + return _rejectMaxWait(elapsed + BigInt.one, maxWaitFrames, lead); + } + return SubmissionHorizonWaitHeld( + boundary: candidate.boundary, + elapsedWaitFrames: elapsed.toInt(), + remainingWaitFrames: maxWaitFrames - elapsed.toInt(), + lead: lead, + ); + } + return _rejectNoBoundary(maxWaitFrames, lead); + } + candidate = later; + reason = SelectPortalReason.leadUnavailable; + } + + final wait = candidate.absolute - displayedAbsolute; + final totalWait = elapsed + wait; + if (totalWait > BigInt.from(maxWaitFrames)) { + return _rejectMaxWait(totalWait, maxWaitFrames, lead); + } + return SubmissionHorizonSelectPortal( + reason: reason, + boundary: candidate.boundary, + waitFrames: wait.toInt(), + totalWaitFrames: totalWait.toInt(), + lead: lead, + ); +} + +EdgeLeadPlan? _createLeadPlan(SubmissionHorizonInput input) { + final transition = input.edge.transition; + if (transition is GraphTransitionReversible) { + return null; + } + return planEdgeLead(EdgeLeadInput( + transitionFrames: transition?.frameCount ?? 0, + ringCapacity: input.ringCapacity, + availableConsecutiveFrames: input.availableConsecutiveEdgeFrames, + )); +} + +bool _leadReady(EdgeLeadPlan? lead) { + return lead == null || lead.ready; +} + +SourceBoundary _boundaryForStart( + GraphBodyDefinition body, + SourceBodyCursor displayed, + GraphStartPolicy start, +) { + if (start is GraphStartPolicyCut) { + return SourceBoundary( + type: SourceBoundaryType.cut, + occurrence: displayed.occurrence, + frame: displayed.frame, + wraps: false, + ); + } + if (start is GraphStartPolicyFinish) { + final search = findFinishBoundary(body, displayed.frame); + return SourceBoundary( + type: SourceBoundaryType.finish, + occurrence: displayed.occurrence, + frame: search.boundaryFrame, + wraps: false, + ); + } + final portalStart = start as GraphStartPolicyPortal; + final search = + findNextPortalBoundary(body, portalStart.sourcePort, displayed.frame); + return SourceBoundary( + type: SourceBoundaryType.portal, + occurrence: displayed.occurrence + (search.wraps ? BigInt.one : BigInt.zero), + frame: search.boundaryFrame, + wraps: search.wraps, + ); +} + +class _PortalCandidate { + const _PortalCandidate({required this.absolute, required this.boundary}); + + final BigInt absolute; + final SourceBoundary boundary; +} + +_PortalCandidate? _findPortalAtOrAfter( + GraphBodyDefinition body, + String sourcePort, + BigInt minimumAbsolute, + BigInt displayedOccurrence, +) { + // Invoke graph's owner first for complete body/port geometry validation. + findNextPortalBoundary(body, sourcePort, 0); + GraphPortDefinition? port; + for (final candidate in body.ports) { + if (candidate.id == sourcePort) { + port = candidate; + break; + } + } + if (port == null) { + return null; + } + + final frameCount = BigInt.from(body.frameCount); + if (body.kind != GraphBodyKind.loop) { + final minimumFrame = minimumAbsolute.toInt(); + final frame = _firstFrameAtOrAfter(port.portalFrames, minimumFrame); + if (frame == null) { + return null; + } + return _PortalCandidate( + absolute: BigInt.from(frame), + boundary: SourceBoundary( + type: SourceBoundaryType.portal, + occurrence: BigInt.zero, + frame: frame, + wraps: false, + ), + ); + } + + var occurrence = minimumAbsolute ~/ frameCount; + final minimumFrame = (minimumAbsolute % frameCount).toInt(); + int? frame = _firstFrameAtOrAfter(port.portalFrames, minimumFrame); + if (frame == null) { + occurrence += BigInt.one; + frame = port.portalFrames.isEmpty ? null : port.portalFrames[0]; + } + if (frame == null) { + return null; + } + final absolute = occurrence * frameCount + BigInt.from(frame); + return _PortalCandidate( + absolute: absolute, + boundary: SourceBoundary( + type: SourceBoundaryType.portal, + occurrence: occurrence, + frame: frame, + wraps: occurrence > displayedOccurrence, + ), + ); +} + +int? _firstFrameAtOrAfter(List portalFrames, int minimumFrame) { + for (final portal in portalFrames) { + if (portal >= minimumFrame) { + return portal; + } + } + return null; +} + +void _validateBody(GraphBodyDefinition body) { + if (body.frameCount <= 0) { + throw RangeError('source body frameCount must be a positive safe integer'); + } + if (body.kind == GraphBodyKind.held && body.frameCount != 1) { + throw RangeError('held source body must contain one frame'); + } +} + +void _validateCursor( + GraphBodyDefinition body, + SourceBodyCursor cursor, + String label, +) { + if (cursor.occurrence < BigInt.zero) { + throw RangeError('$label occurrence must be a non-negative bigint'); + } + if (cursor.frame < 0 || cursor.frame >= body.frameCount) { + throw RangeError('$label frame is out of range'); + } + if (body.kind != GraphBodyKind.loop && cursor.occurrence != BigInt.zero) { + throw RangeError('$label must remain in occurrence zero'); + } +} + +BigInt _cursorAbsolute(GraphBodyDefinition body, BigInt occurrence, int frame) { + return occurrence * BigInt.from(body.frameCount) + BigInt.from(frame); +} + +SourceBodyCursor _cursorFromAbsolute(GraphBodyDefinition body, BigInt absolute) { + if (body.kind != GraphBodyKind.loop) { + return SourceBodyCursor(occurrence: BigInt.zero, frame: absolute.toInt()); + } + final frameCount = BigInt.from(body.frameCount); + return SourceBodyCursor( + occurrence: absolute ~/ frameCount, + frame: (absolute % frameCount).toInt(), + ); +} + +SubmissionHorizonRejectReadiness _rejectMaxWait( + BigInt requiredWaitFrames, + int maxWaitFrames, + EdgeLeadPlan? lead, +) { + return SubmissionHorizonRejectReadiness( + reason: RejectReadinessReason.maxWaitExceeded, + requiredWaitFrames: requiredWaitFrames, + maxWaitFrames: maxWaitFrames, + lead: lead, + ); +} + +SubmissionHorizonRejectReadiness _rejectNoBoundary( + int maxWaitFrames, + EdgeLeadPlan? lead, +) { + return SubmissionHorizonRejectReadiness( + reason: RejectReadinessReason.noReachableBoundary, + requiredWaitFrames: BigInt.from(maxWaitFrames) + BigInt.one, + maxWaitFrames: maxWaitFrames, + lead: lead, + ); +} + +void _validateNonNegativeSafeInteger(int value, String label) { + if (value < 0) { + throw RangeError('$label must be a non-negative safe integer'); + } +} + +BigInt _maximumBigInt(BigInt left, BigInt right) { + return left > right ? left : right; +} + +BigInt _minimumBigInt(BigInt left, BigInt right) { + return left < right ? left : right; +} diff --git a/flutter/packages/aval_player/lib/src/verified_blob_store.dart b/flutter/packages/aval_player/lib/src/verified_blob_store.dart new file mode 100644 index 0000000..6be403e --- /dev/null +++ b/flutter/packages/aval_player/lib/src/verified_blob_store.dart @@ -0,0 +1,66 @@ +/// Frozen contract for the sparse digest-verified blob store. +/// +/// **Partial port** of `packages/player-web/src/runtime/verified-blob-store.ts`. +/// The full store (967 LOC) is fetch/SHA-256-bound (§1.2 bucket B) and belongs +/// to a later networking phase. Only the surface `asset-catalog.ts` binds +/// against — [VerifiedBlobStore]'s `state`/`copyRange`/`inspectAvcRendition`/ +/// `snapshot`/`dispose` methods and the [VerifiedBlobStoreSnapshot] / +/// [VerifiedBlobDescriptor] shapes — is ported here as a frozen interface, +/// mirroring the same deferral pattern used by `worker_samples.dart`'s original +/// scaffold. The complete-owned-bytes catalog path +/// (`installRuntimeAssetCatalog`) never touches this store; only the sparse +/// `createMetadataRuntimeAssetCatalog` path does. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart' show AvcRenditionInspection; + +import 'borrowed_avc_inspection.dart' show BorrowedAvcRenditionPlan; +import 'model.dart' + show RuntimeBlobResidencySnapshot, RuntimeBlobResidencyState; + +/// One declared blob the store must be able to verify and copy. +class VerifiedBlobDescriptor { + const VerifiedBlobDescriptor({ + required this.key, + required this.kind, + required this.byteLength, + }); + + final String key; + + /// Always `"unit"` for catalog-derived descriptors. + final String kind; + final int byteLength; +} + +/// Immutable snapshot of the store's residency accounting. +class VerifiedBlobStoreSnapshot { + const VerifiedBlobStoreSnapshot({ + required this.generation, + required this.verifiedBytes, + required this.persistentBytes, + required this.disposed, + required this.unitBlobs, + }); + + final int generation; + final int verifiedBytes; + final int persistentBytes; + final bool disposed; + final RuntimeBlobResidencySnapshot unitBlobs; +} + +/// A sparse, digest-verified byte store keyed by unit-blob key. +abstract interface class VerifiedBlobStore { + RuntimeBlobResidencyState state(String key); + + Uint8List copyRange(String key, int relativeOffset, int byteLength); + + AvcRenditionInspection inspectAvcRendition(BorrowedAvcRenditionPlan plan); + + VerifiedBlobStoreSnapshot snapshot(); + + Future dispose(); +} diff --git a/flutter/packages/aval_player/lib/src/worker_samples.dart b/flutter/packages/aval_player/lib/src/worker_samples.dart new file mode 100644 index 0000000..58ff043 --- /dev/null +++ b/flutter/packages/aval_player/lib/src/worker_samples.dart @@ -0,0 +1,384 @@ +/// The concrete worker sample factory: joins catalog records, timeline +/// identity, and sample bytes into one closed, atomically-committed batch. +/// +/// Direct port of `packages/player-web/src/runtime/worker-samples.ts`. It +/// supersedes the earlier frozen-interface scaffold: [WorkerSampleFactory] is +/// now the concrete TS class (the path scheduler still binds to it by name, and +/// `implements WorkerSampleFactory` test doubles remain valid). +/// +/// Judgment calls (with TS anchors): +/// - The TS `Reflect`-based hostile-object hardening in `captureResourceHost` / +/// `captureTransferLease` (worker-samples.ts:216-261) collapses to typed +/// interfaces ([WorkerSampleResourceHost] / [WorkerSampleTransferLease]) plus +/// the once-guard on release; Dart cannot invoke an accessor a value did not +/// declare, so the "inaccessible"/"malformed" reflection branches are moot. +/// - The `data instanceof ArrayBuffer` guard (worker-samples.ts:164) is dropped +/// because [WorkerSampleCatalog.copySample] is statically a `ByteBuffer`. +/// - `Object.freeze`/`Object.defineProperty(release)` (worker-samples.ts:196) +/// have no Dart analog; the batch is an immutable class with a `release()` +/// method and `List.unmodifiable` samples. +/// - `Number.isSafeInteger`/`Number.MAX_SAFE_INTEGER` map to explicit bounds +/// against [maxSafeInteger]. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart' + show RenditionV01, UnitV01, isAvcCodec; + +import 'asset_catalog_index.dart' + show + RuntimeCatalogAccessUnit, + RuntimeCatalogIdIndex, + RuntimeCatalogRecordIndex; +import 'decode_timeline.dart' + show DecodeTimeline, DecodeTimelineFrameRequest; +import 'decoder_worker/protocol.dart' + show + DecoderWorkerHardLimits, + DecoderWorkerLimits, + DecoderWorkerSample, + EncodedVideoChunkType; +import 'rational_time.dart' show maxSafeInteger; + +/// The catalog surface a factory reads (a `Pick`-narrowed [RuntimeAssetCatalog]; +/// worker-samples.ts:22-27). +abstract interface class WorkerSampleCatalog { + RuntimeCatalogIdIndex get renditions; + RuntimeCatalogIdIndex get units; + RuntimeCatalogRecordIndex get records; + + ByteBuffer copySample(String rendition, String unit, int localFrame); +} + +/// One requested source/target frame. +class WorkerSampleFrameRequest { + const WorkerSampleFrameRequest({ + required this.unitId, + required this.unitFrame, + }); + + final String unitId; + final int unitFrame; +} + +/// Input to [WorkerSampleFactory.createBatch]. +class CreateWorkerSampleBatchInput { + const CreateWorkerSampleBatchInput({ + required this.frames, + required this.pendingSamples, + required this.outstandingFrames, + }); + + final List frames; + final int pendingSamples; + final int outstandingFrames; +} + +/// A batch of decoder samples with a transfer-claim release hook. +abstract interface class DecoderWorkerSampleBatch { + int get generation; + List get samples; + + /// Release the main-thread transfer claim after submit transfers ownership. + void release(); +} + +/// A resource lease returned by [WorkerSampleResourceHost.claim]. +abstract interface class WorkerSampleTransferLease { + void release(); +} + +/// A resource host that charges the transferred access-unit bytes up front. +abstract interface class WorkerSampleResourceHost { + WorkerSampleTransferLease claim(int byteLength); +} + +/// Construction options for [WorkerSampleFactory]. +class WorkerSampleFactoryOptions { + const WorkerSampleFactoryOptions({ + required this.catalog, + required this.timeline, + required this.rendition, + required this.limits, + this.resourceHost, + }); + + final WorkerSampleCatalog catalog; + final DecodeTimeline timeline; + final String rendition; + final DecoderWorkerLimits limits; + final WorkerSampleResourceHost? resourceHost; +} + +typedef _ClaimTransfer = WorkerSampleTransferLease Function(int byteLength); + +class _ValidatedFrameRequest { + const _ValidatedFrameRequest(this.request, this.unit, this.accessUnit); + + final WorkerSampleFrameRequest request; + final UnitV01 unit; + final RuntimeCatalogAccessUnit accessUnit; +} + +/// Sole owner that joins catalog records, timeline identity, and sample bytes. +class WorkerSampleFactory { + WorkerSampleFactory(WorkerSampleFactoryOptions options) + : _catalog = options.catalog, + _timeline = options.timeline, + _rendition = options.rendition, + _limits = DecoderWorkerLimits( + maxDecodeQueueSize: options.limits.maxDecodeQueueSize, + maxPendingSamples: options.limits.maxPendingSamples, + maxOutstandingFrames: options.limits.maxOutstandingFrames, + maxDecodedBytes: options.limits.maxDecodedBytes, + ) { + _validateWorkerLimits(options.limits); + final rendition = options.catalog.renditions.require(options.rendition); + if ((rendition.profile != 'avc-annexb-opaque-v0' && + rendition.profile != 'avc-annexb-packed-alpha-v0' && + rendition.profile != 'avc-annexb-opaque-v1' && + rendition.profile != 'avc-annexb-packed-alpha-v1') || + !isAvcCodec(rendition.codec)) { + throw RangeError('worker sample factory requires an exact AVC rendition'); + } + _claimTransfer = options.resourceHost == null + ? null + : _captureResourceHost(options.resourceHost!); + } + + final WorkerSampleCatalog _catalog; + final DecodeTimeline _timeline; + final String _rendition; + final DecoderWorkerLimits _limits; + _ClaimTransfer? _claimTransfer; + + DecoderWorkerSampleBatch createBatch(CreateWorkerSampleBatchInput input) { + _validateBatchCredit(input, _limits); + + final validated = <_ValidatedFrameRequest>[]; + final timelineFrames = []; + var transferBytes = 0; + for (final request in input.frames) { + _validateFrameRequest(request); + final unit = _catalog.units.require(request.unitId); + final accessUnit = _catalog.records.require( + _rendition, + request.unitId, + request.unitFrame, + ); + _validateCatalogRecord(accessUnit, unit, _rendition, request); + validated.add(_ValidatedFrameRequest(request, unit, accessUnit)); + transferBytes = _checkedTransferSum(transferBytes, accessUnit.range.length); + timelineFrames.add(DecodeTimelineFrameRequest( + unitId: request.unitId, + unitFrame: request.unitFrame, + unitFrameCount: unit.frameCount, + )); + } + + // Planning validates the complete occurrence grammar and clock without + // advancing any counter. Payload allocation starts only after this point. + final timelinePlan = _timeline.planSampleBatch(timelineFrames); + final transferLease = _claimTransfer == null + ? _noopTransferLease + : _captureTransferLease(_claimTransfer!(transferBytes)); + final samples = []; + final buffers = {}; + try { + for (var index = 0; index < validated.length; index += 1) { + final frame = validated[index]; + final metadata = timelinePlan.samples[index]; + + final data = _catalog.copySample( + _rendition, + frame.request.unitId, + frame.request.unitFrame, + ); + if (data.lengthInBytes != frame.accessUnit.range.length) { + throw RangeError('catalog sample copy must have the exact record length'); + } + if (buffers.contains(data)) { + throw RangeError('every worker sample must own a distinct ArrayBuffer'); + } + buffers.add(data); + + samples.add(DecoderWorkerSample( + ordinal: metadata.ordinal, + unitId: metadata.unitId, + unitInstance: metadata.unitInstance, + unitFrame: metadata.unitFrame, + unitFrameCount: metadata.unitFrameCount, + type: frame.accessUnit.record.key + ? EncodedVideoChunkType.key + : EncodedVideoChunkType.delta, + timestamp: metadata.timestamp, + duration: metadata.duration, + data: data, + )); + } + + final batch = _WorkerSampleBatch( + generation: timelinePlan.generation, + samples: List.unmodifiable(samples), + release: transferLease.release, + ); + timelinePlan.commit(); + return batch; + } catch (_) { + transferLease.release(); + rethrow; + } + } +} + +class _WorkerSampleBatch implements DecoderWorkerSampleBatch { + _WorkerSampleBatch({ + required this.generation, + required this.samples, + required void Function() release, + }) : _release = release; + + @override + final int generation; + + @override + final List samples; + + final void Function() _release; + + @override + void release() => _release(); +} + +final WorkerSampleTransferLease _noopTransferLease = _NoopTransferLease(); + +class _NoopTransferLease implements WorkerSampleTransferLease { + @override + void release() {} +} + +_ClaimTransfer _captureResourceHost(WorkerSampleResourceHost value) { + return (byteLength) => value.claim(byteLength); +} + +WorkerSampleTransferLease _captureTransferLease(WorkerSampleTransferLease value) { + var released = false; + return _GuardedTransferLease(() { + if (released) return; + released = true; + value.release(); + }); +} + +class _GuardedTransferLease implements WorkerSampleTransferLease { + _GuardedTransferLease(this._release); + + final void Function() _release; + + @override + void release() => _release(); +} + +int _checkedTransferSum(int total, int bytes) { + if (total < 0 || + bytes <= 0 || + total > maxSafeInteger || + bytes > maxSafeInteger || + total > maxSafeInteger - bytes) { + throw RangeError('worker sample transfer bytes exceed the safe range'); + } + return total + bytes; +} + +void _validateWorkerLimits(DecoderWorkerLimits limits) { + _validateBoundedPositiveInteger( + limits.maxDecodeQueueSize, + DecoderWorkerHardLimits.maxDecodeQueueSize, + 'worker decode queue limit', + ); + _validateBoundedPositiveInteger( + limits.maxPendingSamples, + DecoderWorkerHardLimits.maxPendingSamples, + 'worker pending sample limit', + ); + _validateBoundedPositiveInteger( + limits.maxOutstandingFrames, + DecoderWorkerHardLimits.maxOutstandingFrames, + 'worker outstanding frame limit', + ); + _validateBoundedPositiveInteger( + limits.maxDecodedBytes, + DecoderWorkerHardLimits.maxDecodedBytes, + 'worker decoded byte limit', + ); +} + +void _validateBatchCredit( + CreateWorkerSampleBatchInput input, + DecoderWorkerLimits limits, +) { + if (input.frames.isEmpty || + input.frames.length > DecoderWorkerHardLimits.maxPendingSamples) { + throw RangeError('worker sample batch length exceeds the hard sample limit'); + } + _validateNonNegativeSafeInteger(input.pendingSamples, 'pending sample count'); + _validateNonNegativeSafeInteger( + input.outstandingFrames, + 'outstanding frame count', + ); + if (input.pendingSamples > limits.maxPendingSamples || + input.frames.length > limits.maxPendingSamples - input.pendingSamples) { + throw RangeError('worker sample batch exceeds the pending sample limit'); + } + if (input.outstandingFrames > limits.maxOutstandingFrames || + input.frames.length > + limits.maxOutstandingFrames - input.outstandingFrames) { + throw RangeError('worker sample batch exceeds the outstanding frame limit'); + } +} + +void _validateFrameRequest(WorkerSampleFrameRequest request) { + if (request.unitId.isEmpty || request.unitId.length > 128) { + throw RangeError('worker sample unit ID length must be 1-128'); + } + _validateNonNegativeSafeInteger(request.unitFrame, 'worker sample unit frame'); +} + +void _validateCatalogRecord( + RuntimeCatalogAccessUnit accessUnit, + UnitV01 unit, + String rendition, + WorkerSampleFrameRequest request, +) { + if (unit.frameCount <= 0 || + unit.frameCount > maxSafeInteger || + request.unitFrame >= unit.frameCount) { + throw RangeError('worker sample unit frame is outside its unit'); + } + if (accessUnit.rendition != rendition || + accessUnit.unit != request.unitId || + accessUnit.localFrame != request.unitFrame || + accessUnit.record.frameIndex != request.unitFrame) { + throw RangeError('catalog access-unit identity did not match the request'); + } + if (accessUnit.range.length < 1 || + accessUnit.range.length > maxSafeInteger || + accessUnit.record.payloadLength != accessUnit.range.length) { + throw RangeError('catalog sample byte length exceeds the worker limit'); + } + // TS `typeof accessUnit.record.key !== "boolean"` cannot fail in Dart: the + // field is statically `bool` (worker-samples.ts:371-373). +} + +void _validateBoundedPositiveInteger(int value, int maximum, String label) { + if (value <= 0 || value > maximum) { + throw RangeError('$label must be a positive integer no greater than $maximum'); + } +} + +void _validateNonNegativeSafeInteger(int value, String label) { + if (value < 0 || value > maxSafeInteger) { + throw RangeError('$label must be a non-negative safe integer'); + } +} diff --git a/flutter/packages/aval_player/pubspec.yaml b/flutter/packages/aval_player/pubspec.yaml new file mode 100644 index 0000000..e556462 --- /dev/null +++ b/flutter/packages/aval_player/pubspec.yaml @@ -0,0 +1,20 @@ +name: aval_player +description: > + Path scheduler and decode/render adapter interfaces for the AVAL player + runtime. Pure-Dart port of @pixel-point/aval-player-web's runtime scheduling + core, with full behavioral parity. No Flutter dependency. +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.5.0 + +dependencies: + aval_graph: + path: ../aval_graph + aval_format: + path: ../aval_format + +dev_dependencies: + test: ^1.25.0 + lints: ^4.0.0 diff --git a/flutter/packages/aval_player/test/asset_catalog_test.dart b/flutter/packages/aval_player/test/asset_catalog_test.dart new file mode 100644 index 0000000..9bab31d --- /dev/null +++ b/flutter/packages/aval_player/test/asset_catalog_test.dart @@ -0,0 +1,53 @@ +/// Port of `packages/player-web/src/runtime/asset-catalog.test.ts` (1:1). +/// +/// JS-only assertions with no Dart analog are noted inline: the TS +/// `"staticFrames" in catalog.manifest` structural check +/// (asset-catalog.test.ts:19) is dropped because [CompiledManifestV01] has no +/// such field in the statically-typed Dart port, and `toEqual`/`toMatchObject` +/// on plain objects become explicit field comparisons. +library; + +import 'dart:typed_data'; + +import 'package:aval_player/aval_player.dart'; +import 'package:test/test.dart'; + +import 'asset_test_fixture.dart'; + +void main() { + group('runtime asset catalog', () { + test('indexes and copies only animation unit payloads', () { + final catalog = RuntimeAssetCatalog(createOpaqueTestAsset()); + + expect(catalog.manifest.initialState, 'idle'); + final idle = catalog.states.require('idle'); + expect(idle.id, 'idle'); + expect(idle.bodyUnit, 'body'); + expect(idle.initialUnit, 'intro'); + + final descriptors = + createRuntimeCatalogBlobDescriptors(catalog.layout.frontIndex); + expect(descriptors, hasLength(2)); + expect(descriptors.every((descriptor) => descriptor.kind == 'unit'), true); + expect( + Uint8List.view(catalog.copySample('opaque', 'body', 0)).length, + greaterThan(0), + ); + final residency = catalog.residencySnapshot().unitBlobs; + expect(residency.total, 2); + expect(residency.verified, 2); + + catalog.dispose(); + expect(catalog.ownedByteLength, 0); + }); + + test('retains an allowlisted AVC-v1 rendition profile', () { + final catalog = RuntimeAssetCatalog(createOpaqueTestAsset( + const OpaqueTestAssetOptions(profile: 'avc-annexb-opaque-v1'), + )); + + expect(catalog.manifest.renditions[0].profile, 'avc-annexb-opaque-v1'); + catalog.dispose(); + }); + }); +} diff --git a/flutter/packages/aval_player/test/asset_test_fixture.dart b/flutter/packages/aval_player/test/asset_test_fixture.dart new file mode 100644 index 0000000..ad84235 --- /dev/null +++ b/flutter/packages/aval_player/test/asset_test_fixture.dart @@ -0,0 +1,142 @@ +/// Test-only synthetic `.avl` builders. +/// +/// Port of the subset of `packages/player-web/src/runtime/asset-test-fixture.ts` +/// used by the ported `worker_samples_test.dart` / `asset_catalog_test.dart` +/// suites: [createOpaqueTestAsset] plus [opaqueTestRendition] and helpers. The +/// `avc-annexb-opaque` synthetic access-unit byte constants are copied verbatim +/// from the TS fixture. The integrated / reference-only / path variants are not +/// needed by these two suites and are omitted. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart'; + +const String _digest = + '0000000000000000000000000000000000000000000000000000000000000000'; + +const List _keyAccessUnit = [ + 0, 0, 0, 1, 9, 16, 0, 0, 0, 1, 103, 66, 224, 32, 218, 16, 154, // + 106, 2, 2, 2, 128, 0, 0, 3, 0, 128, 0, 0, 30, 70, 208, 68, 35, 80, + 0, 0, 1, 104, 206, 50, 200, 0, 0, 1, 101, 184, 79, 192 +]; + +const List _deltaAccessUnit = [ + 0, 0, 0, 1, 9, 48, 0, 0, 1, 97, 226, 63 +]; + +/// Options for [createOpaqueTestAsset]. +class OpaqueTestAssetOptions { + const OpaqueTestAssetOptions({ + this.corruptIntroDelta = false, + this.pixelAspect, + this.profile, + }); + + final bool corruptIntroDelta; + final List? pixelAspect; + + /// `"avc-annexb-opaque-v0"` (default) or `"avc-annexb-opaque-v1"`. + final String? profile; +} + +AvcOpaqueRenditionV01 opaqueTestRendition({ + String id = 'opaque', + int codedWidth = 64, + int codedHeight = 64, + int peakBitrate = 2000000, + int averageBitrate = 1000000, + String profile = 'avc-annexb-opaque-v0', +}) { + return AvcOpaqueRenditionV01( + id: id, + profile: profile, + codec: 'avc1.42E020', + codedWidth: codedWidth, + codedHeight: codedHeight, + colorRect: Rect(0, 0, codedWidth, codedHeight), + bitrate: BitrateV01(average: averageBitrate, peak: peakBitrate), + ); +} + +Uint8List createOpaqueTestAsset([ + OpaqueTestAssetOptions options = const OpaqueTestAssetOptions(), +]) { + final profile = options.profile ?? 'avc-annexb-opaque-v0'; + final rendition = opaqueTestRendition(profile: profile); + final samples = [ + const SampleDigestInputV01(rendition: 'opaque', sha256: _digest), + ]; + final input = CanonicalAssetInputV01( + manifest: CompiledManifestInputV01( + generator: 'player-web-m55-tests', + canvas: CanvasV01( + width: 64, + height: 64, + fit: 'contain', + pixelAspect: options.pixelAspect ?? const [1, 1], + ), + frameRate: const RationalV01(numerator: 30, denominator: 1), + renditions: [rendition], + units: [ + BodyUnitInputV01( + id: 'body', + frameCount: 2, + samples: samples, + playback: 'loop', + ports: [ + const PortV01(id: 'default', portalFrames: [0, 1]), + ], + ), + OneShotUnitInputV01(id: 'intro', frameCount: 2, samples: samples), + ], + initialState: 'idle', + states: const [ + StateV01(id: 'idle', bodyUnit: 'body', initialUnit: 'intro'), + ], + edges: const [], + bindings: const [], + readiness: const ReadinessV01( + bootstrapUnits: ['body', 'intro'], + immediateEdges: [], + ), + limits: const DeclaredLimitsV01( + maxCompiledBytes: 64 * 1024, + maxRuntimeBytes: 1024 * 1024, + decodedPixelBytes: 64 * 64 * 4, + persistentCacheBytes: 0, + runtimeWorkingSetBytes: 64 * 64 * 4, + ), + ), + accessUnits: [ + _accessUnit('body', 0, true, _keyAccessUnit), + _accessUnit('body', 1, false, _deltaAccessUnit), + _accessUnit('intro', 0, true, _keyAccessUnit), + _accessUnit( + 'intro', + 1, + false, + options.corruptIntroDelta + ? const [0, 0, 0, 1, 9, 48, 0, 0, 1, 97] + : _deltaAccessUnit, + ), + ], + ); + + return writeCanonicalAsset(input); +} + +AccessUnitInputV01 _accessUnit( + String unit, + int frameIndex, + bool key, + List values, +) { + return AccessUnitInputV01( + rendition: 'opaque', + unit: unit, + frameIndex: frameIndex, + key: key, + bytes: Uint8List.fromList(values), + ); +} diff --git a/flutter/packages/aval_player/test/decode_timeline_test.dart b/flutter/packages/aval_player/test/decode_timeline_test.dart new file mode 100644 index 0000000..bf835a6 --- /dev/null +++ b/flutter/packages/aval_player/test/decode_timeline_test.dart @@ -0,0 +1,257 @@ +// Port of packages/player-web/src/runtime/decode-timeline.test.ts. +// +// Adaptations from the TS test (documented, behavior-preserving): +// * `Object.isFrozen` assertions become `List.unmodifiable`/immutable-value +// checks — the "deeply immutable" test mutated the input rate object to +// prove the timeline copied it; `RationalFrameRate` is immutable in Dart, so +// the copy is proven by comparing the snapshot's frame rate instead. +// * The `["unit", 1.5]` invalid-metadata case is omitted: the Dart API takes +// an `int`, so a non-integer frame count is not representable. +import 'package:aval_player/aval_player.dart'; +import 'package:test/test.dart'; + +List _identity(DecodeSampleMetadata sample) => [ + sample.generation, + sample.ordinal, + sample.unitId, + sample.unitInstance, + sample.unitFrame, + ]; + +int _occurrenceEnd(List samples) { + final finalSample = samples.last; + return finalSample.timestamp + finalSample.duration; +} + +void main() { + group('DecodeTimeline', () { + test('assigns exact 30,000/1,001 timestamps without accumulating duration', + () { + final timeline = DecodeTimeline( + const RationalFrameRate(numerator: 30000, denominator: 1001), + ); + + expect(timeline.activateNextGeneration(), 1); + final samples = timeline.allocateUnitOccurrence('body', 7); + + expect( + samples.map((sample) => sample.timestamp).toList(), + [0, 33367, 66733, 100100, 133467, 166833, 200200], + ); + expect( + samples.map((sample) => sample.duration).toList(), + [33367, 33366, 33367, 33367, 33366, 33367, 33367], + ); + expect( + _occurrenceEnd(samples), + timestampForFrame( + 7, + const RationalFrameRate(numerator: 30000, denominator: 1001), + ), + ); + }); + + for (final testCase in const [ + { + 'rate': RationalFrameRate(numerator: 24, denominator: 1), + 'timestamps': [0, 41667, 83333, 125000], + }, + { + 'rate': RationalFrameRate(numerator: 30, denominator: 1), + 'timestamps': [0, 33333, 66667, 100000], + }, + { + 'rate': RationalFrameRate(numerator: 60, denominator: 1), + 'timestamps': [0, 16667, 33333, 50000], + }, + ]) { + final rate = testCase['rate'] as RationalFrameRate; + final timestamps = testCase['timestamps'] as List; + test('uses the exact ${rate.numerator}/${rate.denominator} clock', () { + final timeline = DecodeTimeline(rate); + timeline.activateNextGeneration(); + + expect( + timeline + .allocateUnitOccurrence('unit', timestamps.length) + .map((sample) => sample.timestamp) + .toList(), + timestamps, + ); + }); + } + + test('has no long-run drift or duplicate timestamp', () { + const frameCount = 100000; + const rate = RationalFrameRate(numerator: 60000, denominator: 1001); + final timeline = DecodeTimeline(rate); + timeline.activateNextGeneration(); + + final samples = timeline.allocateUnitOccurrence('long-body', frameCount); + var accumulatedDuration = 0; + var previousTimestamp = -1; + final timestamps = {}; + for (final sample in samples) { + expect(sample.timestamp, greaterThan(previousTimestamp)); + accumulatedDuration += sample.duration; + previousTimestamp = sample.timestamp; + timestamps.add(sample.timestamp); + } + + expect(timestamps.length, frameCount); + expect(accumulatedDuration, timestampForFrame(frameCount, rate)); + expect(_occurrenceEnd(samples), timestampForFrame(frameCount, rate)); + }); + + test('keeps ordinals global and resets only unit instances per generation', + () { + final timeline = + DecodeTimeline(const RationalFrameRate(numerator: 30, denominator: 1)); + + expect(timeline.activateNextGeneration(), 1); + final all = timeline.allocateUnitOccurrences(const [ + DecodeUnitOccurrence(unitId: 'intro', unitFrameCount: 2), + DecodeUnitOccurrence(unitId: 'body', unitFrameCount: 3), + ]); + final first = all.sublist(0, 2); + final second = all.sublist(2); + expect(first.map(_identity).toList(), [ + [1, 0, 'intro', 0, 0], + [1, 1, 'intro', 0, 1], + ]); + expect(second.map(_identity).toList(), [ + [1, 2, 'body', 1, 0], + [1, 3, 'body', 1, 1], + [1, 4, 'body', 1, 2], + ]); + + expect(timeline.activateNextGeneration(), 2); + final replacement = timeline.allocateUnitOccurrence('body', 2); + expect(replacement.map(_identity).toList(), [ + [2, 5, 'body', 0, 0], + [2, 6, 'body', 0, 1], + ]); + expect( + replacement[0].timestamp, + greaterThan(second.last.timestamp), + ); + expect( + timeline.snapshot(), + const DecodeTimelineSnapshot( + frameRate: RationalFrameRate(numerator: 30, denominator: 1), + activeGeneration: 2, + nextOrdinal: 7, + nextUnitInstance: 1, + ), + ); + }); + + test('returns deeply immutable sample metadata and snapshots', () { + const rate = RationalFrameRate(numerator: 24, denominator: 1); + final timeline = DecodeTimeline(rate); + timeline.activateNextGeneration(); + + final samples = timeline.allocateUnitOccurrence('unit', 2); + final snapshot = timeline.snapshot(); + + expect( + () => samples.add(samples.first), + throwsA(isA()), + ); + expect( + snapshot.frameRate, + const RationalFrameRate(numerator: 24, denominator: 1), + ); + }); + + test('rejects unsafe timestamp successors atomically', () { + final timeline = DecodeTimeline( + const RationalFrameRate(numerator: 1, denominator: 9007199254), + ); + timeline.activateNextGeneration(); + + expect( + () => timeline.allocateUnitOccurrence('too-long', 2), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('safe-integer range'), + ), + ), + ); + expect(timeline.snapshot().activeGeneration, 1); + expect(timeline.snapshot().nextOrdinal, 0); + expect(timeline.snapshot().nextUnitInstance, 0); + + final first = timeline.allocateUnitOccurrence('last-safe', 1); + expect(first, hasLength(1)); + expect( + () => timeline.allocateUnitOccurrence('overflow', 1), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('safe-integer range'), + ), + ), + ); + expect(timeline.snapshot().activeGeneration, 1); + expect(timeline.snapshot().nextOrdinal, 1); + expect(timeline.snapshot().nextUnitInstance, 1); + }); + + test( + 'requires a generation and rejects invalid occurrence metadata ' + 'atomically', () { + final timeline = + DecodeTimeline(const RationalFrameRate(numerator: 30, denominator: 1)); + + expect( + () => timeline.allocateUnitOccurrence('unit', 1), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('active generation'), + ), + ), + ); + expect(timeline.snapshot().activeGeneration, isNull); + expect(timeline.snapshot().nextOrdinal, 0); + expect(timeline.snapshot().nextUnitInstance, 0); + + timeline.activateNextGeneration(); + expect( + () => timeline.allocateUnitOccurrences(const []), + throwsRangeError, + ); + expect( + () => timeline.allocateUnitOccurrences(const [ + DecodeUnitOccurrence(unitId: 'valid-first', unitFrameCount: 2), + DecodeUnitOccurrence(unitId: 'invalid-second', unitFrameCount: 0), + ]), + throwsRangeError, + ); + final invalidCases = >[ + ['', 1], + ['x' * 129, 1], + ['unit', 0], + ['unit', -1], + ['unit', maxSafeInteger + 1], + ]; + for (final invalid in invalidCases) { + expect( + () => timeline.allocateUnitOccurrence( + invalid[0] as String, + invalid[1] as int, + ), + throwsRangeError, + ); + } + expect(timeline.snapshot().activeGeneration, 1); + expect(timeline.snapshot().nextOrdinal, 0); + expect(timeline.snapshot().nextUnitInstance, 0); + }); + }); +} diff --git a/flutter/packages/aval_player/test/edge_lead_test.dart b/flutter/packages/aval_player/test/edge_lead_test.dart new file mode 100644 index 0000000..d6dd66e --- /dev/null +++ b/flutter/packages/aval_player/test/edge_lead_test.dart @@ -0,0 +1,174 @@ +// Port of packages/player-web/src/runtime/edge-lead.test.ts. +import 'package:aval_player/aval_player.dart'; +import 'package:test/test.dart'; + +void main() { + group('edge-specific consecutive lead', () { + for (final testCase in const [ + [0, 2], + [1, 2], + [2, 3], + [4, 5], + [5, 6], + [6, 6], + [12, 6], + ]) { + final transitionFrames = testCase[0]; + final required = testCase[1]; + test( + 'requires $transitionFrames bridge frames plus target entry within a ' + 'six-frame ring', + () { + expect( + calculateRequiredEdgeLeadFrames(RequiredEdgeLeadInput( + transitionFrames: transitionFrames, + ringCapacity: 6, + )), + required, + ); + }, + ); + } + + test('requires two frames for a transitionless edge', () { + expect( + planEdgeLead(const EdgeLeadInput( + transitionFrames: 0, + ringCapacity: 6, + availableConsecutiveFrames: 1, + )), + const EdgeLeadPlan( + transitionFrames: 0, + targetEntryOffset: 0, + firstPresentation: EdgeLeadFirstPresentation.targetBody, + requiredConsecutiveFrames: 2, + availableConsecutiveFrames: 1, + missingConsecutiveFrames: 1, + ready: false, + ), + ); + }); + + test('counts a one-frame bridge and target frame zero before departure', () { + final plan = planEdgeLead(const EdgeLeadInput( + transitionFrames: 1, + ringCapacity: 6, + availableConsecutiveFrames: 2, + )); + + expect( + plan, + const EdgeLeadPlan( + transitionFrames: 1, + targetEntryOffset: 1, + firstPresentation: EdgeLeadFirstPresentation.bridge, + requiredConsecutiveFrames: 2, + availableConsecutiveFrames: 2, + missingConsecutiveFrames: 0, + ready: true, + ), + ); + }); + + test('uses the complete short bridge plus target and caps longer bridges', + () { + expect( + calculateRequiredEdgeLeadFrames(const RequiredEdgeLeadInput( + transitionFrames: 10, + ringCapacity: 12, + )), + 11, + ); + expect( + calculateRequiredEdgeLeadFrames(const RequiredEdgeLeadInput( + transitionFrames: 11, + ringCapacity: 12, + )), + 12, + ); + expect( + calculateRequiredEdgeLeadFrames(const RequiredEdgeLeadInput( + transitionFrames: 12, + ringCapacity: 12, + )), + 12, + ); + expect( + calculateRequiredEdgeLeadFrames(const RequiredEdgeLeadInput( + transitionFrames: 120, + ringCapacity: 12, + )), + 12, + ); + }); + + test('accepts exactly the required measured lead and rejects one less', () { + final low = planEdgeLead(const EdgeLeadInput( + transitionFrames: 4, + ringCapacity: 6, + availableConsecutiveFrames: 4, + )); + expect(low.ready, false); + expect(low.missingConsecutiveFrames, 1); + + final ready = planEdgeLead(const EdgeLeadInput( + transitionFrames: 4, + ringCapacity: 6, + availableConsecutiveFrames: 5, + )); + expect(ready.ready, true); + expect(ready.missingConsecutiveFrames, 0); + }); + + for (final ringCapacity in [0, 5, 13, maxSafeInteger]) { + test('rejects ring capacity $ringCapacity outside 6-12', () { + expect( + () => calculateRequiredEdgeLeadFrames(RequiredEdgeLeadInput( + transitionFrames: 0, + ringCapacity: ringCapacity, + )), + throwsRangeError, + ); + }); + } + + test('rejects unsafe transition arithmetic and impossible measured lead', + () { + expect( + () => calculateRequiredEdgeLeadFrames(const RequiredEdgeLeadInput( + transitionFrames: maxSafeInteger, + ringCapacity: 12, + )), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('safe successor'), + ), + ), + ); + expect( + () => planEdgeLead(const EdgeLeadInput( + transitionFrames: 0, + ringCapacity: 6, + availableConsecutiveFrames: 7, + )), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('available consecutive'), + ), + ), + ); + expect( + () => planEdgeLead(const EdgeLeadInput( + transitionFrames: -1, + ringCapacity: 6, + availableConsecutiveFrames: 0, + )), + throwsRangeError, + ); + }); + }); +} diff --git a/flutter/packages/aval_player/test/fixtures/grass_rabbit_golden_trace.json b/flutter/packages/aval_player/test/fixtures/grass_rabbit_golden_trace.json new file mode 100644 index 0000000..8a4fdf2 --- /dev/null +++ b/flutter/packages/aval_player/test/fixtures/grass_rabbit_golden_trace.json @@ -0,0 +1,11202 @@ +{ + "meta": { + "rendition": "avc.1x", + "frameRate": { + "numerator": 24, + "denominator": 1 + }, + "units": { + "hover-in": 67, + "hover-loop": 96, + "hover-out": 48, + "idle-loop": 70, + "intro": 30 + } + }, + "media": [ + { + "step": 0, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 0, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 0, + "timestamp": 0, + "intendedPresentationOrdinal": "0" + }, + { + "step": 1, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 1, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 1, + "timestamp": 41667, + "intendedPresentationOrdinal": "1" + }, + { + "step": 2, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 2, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 2, + "timestamp": 83333, + "intendedPresentationOrdinal": "2" + }, + { + "step": 3, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 3, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 3, + "timestamp": 125000, + "intendedPresentationOrdinal": "3" + }, + { + "step": 4, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 4, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 4, + "timestamp": 166667, + "intendedPresentationOrdinal": "4" + }, + { + "step": 5, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 5, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 5, + "timestamp": 208333, + "intendedPresentationOrdinal": "5" + }, + { + "step": 6, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 6, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 6, + "timestamp": 250000, + "intendedPresentationOrdinal": "6" + }, + { + "step": 7, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 7, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 7, + "timestamp": 291667, + "intendedPresentationOrdinal": "7" + }, + { + "step": 8, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 8, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 8, + "timestamp": 333333, + "intendedPresentationOrdinal": "8" + }, + { + "step": 9, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 9, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 9, + "timestamp": 375000, + "intendedPresentationOrdinal": "9" + }, + { + "step": 10, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 10, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 10, + "timestamp": 416667, + "intendedPresentationOrdinal": "10" + }, + { + "step": 11, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 11, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 11, + "timestamp": 458333, + "intendedPresentationOrdinal": "11" + }, + { + "step": 12, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 12, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 12, + "timestamp": 500000, + "intendedPresentationOrdinal": "12" + }, + { + "step": 13, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 13, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 13, + "timestamp": 541667, + "intendedPresentationOrdinal": "13" + }, + { + "step": 14, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 14, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 14, + "timestamp": 583333, + "intendedPresentationOrdinal": "14" + }, + { + "step": 15, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 15, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 15, + "timestamp": 625000, + "intendedPresentationOrdinal": "15" + }, + { + "step": 16, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 16, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 16, + "timestamp": 666667, + "intendedPresentationOrdinal": "16" + }, + { + "step": 17, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 17, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 17, + "timestamp": 708333, + "intendedPresentationOrdinal": "17" + }, + { + "step": 18, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 18, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 18, + "timestamp": 750000, + "intendedPresentationOrdinal": "18" + }, + { + "step": 19, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 19, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 19, + "timestamp": 791667, + "intendedPresentationOrdinal": "19" + }, + { + "step": 20, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 20, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 20, + "timestamp": 833333, + "intendedPresentationOrdinal": "20" + }, + { + "step": 21, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 21, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 21, + "timestamp": 875000, + "intendedPresentationOrdinal": "21" + }, + { + "step": 22, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 22, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 22, + "timestamp": 916667, + "intendedPresentationOrdinal": "22" + }, + { + "step": 23, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 23, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 23, + "timestamp": 958333, + "intendedPresentationOrdinal": "23" + }, + { + "step": 24, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 24, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 24, + "timestamp": 1000000, + "intendedPresentationOrdinal": "24" + }, + { + "step": 25, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 25, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 25, + "timestamp": 1041667, + "intendedPresentationOrdinal": "25" + }, + { + "step": 26, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 26, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 26, + "timestamp": 1083333, + "intendedPresentationOrdinal": "26" + }, + { + "step": 27, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 27, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 27, + "timestamp": 1125000, + "intendedPresentationOrdinal": "27" + }, + { + "step": 28, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 28, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 28, + "timestamp": 1166667, + "intendedPresentationOrdinal": "28" + }, + { + "step": 29, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 29, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 29, + "timestamp": 1208333, + "intendedPresentationOrdinal": "29" + }, + { + "step": 30, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 30, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 30, + "timestamp": 1250000, + "intendedPresentationOrdinal": "30" + }, + { + "step": 31, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 31, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 31, + "timestamp": 1291667, + "intendedPresentationOrdinal": "31" + }, + { + "step": 32, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 32, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 32, + "timestamp": 1333333, + "intendedPresentationOrdinal": "32" + }, + { + "step": 33, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 33, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 33, + "timestamp": 1375000, + "intendedPresentationOrdinal": "33" + }, + { + "step": 34, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 34, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 34, + "timestamp": 1416667, + "intendedPresentationOrdinal": "34" + }, + { + "step": 35, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 35, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 35, + "timestamp": 1458333, + "intendedPresentationOrdinal": "35" + }, + { + "step": 36, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 36, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 36, + "timestamp": 1500000, + "intendedPresentationOrdinal": "36" + }, + { + "step": 37, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 37, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 37, + "timestamp": 1541667, + "intendedPresentationOrdinal": "37" + }, + { + "step": 38, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 38, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 38, + "timestamp": 1583333, + "intendedPresentationOrdinal": "38" + }, + { + "step": 39, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 39, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 39, + "timestamp": 1625000, + "intendedPresentationOrdinal": "39" + }, + { + "step": 40, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 40, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 40, + "timestamp": 1666667, + "intendedPresentationOrdinal": "40" + }, + { + "step": 41, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 41, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 41, + "timestamp": 1708333, + "intendedPresentationOrdinal": "41" + }, + { + "step": 42, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 42, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 42, + "timestamp": 1750000, + "intendedPresentationOrdinal": "42" + }, + { + "step": 43, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 43, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 43, + "timestamp": 1791667, + "intendedPresentationOrdinal": "43" + }, + { + "step": 44, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 44, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 44, + "timestamp": 1833333, + "intendedPresentationOrdinal": "44" + }, + { + "step": 45, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 45, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 45, + "timestamp": 1875000, + "intendedPresentationOrdinal": "45" + }, + { + "step": 46, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 46, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 46, + "timestamp": 1916667, + "intendedPresentationOrdinal": "46" + }, + { + "step": 47, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 47, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 47, + "timestamp": 1958333, + "intendedPresentationOrdinal": "47" + }, + { + "step": 48, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 48, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 48, + "timestamp": 2000000, + "intendedPresentationOrdinal": "48" + }, + { + "step": 49, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 49, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 49, + "timestamp": 2041667, + "intendedPresentationOrdinal": "49" + }, + { + "step": 50, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 50, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 50, + "timestamp": 2083333, + "intendedPresentationOrdinal": "50" + }, + { + "step": 51, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 51, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 51, + "timestamp": 2125000, + "intendedPresentationOrdinal": "51" + }, + { + "step": 52, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 52, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 52, + "timestamp": 2166667, + "intendedPresentationOrdinal": "52" + }, + { + "step": 53, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 53, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 53, + "timestamp": 2208333, + "intendedPresentationOrdinal": "53" + }, + { + "step": 54, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 54, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 54, + "timestamp": 2250000, + "intendedPresentationOrdinal": "54" + }, + { + "step": 55, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 55, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 55, + "timestamp": 2291667, + "intendedPresentationOrdinal": "55" + }, + { + "step": 56, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 56, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 56, + "timestamp": 2333333, + "intendedPresentationOrdinal": "56" + }, + { + "step": 57, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 57, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 57, + "timestamp": 2375000, + "intendedPresentationOrdinal": "57" + }, + { + "step": 58, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 58, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 58, + "timestamp": 2416667, + "intendedPresentationOrdinal": "58" + }, + { + "step": 59, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 59, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 59, + "timestamp": 2458333, + "intendedPresentationOrdinal": "59" + }, + { + "step": 60, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 60, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 60, + "timestamp": 2500000, + "intendedPresentationOrdinal": "60" + }, + { + "step": 61, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 61, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 61, + "timestamp": 2541667, + "intendedPresentationOrdinal": "61" + }, + { + "step": 62, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 62, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 62, + "timestamp": 2583333, + "intendedPresentationOrdinal": "62" + }, + { + "step": 63, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 63, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 63, + "timestamp": 2625000, + "intendedPresentationOrdinal": "63" + }, + { + "step": 64, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 64, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 64, + "timestamp": 2666667, + "intendedPresentationOrdinal": "64" + }, + { + "step": 65, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 65, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 65, + "timestamp": 2708333, + "intendedPresentationOrdinal": "65" + }, + { + "step": 66, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 66, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 66, + "timestamp": 2750000, + "intendedPresentationOrdinal": "66" + }, + { + "step": 67, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 67, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 67, + "timestamp": 2791667, + "intendedPresentationOrdinal": "67" + }, + { + "step": 68, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 68, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 68, + "timestamp": 2833333, + "intendedPresentationOrdinal": "68" + }, + { + "step": 69, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 69, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 0, + "decodeOrdinal": 69, + "timestamp": 2875000, + "intendedPresentationOrdinal": "69" + }, + { + "step": 70, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 0, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 70, + "timestamp": 2916667, + "intendedPresentationOrdinal": "70" + }, + { + "step": 71, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 1, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 71, + "timestamp": 2958333, + "intendedPresentationOrdinal": "71" + }, + { + "step": 72, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 2, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 72, + "timestamp": 3000000, + "intendedPresentationOrdinal": "72" + }, + { + "step": 73, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 3, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 73, + "timestamp": 3041667, + "intendedPresentationOrdinal": "73" + }, + { + "step": 74, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 4, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 74, + "timestamp": 3083333, + "intendedPresentationOrdinal": "74" + }, + { + "step": 75, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 5, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 75, + "timestamp": 3125000, + "intendedPresentationOrdinal": "75" + }, + { + "step": 76, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 6, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 76, + "timestamp": 3166667, + "intendedPresentationOrdinal": "76" + }, + { + "step": 77, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 7, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 77, + "timestamp": 3208333, + "intendedPresentationOrdinal": "77" + }, + { + "step": 78, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 8, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 78, + "timestamp": 3250000, + "intendedPresentationOrdinal": "78" + }, + { + "step": 79, + "label": "idle", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 9, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 79, + "timestamp": 3291667, + "intendedPresentationOrdinal": "79" + }, + { + "step": 80, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 10, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 80, + "timestamp": 3333333, + "intendedPresentationOrdinal": "80" + }, + { + "step": 81, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 11, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 81, + "timestamp": 3375000, + "intendedPresentationOrdinal": "81" + }, + { + "step": 82, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 12, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 82, + "timestamp": 3416667, + "intendedPresentationOrdinal": "82" + }, + { + "step": 83, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 13, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 83, + "timestamp": 3458333, + "intendedPresentationOrdinal": "83" + }, + { + "step": 84, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 14, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 84, + "timestamp": 3500000, + "intendedPresentationOrdinal": "84" + }, + { + "step": 85, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 15, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 85, + "timestamp": 3541667, + "intendedPresentationOrdinal": "85" + }, + { + "step": 86, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 16, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 86, + "timestamp": 3583333, + "intendedPresentationOrdinal": "86" + }, + { + "step": 87, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 17, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 87, + "timestamp": 3625000, + "intendedPresentationOrdinal": "87" + }, + { + "step": 88, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 18, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 88, + "timestamp": 3666667, + "intendedPresentationOrdinal": "88" + }, + { + "step": 89, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 19, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 89, + "timestamp": 3708333, + "intendedPresentationOrdinal": "89" + }, + { + "step": 90, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 20, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 90, + "timestamp": 3750000, + "intendedPresentationOrdinal": "90" + }, + { + "step": 91, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 21, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 91, + "timestamp": 3791667, + "intendedPresentationOrdinal": "91" + }, + { + "step": 92, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 22, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 92, + "timestamp": 3833333, + "intendedPresentationOrdinal": "92" + }, + { + "step": 93, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 23, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 93, + "timestamp": 3875000, + "intendedPresentationOrdinal": "93" + }, + { + "step": 94, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 24, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 94, + "timestamp": 3916667, + "intendedPresentationOrdinal": "94" + }, + { + "step": 95, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 25, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 95, + "timestamp": 3958333, + "intendedPresentationOrdinal": "95" + }, + { + "step": 96, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 26, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 96, + "timestamp": 4000000, + "intendedPresentationOrdinal": "96" + }, + { + "step": 97, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 27, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 97, + "timestamp": 4041667, + "intendedPresentationOrdinal": "97" + }, + { + "step": 98, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 28, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 98, + "timestamp": 4083333, + "intendedPresentationOrdinal": "98" + }, + { + "step": 99, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 29, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 99, + "timestamp": 4125000, + "intendedPresentationOrdinal": "99" + }, + { + "step": 100, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 30, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 100, + "timestamp": 4166667, + "intendedPresentationOrdinal": "100" + }, + { + "step": 101, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 31, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 101, + "timestamp": 4208333, + "intendedPresentationOrdinal": "101" + }, + { + "step": 102, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 32, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 102, + "timestamp": 4250000, + "intendedPresentationOrdinal": "102" + }, + { + "step": 103, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 33, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 103, + "timestamp": 4291667, + "intendedPresentationOrdinal": "103" + }, + { + "step": 104, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 34, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 104, + "timestamp": 4333333, + "intendedPresentationOrdinal": "104" + }, + { + "step": 105, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 35, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 105, + "timestamp": 4375000, + "intendedPresentationOrdinal": "105" + }, + { + "step": 106, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 36, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 106, + "timestamp": 4416667, + "intendedPresentationOrdinal": "106" + }, + { + "step": 107, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 37, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 107, + "timestamp": 4458333, + "intendedPresentationOrdinal": "107" + }, + { + "step": 108, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 38, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 108, + "timestamp": 4500000, + "intendedPresentationOrdinal": "108" + }, + { + "step": 109, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 39, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 109, + "timestamp": 4541667, + "intendedPresentationOrdinal": "109" + }, + { + "step": 110, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 40, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 110, + "timestamp": 4583333, + "intendedPresentationOrdinal": "110" + }, + { + "step": 111, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 41, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 111, + "timestamp": 4625000, + "intendedPresentationOrdinal": "111" + }, + { + "step": 112, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 42, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 112, + "timestamp": 4666667, + "intendedPresentationOrdinal": "112" + }, + { + "step": 113, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 43, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 113, + "timestamp": 4708333, + "intendedPresentationOrdinal": "113" + }, + { + "step": 114, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 44, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 114, + "timestamp": 4750000, + "intendedPresentationOrdinal": "114" + }, + { + "step": 115, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 45, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 115, + "timestamp": 4791667, + "intendedPresentationOrdinal": "115" + }, + { + "step": 116, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 46, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 116, + "timestamp": 4833333, + "intendedPresentationOrdinal": "116" + }, + { + "step": 117, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 47, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 117, + "timestamp": 4875000, + "intendedPresentationOrdinal": "117" + }, + { + "step": 118, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 48, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 118, + "timestamp": 4916667, + "intendedPresentationOrdinal": "118" + }, + { + "step": 119, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 49, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 119, + "timestamp": 4958333, + "intendedPresentationOrdinal": "119" + }, + { + "step": 120, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 50, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 120, + "timestamp": 5000000, + "intendedPresentationOrdinal": "120" + }, + { + "step": 121, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 51, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 121, + "timestamp": 5041667, + "intendedPresentationOrdinal": "121" + }, + { + "step": 122, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 52, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 122, + "timestamp": 5083333, + "intendedPresentationOrdinal": "122" + }, + { + "step": 123, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 53, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 123, + "timestamp": 5125000, + "intendedPresentationOrdinal": "123" + }, + { + "step": 124, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 54, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 124, + "timestamp": 5166667, + "intendedPresentationOrdinal": "124" + }, + { + "step": 125, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 55, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 125, + "timestamp": 5208333, + "intendedPresentationOrdinal": "125" + }, + { + "step": 126, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 56, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 126, + "timestamp": 5250000, + "intendedPresentationOrdinal": "126" + }, + { + "step": 127, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 57, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 127, + "timestamp": 5291667, + "intendedPresentationOrdinal": "127" + }, + { + "step": 128, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 58, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 128, + "timestamp": 5333333, + "intendedPresentationOrdinal": "128" + }, + { + "step": 129, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 59, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 129, + "timestamp": 5375000, + "intendedPresentationOrdinal": "129" + }, + { + "step": 130, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 60, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 130, + "timestamp": 5416667, + "intendedPresentationOrdinal": "130" + }, + { + "step": 131, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 61, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 131, + "timestamp": 5458333, + "intendedPresentationOrdinal": "131" + }, + { + "step": 132, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 62, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 132, + "timestamp": 5500000, + "intendedPresentationOrdinal": "132" + }, + { + "step": 133, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 63, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 133, + "timestamp": 5541667, + "intendedPresentationOrdinal": "133" + }, + { + "step": 134, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 64, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 134, + "timestamp": 5583333, + "intendedPresentationOrdinal": "134" + }, + { + "step": 135, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 65, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 135, + "timestamp": 5625000, + "intendedPresentationOrdinal": "135" + }, + { + "step": 136, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 66, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 136, + "timestamp": 5666667, + "intendedPresentationOrdinal": "136" + }, + { + "step": 137, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 67, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 137, + "timestamp": 5708333, + "intendedPresentationOrdinal": "137" + }, + { + "step": 138, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 68, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 138, + "timestamp": 5750000, + "intendedPresentationOrdinal": "138" + }, + { + "step": 139, + "label": "enter-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "idle", + "edge": null, + "path": "idle", + "unit": "idle-loop", + "localFrame": 69, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 1, + "decodeOrdinal": 139, + "timestamp": 5791667, + "intendedPresentationOrdinal": "139" + }, + { + "step": 140, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 0, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 140, + "timestamp": 5833333, + "intendedPresentationOrdinal": "140" + }, + { + "step": 141, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 1, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 141, + "timestamp": 5875000, + "intendedPresentationOrdinal": "141" + }, + { + "step": 142, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 2, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 142, + "timestamp": 5916667, + "intendedPresentationOrdinal": "142" + }, + { + "step": 143, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 3, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 143, + "timestamp": 5958333, + "intendedPresentationOrdinal": "143" + }, + { + "step": 144, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 4, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 144, + "timestamp": 6000000, + "intendedPresentationOrdinal": "144" + }, + { + "step": 145, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 5, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 145, + "timestamp": 6041667, + "intendedPresentationOrdinal": "145" + }, + { + "step": 146, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 6, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 146, + "timestamp": 6083333, + "intendedPresentationOrdinal": "146" + }, + { + "step": 147, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 7, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 147, + "timestamp": 6125000, + "intendedPresentationOrdinal": "147" + }, + { + "step": 148, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 8, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 148, + "timestamp": 6166667, + "intendedPresentationOrdinal": "148" + }, + { + "step": 149, + "label": "enter-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "entering", + "edge": "idle.entering", + "path": "idle", + "unit": "hover-in", + "localFrame": 9, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 149, + "timestamp": 6208333, + "intendedPresentationOrdinal": "149" + }, + { + "step": 150, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 10, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 150, + "timestamp": 6250000, + "intendedPresentationOrdinal": "150" + }, + { + "step": 151, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 11, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 151, + "timestamp": 6291667, + "intendedPresentationOrdinal": "151" + }, + { + "step": 152, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 12, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 152, + "timestamp": 6333333, + "intendedPresentationOrdinal": "152" + }, + { + "step": 153, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 13, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 153, + "timestamp": 6375000, + "intendedPresentationOrdinal": "153" + }, + { + "step": 154, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 14, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 154, + "timestamp": 6416667, + "intendedPresentationOrdinal": "154" + }, + { + "step": 155, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 15, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 155, + "timestamp": 6458333, + "intendedPresentationOrdinal": "155" + }, + { + "step": 156, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 16, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 156, + "timestamp": 6500000, + "intendedPresentationOrdinal": "156" + }, + { + "step": 157, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 17, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 157, + "timestamp": 6541667, + "intendedPresentationOrdinal": "157" + }, + { + "step": 158, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 18, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 158, + "timestamp": 6583333, + "intendedPresentationOrdinal": "158" + }, + { + "step": 159, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 19, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 159, + "timestamp": 6625000, + "intendedPresentationOrdinal": "159" + }, + { + "step": 160, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 20, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 160, + "timestamp": 6666667, + "intendedPresentationOrdinal": "160" + }, + { + "step": 161, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 21, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 161, + "timestamp": 6708333, + "intendedPresentationOrdinal": "161" + }, + { + "step": 162, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 22, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 162, + "timestamp": 6750000, + "intendedPresentationOrdinal": "162" + }, + { + "step": 163, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 23, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 163, + "timestamp": 6791667, + "intendedPresentationOrdinal": "163" + }, + { + "step": 164, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 24, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 164, + "timestamp": 6833333, + "intendedPresentationOrdinal": "164" + }, + { + "step": 165, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 25, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 165, + "timestamp": 6875000, + "intendedPresentationOrdinal": "165" + }, + { + "step": 166, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 26, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 166, + "timestamp": 6916667, + "intendedPresentationOrdinal": "166" + }, + { + "step": 167, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 27, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 167, + "timestamp": 6958333, + "intendedPresentationOrdinal": "167" + }, + { + "step": 168, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 28, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 168, + "timestamp": 7000000, + "intendedPresentationOrdinal": "168" + }, + { + "step": 169, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 29, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 169, + "timestamp": 7041667, + "intendedPresentationOrdinal": "169" + }, + { + "step": 170, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 30, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 170, + "timestamp": 7083333, + "intendedPresentationOrdinal": "170" + }, + { + "step": 171, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 31, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 171, + "timestamp": 7125000, + "intendedPresentationOrdinal": "171" + }, + { + "step": 172, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 32, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 172, + "timestamp": 7166667, + "intendedPresentationOrdinal": "172" + }, + { + "step": 173, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 33, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 173, + "timestamp": 7208333, + "intendedPresentationOrdinal": "173" + }, + { + "step": 174, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 34, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 174, + "timestamp": 7250000, + "intendedPresentationOrdinal": "174" + }, + { + "step": 175, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 35, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 175, + "timestamp": 7291667, + "intendedPresentationOrdinal": "175" + }, + { + "step": 176, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 36, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 176, + "timestamp": 7333333, + "intendedPresentationOrdinal": "176" + }, + { + "step": 177, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 37, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 177, + "timestamp": 7375000, + "intendedPresentationOrdinal": "177" + }, + { + "step": 178, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 38, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 178, + "timestamp": 7416667, + "intendedPresentationOrdinal": "178" + }, + { + "step": 179, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 39, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 179, + "timestamp": 7458333, + "intendedPresentationOrdinal": "179" + }, + { + "step": 180, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 40, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 180, + "timestamp": 7500000, + "intendedPresentationOrdinal": "180" + }, + { + "step": 181, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 41, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 181, + "timestamp": 7541667, + "intendedPresentationOrdinal": "181" + }, + { + "step": 182, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 42, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 182, + "timestamp": 7583333, + "intendedPresentationOrdinal": "182" + }, + { + "step": 183, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 43, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 183, + "timestamp": 7625000, + "intendedPresentationOrdinal": "183" + }, + { + "step": 184, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 44, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 184, + "timestamp": 7666667, + "intendedPresentationOrdinal": "184" + }, + { + "step": 185, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 45, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 185, + "timestamp": 7708333, + "intendedPresentationOrdinal": "185" + }, + { + "step": 186, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 46, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 186, + "timestamp": 7750000, + "intendedPresentationOrdinal": "186" + }, + { + "step": 187, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 47, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 187, + "timestamp": 7791667, + "intendedPresentationOrdinal": "187" + }, + { + "step": 188, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 48, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 188, + "timestamp": 7833333, + "intendedPresentationOrdinal": "188" + }, + { + "step": 189, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 49, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 189, + "timestamp": 7875000, + "intendedPresentationOrdinal": "189" + }, + { + "step": 190, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 50, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 190, + "timestamp": 7916667, + "intendedPresentationOrdinal": "190" + }, + { + "step": 191, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 51, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 191, + "timestamp": 7958333, + "intendedPresentationOrdinal": "191" + }, + { + "step": 192, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 52, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 192, + "timestamp": 8000000, + "intendedPresentationOrdinal": "192" + }, + { + "step": 193, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 53, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 193, + "timestamp": 8041667, + "intendedPresentationOrdinal": "193" + }, + { + "step": 194, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 54, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 194, + "timestamp": 8083333, + "intendedPresentationOrdinal": "194" + }, + { + "step": 195, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 55, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 195, + "timestamp": 8125000, + "intendedPresentationOrdinal": "195" + }, + { + "step": 196, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 56, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 196, + "timestamp": 8166667, + "intendedPresentationOrdinal": "196" + }, + { + "step": 197, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 57, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 197, + "timestamp": 8208333, + "intendedPresentationOrdinal": "197" + }, + { + "step": 198, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 58, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 198, + "timestamp": 8250000, + "intendedPresentationOrdinal": "198" + }, + { + "step": 199, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 59, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 199, + "timestamp": 8291667, + "intendedPresentationOrdinal": "199" + }, + { + "step": 200, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 60, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 200, + "timestamp": 8333333, + "intendedPresentationOrdinal": "200" + }, + { + "step": 201, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 61, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 201, + "timestamp": 8375000, + "intendedPresentationOrdinal": "201" + }, + { + "step": 202, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 62, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 202, + "timestamp": 8416667, + "intendedPresentationOrdinal": "202" + }, + { + "step": 203, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 63, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 203, + "timestamp": 8458333, + "intendedPresentationOrdinal": "203" + }, + { + "step": 204, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 64, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 204, + "timestamp": 8500000, + "intendedPresentationOrdinal": "204" + }, + { + "step": 205, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 65, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 205, + "timestamp": 8541667, + "intendedPresentationOrdinal": "205" + }, + { + "step": 206, + "label": "leave-source", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "entering", + "edge": null, + "path": "idle", + "unit": "hover-in", + "localFrame": 66, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 2, + "decodeOrdinal": 206, + "timestamp": 8583333, + "intendedPresentationOrdinal": "206" + }, + { + "step": 207, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 0, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 207, + "timestamp": 8625000, + "intendedPresentationOrdinal": "207" + }, + { + "step": 208, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 1, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 208, + "timestamp": 8666667, + "intendedPresentationOrdinal": "208" + }, + { + "step": 209, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 2, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 209, + "timestamp": 8708333, + "intendedPresentationOrdinal": "209" + }, + { + "step": 210, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 3, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 210, + "timestamp": 8750000, + "intendedPresentationOrdinal": "210" + }, + { + "step": 211, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 4, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 211, + "timestamp": 8791667, + "intendedPresentationOrdinal": "211" + }, + { + "step": 212, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 5, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 212, + "timestamp": 8833333, + "intendedPresentationOrdinal": "212" + }, + { + "step": 213, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 6, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 213, + "timestamp": 8875000, + "intendedPresentationOrdinal": "213" + }, + { + "step": 214, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 7, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 214, + "timestamp": 8916667, + "intendedPresentationOrdinal": "214" + }, + { + "step": 215, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 8, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 215, + "timestamp": 8958333, + "intendedPresentationOrdinal": "215" + }, + { + "step": 216, + "label": "leave-target", + "kind": "frame", + "purpose": "target", + "graphKind": "body", + "state": "exiting", + "edge": "entering.exiting", + "path": "idle", + "unit": "hover-out", + "localFrame": 9, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 216, + "timestamp": 9000000, + "intendedPresentationOrdinal": "216" + }, + { + "step": 217, + "label": "exiting-tail", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "exiting", + "edge": null, + "path": "idle", + "unit": "hover-out", + "localFrame": 10, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 217, + "timestamp": 9041667, + "intendedPresentationOrdinal": "217" + }, + { + "step": 218, + "label": "exiting-tail", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "exiting", + "edge": null, + "path": "idle", + "unit": "hover-out", + "localFrame": 11, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 218, + "timestamp": 9083333, + "intendedPresentationOrdinal": "218" + }, + { + "step": 219, + "label": "exiting-tail", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "exiting", + "edge": null, + "path": "idle", + "unit": "hover-out", + "localFrame": 12, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 219, + "timestamp": 9125000, + "intendedPresentationOrdinal": "219" + }, + { + "step": 220, + "label": "exiting-tail", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "exiting", + "edge": null, + "path": "idle", + "unit": "hover-out", + "localFrame": 13, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 220, + "timestamp": 9166667, + "intendedPresentationOrdinal": "220" + }, + { + "step": 221, + "label": "exiting-tail", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "exiting", + "edge": null, + "path": "idle", + "unit": "hover-out", + "localFrame": 14, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 221, + "timestamp": 9208333, + "intendedPresentationOrdinal": "221" + }, + { + "step": 222, + "label": "exiting-tail", + "kind": "frame", + "purpose": "source", + "graphKind": "body", + "state": "exiting", + "edge": null, + "path": "idle", + "unit": "hover-out", + "localFrame": 15, + "drawSource": "streaming", + "generation": 1, + "unitInstance": 3, + "decodeOrdinal": 222, + "timestamp": 9250000, + "intendedPresentationOrdinal": "222" + } + ], + "trace": [ + { + "index": 172, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 53, + "decodeOrdinal": 53, + "intendedPresentationOrdinal": "53", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 173, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 59, + "decodeOrdinal": 59, + "intendedPresentationOrdinal": "59", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 174, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 59, + "decodeOrdinal": 59, + "intendedPresentationOrdinal": "59", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 175, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 54, + "decodeOrdinal": 54, + "intendedPresentationOrdinal": "54", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 176, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 60, + "decodeOrdinal": 60, + "intendedPresentationOrdinal": "60", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 177, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 60, + "decodeOrdinal": 60, + "intendedPresentationOrdinal": "60", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 178, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 55, + "decodeOrdinal": 55, + "intendedPresentationOrdinal": "55", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 179, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 61, + "decodeOrdinal": 61, + "intendedPresentationOrdinal": "61", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 180, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 61, + "decodeOrdinal": 61, + "intendedPresentationOrdinal": "61", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 181, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 56, + "decodeOrdinal": 56, + "intendedPresentationOrdinal": "56", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 182, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 62, + "decodeOrdinal": 62, + "intendedPresentationOrdinal": "62", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 183, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 62, + "decodeOrdinal": 62, + "intendedPresentationOrdinal": "62", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 184, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 57, + "decodeOrdinal": 57, + "intendedPresentationOrdinal": "57", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 185, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 63, + "decodeOrdinal": 63, + "intendedPresentationOrdinal": "63", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 186, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 63, + "decodeOrdinal": 63, + "intendedPresentationOrdinal": "63", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 187, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 58, + "decodeOrdinal": 58, + "intendedPresentationOrdinal": "58", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 188, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 64, + "decodeOrdinal": 64, + "intendedPresentationOrdinal": "64", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 189, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 64, + "decodeOrdinal": 64, + "intendedPresentationOrdinal": "64", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 190, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 59, + "decodeOrdinal": 59, + "intendedPresentationOrdinal": "59", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 191, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 65, + "decodeOrdinal": 65, + "intendedPresentationOrdinal": "65", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 192, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 65, + "decodeOrdinal": 65, + "intendedPresentationOrdinal": "65", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 193, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 60, + "decodeOrdinal": 60, + "intendedPresentationOrdinal": "60", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 194, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 66, + "decodeOrdinal": 66, + "intendedPresentationOrdinal": "66", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 195, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 66, + "decodeOrdinal": 66, + "intendedPresentationOrdinal": "66", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 196, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 61, + "decodeOrdinal": 61, + "intendedPresentationOrdinal": "61", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 197, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 67, + "decodeOrdinal": 67, + "intendedPresentationOrdinal": "67", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 198, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 67, + "decodeOrdinal": 67, + "intendedPresentationOrdinal": "67", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 199, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 62, + "decodeOrdinal": 62, + "intendedPresentationOrdinal": "62", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 200, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 68, + "decodeOrdinal": 68, + "intendedPresentationOrdinal": "68", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 201, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 68, + "decodeOrdinal": 68, + "intendedPresentationOrdinal": "68", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 202, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 63, + "decodeOrdinal": 63, + "intendedPresentationOrdinal": "63", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 203, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 69, + "decodeOrdinal": 69, + "intendedPresentationOrdinal": "69", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 204, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 69, + "decodeOrdinal": 69, + "intendedPresentationOrdinal": "69", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 205, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 64, + "decodeOrdinal": 64, + "intendedPresentationOrdinal": "64", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 206, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 0, + "decodeOrdinal": 70, + "intendedPresentationOrdinal": "70", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 207, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 0, + "decodeOrdinal": 70, + "intendedPresentationOrdinal": "70", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 208, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 65, + "decodeOrdinal": 65, + "intendedPresentationOrdinal": "65", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 209, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 1, + "decodeOrdinal": 71, + "intendedPresentationOrdinal": "71", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 210, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 1, + "decodeOrdinal": 71, + "intendedPresentationOrdinal": "71", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 211, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 66, + "decodeOrdinal": 66, + "intendedPresentationOrdinal": "66", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 212, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 2, + "decodeOrdinal": 72, + "intendedPresentationOrdinal": "72", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 213, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 2, + "decodeOrdinal": 72, + "intendedPresentationOrdinal": "72", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 214, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 67, + "decodeOrdinal": 67, + "intendedPresentationOrdinal": "67", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 215, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 3, + "decodeOrdinal": 73, + "intendedPresentationOrdinal": "73", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 216, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 3, + "decodeOrdinal": 73, + "intendedPresentationOrdinal": "73", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 217, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 68, + "decodeOrdinal": 68, + "intendedPresentationOrdinal": "68", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 218, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 4, + "decodeOrdinal": 74, + "intendedPresentationOrdinal": "74", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 219, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 4, + "decodeOrdinal": 74, + "intendedPresentationOrdinal": "74", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 220, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 0, + "unitFrame": 69, + "decodeOrdinal": 69, + "intendedPresentationOrdinal": "69", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 221, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 5, + "decodeOrdinal": 75, + "intendedPresentationOrdinal": "75", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 222, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 5, + "decodeOrdinal": 75, + "intendedPresentationOrdinal": "75", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 223, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 0, + "decodeOrdinal": 70, + "intendedPresentationOrdinal": "70", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 224, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 6, + "decodeOrdinal": 76, + "intendedPresentationOrdinal": "76", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 225, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 6, + "decodeOrdinal": 76, + "intendedPresentationOrdinal": "76", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 226, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 1, + "decodeOrdinal": 71, + "intendedPresentationOrdinal": "71", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 227, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 7, + "decodeOrdinal": 77, + "intendedPresentationOrdinal": "77", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 228, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 7, + "decodeOrdinal": 77, + "intendedPresentationOrdinal": "77", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 229, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 2, + "decodeOrdinal": 72, + "intendedPresentationOrdinal": "72", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 230, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 8, + "decodeOrdinal": 78, + "intendedPresentationOrdinal": "78", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 231, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 8, + "decodeOrdinal": 78, + "intendedPresentationOrdinal": "78", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 232, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 3, + "decodeOrdinal": 73, + "intendedPresentationOrdinal": "73", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 233, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 9, + "decodeOrdinal": 79, + "intendedPresentationOrdinal": "79", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 234, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 9, + "decodeOrdinal": 79, + "intendedPresentationOrdinal": "79", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 235, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 4, + "decodeOrdinal": 74, + "intendedPresentationOrdinal": "74", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 236, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 10, + "decodeOrdinal": 80, + "intendedPresentationOrdinal": "80", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 237, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 10, + "decodeOrdinal": 80, + "intendedPresentationOrdinal": "80", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 238, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 5, + "decodeOrdinal": 75, + "intendedPresentationOrdinal": "75", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 239, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 11, + "decodeOrdinal": 81, + "intendedPresentationOrdinal": "81", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 240, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 11, + "decodeOrdinal": 81, + "intendedPresentationOrdinal": "81", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 241, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 6, + "decodeOrdinal": 76, + "intendedPresentationOrdinal": "76", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 242, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 12, + "decodeOrdinal": 82, + "intendedPresentationOrdinal": "82", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 243, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 12, + "decodeOrdinal": 82, + "intendedPresentationOrdinal": "82", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 244, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 7, + "decodeOrdinal": 77, + "intendedPresentationOrdinal": "77", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 245, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 13, + "decodeOrdinal": 83, + "intendedPresentationOrdinal": "83", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 246, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 13, + "decodeOrdinal": 83, + "intendedPresentationOrdinal": "83", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 247, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 8, + "decodeOrdinal": 78, + "intendedPresentationOrdinal": "78", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 248, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 14, + "decodeOrdinal": 84, + "intendedPresentationOrdinal": "84", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 249, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 14, + "decodeOrdinal": 84, + "intendedPresentationOrdinal": "84", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 250, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 9, + "decodeOrdinal": 79, + "intendedPresentationOrdinal": "79", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 251, + "operation": "route-select", + "generation": 1, + "path": "idle", + "unit": null, + "unitInstance": null, + "unitFrame": null, + "decodeOrdinal": null, + "intendedPresentationOrdinal": null, + "ringSize": 5, + "expectedOutputs": 0, + "reason": "idle.entering" + }, + { + "index": 252, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 15, + "decodeOrdinal": 85, + "intendedPresentationOrdinal": "85", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 253, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 15, + "decodeOrdinal": 85, + "intendedPresentationOrdinal": "85", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 254, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 10, + "decodeOrdinal": 80, + "intendedPresentationOrdinal": "80", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 255, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 16, + "decodeOrdinal": 86, + "intendedPresentationOrdinal": "86", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 256, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 16, + "decodeOrdinal": 86, + "intendedPresentationOrdinal": "86", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 257, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 11, + "decodeOrdinal": 81, + "intendedPresentationOrdinal": "81", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 258, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 17, + "decodeOrdinal": 87, + "intendedPresentationOrdinal": "87", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 259, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 17, + "decodeOrdinal": 87, + "intendedPresentationOrdinal": "87", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 260, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 12, + "decodeOrdinal": 82, + "intendedPresentationOrdinal": "82", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 261, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 18, + "decodeOrdinal": 88, + "intendedPresentationOrdinal": "88", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 262, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 18, + "decodeOrdinal": 88, + "intendedPresentationOrdinal": "88", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 263, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 13, + "decodeOrdinal": 83, + "intendedPresentationOrdinal": "83", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 264, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 19, + "decodeOrdinal": 89, + "intendedPresentationOrdinal": "89", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 265, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 19, + "decodeOrdinal": 89, + "intendedPresentationOrdinal": "89", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 266, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 14, + "decodeOrdinal": 84, + "intendedPresentationOrdinal": "84", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 267, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 20, + "decodeOrdinal": 90, + "intendedPresentationOrdinal": "90", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 268, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 20, + "decodeOrdinal": 90, + "intendedPresentationOrdinal": "90", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 269, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 15, + "decodeOrdinal": 85, + "intendedPresentationOrdinal": "85", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 270, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 21, + "decodeOrdinal": 91, + "intendedPresentationOrdinal": "91", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 271, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 21, + "decodeOrdinal": 91, + "intendedPresentationOrdinal": "91", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 272, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 16, + "decodeOrdinal": 86, + "intendedPresentationOrdinal": "86", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 273, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 22, + "decodeOrdinal": 92, + "intendedPresentationOrdinal": "92", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 274, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 22, + "decodeOrdinal": 92, + "intendedPresentationOrdinal": "92", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 275, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 17, + "decodeOrdinal": 87, + "intendedPresentationOrdinal": "87", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 276, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 23, + "decodeOrdinal": 93, + "intendedPresentationOrdinal": "93", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 277, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 23, + "decodeOrdinal": 93, + "intendedPresentationOrdinal": "93", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 278, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 18, + "decodeOrdinal": 88, + "intendedPresentationOrdinal": "88", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 279, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 24, + "decodeOrdinal": 94, + "intendedPresentationOrdinal": "94", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 280, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 24, + "decodeOrdinal": 94, + "intendedPresentationOrdinal": "94", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 281, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 19, + "decodeOrdinal": 89, + "intendedPresentationOrdinal": "89", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 282, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 25, + "decodeOrdinal": 95, + "intendedPresentationOrdinal": "95", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 283, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 25, + "decodeOrdinal": 95, + "intendedPresentationOrdinal": "95", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 284, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 20, + "decodeOrdinal": 90, + "intendedPresentationOrdinal": "90", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 285, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 26, + "decodeOrdinal": 96, + "intendedPresentationOrdinal": "96", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 286, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 26, + "decodeOrdinal": 96, + "intendedPresentationOrdinal": "96", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 287, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 21, + "decodeOrdinal": 91, + "intendedPresentationOrdinal": "91", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 288, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 27, + "decodeOrdinal": 97, + "intendedPresentationOrdinal": "97", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 289, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 27, + "decodeOrdinal": 97, + "intendedPresentationOrdinal": "97", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 290, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 22, + "decodeOrdinal": 92, + "intendedPresentationOrdinal": "92", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 291, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 28, + "decodeOrdinal": 98, + "intendedPresentationOrdinal": "98", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 292, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 28, + "decodeOrdinal": 98, + "intendedPresentationOrdinal": "98", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 293, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 23, + "decodeOrdinal": 93, + "intendedPresentationOrdinal": "93", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 294, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 29, + "decodeOrdinal": 99, + "intendedPresentationOrdinal": "99", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 295, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 29, + "decodeOrdinal": 99, + "intendedPresentationOrdinal": "99", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 296, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 24, + "decodeOrdinal": 94, + "intendedPresentationOrdinal": "94", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 297, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 30, + "decodeOrdinal": 100, + "intendedPresentationOrdinal": "100", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 298, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 30, + "decodeOrdinal": 100, + "intendedPresentationOrdinal": "100", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 299, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 25, + "decodeOrdinal": 95, + "intendedPresentationOrdinal": "95", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 300, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 31, + "decodeOrdinal": 101, + "intendedPresentationOrdinal": "101", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 301, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 31, + "decodeOrdinal": 101, + "intendedPresentationOrdinal": "101", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 302, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 26, + "decodeOrdinal": 96, + "intendedPresentationOrdinal": "96", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 303, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 32, + "decodeOrdinal": 102, + "intendedPresentationOrdinal": "102", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 304, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 32, + "decodeOrdinal": 102, + "intendedPresentationOrdinal": "102", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 305, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 27, + "decodeOrdinal": 97, + "intendedPresentationOrdinal": "97", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 306, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 33, + "decodeOrdinal": 103, + "intendedPresentationOrdinal": "103", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 307, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 33, + "decodeOrdinal": 103, + "intendedPresentationOrdinal": "103", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 308, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 28, + "decodeOrdinal": 98, + "intendedPresentationOrdinal": "98", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 309, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 34, + "decodeOrdinal": 104, + "intendedPresentationOrdinal": "104", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 310, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 34, + "decodeOrdinal": 104, + "intendedPresentationOrdinal": "104", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 311, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 29, + "decodeOrdinal": 99, + "intendedPresentationOrdinal": "99", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 312, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 35, + "decodeOrdinal": 105, + "intendedPresentationOrdinal": "105", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 313, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 35, + "decodeOrdinal": 105, + "intendedPresentationOrdinal": "105", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 314, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 30, + "decodeOrdinal": 100, + "intendedPresentationOrdinal": "100", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 315, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 36, + "decodeOrdinal": 106, + "intendedPresentationOrdinal": "106", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 316, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 36, + "decodeOrdinal": 106, + "intendedPresentationOrdinal": "106", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 317, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 31, + "decodeOrdinal": 101, + "intendedPresentationOrdinal": "101", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 318, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 37, + "decodeOrdinal": 107, + "intendedPresentationOrdinal": "107", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 319, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 37, + "decodeOrdinal": 107, + "intendedPresentationOrdinal": "107", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 320, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 32, + "decodeOrdinal": 102, + "intendedPresentationOrdinal": "102", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 321, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 38, + "decodeOrdinal": 108, + "intendedPresentationOrdinal": "108", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 322, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 38, + "decodeOrdinal": 108, + "intendedPresentationOrdinal": "108", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 323, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 33, + "decodeOrdinal": 103, + "intendedPresentationOrdinal": "103", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 324, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 39, + "decodeOrdinal": 109, + "intendedPresentationOrdinal": "109", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 325, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 39, + "decodeOrdinal": 109, + "intendedPresentationOrdinal": "109", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 326, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 34, + "decodeOrdinal": 104, + "intendedPresentationOrdinal": "104", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 327, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 40, + "decodeOrdinal": 110, + "intendedPresentationOrdinal": "110", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 328, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 40, + "decodeOrdinal": 110, + "intendedPresentationOrdinal": "110", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 329, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 35, + "decodeOrdinal": 105, + "intendedPresentationOrdinal": "105", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 330, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 41, + "decodeOrdinal": 111, + "intendedPresentationOrdinal": "111", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 331, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 41, + "decodeOrdinal": 111, + "intendedPresentationOrdinal": "111", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 332, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 36, + "decodeOrdinal": 106, + "intendedPresentationOrdinal": "106", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 333, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 42, + "decodeOrdinal": 112, + "intendedPresentationOrdinal": "112", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 334, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 42, + "decodeOrdinal": 112, + "intendedPresentationOrdinal": "112", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 335, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 37, + "decodeOrdinal": 107, + "intendedPresentationOrdinal": "107", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 336, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 43, + "decodeOrdinal": 113, + "intendedPresentationOrdinal": "113", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 337, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 43, + "decodeOrdinal": 113, + "intendedPresentationOrdinal": "113", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 338, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 38, + "decodeOrdinal": 108, + "intendedPresentationOrdinal": "108", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 339, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 44, + "decodeOrdinal": 114, + "intendedPresentationOrdinal": "114", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 340, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 44, + "decodeOrdinal": 114, + "intendedPresentationOrdinal": "114", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 341, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 39, + "decodeOrdinal": 109, + "intendedPresentationOrdinal": "109", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 342, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 45, + "decodeOrdinal": 115, + "intendedPresentationOrdinal": "115", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 343, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 45, + "decodeOrdinal": 115, + "intendedPresentationOrdinal": "115", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 344, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 40, + "decodeOrdinal": 110, + "intendedPresentationOrdinal": "110", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 345, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 46, + "decodeOrdinal": 116, + "intendedPresentationOrdinal": "116", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 346, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 46, + "decodeOrdinal": 116, + "intendedPresentationOrdinal": "116", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 347, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 41, + "decodeOrdinal": 111, + "intendedPresentationOrdinal": "111", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 348, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 47, + "decodeOrdinal": 117, + "intendedPresentationOrdinal": "117", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 349, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 47, + "decodeOrdinal": 117, + "intendedPresentationOrdinal": "117", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 350, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 42, + "decodeOrdinal": 112, + "intendedPresentationOrdinal": "112", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 351, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 48, + "decodeOrdinal": 118, + "intendedPresentationOrdinal": "118", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 352, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 48, + "decodeOrdinal": 118, + "intendedPresentationOrdinal": "118", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 353, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 43, + "decodeOrdinal": 113, + "intendedPresentationOrdinal": "113", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 354, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 49, + "decodeOrdinal": 119, + "intendedPresentationOrdinal": "119", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 355, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 49, + "decodeOrdinal": 119, + "intendedPresentationOrdinal": "119", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 356, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 44, + "decodeOrdinal": 114, + "intendedPresentationOrdinal": "114", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 357, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 50, + "decodeOrdinal": 120, + "intendedPresentationOrdinal": "120", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 358, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 50, + "decodeOrdinal": 120, + "intendedPresentationOrdinal": "120", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 359, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 45, + "decodeOrdinal": 115, + "intendedPresentationOrdinal": "115", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 360, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 51, + "decodeOrdinal": 121, + "intendedPresentationOrdinal": "121", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 361, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 51, + "decodeOrdinal": 121, + "intendedPresentationOrdinal": "121", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 362, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 46, + "decodeOrdinal": 116, + "intendedPresentationOrdinal": "116", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 363, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 52, + "decodeOrdinal": 122, + "intendedPresentationOrdinal": "122", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 364, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 52, + "decodeOrdinal": 122, + "intendedPresentationOrdinal": "122", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 365, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 47, + "decodeOrdinal": 117, + "intendedPresentationOrdinal": "117", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 366, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 53, + "decodeOrdinal": 123, + "intendedPresentationOrdinal": "123", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 367, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 53, + "decodeOrdinal": 123, + "intendedPresentationOrdinal": "123", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 368, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 48, + "decodeOrdinal": 118, + "intendedPresentationOrdinal": "118", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 369, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 54, + "decodeOrdinal": 124, + "intendedPresentationOrdinal": "124", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 370, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 54, + "decodeOrdinal": 124, + "intendedPresentationOrdinal": "124", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 371, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 49, + "decodeOrdinal": 119, + "intendedPresentationOrdinal": "119", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 372, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 55, + "decodeOrdinal": 125, + "intendedPresentationOrdinal": "125", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 373, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 55, + "decodeOrdinal": 125, + "intendedPresentationOrdinal": "125", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 374, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 50, + "decodeOrdinal": 120, + "intendedPresentationOrdinal": "120", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 375, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 56, + "decodeOrdinal": 126, + "intendedPresentationOrdinal": "126", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 376, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 56, + "decodeOrdinal": 126, + "intendedPresentationOrdinal": "126", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 377, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 51, + "decodeOrdinal": 121, + "intendedPresentationOrdinal": "121", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 378, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 57, + "decodeOrdinal": 127, + "intendedPresentationOrdinal": "127", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 379, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 57, + "decodeOrdinal": 127, + "intendedPresentationOrdinal": "127", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 380, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 52, + "decodeOrdinal": 122, + "intendedPresentationOrdinal": "122", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 381, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 58, + "decodeOrdinal": 128, + "intendedPresentationOrdinal": "128", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 382, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 58, + "decodeOrdinal": 128, + "intendedPresentationOrdinal": "128", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 383, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 53, + "decodeOrdinal": 123, + "intendedPresentationOrdinal": "123", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 384, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 59, + "decodeOrdinal": 129, + "intendedPresentationOrdinal": "129", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 385, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 59, + "decodeOrdinal": 129, + "intendedPresentationOrdinal": "129", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 386, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 54, + "decodeOrdinal": 124, + "intendedPresentationOrdinal": "124", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 387, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 60, + "decodeOrdinal": 130, + "intendedPresentationOrdinal": "130", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 388, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 60, + "decodeOrdinal": 130, + "intendedPresentationOrdinal": "130", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 389, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 55, + "decodeOrdinal": 125, + "intendedPresentationOrdinal": "125", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 390, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 61, + "decodeOrdinal": 131, + "intendedPresentationOrdinal": "131", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 391, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 61, + "decodeOrdinal": 131, + "intendedPresentationOrdinal": "131", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 392, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 56, + "decodeOrdinal": 126, + "intendedPresentationOrdinal": "126", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 393, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 62, + "decodeOrdinal": 132, + "intendedPresentationOrdinal": "132", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 394, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 62, + "decodeOrdinal": 132, + "intendedPresentationOrdinal": "132", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 395, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 57, + "decodeOrdinal": 127, + "intendedPresentationOrdinal": "127", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 396, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 63, + "decodeOrdinal": 133, + "intendedPresentationOrdinal": "133", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 397, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 63, + "decodeOrdinal": 133, + "intendedPresentationOrdinal": "133", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 398, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 58, + "decodeOrdinal": 128, + "intendedPresentationOrdinal": "128", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 399, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 64, + "decodeOrdinal": 134, + "intendedPresentationOrdinal": "134", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 400, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 64, + "decodeOrdinal": 134, + "intendedPresentationOrdinal": "134", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 401, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 59, + "decodeOrdinal": 129, + "intendedPresentationOrdinal": "129", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 402, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 65, + "decodeOrdinal": 135, + "intendedPresentationOrdinal": "135", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 403, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 65, + "decodeOrdinal": 135, + "intendedPresentationOrdinal": "135", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 404, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 60, + "decodeOrdinal": 130, + "intendedPresentationOrdinal": "130", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 405, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 66, + "decodeOrdinal": 136, + "intendedPresentationOrdinal": "136", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 406, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 66, + "decodeOrdinal": 136, + "intendedPresentationOrdinal": "136", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 407, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 61, + "decodeOrdinal": 131, + "intendedPresentationOrdinal": "131", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 408, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 67, + "decodeOrdinal": 137, + "intendedPresentationOrdinal": "137", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 409, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 67, + "decodeOrdinal": 137, + "intendedPresentationOrdinal": "137", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 410, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 62, + "decodeOrdinal": 132, + "intendedPresentationOrdinal": "132", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 411, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 68, + "decodeOrdinal": 138, + "intendedPresentationOrdinal": "138", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 412, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 68, + "decodeOrdinal": 138, + "intendedPresentationOrdinal": "138", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 413, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 63, + "decodeOrdinal": 133, + "intendedPresentationOrdinal": "133", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 414, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 69, + "decodeOrdinal": 139, + "intendedPresentationOrdinal": "139", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 415, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 69, + "decodeOrdinal": 139, + "intendedPresentationOrdinal": "139", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 416, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 64, + "decodeOrdinal": 134, + "intendedPresentationOrdinal": "134", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 417, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 0, + "decodeOrdinal": 140, + "intendedPresentationOrdinal": "140", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 418, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 0, + "decodeOrdinal": 140, + "intendedPresentationOrdinal": "140", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 419, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 65, + "decodeOrdinal": 135, + "intendedPresentationOrdinal": "135", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 420, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 1, + "decodeOrdinal": 141, + "intendedPresentationOrdinal": "141", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 421, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 1, + "decodeOrdinal": 141, + "intendedPresentationOrdinal": "141", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 422, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 66, + "decodeOrdinal": 136, + "intendedPresentationOrdinal": "136", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 423, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 2, + "decodeOrdinal": 142, + "intendedPresentationOrdinal": "142", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 424, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 2, + "decodeOrdinal": 142, + "intendedPresentationOrdinal": "142", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 425, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 67, + "decodeOrdinal": 137, + "intendedPresentationOrdinal": "137", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 426, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 3, + "decodeOrdinal": 143, + "intendedPresentationOrdinal": "143", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 427, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 3, + "decodeOrdinal": 143, + "intendedPresentationOrdinal": "143", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 428, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 68, + "decodeOrdinal": 138, + "intendedPresentationOrdinal": "138", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 429, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 4, + "decodeOrdinal": 144, + "intendedPresentationOrdinal": "144", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 430, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 4, + "decodeOrdinal": 144, + "intendedPresentationOrdinal": "144", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 431, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "idle-loop", + "unitInstance": 1, + "unitFrame": 69, + "decodeOrdinal": 139, + "intendedPresentationOrdinal": "139", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 432, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 5, + "decodeOrdinal": 145, + "intendedPresentationOrdinal": "145", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 433, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 5, + "decodeOrdinal": 145, + "intendedPresentationOrdinal": "145", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 434, + "operation": "route-commit", + "generation": 1, + "path": "idle", + "unit": null, + "unitInstance": null, + "unitFrame": null, + "decodeOrdinal": null, + "intendedPresentationOrdinal": null, + "ringSize": 6, + "expectedOutputs": 0, + "reason": "idle.entering" + }, + { + "index": 435, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 0, + "decodeOrdinal": 140, + "intendedPresentationOrdinal": "140", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 436, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 6, + "decodeOrdinal": 146, + "intendedPresentationOrdinal": "146", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 437, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 6, + "decodeOrdinal": 146, + "intendedPresentationOrdinal": "146", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 438, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 1, + "decodeOrdinal": 141, + "intendedPresentationOrdinal": "141", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 439, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 7, + "decodeOrdinal": 147, + "intendedPresentationOrdinal": "147", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 440, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 7, + "decodeOrdinal": 147, + "intendedPresentationOrdinal": "147", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 441, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 2, + "decodeOrdinal": 142, + "intendedPresentationOrdinal": "142", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 442, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 8, + "decodeOrdinal": 148, + "intendedPresentationOrdinal": "148", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 443, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 8, + "decodeOrdinal": 148, + "intendedPresentationOrdinal": "148", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 444, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 3, + "decodeOrdinal": 143, + "intendedPresentationOrdinal": "143", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 445, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 9, + "decodeOrdinal": 149, + "intendedPresentationOrdinal": "149", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 446, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 9, + "decodeOrdinal": 149, + "intendedPresentationOrdinal": "149", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 447, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 4, + "decodeOrdinal": 144, + "intendedPresentationOrdinal": "144", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 448, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 10, + "decodeOrdinal": 150, + "intendedPresentationOrdinal": "150", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 449, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 10, + "decodeOrdinal": 150, + "intendedPresentationOrdinal": "150", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 450, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 5, + "decodeOrdinal": 145, + "intendedPresentationOrdinal": "145", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 451, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 11, + "decodeOrdinal": 151, + "intendedPresentationOrdinal": "151", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 452, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 11, + "decodeOrdinal": 151, + "intendedPresentationOrdinal": "151", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 453, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 6, + "decodeOrdinal": 146, + "intendedPresentationOrdinal": "146", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 454, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 12, + "decodeOrdinal": 152, + "intendedPresentationOrdinal": "152", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 455, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 12, + "decodeOrdinal": 152, + "intendedPresentationOrdinal": "152", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 456, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 7, + "decodeOrdinal": 147, + "intendedPresentationOrdinal": "147", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 457, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 13, + "decodeOrdinal": 153, + "intendedPresentationOrdinal": "153", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 458, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 13, + "decodeOrdinal": 153, + "intendedPresentationOrdinal": "153", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 459, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 8, + "decodeOrdinal": 148, + "intendedPresentationOrdinal": "148", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 460, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 14, + "decodeOrdinal": 154, + "intendedPresentationOrdinal": "154", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 461, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 14, + "decodeOrdinal": 154, + "intendedPresentationOrdinal": "154", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 462, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 9, + "decodeOrdinal": 149, + "intendedPresentationOrdinal": "149", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 463, + "operation": "route-select", + "generation": 1, + "path": "idle", + "unit": null, + "unitInstance": null, + "unitFrame": null, + "decodeOrdinal": null, + "intendedPresentationOrdinal": null, + "ringSize": 5, + "expectedOutputs": 0, + "reason": "entering.exiting" + }, + { + "index": 464, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 15, + "decodeOrdinal": 155, + "intendedPresentationOrdinal": "155", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 465, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 15, + "decodeOrdinal": 155, + "intendedPresentationOrdinal": "155", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 466, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 10, + "decodeOrdinal": 150, + "intendedPresentationOrdinal": "150", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 467, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 16, + "decodeOrdinal": 156, + "intendedPresentationOrdinal": "156", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 468, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 16, + "decodeOrdinal": 156, + "intendedPresentationOrdinal": "156", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 469, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 11, + "decodeOrdinal": 151, + "intendedPresentationOrdinal": "151", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 470, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 17, + "decodeOrdinal": 157, + "intendedPresentationOrdinal": "157", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 471, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 17, + "decodeOrdinal": 157, + "intendedPresentationOrdinal": "157", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 472, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 12, + "decodeOrdinal": 152, + "intendedPresentationOrdinal": "152", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 473, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 18, + "decodeOrdinal": 158, + "intendedPresentationOrdinal": "158", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 474, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 18, + "decodeOrdinal": 158, + "intendedPresentationOrdinal": "158", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 475, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 13, + "decodeOrdinal": 153, + "intendedPresentationOrdinal": "153", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 476, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 19, + "decodeOrdinal": 159, + "intendedPresentationOrdinal": "159", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 477, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 19, + "decodeOrdinal": 159, + "intendedPresentationOrdinal": "159", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 478, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 14, + "decodeOrdinal": 154, + "intendedPresentationOrdinal": "154", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 479, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 20, + "decodeOrdinal": 160, + "intendedPresentationOrdinal": "160", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 480, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 20, + "decodeOrdinal": 160, + "intendedPresentationOrdinal": "160", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 481, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 15, + "decodeOrdinal": 155, + "intendedPresentationOrdinal": "155", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 482, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 21, + "decodeOrdinal": 161, + "intendedPresentationOrdinal": "161", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 483, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 21, + "decodeOrdinal": 161, + "intendedPresentationOrdinal": "161", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 484, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 16, + "decodeOrdinal": 156, + "intendedPresentationOrdinal": "156", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 485, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 22, + "decodeOrdinal": 162, + "intendedPresentationOrdinal": "162", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 486, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 22, + "decodeOrdinal": 162, + "intendedPresentationOrdinal": "162", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 487, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 17, + "decodeOrdinal": 157, + "intendedPresentationOrdinal": "157", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 488, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 23, + "decodeOrdinal": 163, + "intendedPresentationOrdinal": "163", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 489, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 23, + "decodeOrdinal": 163, + "intendedPresentationOrdinal": "163", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 490, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 18, + "decodeOrdinal": 158, + "intendedPresentationOrdinal": "158", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 491, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 24, + "decodeOrdinal": 164, + "intendedPresentationOrdinal": "164", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 492, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 24, + "decodeOrdinal": 164, + "intendedPresentationOrdinal": "164", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 493, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 19, + "decodeOrdinal": 159, + "intendedPresentationOrdinal": "159", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 494, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 25, + "decodeOrdinal": 165, + "intendedPresentationOrdinal": "165", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 495, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 25, + "decodeOrdinal": 165, + "intendedPresentationOrdinal": "165", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 496, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 20, + "decodeOrdinal": 160, + "intendedPresentationOrdinal": "160", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 497, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 26, + "decodeOrdinal": 166, + "intendedPresentationOrdinal": "166", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 498, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 26, + "decodeOrdinal": 166, + "intendedPresentationOrdinal": "166", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 499, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 21, + "decodeOrdinal": 161, + "intendedPresentationOrdinal": "161", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 500, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 27, + "decodeOrdinal": 167, + "intendedPresentationOrdinal": "167", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 501, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 27, + "decodeOrdinal": 167, + "intendedPresentationOrdinal": "167", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 502, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 22, + "decodeOrdinal": 162, + "intendedPresentationOrdinal": "162", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 503, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 28, + "decodeOrdinal": 168, + "intendedPresentationOrdinal": "168", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 504, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 28, + "decodeOrdinal": 168, + "intendedPresentationOrdinal": "168", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 505, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 23, + "decodeOrdinal": 163, + "intendedPresentationOrdinal": "163", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 506, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 29, + "decodeOrdinal": 169, + "intendedPresentationOrdinal": "169", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 507, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 29, + "decodeOrdinal": 169, + "intendedPresentationOrdinal": "169", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 508, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 24, + "decodeOrdinal": 164, + "intendedPresentationOrdinal": "164", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 509, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 30, + "decodeOrdinal": 170, + "intendedPresentationOrdinal": "170", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 510, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 30, + "decodeOrdinal": 170, + "intendedPresentationOrdinal": "170", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 511, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 25, + "decodeOrdinal": 165, + "intendedPresentationOrdinal": "165", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 512, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 31, + "decodeOrdinal": 171, + "intendedPresentationOrdinal": "171", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 513, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 31, + "decodeOrdinal": 171, + "intendedPresentationOrdinal": "171", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 514, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 26, + "decodeOrdinal": 166, + "intendedPresentationOrdinal": "166", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 515, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 32, + "decodeOrdinal": 172, + "intendedPresentationOrdinal": "172", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 516, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 32, + "decodeOrdinal": 172, + "intendedPresentationOrdinal": "172", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 517, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 27, + "decodeOrdinal": 167, + "intendedPresentationOrdinal": "167", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 518, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 33, + "decodeOrdinal": 173, + "intendedPresentationOrdinal": "173", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 519, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 33, + "decodeOrdinal": 173, + "intendedPresentationOrdinal": "173", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 520, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 28, + "decodeOrdinal": 168, + "intendedPresentationOrdinal": "168", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 521, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 34, + "decodeOrdinal": 174, + "intendedPresentationOrdinal": "174", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 522, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 34, + "decodeOrdinal": 174, + "intendedPresentationOrdinal": "174", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 523, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 29, + "decodeOrdinal": 169, + "intendedPresentationOrdinal": "169", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 524, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 35, + "decodeOrdinal": 175, + "intendedPresentationOrdinal": "175", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 525, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 35, + "decodeOrdinal": 175, + "intendedPresentationOrdinal": "175", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 526, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 30, + "decodeOrdinal": 170, + "intendedPresentationOrdinal": "170", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 527, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 36, + "decodeOrdinal": 176, + "intendedPresentationOrdinal": "176", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 528, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 36, + "decodeOrdinal": 176, + "intendedPresentationOrdinal": "176", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 529, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 31, + "decodeOrdinal": 171, + "intendedPresentationOrdinal": "171", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 530, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 37, + "decodeOrdinal": 177, + "intendedPresentationOrdinal": "177", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 531, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 37, + "decodeOrdinal": 177, + "intendedPresentationOrdinal": "177", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 532, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 32, + "decodeOrdinal": 172, + "intendedPresentationOrdinal": "172", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 533, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 38, + "decodeOrdinal": 178, + "intendedPresentationOrdinal": "178", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 534, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 38, + "decodeOrdinal": 178, + "intendedPresentationOrdinal": "178", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 535, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 33, + "decodeOrdinal": 173, + "intendedPresentationOrdinal": "173", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 536, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 39, + "decodeOrdinal": 179, + "intendedPresentationOrdinal": "179", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 537, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 39, + "decodeOrdinal": 179, + "intendedPresentationOrdinal": "179", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 538, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 34, + "decodeOrdinal": 174, + "intendedPresentationOrdinal": "174", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 539, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 40, + "decodeOrdinal": 180, + "intendedPresentationOrdinal": "180", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 540, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 40, + "decodeOrdinal": 180, + "intendedPresentationOrdinal": "180", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 541, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 35, + "decodeOrdinal": 175, + "intendedPresentationOrdinal": "175", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 542, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 41, + "decodeOrdinal": 181, + "intendedPresentationOrdinal": "181", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 543, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 41, + "decodeOrdinal": 181, + "intendedPresentationOrdinal": "181", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 544, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 36, + "decodeOrdinal": 176, + "intendedPresentationOrdinal": "176", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 545, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 42, + "decodeOrdinal": 182, + "intendedPresentationOrdinal": "182", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 546, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 42, + "decodeOrdinal": 182, + "intendedPresentationOrdinal": "182", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 547, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 37, + "decodeOrdinal": 177, + "intendedPresentationOrdinal": "177", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 548, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 43, + "decodeOrdinal": 183, + "intendedPresentationOrdinal": "183", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 549, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 43, + "decodeOrdinal": 183, + "intendedPresentationOrdinal": "183", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 550, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 38, + "decodeOrdinal": 178, + "intendedPresentationOrdinal": "178", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 551, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 44, + "decodeOrdinal": 184, + "intendedPresentationOrdinal": "184", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 552, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 44, + "decodeOrdinal": 184, + "intendedPresentationOrdinal": "184", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 553, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 39, + "decodeOrdinal": 179, + "intendedPresentationOrdinal": "179", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 554, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 45, + "decodeOrdinal": 185, + "intendedPresentationOrdinal": "185", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 555, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 45, + "decodeOrdinal": 185, + "intendedPresentationOrdinal": "185", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 556, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 40, + "decodeOrdinal": 180, + "intendedPresentationOrdinal": "180", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 557, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 46, + "decodeOrdinal": 186, + "intendedPresentationOrdinal": "186", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 558, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 46, + "decodeOrdinal": 186, + "intendedPresentationOrdinal": "186", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 559, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 41, + "decodeOrdinal": 181, + "intendedPresentationOrdinal": "181", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 560, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 47, + "decodeOrdinal": 187, + "intendedPresentationOrdinal": "187", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 561, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 47, + "decodeOrdinal": 187, + "intendedPresentationOrdinal": "187", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 562, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 42, + "decodeOrdinal": 182, + "intendedPresentationOrdinal": "182", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 563, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 48, + "decodeOrdinal": 188, + "intendedPresentationOrdinal": "188", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 564, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 48, + "decodeOrdinal": 188, + "intendedPresentationOrdinal": "188", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 565, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 43, + "decodeOrdinal": 183, + "intendedPresentationOrdinal": "183", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 566, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 49, + "decodeOrdinal": 189, + "intendedPresentationOrdinal": "189", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 567, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 49, + "decodeOrdinal": 189, + "intendedPresentationOrdinal": "189", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 568, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 44, + "decodeOrdinal": 184, + "intendedPresentationOrdinal": "184", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 569, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 50, + "decodeOrdinal": 190, + "intendedPresentationOrdinal": "190", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 570, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 50, + "decodeOrdinal": 190, + "intendedPresentationOrdinal": "190", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 571, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 45, + "decodeOrdinal": 185, + "intendedPresentationOrdinal": "185", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 572, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 51, + "decodeOrdinal": 191, + "intendedPresentationOrdinal": "191", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 573, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 51, + "decodeOrdinal": 191, + "intendedPresentationOrdinal": "191", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 574, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 46, + "decodeOrdinal": 186, + "intendedPresentationOrdinal": "186", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 575, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 52, + "decodeOrdinal": 192, + "intendedPresentationOrdinal": "192", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 576, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 52, + "decodeOrdinal": 192, + "intendedPresentationOrdinal": "192", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 577, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 47, + "decodeOrdinal": 187, + "intendedPresentationOrdinal": "187", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 578, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 53, + "decodeOrdinal": 193, + "intendedPresentationOrdinal": "193", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 579, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 53, + "decodeOrdinal": 193, + "intendedPresentationOrdinal": "193", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 580, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 48, + "decodeOrdinal": 188, + "intendedPresentationOrdinal": "188", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 581, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 54, + "decodeOrdinal": 194, + "intendedPresentationOrdinal": "194", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 582, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 54, + "decodeOrdinal": 194, + "intendedPresentationOrdinal": "194", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 583, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 49, + "decodeOrdinal": 189, + "intendedPresentationOrdinal": "189", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 584, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 55, + "decodeOrdinal": 195, + "intendedPresentationOrdinal": "195", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 585, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 55, + "decodeOrdinal": 195, + "intendedPresentationOrdinal": "195", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 586, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 50, + "decodeOrdinal": 190, + "intendedPresentationOrdinal": "190", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 587, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 56, + "decodeOrdinal": 196, + "intendedPresentationOrdinal": "196", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 588, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 56, + "decodeOrdinal": 196, + "intendedPresentationOrdinal": "196", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 589, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 51, + "decodeOrdinal": 191, + "intendedPresentationOrdinal": "191", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 590, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 57, + "decodeOrdinal": 197, + "intendedPresentationOrdinal": "197", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 591, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 57, + "decodeOrdinal": 197, + "intendedPresentationOrdinal": "197", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 592, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 52, + "decodeOrdinal": 192, + "intendedPresentationOrdinal": "192", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 593, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 58, + "decodeOrdinal": 198, + "intendedPresentationOrdinal": "198", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 594, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 58, + "decodeOrdinal": 198, + "intendedPresentationOrdinal": "198", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 595, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 53, + "decodeOrdinal": 193, + "intendedPresentationOrdinal": "193", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 596, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 59, + "decodeOrdinal": 199, + "intendedPresentationOrdinal": "199", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 597, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 59, + "decodeOrdinal": 199, + "intendedPresentationOrdinal": "199", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 598, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 54, + "decodeOrdinal": 194, + "intendedPresentationOrdinal": "194", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 599, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 60, + "decodeOrdinal": 200, + "intendedPresentationOrdinal": "200", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 600, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 60, + "decodeOrdinal": 200, + "intendedPresentationOrdinal": "200", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 601, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 55, + "decodeOrdinal": 195, + "intendedPresentationOrdinal": "195", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 602, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 61, + "decodeOrdinal": 201, + "intendedPresentationOrdinal": "201", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 603, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 61, + "decodeOrdinal": 201, + "intendedPresentationOrdinal": "201", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 604, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 56, + "decodeOrdinal": 196, + "intendedPresentationOrdinal": "196", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 605, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 62, + "decodeOrdinal": 202, + "intendedPresentationOrdinal": "202", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 606, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 62, + "decodeOrdinal": 202, + "intendedPresentationOrdinal": "202", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 607, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 57, + "decodeOrdinal": 197, + "intendedPresentationOrdinal": "197", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 608, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 63, + "decodeOrdinal": 203, + "intendedPresentationOrdinal": "203", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 609, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 63, + "decodeOrdinal": 203, + "intendedPresentationOrdinal": "203", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 610, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 58, + "decodeOrdinal": 198, + "intendedPresentationOrdinal": "198", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 611, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 64, + "decodeOrdinal": 204, + "intendedPresentationOrdinal": "204", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 612, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 64, + "decodeOrdinal": 204, + "intendedPresentationOrdinal": "204", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 613, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 59, + "decodeOrdinal": 199, + "intendedPresentationOrdinal": "199", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 614, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 65, + "decodeOrdinal": 205, + "intendedPresentationOrdinal": "205", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 615, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 65, + "decodeOrdinal": 205, + "intendedPresentationOrdinal": "205", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 616, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 60, + "decodeOrdinal": 200, + "intendedPresentationOrdinal": "200", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 617, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 66, + "decodeOrdinal": 206, + "intendedPresentationOrdinal": "206", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 618, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 66, + "decodeOrdinal": 206, + "intendedPresentationOrdinal": "206", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 619, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 61, + "decodeOrdinal": 201, + "intendedPresentationOrdinal": "201", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 620, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 0, + "decodeOrdinal": 207, + "intendedPresentationOrdinal": "207", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 621, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 0, + "decodeOrdinal": 207, + "intendedPresentationOrdinal": "207", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 622, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 62, + "decodeOrdinal": 202, + "intendedPresentationOrdinal": "202", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 623, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 1, + "decodeOrdinal": 208, + "intendedPresentationOrdinal": "208", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 624, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 1, + "decodeOrdinal": 208, + "intendedPresentationOrdinal": "208", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 625, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 63, + "decodeOrdinal": 203, + "intendedPresentationOrdinal": "203", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 626, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 2, + "decodeOrdinal": 209, + "intendedPresentationOrdinal": "209", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 627, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 2, + "decodeOrdinal": 209, + "intendedPresentationOrdinal": "209", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 628, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 64, + "decodeOrdinal": 204, + "intendedPresentationOrdinal": "204", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 629, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 3, + "decodeOrdinal": 210, + "intendedPresentationOrdinal": "210", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 630, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 3, + "decodeOrdinal": 210, + "intendedPresentationOrdinal": "210", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 631, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 65, + "decodeOrdinal": 205, + "intendedPresentationOrdinal": "205", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 632, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 4, + "decodeOrdinal": 211, + "intendedPresentationOrdinal": "211", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 633, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 4, + "decodeOrdinal": 211, + "intendedPresentationOrdinal": "211", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 634, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-in", + "unitInstance": 2, + "unitFrame": 66, + "decodeOrdinal": 206, + "intendedPresentationOrdinal": "206", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 635, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 5, + "decodeOrdinal": 212, + "intendedPresentationOrdinal": "212", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 636, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 5, + "decodeOrdinal": 212, + "intendedPresentationOrdinal": "212", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 637, + "operation": "route-commit", + "generation": 1, + "path": "idle", + "unit": null, + "unitInstance": null, + "unitFrame": null, + "decodeOrdinal": null, + "intendedPresentationOrdinal": null, + "ringSize": 6, + "expectedOutputs": 0, + "reason": "entering.exiting" + }, + { + "index": 638, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 0, + "decodeOrdinal": 207, + "intendedPresentationOrdinal": "207", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 639, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 6, + "decodeOrdinal": 213, + "intendedPresentationOrdinal": "213", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 640, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 6, + "decodeOrdinal": 213, + "intendedPresentationOrdinal": "213", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 641, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 1, + "decodeOrdinal": 208, + "intendedPresentationOrdinal": "208", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 642, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 7, + "decodeOrdinal": 214, + "intendedPresentationOrdinal": "214", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 643, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 7, + "decodeOrdinal": 214, + "intendedPresentationOrdinal": "214", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 644, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 2, + "decodeOrdinal": 209, + "intendedPresentationOrdinal": "209", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 645, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 8, + "decodeOrdinal": 215, + "intendedPresentationOrdinal": "215", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 646, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 8, + "decodeOrdinal": 215, + "intendedPresentationOrdinal": "215", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 647, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 3, + "decodeOrdinal": 210, + "intendedPresentationOrdinal": "210", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 648, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 9, + "decodeOrdinal": 216, + "intendedPresentationOrdinal": "216", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 649, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 9, + "decodeOrdinal": 216, + "intendedPresentationOrdinal": "216", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 650, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 4, + "decodeOrdinal": 211, + "intendedPresentationOrdinal": "211", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 651, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 10, + "decodeOrdinal": 217, + "intendedPresentationOrdinal": "217", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 652, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 10, + "decodeOrdinal": 217, + "intendedPresentationOrdinal": "217", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 653, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 5, + "decodeOrdinal": 212, + "intendedPresentationOrdinal": "212", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 654, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 11, + "decodeOrdinal": 218, + "intendedPresentationOrdinal": "218", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 655, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 11, + "decodeOrdinal": 218, + "intendedPresentationOrdinal": "218", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 656, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 6, + "decodeOrdinal": 213, + "intendedPresentationOrdinal": "213", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 657, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 12, + "decodeOrdinal": 219, + "intendedPresentationOrdinal": "219", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 658, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 12, + "decodeOrdinal": 219, + "intendedPresentationOrdinal": "219", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 659, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 7, + "decodeOrdinal": 214, + "intendedPresentationOrdinal": "214", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 660, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 13, + "decodeOrdinal": 220, + "intendedPresentationOrdinal": "220", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 661, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 13, + "decodeOrdinal": 220, + "intendedPresentationOrdinal": "220", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 662, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 8, + "decodeOrdinal": 215, + "intendedPresentationOrdinal": "215", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 663, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 14, + "decodeOrdinal": 221, + "intendedPresentationOrdinal": "221", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 664, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 14, + "decodeOrdinal": 221, + "intendedPresentationOrdinal": "221", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 665, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 9, + "decodeOrdinal": 216, + "intendedPresentationOrdinal": "216", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 666, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 15, + "decodeOrdinal": 222, + "intendedPresentationOrdinal": "222", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 667, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 15, + "decodeOrdinal": 222, + "intendedPresentationOrdinal": "222", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 668, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 10, + "decodeOrdinal": 217, + "intendedPresentationOrdinal": "217", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 669, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 16, + "decodeOrdinal": 223, + "intendedPresentationOrdinal": "223", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 670, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 16, + "decodeOrdinal": 223, + "intendedPresentationOrdinal": "223", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 671, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 11, + "decodeOrdinal": 218, + "intendedPresentationOrdinal": "218", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 672, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 17, + "decodeOrdinal": 224, + "intendedPresentationOrdinal": "224", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 673, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 17, + "decodeOrdinal": 224, + "intendedPresentationOrdinal": "224", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 674, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 12, + "decodeOrdinal": 219, + "intendedPresentationOrdinal": "219", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 675, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 18, + "decodeOrdinal": 225, + "intendedPresentationOrdinal": "225", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 676, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 18, + "decodeOrdinal": 225, + "intendedPresentationOrdinal": "225", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 677, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 13, + "decodeOrdinal": 220, + "intendedPresentationOrdinal": "220", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 678, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 19, + "decodeOrdinal": 226, + "intendedPresentationOrdinal": "226", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 679, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 19, + "decodeOrdinal": 226, + "intendedPresentationOrdinal": "226", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 680, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 14, + "decodeOrdinal": 221, + "intendedPresentationOrdinal": "221", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 681, + "operation": "submit", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 20, + "decodeOrdinal": 227, + "intendedPresentationOrdinal": "227", + "ringSize": 5, + "expectedOutputs": 1, + "reason": null + }, + { + "index": 682, + "operation": "output", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 20, + "decodeOrdinal": 227, + "intendedPresentationOrdinal": "227", + "ringSize": 6, + "expectedOutputs": 0, + "reason": null + }, + { + "index": 683, + "operation": "present", + "generation": 1, + "path": "idle", + "unit": "hover-out", + "unitInstance": 3, + "unitFrame": 15, + "decodeOrdinal": 222, + "intendedPresentationOrdinal": "222", + "ringSize": 5, + "expectedOutputs": 0, + "reason": null + } + ] +} diff --git a/flutter/packages/aval_player/test/golden_trace_test.dart b/flutter/packages/aval_player/test/golden_trace_test.dart new file mode 100644 index 0000000..d55e966 --- /dev/null +++ b/flutter/packages/aval_player/test/golden_trace_test.dart @@ -0,0 +1,537 @@ +/// Golden-trace parity test: drives the *identical* deterministic scenario as +/// the TS harness (`packages/player-web/src/runtime/grass-rabbit-golden-trace. +/// test.ts`) through the Dart [PathScheduler] — with the real +/// [WorkerSampleFactory] + [RuntimeAssetCatalog] over the real grass-rabbit.avl +/// and a byte-identical fake decoder worker — and diffs the resulting trace + +/// takeNext media sequence against the committed golden JSON. +/// +/// The golden fixture (test/fixtures/grass_rabbit_golden_trace.json) is +/// regenerated from the TS side with: +/// WRITE_GOLDEN=1 npx vitest run --config vitest.m9.config.ts \ +/// packages/player-web/src/runtime/grass-rabbit-golden-trace.test.ts +/// +/// This closes the Phase-2 architecture exit criterion: "the scheduler produces +/// the identical PathFramePlan sequence as the TS version for grass-rabbit +/// (golden-trace diff)". +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart' show BodyUnitV01; +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_player/aval_player.dart'; +import 'package:test/test.dart'; + +const DecoderWorkerLimits _limits = DecoderWorkerLimits( + maxDecodeQueueSize: 8, + maxPendingSamples: 12, + maxOutstandingFrames: 12, + maxDecodedBytes: 12 * 1280 * 720 * 4, +); + +const int _ringCapacity = 6; +const int _idleTicks = 80; + +void main() { + test('Dart PathScheduler matches the TS golden trace for grass-rabbit', + () async { + final fixture = + jsonDecode(_readText(_fixturePath())) as Map; + final actual = await _runScenario(); + + // Metadata (rendition id, frame rate, unit frame counts) must match first; + // a mismatch here means the parsed asset diverged. + expect(actual['meta'], fixture['meta'], reason: 'meta mismatch'); + + final expectedMedia = fixture['media'] as List; + final actualMedia = actual['media'] as List; + expect(actualMedia.length, expectedMedia.length, + reason: 'media length mismatch'); + for (var index = 0; index < expectedMedia.length; index += 1) { + expect(actualMedia[index], expectedMedia[index], + reason: 'media[$index] mismatch'); + } + + final expectedTrace = fixture['trace'] as List; + final actualTrace = actual['trace'] as List; + expect(actualTrace.length, expectedTrace.length, + reason: 'trace length mismatch'); + for (var index = 0; index < expectedTrace.length; index += 1) { + expect(actualTrace[index], expectedTrace[index], + reason: 'trace[$index] mismatch'); + } + }); +} + +Future> _runScenario() async { + final bytes = Uint8List.fromList(File(_assetPath()).readAsBytesSync()); + final catalog = installRuntimeAssetCatalog(bytes); + final manifest = catalog.manifest; + final rendition = manifest.renditions + .firstWhere((candidate) => candidate.profile.startsWith('avc-annexb')); + final timeline = DecodeTimeline(RationalFrameRate( + numerator: manifest.frameRate.numerator, + denominator: manifest.frameRate.denominator, + )); + final worker = _FakeWorker(); + final samples = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: catalog, + timeline: timeline, + rendition: rendition.id, + limits: _limits, + )); + var now = 0; + final scheduler = PathScheduler(PathSchedulerOptions( + timeline: timeline, + samples: samples, + worker: worker, + rendition: rendition.id, + ringCapacity: _ringCapacity, + limits: _limits, + clock: _InlineClock(() => ++now), + )); + + final unitFrameCounts = {}; + for (final unit in manifest.units) { + unitFrameCounts[unit.id] = unit.frameCount; + } + + final media = []; + final step = _StepBox(); + void record(String label, PathSchedulerTakeResult result) { + media.add(_serializeTake(step.value, label, result)); + step.value += 1; + if (result is PathSchedulerTakeFrame) result.frame.close(); + } + + // 1-2. Idle loop with wrap. + await scheduler.startBody(StartScheduledBodyInput( + state: 'idle', + body: _body(manifest, 'idle-loop'), + outgoingStarts: [_portalStart('default', 'default', 139)], + path: 'idle', + )); + for (var index = 0; index < _idleTicks; index += 1) { + await scheduler.pump(const PathSchedulerPumpOptions( + targetRingFrames: _ringCapacity, + )); + record('idle', scheduler.takeNext()); + } + + // 3. hover.enter -> entering (portal edge). + await _routeThrough( + scheduler, + _portalEdge('idle.entering', 'idle', 'entering', 'default', 'default', 139), + 'entering', + _body(manifest, 'hover-in'), + 'enter', + media, + step, + ); + + // 4. hover.leave -> exiting (finish edge from the finite hover-in body). + await _routeThrough( + scheduler, + _finishEdge('entering.exiting', 'entering', 'exiting', 'default', 66), + 'exiting', + _body(manifest, 'hover-out'), + 'leave', + media, + step, + ); + + for (var index = 0; index < 6; index += 1) { + await scheduler.pump(const PathSchedulerPumpOptions( + targetRingFrames: _ringCapacity, + )); + record('exiting-tail', scheduler.takeNext()); + } + + final trace = scheduler.trace().map(_serializeTraceRecord).toList(); + await scheduler.dispose(); + + return { + 'meta': { + 'rendition': rendition.id, + 'frameRate': { + 'numerator': manifest.frameRate.numerator, + 'denominator': manifest.frameRate.denominator, + }, + 'units': unitFrameCounts, + }, + 'media': media, + 'trace': trace, + }; +} + +Future _routeThrough( + PathScheduler scheduler, + GraphEdgeDefinition edge, + String targetState, + GraphBodyDefinition targetBody, + String label, + List media, + _StepBox step, +) async { + await scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: edge, + targetState: targetState, + targetBody: targetBody, + )); + var committed = false; + for (var guard = 0; guard < 600 && !committed; guard += 1) { + await scheduler.pump(const PathSchedulerPumpOptions( + targetRingFrames: _ringCapacity, + )); + final decision = scheduler.routeDecision(); + if (decision is SubmissionHorizonCommitEdge) { + scheduler.commitPreparedRoute(); + committed = true; + break; + } + final result = scheduler.reserveNext(true); + if (result is PathSchedulerTakeFrame) { + scheduler.commitPreparedPresentation(result.media); + media.add(_serializeTake(step.value, '$label-source', result)); + step.value += 1; + result.frame.close(); + } else { + media.add(_serializeTake(step.value, '$label-wait', result)); + step.value += 1; + } + } + if (!committed) throw StateError('$label route never committed'); + for (var index = 0; index < 10; index += 1) { + await scheduler.pump(const PathSchedulerPumpOptions( + targetRingFrames: _ringCapacity, + )); + media.add(_serializeTake(step.value, '$label-target', scheduler.takeNext())); + step.value += 1; + } + scheduler.promoteTargetToSource( + state: targetState, + body: targetBody, + outgoingStarts: [_portalStart('default', 'default', 139)], + ); +} + +GraphBodyDefinition _body(dynamic manifest, String unitId) { + final unit = (manifest.units as List) + .firstWhere((candidate) => candidate.id == unitId) as BodyUnitV01; + return GraphBodyDefinition( + unitId: unit.id, + kind: unit.playback == 'loop' ? GraphBodyKind.loop : GraphBodyKind.finite, + frameCount: unit.frameCount, + ports: unit.ports + .map((port) => GraphPortDefinition( + id: port.id, + portalFrames: List.from(port.portalFrames), + )) + .toList(), + ); +} + +GraphStartPolicyPortal _portalStart( + String sourcePort, + String targetPort, + int maxWaitFrames, +) { + return GraphStartPolicyPortal( + sourcePort: sourcePort, + targetPort: targetPort, + maxWaitFrames: maxWaitFrames, + ); +} + +GraphEdgeDefinition _portalEdge( + String id, + String from, + String to, + String sourcePort, + String targetPort, + int maxWaitFrames, +) { + return GraphEdgeDefinition( + id: id, + from: from, + to: to, + start: _portalStart(sourcePort, targetPort, maxWaitFrames), + continuity: GraphContinuity.exactAuthored, + ); +} + +GraphEdgeDefinition _finishEdge( + String id, + String from, + String to, + String targetPort, + int maxWaitFrames, +) { + return GraphEdgeDefinition( + id: id, + from: from, + to: to, + start: GraphStartPolicyFinish( + targetPort: targetPort, + maxWaitFrames: maxWaitFrames, + ), + continuity: GraphContinuity.exactAuthored, + ); +} + +Map _serializeTake( + int step, + String label, + PathSchedulerTakeResult result, +) { + final base = { + 'step': step, + 'label': label, + 'kind': result.kind, + }; + if (result is PathSchedulerTakeFrame) { + base['purpose'] = result.purpose.wireValue; + base.addAll(_mediaFields(result.media)); + // Mirror the TS harness's serializeTake: close (release) the frame here so + // outstanding-frame credit replenishes identically on both sides. + result.frame.close(); + } else if (result is PathSchedulerTakeResident) { + base.addAll(_mediaFields(result.media)); + } + return base; +} + +Map _mediaFields(RuntimeMediaPresentationFrame media) { + return { + 'graphKind': media.graphKind.wireValue, + 'state': media.state, + 'edge': media.edge, + 'path': media.path, + 'unit': media.frame.unit, + 'localFrame': media.frame.localFrame, + 'drawSource': media.drawSource.wireValue, + 'generation': media.generation, + 'unitInstance': media.unitInstance, + 'decodeOrdinal': media.decodeOrdinal, + 'timestamp': media.timestamp, + 'intendedPresentationOrdinal': media.intendedPresentationOrdinal.toString(), + }; +} + +Map _serializeTraceRecord(PathSchedulerTraceRecord record) { + return { + 'index': record.index, + 'operation': record.operation.wireValue, + 'generation': record.generation, + 'path': record.path, + 'unit': record.unit, + 'unitInstance': record.unitInstance, + 'unitFrame': record.unitFrame, + 'decodeOrdinal': record.decodeOrdinal, + 'intendedPresentationOrdinal': + record.intendedPresentationOrdinal?.toString(), + 'ringSize': record.ringSize, + 'expectedOutputs': record.expectedOutputs, + 'reason': record.reason, + }; +} + +String _readText(String path) => File(path).readAsStringSync(); + +String _fixturePath() { + for (final candidate in [ + 'test/fixtures/grass_rabbit_golden_trace.json', + 'fixtures/grass_rabbit_golden_trace.json', + ]) { + if (File(candidate).existsSync()) return candidate; + } + return 'test/fixtures/grass_rabbit_golden_trace.json'; +} + +String _assetPath() { + for (final candidate in [ + '../../../examples/grass-rabbit/public/grass-rabbit.avl', + '../../../../examples/grass-rabbit/public/grass-rabbit.avl', + ]) { + if (File(candidate).existsSync()) return candidate; + } + return '../../../examples/grass-rabbit/public/grass-rabbit.avl'; +} + +class _StepBox { + int value = 0; +} + +class _InlineClock implements PathSchedulerClock { + _InlineClock(this._now); + final int Function() _now; + @override + int now() => _now(); +} + +// --- Fake decoder worker (port of the TS harness / path_scheduler_test.dart) - + +class _PendingFakeSample { + const _PendingFakeSample(this.generation, this.sample); + final int generation; + final DecoderWorkerSample sample; +} + +class _FakeWorker implements PathSchedulerWorkerAdapter { + @override + int? activeGeneration; + final int _outputsPerWait = 1; + final List<_PendingFakeSample> _pending = <_PendingFakeSample>[]; + final List<_FakeManagedFrame> _ready = <_FakeManagedFrame>[]; + final Set<_FakeManagedFrame> _open = <_FakeManagedFrame>{}; + int _acceptedSamples = 0; + int _releasedFrames = 0; + + @override + int get queuedFrames => _ready.length; + + @override + int get openFrames => _open.length; + + @override + Future activateGeneration(int generation) async { + activeGeneration = generation; + for (final frame in [..._ready]) { + if (frame.generation != generation) frame.close(); + } + _ready.removeWhere((frame) => frame.closed); + _pending.clear(); + } + + @override + Future submit(int generation, List samples) async { + if (generation != activeGeneration) { + throw StateError('fake generation mismatch'); + } + for (final sample in samples) { + _pending.add(_PendingFakeSample(generation, sample)); + _acceptedSamples += 1; + } + } + + @override + Future abortGeneration(int generation) async { + _pending.removeWhere((item) => item.generation == generation); + for (final frame in [..._open]) { + if (frame.generation == generation) frame.close(); + } + _ready.removeWhere((frame) => frame.closed); + if (activeGeneration == generation) activeGeneration = null; + } + + @override + ManagedDecoderWorkerFrame? takeFrame() => + _ready.isEmpty ? null : _ready.removeAt(0); + + @override + Future waitForFrames([ + int? minimum, + DecoderWorkerWaitOptions? options, + ]) async { + final min = minimum ?? 1; + var released = 0; + while (_pending.isNotEmpty && + (_ready.length < min || released < _outputsPerWait) && + released < _outputsPerWait) { + final pending = _pending.removeAt(0); + late final _FakeManagedFrame frame; + frame = _FakeManagedFrame(pending, () { + _open.remove(frame); + _releasedFrames += 1; + }); + _open.add(frame); + _ready.add(frame); + released += 1; + } + } + + @override + Future snapshotMetrics() async { + final generation = activeGeneration; + final submittedFrames = + _pending.where((item) => item.generation == generation).length; + final leasedFrames = + _open.where((frame) => frame.generation == generation).length; + return DecoderWorkerMetrics( + configureCalls: 1, + resetCalls: 0, + flushCalls: 0, + boundaryFlushCalls: 0, + acceptedSamples: _acceptedSamples, + submittedChunks: _acceptedSamples, + outputFrames: _acceptedSamples - _pending.length, + deliveredFrames: _acceptedSamples - _pending.length, + releasedFrames: _releasedFrames, + staleFrames: 0, + closedFrames: _releasedFrames, + pendingSamples: 0, + submittedFrames: submittedFrames, + leasedFrames: leasedFrames, + leasedDecodedBytes: leasedFrames * 128, + decodeQueueSize: submittedFrames, + activeGeneration: generation, + nextSubmissionOrdinal: _acceptedSamples, + nextOutputOrdinal: _acceptedSamples - _pending.length, + errors: 0, + disposed: false, + ); + } +} + +class _FakeManagedFrame implements ManagedDecoderWorkerFrame { + _FakeManagedFrame(_PendingFakeSample pending, this._release) + : frame = _FakeVideoFrame(), + frameId = pending.sample.ordinal + 1, + generation = pending.generation, + ordinal = pending.sample.ordinal, + unitId = pending.sample.unitId, + unitInstance = pending.sample.unitInstance, + unitFrame = pending.sample.unitFrame, + timestamp = pending.sample.timestamp, + duration = pending.sample.duration; + + @override + final VideoFrame frame; + @override + final int frameId; + @override + final int generation; + @override + final int ordinal; + @override + final String unitId; + @override + final int unitInstance; + @override + final int unitFrame; + @override + final int timestamp; + @override + final int duration; + @override + final int decodedBytes = 128; + @override + int? get outputCallbackMicroseconds => null; + + final void Function() _release; + bool _closed = false; + + @override + bool get closed => _closed; + + @override + void close() { + if (_closed) return; + _closed = true; + _release(); + } +} + +class _FakeVideoFrame implements VideoFrame {} diff --git a/flutter/packages/aval_player/test/path_scheduler_test.dart b/flutter/packages/aval_player/test/path_scheduler_test.dart new file mode 100644 index 0000000..34bf273 --- /dev/null +++ b/flutter/packages/aval_player/test/path_scheduler_test.dart @@ -0,0 +1,1218 @@ +/// Port of `packages/player-web/src/runtime/path-scheduler.test.ts` (1:1). +/// +/// The TS suite drives the real `WorkerSampleFactory` + asset catalog + the +/// `.avl` container fixtures (`asset-test-fixture.ts`). Those modules depend on +/// `aval_format`'s container/AVC surface, which is a later phase and not yet +/// ported. Because the frozen `worker_samples.dart` deliberately exposes +/// `WorkerSampleFactory` as an *interface*, this port substitutes a faithful +/// test-local factory (`_FakeWorkerSampleFactory`) that reproduces exactly the +/// load-bearing behavior the scheduler observes: it drives the same +/// `DecodeTimeline.planSampleBatch(...).commit()` the real factory does +/// (worker-samples.ts:145,203) to assign ordinal/unitInstance/timestamp/ +/// duration, and classifies frame 0 as `key` else `delta` (matching the fixture +/// access units in asset-test-fixture.ts). Sample bytes are irrelevant to every +/// scheduler assertion, so a placeholder buffer is used. The unit frame-count +/// tables mirror `createOpaqueTestAsset` / `createIntegratedPathTestAsset`. +/// +/// `FakeWorker` / `FakeManagedFrame` are ported verbatim from the TS fixture +/// (Promise → Future, resolve-callbacks → Completers). +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_player/aval_player.dart'; +import 'package:test/test.dart'; + +const DecoderWorkerLimits limits = DecoderWorkerLimits( + maxDecodeQueueSize: 8, + maxPendingSamples: 12, + maxOutstandingFrames: 12, + maxDecodedBytes: 12 * 64 * 64 * 4, +); + +void main() { + group('PathScheduler continuous source pumping', () { + test('keeps complete loop occurrences continuous under bounded credit', + () async { + final fixture = createFixture(); + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'idle', + body: body('body', GraphBodyKind.loop, 2, [1]), + outgoingStarts: [portalStart()], + path: 'idle-loop', + )); + + final presented = >[]; + for (var index = 0; index < 10; index += 1) { + final report = + await fixture.scheduler.pump(const PathSchedulerPumpOptions( + targetRingFrames: 6, + )); + expect(report.ringSize, lessThanOrEqualTo(6)); + final result = fixture.scheduler.takeNext(); + final frame = requireStreaming(result); + presented.add([ + frame.media.unitInstance, + frame.media.frame.localFrame, + frame.media.decodeOrdinal, + ]); + frame.frame.closeFrame(); + } + + expect( + presented.map((value) => value.sublist(0, 2)).toList(), + [ + [0, 0], [0, 1], [1, 0], [1, 1], [2, 0], // + [2, 1], [3, 0], [3, 1], [4, 0], [4, 1], + ], + ); + expect( + presented.map((value) => value[2]).toList(), + List.generate(10, (index) => index), + ); + expect(fixture.worker.maximumSubmittedBatch, lessThanOrEqualTo(6)); + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.generation, 1); + expect(snapshot.activePath, 'idle-loop'); + expect(snapshot.smoothSession, true); + expect(snapshot.status, PathSchedulerStatus.active); + await fixture.scheduler.dispose(); + expect(fixture.worker.openFrames, 0); + }); + + test('never submits past the unresolved portal horizon', () async { + final fixture = createFixture(); + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'idle', + body: body('body', GraphBodyKind.loop, 2, [0, 1]), + outgoingStarts: [portalStart()], + path: 'bounded-source', + )); + + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 6)); + final snapshot = fixture.scheduler.snapshot(); + expect( + snapshot.submittedSource, + SourceBodyCursor(occurrence: BigInt.two, frame: 1), + ); + expect( + snapshot.unresolvedMaximumSubmitted, + SourceBodyCursor(occurrence: BigInt.from(3), frame: 0), + ); + expect(snapshot.ringSize, 6); + }); + + test('maintains the same order under controllable worker output latency', + () async { + final slow = createFixture(FakeWorkerOptions(outputsPerWait: 1)); + final fast = createFixture(FakeWorkerOptions(outputsPerWait: 4)); + for (final fixture in [slow, fast]) { + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'idle', + body: body('body', GraphBodyKind.loop, 2, [1]), + outgoingStarts: [portalStart()], + path: 'latency', + )); + } + + final slowReport = await slow.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 4)); + final fastReport = await fast.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 4)); + expect(slowReport.waits, 4); + expect(fastReport.waits, 1); + expect( + [slow, fast].map((fixture) => fixture.scheduler.snapshot().ringSize), + [4, 4], + ); + }); + }); + + group('PathScheduler locked and target paths', () { + test('prepares a complete locked bridge plus target zero before route commit', + () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + + final decision = await fixture.scheduler.prepareRoute( + PrepareScheduledRouteInput( + edge: lockedEdge('to-target', 2), + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + ), + ); + expect(decision, isA()); + expect((decision as SubmissionHorizonSelectPortal).boundary.frame, 1); + + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 6)); + final sourceBoundary = requireStreaming(fixture.scheduler.takeNext()); + expect(sourceBoundary.purpose, PathSchedulerFramePurpose.source); + expect(sourceBoundary.media.frame.localFrame, 1); + sourceBoundary.frame.closeFrame(); + + final routeDecision = fixture.scheduler.routeDecision(); + expect(routeDecision, isA()); + final commitEdge = routeDecision! as SubmissionHorizonCommitEdge; + expect(commitEdge.lead?.requiredConsecutiveFrames, 3); + expect(commitEdge.lead?.ready, true); + fixture.scheduler.commitPreparedRoute(); + + final bridgeZero = requireStreaming(fixture.scheduler.takeNext()); + final bridgeOne = requireStreaming(fixture.scheduler.takeNext()); + final targetZero = requireStreaming(fixture.scheduler.takeNext()); + expect( + [identity(bridgeZero), identity(bridgeOne), identity(targetZero)], + [ + ['bridge', 'intro', 0], + ['bridge', 'intro', 1], + ['target', 'body', 0], + ], + ); + bridgeZero.frame.closeFrame(); + bridgeOne.frame.closeFrame(); + targetZero.frame.closeFrame(); + }); + + test('continues the target loop with complete new occurrences', () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('to-target', 2), + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 6)); + closeStreaming(fixture.scheduler.takeNext()); + fixture.scheduler.commitPreparedRoute(); + + final values = >[]; + for (var index = 0; index < 6; index += 1) { + final current = requireStreaming(fixture.scheduler.takeNext()); + values.add([ + current.purpose.wireValue, + current.media.unitInstance, + current.media.frame.localFrame, + ]); + current.frame.closeFrame(); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 6)); + } + expect(values, [ + ['bridge', 1, 0], + ['bridge', 1, 1], + ['target', 2, 0], + ['target', 2, 1], + ['target', 3, 0], + ['target', 3, 1], + ]); + }); + }); + + group('PathScheduler generation replacement and recovery', () { + test( + 'restarts pending replacement from frame zero while retaining global decode time', + () async { + final fixture = createFixture(FakeWorkerOptions(retainOneStaleOutput: true)); + await startAtSourceZero(fixture); + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('first-route', 2), + targetState: 'first', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 4)); + final before = fixture.scheduler.snapshot(); + + final replacement = + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('replacement-route', 2), + targetState: 'replacement', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + expect(replacement, isA()); + final afterReplace = fixture.scheduler.snapshot(); + expect(afterReplace.generation, 2); + expect(afterReplace.pendingEdge, 'replacement-route'); + expect(afterReplace.ringSize, 0); + + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 4)); + final resumed = requireStreaming(fixture.scheduler.takeNext()); + expect(resumed.purpose, PathSchedulerFramePurpose.source); + expect(resumed.media.frame.localFrame, 1); + expect( + resumed.media.decodeOrdinal, + greaterThan(before.nextDecodeOrdinal - 1), + ); + resumed.frame.closeFrame(); + expect( + fixture.scheduler.trace().any( + (record) => + record.operation == PathSchedulerTraceOperation.staleOutput, + ), + true, + ); + }); + + test('hands a resident runway to the exact streamed continuation', () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + final runway = List.generate( + 6, + (index) => resident(index % 2), + ); + + await fixture.scheduler.startResidentRunway(StartResidentRunwayInput( + edgeId: 'cut-to-target', + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + frames: runway, + path: 'cut-target', + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 2)); + + final ordinals = []; + for (var index = 0; index < runway.length; index += 1) { + final current = fixture.scheduler.takeNext(); + expect(current.kind, 'resident'); + final residentResult = current as PathSchedulerTakeResident; + expect(residentResult.media.frame.localFrame, index % 2); + ordinals.add(residentResult.media.intendedPresentationOrdinal); + } + final streamed = requireStreaming(fixture.scheduler.takeNext()); + expect(streamed.purpose, PathSchedulerFramePurpose.target); + expect(streamed.media.frame.localFrame, 0); + expect( + streamed.media.intendedPresentationOrdinal, + ordinals.last + BigInt.one, + ); + streamed.frame.closeFrame(); + expect(fixture.scheduler.snapshot().discardedDependencyFrames, 6); + }); + + test('rolls back a staged runway without disturbing a source reservation', + () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 1)); + final source = requireStreaming(fixture.scheduler.reserveNext()); + final before = fixture.scheduler.snapshot(); + final transaction = + fixture.scheduler.stageResidentRunway(StartResidentRunwayInput( + edgeId: 'cut-to-target', + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + frames: List.generate( + 6, + (index) => resident(index % 2), + ), + path: 'cut:staged', + )); + + expect(fixture.scheduler.snapshot(), before); + expect(fixture.scheduler.rollbackResidentRunway(transaction), true); + fixture.scheduler.commitPreparedPresentation(source.media); + source.frame.closeFrame(); + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.generation, 1); + expect(snapshot.activePath, 'source'); + expect( + snapshot.displayedSource, + SourceBodyCursor(occurrence: BigInt.zero, frame: 1), + ); + }); + + test('commits the exact staged generation and records drawn frame zero directly', + () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + final gate = fixture.worker.gateNextActivation(); + final transaction = + fixture.scheduler.stageResidentRunway(StartResidentRunwayInput( + edgeId: 'cut-to-target', + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + frames: List.generate( + 6, + (index) => resident(index % 2), + ), + path: 'cut:transaction', + firstPresentationOrdinal: BigInt.from(9), + )); + + expect(transaction.generation, 2); + expect( + transaction.media.map((frame) => frame.generation).toList(), + List.generate(6, (_) => 2), + ); + expect( + transaction.media + .map((frame) => frame.intendedPresentationOrdinal) + .toList(), + [ + BigInt.from(9), + BigInt.from(10), + BigInt.from(11), + BigInt.from(12), + BigInt.from(13), + BigInt.from(14), + ], + ); + final staged = fixture.scheduler.snapshot(); + expect(staged.generation, 1); + expect(staged.activePath, 'source'); + expect(staged.residentFrames, 0); + + final activateWorker = fixture.scheduler.commitResidentRunway( + transaction, + const CommitResidentRunwayOptions(alreadyPresented: 1), + ); + final afterCommit = fixture.scheduler.snapshot(); + expect(afterCommit.generation, 2); + expect(afterCommit.activePath, 'cut:transaction'); + expect(afterCommit.residentFrames, 5); + expect(afterCommit.displayedCursor?.path, 'cut:transaction'); + expect(afterCommit.displayedCursor?.unit, 'body'); + expect(afterCommit.displayedCursor?.localFrame, 0); + expect(fixture.worker.activeGeneration, 1); + final next = fixture.scheduler.reserveNext(); + expect(next.kind, 'resident'); + final residentNext = next as PathSchedulerTakeResident; + expect(identical(residentNext.media, transaction.media[1]), true); + fixture.scheduler.commitPreparedPresentation(residentNext.media); + + final activation = activateWorker(); + await gate.entered; + gate.release(); + await activation; + expect( + fixture.scheduler + .trace() + .where((record) => + record.operation == + PathSchedulerTraceOperation.residentPresent) + .length, + 2, + ); + }); + + test('locks non-token replacement until a staged runway rolls back', + () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('obsolete', 2), + targetState: 'obsolete', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + final transaction = + fixture.scheduler.stageResidentRunway(StartResidentRunwayInput( + edgeId: 'cut-to-target', + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + frames: List.generate( + 6, + (index) => resident(index % 2), + ), + path: 'cut:locked', + )); + + await expectLater( + fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('replacement', 2), + targetState: 'replacement', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )), + throwsA(isA().having( + (error) => error.toString(), + 'message', + contains('locked by a staged resident runway'), + )), + ); + final locked = fixture.scheduler.snapshot(); + expect(locked.generation, 1); + expect(locked.pendingEdge, 'obsolete'); + + expect(fixture.scheduler.rollbackResidentRunway(transaction), true); + final resolved = + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('replacement', 2), + targetState: 'replacement', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + expect(resolved, isA()); + expect(fixture.scheduler.snapshot().generation, 2); + }); + + test('reserves after a synchronously advanced in-flight replacement', + () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('obsolete', 2), + targetState: 'obsolete', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + final gate = fixture.worker.gateNextActivation(); + final replacement = + fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('replacement', 2), + targetState: 'replacement', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + await gate.entered; + expect(fixture.scheduler.snapshot().generation, 2); + + final transaction = + fixture.scheduler.stageResidentRunway(StartResidentRunwayInput( + edgeId: 'cut-to-target', + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + frames: List.generate( + 6, + (index) => resident(index % 2), + ), + path: 'cut:after-in-flight', + )); + expect(transaction.generation, 3); + gate.release(); + await replacement; + await fixture.scheduler.commitResidentRunway(transaction)(); + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.generation, 3); + expect(snapshot.activePath, 'cut:after-in-flight'); + }); + + test('keeps a preserved source coherent when replacement acknowledgement aborts', + () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('obsolete', 2), + targetState: 'obsolete', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 6)); + final reserved = requireStreaming(fixture.scheduler.reserveNext()); + expect(reserved.media.frame.localFrame, 1); + final gate = fixture.worker.gateNextActivation(); + final controller = AbortController(); + final cancellation = fixture.scheduler.cancelPreparedRoute( + 'cancel:obsolete', + controller.signal, + true, + ); + await gate.entered; + + final duringAbort = fixture.scheduler.snapshot(); + expect(duringAbort.generation, 2); + expect(duringAbort.activePath, 'cancel:obsolete'); + expect(duringAbort.pendingEdge, null); + expect( + duringAbort.displayedSource, + SourceBodyCursor(occurrence: BigInt.zero, frame: 0), + ); + controller.abort(DOMException('replacement superseded', 'AbortError')); + await expectLater( + cancellation, + throwsA(isA() + .having((error) => error.name, 'name', 'AbortError')), + ); + gate.release(); + await Future.value(); + + fixture.scheduler.commitPreparedPresentation(reserved.media); + reserved.frame.closeFrame(); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 1)); + final adjacent = requireStreaming(fixture.scheduler.takeNext()); + expect(adjacent.media.frame.localFrame, 0); + expect( + adjacent.media.intendedPresentationOrdinal, + reserved.media.intendedPresentationOrdinal + BigInt.one, + ); + adjacent.frame.closeFrame(); + }); + + test('does not let a stale token rollback a newer staged runway', () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + StartResidentRunwayInput input(String path) => StartResidentRunwayInput( + edgeId: 'cut-to-target', + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + frames: List.generate( + 6, + (index) => resident(index % 2), + ), + path: path, + ); + final stale = fixture.scheduler.stageResidentRunway(input('cut:first')); + expect(fixture.scheduler.rollbackResidentRunway(stale), true); + final current = + fixture.scheduler.stageResidentRunway(input('cut:current')); + + expect(fixture.scheduler.rollbackResidentRunway(stale), false); + await fixture.scheduler.commitResidentRunway(current)(); + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.generation, 2); + expect(snapshot.activePath, 'cut:current'); + expect(snapshot.residentFrames, 6); + expect(fixture.scheduler.rollbackResidentRunway(current), false); + }); + + test('hands a long finite runway to its terminal frame and then holds', + () async { + final fixture = createFixture(FakeWorkerOptions(integratedPathAsset: true)); + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'source', + body: body('idle-body', GraphBodyKind.loop, 4, [3]), + outgoingStarts: [portalStart()], + path: 'source', + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 1)); + closeStreaming(fixture.scheduler.takeNext()); + final finite = body('idle-body', GraphBodyKind.finite, 4, [3]); + final runway = [0, 1, 2, 3, 3, 3] + .map((frame) => residentFor('opaque-path', 'idle-body', frame)) + .toList(); + + await fixture.scheduler.startResidentRunway(StartResidentRunwayInput( + edgeId: 'cut-to-finite', + targetState: 'finite', + targetBody: finite, + frames: runway, + path: 'cut-finite', + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 2)); + for (final expected in [0, 1, 2, 3, 3, 3]) { + final current = fixture.scheduler.takeNext(); + expect(current.kind, 'resident'); + expect((current as PathSchedulerTakeResident).media.frame.localFrame, + expected); + } + final handoff = requireStreaming(fixture.scheduler.takeNext()); + expect(handoff.media.frame.localFrame, 3); + handoff.frame.closeFrame(); + fixture.scheduler.promoteTargetToSource( + state: 'finite', + body: finite, + outgoingStarts: const [], + ); + + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 2)); + expect(fixture.scheduler.takeNext().kind, 'held'); + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.discardedDependencyFrames, 3); + expect( + snapshot.displayedSource, + SourceBodyCursor(occurrence: BigInt.zero, frame: 3), + ); + }); + + test('discards a reserved route without advancing and replaces it in-place', + () async { + final fixture = createFixture(); + await startAtSourceZero(fixture); + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('obsolete', 2), + targetState: 'obsolete', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 6)); + closeStreaming(fixture.scheduler.takeNext()); + final before = fixture.scheduler.snapshot().displayedSource; + final obsolete = fixture.scheduler.reserveNext(true); + expect(obsolete.kind, 'frame'); + (obsolete as PathSchedulerTakeFrame).frame.closeFrame(); + expect(fixture.scheduler.snapshot().displayedSource, before); + fixture.scheduler.discardPreparedPresentation(); + + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdge('latest', 2), + targetState: 'latest', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + replacementPath: 'route:latest', + )); + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.generation, 2); + expect(snapshot.activePath, 'route:latest'); + expect(snapshot.pendingEdge, 'latest'); + expect(snapshot.displayedSource, before); + expect( + fixture.scheduler.trace().any((record) => + record.operation == PathSchedulerTraceOperation.routeCommit && + record.reason == 'obsolete'), + false, + ); + }); + + test('cancels an uncommitted route at a terminal finite source', () async { + final fixture = createFixture(); + final finite = body('body', GraphBodyKind.finite, 2, [1]); + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'finite', + body: finite, + outgoingStarts: [portalStart()], + path: 'finite', + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 2)); + closeStreaming(fixture.scheduler.takeNext()); + closeStreaming(fixture.scheduler.takeNext()); + await fixture.scheduler.prepareRoute(PrepareScheduledRouteInput( + edge: lockedEdgeFrom('obsolete', 'finite', 'target', 2), + targetState: 'target', + targetBody: body('body', GraphBodyKind.loop, 2, [1]), + )); + await fixture.scheduler.cancelPreparedRoute('cancel:finite'); + + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.generation, 2); + expect(snapshot.activePath, 'cancel:finite'); + expect(snapshot.pendingEdge, null); + expect( + snapshot.displayedSource, + SourceBodyCursor(occurrence: BigInt.zero, frame: 1), + ); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 2)); + expect(fixture.scheduler.takeNext().kind, 'held'); + }); + + test('turns a worker watchdog into a failed, cleaned scheduler', () async { + final fixture = createFixture(FakeWorkerOptions(watchdog: true)); + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'idle', + body: body('body', GraphBodyKind.loop, 2, [1]), + outgoingStarts: [portalStart()], + path: 'watchdog', + )); + + await expectLater( + fixture.scheduler.pump(const PathSchedulerPumpOptions( + targetRingFrames: 2, + timeoutMs: 5, + )), + throwsA(isA()), + ); + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.status, PathSchedulerStatus.error); + expect(snapshot.smoothSession, false); + expect(snapshot.ringSize, 0); + expect(fixture.worker.openFrames, 0); + }); + }); + + group('PathScheduler ownership and diagnostics', () { + test('reports underflow without fabricating a presentation and bounds traces', + () async { + final fixture = createFixture(); + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'idle', + body: body('body', GraphBodyKind.loop, 2, [1]), + outgoingStarts: [portalStart()], + path: 'underflow', + )); + + for (var index = 0; index < 520; index += 1) { + expect(fixture.scheduler.takeNext().kind, 'underflow'); + } + final trace = fixture.scheduler.trace(); + expect(trace.length, 512); + expect(trace[0].index, greaterThan(0)); + expect(trace.last.operation, PathSchedulerTraceOperation.underflow); + expect(fixture.scheduler.snapshot().smoothSession, false); + }); + + test('disposes queued, ring-owned, and worker-owned frames exactly once', + () async { + final fixture = createFixture(); + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'idle', + body: body('body', GraphBodyKind.loop, 2, [1]), + outgoingStarts: [portalStart()], + path: 'cleanup', + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 6)); + expect(fixture.worker.openFrames, 6); + + await fixture.scheduler.dispose(); + await fixture.scheduler.dispose(); + expect(fixture.worker.openFrames, 0); + expect(fixture.worker.abortCalls, 1); + final snapshot = fixture.scheduler.snapshot(); + expect(snapshot.status, PathSchedulerStatus.disposed); + expect(snapshot.ringSize, 0); + expect(snapshot.expectedOutputs, 0); + expect(snapshot.residentFrames, 0); + }); + }); +} + +// -------------------------------------------------------------------------- +// Fixture scaffolding (ported from path-scheduler.test.ts:666-1013). +// -------------------------------------------------------------------------- + +class Fixture { + Fixture(this.worker, this.scheduler); + + final FakeWorker worker; + final PathScheduler scheduler; +} + +const Map _opaqueUnitFrames = {'body': 2, 'intro': 2}; + +const Map _integratedPathUnitFrames = { + 'idle-body': 4, + 'hover-body': 3, + 'loading-body': 3, + 'archive-body': 3, + 'success-body': 2, + 'done-body': 1, + 'intro': 2, + 'one-bridge': 1, + 'long-bridge': 5, +}; + +Fixture createFixture([FakeWorkerOptions options = const FakeWorkerOptions()]) { + final integrated = options.integratedPathAsset; + final timeline = DecodeTimeline( + const RationalFrameRate(numerator: 30, denominator: 1), + ); + final worker = FakeWorker(options); + final samples = _FakeWorkerSampleFactory( + timeline: timeline, + units: integrated ? _integratedPathUnitFrames : _opaqueUnitFrames, + ); + final scheduler = PathScheduler(PathSchedulerOptions( + timeline: timeline, + samples: samples, + worker: worker, + rendition: integrated ? 'opaque-path' : 'opaque', + ringCapacity: 6, + limits: limits, + clock: _CountingClock(), + )); + return Fixture(worker, scheduler); +} + +Future startAtSourceZero(Fixture fixture) async { + await fixture.scheduler.startBody(StartScheduledBodyInput( + state: 'idle', + body: body('body', GraphBodyKind.loop, 2, [1]), + outgoingStarts: [portalStart()], + path: 'source', + )); + await fixture.scheduler + .pump(const PathSchedulerPumpOptions(targetRingFrames: 1)); + closeStreaming(fixture.scheduler.takeNext()); +} + +GraphBodyDefinition body( + String unitId, + GraphBodyKind kind, + int frameCount, + List portals, +) { + return GraphBodyDefinition( + unitId: unitId, + kind: kind, + frameCount: frameCount, + ports: [GraphPortDefinition(id: 'default', portalFrames: portals)], + ); +} + +GraphStartPolicyPortal portalStart() { + return const GraphStartPolicyPortal( + sourcePort: 'default', + targetPort: 'default', + maxWaitFrames: 6, + ); +} + +GraphEdgeDefinition lockedEdge(String id, int frameCount) { + return lockedEdgeFrom(id, 'idle', 'target', frameCount); +} + +GraphEdgeDefinition lockedEdgeFrom( + String id, + String from, + String to, + int frameCount, +) { + return GraphEdgeDefinition( + id: id, + from: from, + to: to, + start: portalStart(), + transition: GraphTransitionLocked(unitId: 'intro', frameCount: frameCount), + continuity: GraphContinuity.exactAuthored, + ); +} + +PathSchedulerResidentFrame resident(int localFrame) { + return residentFor('opaque', 'body', localFrame); +} + +PathSchedulerResidentFrame residentFor( + String rendition, + String unit, + int localFrame, +) { + return PathSchedulerResidentFrame( + frame: RuntimeFrameKey( + rendition: rendition, + unit: unit, + localFrame: localFrame, + ), + unitInstance: 0, + decodeOrdinal: localFrame, + timestamp: localFrame * 33333, + ); +} + +PathSchedulerTakeFrame requireStreaming(PathSchedulerTakeResult result) { + if (result is! PathSchedulerTakeFrame) { + throw StateError('expected streaming frame, received ${result.kind}'); + } + return result; +} + +void closeStreaming(PathSchedulerTakeResult result) { + requireStreaming(result).frame.closeFrame(); +} + +List identity(PathSchedulerTakeFrame result) { + return [ + result.purpose.wireValue, + result.media.frame.unit, + result.media.frame.localFrame, + ]; +} + +/// Pre-incrementing monotonic clock (`{ now: () => ++now }`, first call → 1). +class _CountingClock implements PathSchedulerClock { + int _now = 0; + + @override + int now() => ++_now; +} + +/// Faithful test substitute for the concrete `WorkerSampleFactory`. +class _FakeWorkerSampleFactory implements WorkerSampleFactory { + _FakeWorkerSampleFactory({required this.timeline, required this.units}); + + final DecodeTimeline timeline; + final Map units; + + @override + DecoderWorkerSampleBatch createBatch(CreateWorkerSampleBatchInput input) { + final timelineFrames = input.frames + .map((request) => DecodeTimelineFrameRequest( + unitId: request.unitId, + unitFrame: request.unitFrame, + unitFrameCount: units[request.unitId]!, + )) + .toList(); + final plan = timeline.planSampleBatch(timelineFrames); + final samples = []; + for (var index = 0; index < input.frames.length; index += 1) { + final meta = plan.samples[index]; + samples.add(DecoderWorkerSample( + ordinal: meta.ordinal, + unitId: meta.unitId, + unitInstance: meta.unitInstance, + unitFrame: meta.unitFrame, + unitFrameCount: meta.unitFrameCount, + type: meta.unitFrame == 0 + ? EncodedVideoChunkType.key + : EncodedVideoChunkType.delta, + timestamp: meta.timestamp, + duration: meta.duration, + data: Uint8List(4).buffer, + )); + } + final generation = plan.generation; + plan.commit(); + return _FakeSampleBatch(generation, samples); + } +} + +class _FakeSampleBatch implements DecoderWorkerSampleBatch { + _FakeSampleBatch(this.generation, this.samples); + + @override + final int generation; + + @override + final List samples; + + @override + void release() {} +} + +/// Options for the fake worker (path-scheduler.test.ts:789). +class FakeWorkerOptions { + const FakeWorkerOptions({ + this.watchdog = false, + this.retainOneStaleOutput = false, + this.outputsPerWait = 1, + this.integratedPathAsset = false, + }); + + final bool watchdog; + final bool retainOneStaleOutput; + final int outputsPerWait; + final bool integratedPathAsset; +} + +class _PendingFakeSample { + const _PendingFakeSample(this.generation, this.sample); + + final int generation; + final DecoderWorkerSample sample; +} + +class _ActivationGate { + _ActivationGate({required this.entered, required this.released}); + + final void Function() entered; + final Future released; +} + +typedef ActivationHandle = ({Future entered, void Function() release}); + +class FakeWorker implements PathSchedulerWorkerAdapter { + FakeWorker(FakeWorkerOptions options) + : _watchdog = options.watchdog, + _retainOneStaleOutput = options.retainOneStaleOutput, + _outputsPerWait = options.outputsPerWait; + + @override + int? activeGeneration; + int maximumSubmittedBatch = 0; + int abortCalls = 0; + final bool _watchdog; + final bool _retainOneStaleOutput; + final int _outputsPerWait; + final List<_PendingFakeSample> _pending = <_PendingFakeSample>[]; + final List<_FakeManagedFrame> _ready = <_FakeManagedFrame>[]; + final Set<_FakeManagedFrame> _open = <_FakeManagedFrame>{}; + int _acceptedSamples = 0; + int _releasedFrames = 0; + bool _staleRetained = false; + _PendingFakeSample? _lastSubmitted; + _ActivationGate? _activationGate; + + @override + int get queuedFrames => _ready.length; + + @override + int get openFrames => _open.length; + + ActivationHandle gateNextActivation() { + if (_activationGate != null) { + throw StateError('fake activation is already gated'); + } + final enter = Completer(); + final release = Completer(); + _activationGate = _ActivationGate( + entered: enter.complete, + released: release.future, + ); + return (entered: enter.future, release: release.complete); + } + + @override + Future activateGeneration(int generation) async { + final gate = _activationGate; + if (gate != null) { + _activationGate = null; + gate.entered(); + await gate.released; + } + final previous = activeGeneration; + activeGeneration = generation; + for (final frame in [..._ready]) { + if (frame.generation != generation) frame.close(); + } + _ready.removeWhere((frame) => frame.closed); + if (previous != null && _retainOneStaleOutput && !_staleRetained) { + _PendingFakeSample? retained; + for (final item in _pending) { + if (item.generation == previous) { + retained = item; + break; + } + } + retained ??= + _lastSubmitted?.generation == previous ? _lastSubmitted : null; + _pending.clear(); + if (retained != null) _pending.add(retained); + _staleRetained = retained != null; + } else { + _pending.clear(); + } + } + + @override + Future submit(int generation, List samples) async { + if (generation != activeGeneration) { + throw StateError('fake generation mismatch'); + } + maximumSubmittedBatch = maximumSubmittedBatch > samples.length + ? maximumSubmittedBatch + : samples.length; + for (final sample in samples) { + final pending = _PendingFakeSample(generation, sample); + _pending.add(pending); + _lastSubmitted = pending; + _acceptedSamples += 1; + } + } + + @override + Future abortGeneration(int generation) async { + abortCalls += 1; + _pending.removeWhere((item) => item.generation == generation); + for (final frame in [..._open]) { + if (frame.generation == generation) frame.close(); + } + _ready.removeWhere((frame) => frame.closed); + if (activeGeneration == generation) activeGeneration = null; + } + + @override + ManagedDecoderWorkerFrame? takeFrame() { + return _ready.isEmpty ? null : _ready.removeAt(0); + } + + @override + Future waitForFrames([ + int? minimum, + DecoderWorkerWaitOptions? options, + ]) async { + if (_watchdog) { + throw DecoderWorkerWatchdogError('injected path scheduler watchdog'); + } + final min = minimum ?? 1; + var released = 0; + while (_pending.isNotEmpty && + (_ready.length < min || released < _outputsPerWait) && + released < _outputsPerWait) { + final pending = _pending.removeAt(0); + late final _FakeManagedFrame frame; + frame = _FakeManagedFrame(pending, () { + _open.remove(frame); + _releasedFrames += 1; + }); + _open.add(frame); + _ready.add(frame); + released += 1; + } + } + + @override + Future snapshotMetrics() async { + final generation = activeGeneration; + final submittedFrames = + _pending.where((item) => item.generation == generation).length; + final leasedFrames = + _open.where((frame) => frame.generation == generation).length; + return DecoderWorkerMetrics( + configureCalls: 1, + resetCalls: 0, + flushCalls: 0, + boundaryFlushCalls: 0, + acceptedSamples: _acceptedSamples, + submittedChunks: _acceptedSamples, + outputFrames: _acceptedSamples - _pending.length, + deliveredFrames: _acceptedSamples - _pending.length, + releasedFrames: _releasedFrames, + staleFrames: 0, + closedFrames: _releasedFrames, + pendingSamples: 0, + submittedFrames: submittedFrames, + leasedFrames: leasedFrames, + leasedDecodedBytes: leasedFrames * 128, + decodeQueueSize: submittedFrames, + activeGeneration: generation, + nextSubmissionOrdinal: _acceptedSamples, + nextOutputOrdinal: _acceptedSamples - _pending.length, + errors: 0, + disposed: false, + ); + } +} + +class _FakeVideoFrame implements VideoFrame {} + +class _FakeManagedFrame implements ManagedDecoderWorkerFrame { + _FakeManagedFrame(_PendingFakeSample pending, this._release) + : frame = _FakeVideoFrame(), + frameId = pending.sample.ordinal + 1, + generation = pending.generation, + ordinal = pending.sample.ordinal, + unitId = pending.sample.unitId, + unitInstance = pending.sample.unitInstance, + unitFrame = pending.sample.unitFrame, + timestamp = pending.sample.timestamp, + duration = pending.sample.duration; + + @override + final VideoFrame frame; + @override + final int frameId; + @override + final int generation; + @override + final int ordinal; + @override + final String unitId; + @override + final int unitInstance; + @override + final int unitFrame; + @override + final int timestamp; + @override + final int duration; + @override + final int decodedBytes = 128; + @override + int? get outputCallbackMicroseconds => null; + + final void Function() _release; + bool _closed = false; + + @override + bool get closed => _closed; + + @override + void close() { + if (_closed) return; + _closed = true; + _release(); + } +} + +/// Test ergonomic: the TS suite closes a frame via `frame.frame.close()`, but +/// the `VideoFrame` platform seam has no `close`; the managed frame's own +/// [ManagedDecoderWorkerFrame.close] carries the identical release behavior. +extension on ManagedDecoderWorkerFrame { + void closeFrame() => close(); +} diff --git a/flutter/packages/aval_player/test/submission_horizon_test.dart b/flutter/packages/aval_player/test/submission_horizon_test.dart new file mode 100644 index 0000000..5b6837b --- /dev/null +++ b/flutter/packages/aval_player/test/submission_horizon_test.dart @@ -0,0 +1,609 @@ +// Port of packages/player-web/src/runtime/submission-horizon.test.ts. +// +// Adaptation: the TS `Object.isFrozen` assertions become immutable-value +// (`is`) checks, since ported decisions/boundaries are immutable Dart classes. +import 'package:aval_graph/aval_graph.dart'; +import 'package:aval_player/aval_player.dart'; +import 'package:test/test.dart'; + +SourceBodyCursor _cursor(int occurrence, int frame) => + SourceBodyCursor(occurrence: BigInt.from(occurrence), frame: frame); + +SourceBoundaryType _boundaryType(String type) => switch (type) { + 'portal' => SourceBoundaryType.portal, + 'finish' => SourceBoundaryType.finish, + 'cut' => SourceBoundaryType.cut, + _ => throw ArgumentError('unknown boundary type $type'), + }; + +SourceBoundary _boundary(String type, int occurrence, int frame, bool wraps) => + SourceBoundary( + type: _boundaryType(type), + occurrence: BigInt.from(occurrence), + frame: frame, + wraps: wraps, + ); + +GraphBodyDefinition _body( + GraphBodyKind kind, + int frameCount, + Map> ports, +) { + return GraphBodyDefinition( + unitId: '${kind.name}-body', + kind: kind, + frameCount: frameCount, + ports: ports.entries + .map((entry) => + GraphPortDefinition(id: entry.key, portalFrames: entry.value)) + .toList(), + ); +} + +GraphBodyDefinition _loop(int frameCount, Map> ports) => + _body(GraphBodyKind.loop, frameCount, ports); + +GraphBodyDefinition _finite(int frameCount, Map> ports) => + _body(GraphBodyKind.finite, frameCount, ports); + +GraphBodyDefinition _held() => _body(GraphBodyKind.held, 1, { + 'exit': [0], + }); + +GraphStartPolicyPortal _portalStart(String sourcePort, + [int maxWaitFrames = 12]) => + GraphStartPolicyPortal( + sourcePort: sourcePort, + targetPort: 'entry', + maxWaitFrames: maxWaitFrames, + ); + +GraphStartPolicyFinish _finishStart([int maxWaitFrames = 12]) => + GraphStartPolicyFinish(targetPort: 'entry', maxWaitFrames: maxWaitFrames); + +GraphEdgeDefinition _edge(GraphStartPolicy start, [int? lockedFrames]) => + GraphEdgeDefinition( + id: 'edge', + from: 'source', + to: 'target', + start: start, + continuity: start is GraphStartPolicyCut + ? GraphContinuity.cut + : GraphContinuity.exactAuthored, + transition: lockedFrames == null + ? null + : GraphTransitionLocked(unitId: 'bridge', frameCount: lockedFrames), + ); + +GraphEdgeDefinition _reversibleEdge(GraphStartPolicy start) => + GraphEdgeDefinition( + id: 'reversible-edge', + from: 'source', + to: 'target', + start: start, + transition: const GraphTransitionReversible( + unitId: 'resident-shift', + frameCount: 6, + direction: TransitionDirection.forward, + ), + continuity: GraphContinuity.exactAuthored, + ); + +int _waitFramesOf(SubmissionHorizonDecision decision) { + if (decision is SubmissionHorizonContinueSource) return decision.waitFrames; + if (decision is SubmissionHorizonSelectPortal) return decision.waitFrames; + return 0; +} + +void main() { + group('unresolved source submission horizon', () { + test( + 'allows at most one ring capacity beyond the earliest unresolved portal', + () { + final body = _loop(12, { + 'first': [4, 10], + 'second': [7], + }); + + expect( + planUnresolvedSubmissionHorizon(UnresolvedSubmissionHorizonInput( + body: body, + displayed: _cursor(0, 2), + submitted: _cursor(0, 10), + outgoingStarts: [_portalStart('first'), _portalStart('second')], + ringCapacity: 6, + )), + UnresolvedSubmissionHorizon( + earliestBoundary: _boundary('portal', 0, 4, false), + maximumSubmitted: _cursor(0, 10), + submittedWithinHorizon: true, + framesBeyondEarliestBoundary: BigInt.from(6), + ), + ); + + final beyond = planUnresolvedSubmissionHorizon( + UnresolvedSubmissionHorizonInput( + body: body, + displayed: _cursor(0, 2), + submitted: _cursor(0, 11), + outgoingStarts: [_portalStart('first'), _portalStart('second')], + ringCapacity: 6, + ), + ); + expect(beyond.maximumSubmitted, _cursor(0, 10)); + expect(beyond.submittedWithinHorizon, false); + expect(beyond.framesBeyondEarliestBoundary, BigInt.from(7)); + }); + + test('caps finite and held horizons at the final authored frame', () { + final finiteResult = planUnresolvedSubmissionHorizon( + UnresolvedSubmissionHorizonInput( + body: _finite(4, { + 'exit': [3], + }), + displayed: _cursor(0, 1), + submitted: _cursor(0, 3), + outgoingStarts: [_finishStart()], + ringCapacity: 12, + ), + ); + expect(finiteResult.earliestBoundary, _boundary('finish', 0, 3, false)); + expect(finiteResult.maximumSubmitted, _cursor(0, 3)); + expect(finiteResult.submittedWithinHorizon, true); + + final heldResult = planUnresolvedSubmissionHorizon( + UnresolvedSubmissionHorizonInput( + body: _held(), + displayed: _cursor(0, 0), + submitted: _cursor(0, 0), + outgoingStarts: [_portalStart('exit')], + ringCapacity: 6, + ), + ); + expect(heldResult.maximumSubmitted, _cursor(0, 0)); + }); + }); + + group('selected portal submission planning', () { + test( + 'discards speculative source debt only for a resident reversible portal', + () { + final body = _loop(8, { + 'exit': [7], + }); + + final reversible = planSubmissionHorizon(SubmissionHorizonInput( + body: body, + edge: _reversibleEdge(_portalStart('exit', 12)), + displayed: _cursor(0, 0), + submitted: _cursor(1, 5), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 0, + elapsedWaitFrames: 0, + )); + expect(reversible, isA()); + reversible as SubmissionHorizonSelectPortal; + expect(reversible.reason, SelectPortalReason.authoredBoundary); + expect(reversible.boundary, _boundary('portal', 0, 7, false)); + expect(reversible.waitFrames, 7); + expect(reversible.totalWaitFrames, 7); + + final streamed = planSubmissionHorizon(SubmissionHorizonInput( + body: body, + edge: _edge(_portalStart('exit', 12)), + displayed: _cursor(0, 0), + submitted: _cursor(1, 5), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )); + expect(streamed, isA()); + streamed as SubmissionHorizonRejectReadiness; + expect(streamed.reason, RejectReadinessReason.maxWaitExceeded); + expect(streamed.requiredWaitFrames, BigInt.from(15)); + expect(streamed.maxWaitFrames, 12); + }); + + test( + 'selects a later portal when source submission has passed an early one', + () { + final decision = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(12, { + 'exit': [2, 6, 9], + }), + edge: _edge(_portalStart('exit', 8)), + displayed: _cursor(0, 3), + submitted: _cursor(0, 7), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )); + + expect(decision, isA()); + decision as SubmissionHorizonSelectPortal; + expect(decision.reason, SelectPortalReason.submittedHorizon); + expect(decision.boundary, _boundary('portal', 0, 9, false)); + expect(decision.waitFrames, 6); + expect(decision.totalWaitFrames, 6); + }); + + test('searches a loop circularly without inventing a finite wrap', () { + final decision = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(12, { + 'exit': [0, 4, 9], + }), + edge: _edge(_portalStart('exit', 4)), + displayed: _cursor(0, 10), + submitted: _cursor(0, 11), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )); + + expect(decision, isA()); + decision as SubmissionHorizonSelectPortal; + expect(decision.boundary, _boundary('portal', 1, 0, true)); + expect(decision.waitFrames, 2); + expect(decision.totalWaitFrames, 2); + }); + + test('commits a transitionless portal only with its two-frame lead', () { + final committed = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(6, { + 'exit': [0, 3], + }), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(0, 0), + submitted: _cursor(0, 0), + ringCapacity: 6, + elapsedWaitFrames: 0, + availableConsecutiveEdgeFrames: 2, + )); + expect(committed, isA()); + committed as SubmissionHorizonCommitEdge; + expect(committed.boundary, _boundary('portal', 0, 0, false)); + expect(committed.lead!.requiredConsecutiveFrames, 2); + expect(committed.lead!.ready, true); + + final selected = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(6, { + 'exit': [0, 3], + }), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(0, 0), + submitted: _cursor(0, 0), + ringCapacity: 6, + elapsedWaitFrames: 0, + availableConsecutiveEdgeFrames: 1, + )); + expect(selected, isA()); + selected as SubmissionHorizonSelectPortal; + expect(selected.reason, SelectPortalReason.leadUnavailable); + expect(selected.boundary, _boundary('portal', 0, 3, false)); + expect(selected.waitFrames, 3); + }); + + test('requires one bridge frame followed by target frame zero', () { + GraphEdgeDefinition locked() => _edge(_portalStart('exit', 3), 1); + + final low = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(4, { + 'exit': [0, 2], + }), + edge: locked(), + displayed: _cursor(0, 0), + submitted: _cursor(0, 0), + ringCapacity: 6, + elapsedWaitFrames: 0, + availableConsecutiveEdgeFrames: 1, + )); + expect(low, isA()); + low as SubmissionHorizonSelectPortal; + expect(low.reason, SelectPortalReason.leadUnavailable); + expect(low.lead!.targetEntryOffset, 1); + expect(low.lead!.requiredConsecutiveFrames, 2); + expect(low.lead!.ready, false); + + final ready = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(4, { + 'exit': [0, 2], + }), + edge: locked(), + displayed: _cursor(0, 0), + submitted: _cursor(0, 0), + ringCapacity: 6, + elapsedWaitFrames: 0, + availableConsecutiveEdgeFrames: 2, + )); + expect(ready, isA()); + ready as SubmissionHorizonCommitEdge; + expect(ready.lead!.targetEntryOffset, 1); + expect(ready.lead!.ready, true); + }); + + for (final testCase in const [ + [0, 0], + [1, 2], + [2, 1], + [3, 0], + [4, 2], + [5, 1], + ]) { + final displayedFrame = testCase[0]; + final waitFrames = testCase[1]; + test('matches graph loop portal geometry from body frame $displayedFrame', + () { + final decision = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(6, { + 'exit': [0, 3], + }), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(0, displayedFrame), + submitted: _cursor(0, displayedFrame), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )); + expect( + decision.kind, + waitFrames == 0 ? 'commit-edge' : 'select-portal', + ); + expect(_waitFramesOf(decision), waitFrames); + }); + } + }); + + group('finite, held, finish, and max-wait planning', () { + test('selects only forward finite portals and holds the final portal', () { + final selected = planSubmissionHorizon(SubmissionHorizonInput( + body: _finite(4, { + 'exit': [1, 3], + }), + edge: _edge(_portalStart('exit', 4)), + displayed: _cursor(0, 2), + submitted: _cursor(0, 2), + ringCapacity: 6, + elapsedWaitFrames: 0, + availableConsecutiveEdgeFrames: 2, + )); + expect(selected, isA()); + selected as SubmissionHorizonSelectPortal; + expect(selected.boundary, _boundary('portal', 0, 3, false)); + expect(selected.waitFrames, 1); + + final held = planSubmissionHorizon(SubmissionHorizonInput( + body: _finite(4, { + 'exit': [1, 3], + }), + edge: _edge(_portalStart('exit', 4)), + displayed: _cursor(0, 3), + submitted: _cursor(0, 3), + ringCapacity: 6, + elapsedWaitFrames: 0, + availableConsecutiveEdgeFrames: 1, + )); + expect(held, isA()); + held as SubmissionHorizonWaitHeld; + expect(held.boundary, _boundary('portal', 0, 3, false)); + }); + + for (final frame in const [0, 1, 2, 3]) { + test('matches finite finish geometry from frame $frame', () { + final decision = planSubmissionHorizon(SubmissionHorizonInput( + body: _finite(4, { + 'exit': [3], + }), + edge: _edge(_finishStart(3)), + displayed: _cursor(0, frame), + submitted: _cursor(0, frame), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )); + expect( + decision.kind, + frame == 3 ? 'commit-edge' : 'continue-source', + ); + expect(_waitFramesOf(decision), 3 - frame); + }); + } + + test('holds a finite/held final boundary when lead is missing', () { + for (final body in [ + _finite(4, { + 'exit': [3], + }), + _held(), + ]) { + final frame = body.frameCount - 1; + final decision = planSubmissionHorizon(SubmissionHorizonInput( + body: body, + edge: _edge(_finishStart(4)), + displayed: _cursor(0, frame), + submitted: _cursor(0, frame), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 1, + elapsedWaitFrames: 2, + )); + expect(decision, isA()); + decision as SubmissionHorizonWaitHeld; + expect(decision.boundary, _boundary('finish', 0, frame, false)); + expect(decision.remainingWaitFrames, 2); + expect(decision.lead.ready, false); + } + }); + + test('allows the exact maxWaitFrames boundary and rejects one frame beyond', + () { + final within = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(4, { + 'exit': [0, 2], + }), + edge: _edge(_portalStart('exit', 2)), + displayed: _cursor(0, 1), + submitted: _cursor(0, 1), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 1, + )); + expect(within, isA()); + within as SubmissionHorizonSelectPortal; + expect(within.waitFrames, 1); + expect(within.totalWaitFrames, 2); + + final beyond = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(4, { + 'exit': [0, 2], + }), + edge: _edge(_portalStart('exit', 1)), + displayed: _cursor(0, 1), + submitted: _cursor(0, 1), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 1, + )); + expect(beyond, isA()); + beyond as SubmissionHorizonRejectReadiness; + expect(beyond.reason, RejectReadinessReason.maxWaitExceeded); + expect(beyond.requiredWaitFrames, BigInt.from(2)); + expect(beyond.maxWaitFrames, 1); + }); + + test('restarts a generation for a one-tick cut', () { + final decision = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(4, { + 'exit': [0], + }), + edge: _edge(const GraphStartPolicyCut(targetPort: 'entry')), + displayed: _cursor(0, 2), + submitted: _cursor(0, 3), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 0, + elapsedWaitFrames: 0, + )); + expect(decision, isA()); + decision as SubmissionHorizonRestartGeneration; + expect(decision.reason, 'cut'); + expect(decision.responseFrames, 1); + expect(decision.totalWaitFrames, 1); + }); + }); + + group('submission planner validation', () { + test( + 'rejects malformed cursors, backwards submission, and invalid ring lead', + () { + GraphBodyDefinition loopBody() => _loop(4, { + 'exit': [0, 2], + }); + + expect( + () => planSubmissionHorizon(SubmissionHorizonInput( + body: loopBody(), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(1, 0), + submitted: _cursor(0, 3), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('behind'), + ), + ), + ); + expect( + () => planSubmissionHorizon(SubmissionHorizonInput( + body: loopBody(), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(0, 4), + submitted: _cursor(0, 4), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('out of range'), + ), + ), + ); + expect( + () => planSubmissionHorizon(SubmissionHorizonInput( + body: loopBody(), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(0, 0), + submitted: _cursor(0, 0), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 7, + elapsedWaitFrames: 0, + )), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('available consecutive'), + ), + ), + ); + expect( + () => planSubmissionHorizon(SubmissionHorizonInput( + body: _finite(4, { + 'exit': [3], + }), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(1, 0), + submitted: _cursor(1, 0), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('occurrence zero'), + ), + ), + ); + + final huge = planSubmissionHorizon(SubmissionHorizonInput( + body: loopBody(), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(0, 0), + submitted: SourceBodyCursor( + occurrence: BigInt.from(maxSafeInteger) * BigInt.from(1000000), + frame: 0, + ), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )); + expect(huge, isA()); + huge as SubmissionHorizonRejectReadiness; + expect(huge.reason, RejectReadinessReason.maxWaitExceeded); + }); + + test('returns immutable decisions and nested boundaries', () { + final decision = planSubmissionHorizon(SubmissionHorizonInput( + body: _loop(4, { + 'exit': [0, 2], + }), + edge: _edge(_portalStart('exit', 3)), + displayed: _cursor(0, 1), + submitted: _cursor(0, 1), + ringCapacity: 6, + availableConsecutiveEdgeFrames: 2, + elapsedWaitFrames: 0, + )); + expect(decision, isA()); + decision as SubmissionHorizonSelectPortal; + expect(decision.boundary, isA()); + }); + }); +} diff --git a/flutter/packages/aval_player/test/worker_samples_test.dart b/flutter/packages/aval_player/test/worker_samples_test.dart new file mode 100644 index 0000000..6fe45ec --- /dev/null +++ b/flutter/packages/aval_player/test/worker_samples_test.dart @@ -0,0 +1,698 @@ +/// Port of `packages/player-web/src/runtime/worker-samples.test.ts` (1:1). +/// +/// JS-only assertions with no Dart analog are dropped and noted inline: +/// `Object.keys(batch)` / `Object.isFrozen` (worker-samples.test.ts:105-108,219) +/// — the Dart batch is an immutable class with `List.unmodifiable` samples — and +/// the `structuredClone(..., {transfer})` neuter check +/// (worker-samples.test.ts:152-160) — Dart has no ArrayBuffer transfer; the +/// load-bearing distinctness/content/preservation assertions are kept. +library; + +import 'dart:typed_data'; + +import 'package:aval_format/aval_format.dart' + show AccessUnitRecord, AvcPackedAlphaRenditionV01, BitrateV01, ByteRange, + Rect, RenditionV01, UnitV01; +import 'package:aval_player/aval_player.dart'; +import 'package:test/test.dart'; + +import 'asset_test_fixture.dart'; + +const DecoderWorkerLimits limits = DecoderWorkerLimits( + maxDecodeQueueSize: 8, + maxPendingSamples: 12, + maxOutstandingFrames: 12, + maxDecodedBytes: 12 * 64 * 64 * 4, +); + +void main() { + group('WorkerSampleFactory', () { + test('accepts the exact packed-alpha AVC profile on the shared sample path', + () { + final timeline = DecodeTimeline( + const RationalFrameRate(numerator: 30, denominator: 1), + ); + final catalog = _StaticCatalog( + rendition: AvcPackedAlphaRenditionV01( + id: 'packed', + profile: 'avc-annexb-packed-alpha-v0', + codec: 'avc1.42E020', + codedWidth: 64, + codedHeight: 144, + colorRect: Rect(0, 0, 64, 64), + alphaRect: Rect(0, 72, 64, 64), + bitrate: const BitrateV01(average: 1000, peak: 2000), + ), + ); + + expect( + () => WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: catalog, + timeline: timeline, + rendition: 'packed', + limits: limits, + )), + returnsNormally, + ); + }); + + test('creates one closed batch across complete unit boundaries', () { + final fixture = _makeFixture(); + + final batch = fixture.factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0), frame('body', 1), frame('intro', 0)], + pendingSamples: 0, + outstandingFrames: 0, + )); + + expect(batch.generation, 1); + final actual = batch.samples + .map((sample) => [ + sample.ordinal, + sample.unitId, + sample.unitInstance, + sample.unitFrame, + sample.unitFrameCount, + sample.type.wireValue, + sample.timestamp, + sample.duration, + ]) + .toList(); + expect(actual, [ + [0, 'body', 0, 0, 2, 'key', 0, 33333], + [1, 'body', 0, 1, 2, 'delta', 33333, 33334], + [2, 'intro', 1, 0, 2, 'key', 66667, 33333], + ]); + }); + + test('continues a split occurrence and crosses into a new loop instance', + () { + final fixture = _makeFixture(); + + final first = fixture.factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0)], + pendingSamples: 0, + outstandingFrames: 0, + )); + final second = fixture.factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 1), frame('body', 0)], + pendingSamples: 0, + outstandingFrames: 0, + )); + + expect(first.samples.map(identity), [ + [0, 'body', 0, 0], + ]); + expect(second.samples.map(identity), [ + [1, 'body', 0, 1], + [2, 'body', 1, 0], + ]); + }); + + test( + 'allocates one distinct exact-length buffer and preserves catalog bytes', + () { + final fixture = _makeFixture(); + final expected = [0, 1] + .map((localFrame) => Uint8List.view( + fixture.catalog.copySample('opaque', 'body', localFrame))) + .toList(); + final batch = fixture.factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0), frame('body', 1)], + pendingSamples: 0, + outstandingFrames: 0, + )); + + expect(batch.samples.map((sample) => sample.data).toSet().length, 2); + for (var index = 0; index < batch.samples.length; index += 1) { + final data = Uint8List.view(batch.samples[index].data); + expect(data.length, expected[index].length); + expect(data, expected[index]); + } + + // Catalog copies are independent: re-copying yields the same bytes. + expect( + Uint8List.view(fixture.catalog.copySample('opaque', 'body', 0)), + expected[0], + ); + expect( + Uint8List.view(fixture.catalog.copySample('opaque', 'body', 1)), + expected[1], + ); + }); + + test( + 'claims exact transfer bytes before copying and releases after transfer', + () { + final fixture = _makeFixture(); + final events = []; + var activeBytes = 0; + var releases = 0; + final resourceHost = _InlineResourceHost((byteLength) { + events.add('claim:$byteLength'); + activeBytes += byteLength; + var released = false; + return _InlineTransferLease(() { + if (released) return; + released = true; + activeBytes -= byteLength; + releases += 1; + }); + }); + final factory = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: _CatalogView( + fixture.catalog, + copySample: (rendition, unit, localFrame) { + events.add('copy'); + return fixture.catalog.copySample(rendition, unit, localFrame); + }, + ), + timeline: fixture.timeline, + rendition: 'opaque', + limits: limits, + resourceHost: resourceHost, + )); + final expectedBytes = + fixture.catalog.records.require('opaque', 'body', 0).range.length + + fixture.catalog.records.require('opaque', 'body', 1).range.length; + + final batch = factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0), frame('body', 1)], + pendingSamples: 0, + outstandingFrames: 0, + )); + + expect(events, ['claim:$expectedBytes', 'copy', 'copy']); + expect(activeBytes, expectedBytes); + batch.release(); + batch.release(); + expect(activeBytes, 0); + expect(releases, 1); + }); + + test('rejects a transfer one byte over budget before any sample allocation', + () { + final fixture = _makeFixture(); + var copyCalls = 0; + final expectedBytes = + fixture.catalog.records.require('opaque', 'body', 0).range.length + + fixture.catalog.records.require('opaque', 'body', 1).range.length; + final claims = []; + final factory = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: _CatalogView( + fixture.catalog, + copySample: (rendition, unit, localFrame) { + copyCalls += 1; + return fixture.catalog.copySample(rendition, unit, localFrame); + }, + ), + timeline: fixture.timeline, + rendition: 'opaque', + limits: limits, + resourceHost: _InlineResourceHost((byteLength) { + claims.add(byteLength); + if (byteLength > expectedBytes - 1) { + throw RangeError('injected one-byte-over transfer pressure'); + } + return _InlineTransferLease(() {}); + }), + )); + final before = fixture.timeline.snapshot(); + + expect( + () => factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0), frame('body', 1)], + pendingSamples: 0, + outstandingFrames: 0, + )), + throwsA(isA().having( + (error) => error.toString(), + 'message', + contains('one-byte-over transfer pressure'), + )), + ); + + expect(claims, [expectedBytes]); + expect(copyCalls, 0); + expect(fixture.timeline.snapshot(), before); + }); + + test('releases a transfer claim when a later sample copy fails', () { + final fixture = _makeFixture(); + var activeClaims = 0; + final factory = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: _CatalogView( + fixture.catalog, + copySample: (rendition, unit, localFrame) { + if (localFrame == 1) throw StateError('injected copy failure'); + return fixture.catalog.copySample(rendition, unit, localFrame); + }, + ), + timeline: fixture.timeline, + rendition: 'opaque', + limits: limits, + resourceHost: _InlineResourceHost((byteLength) { + activeClaims += 1; + return _InlineTransferLease(() => activeClaims -= 1); + }), + )); + + expect( + () => factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0), frame('body', 1)], + pendingSamples: 0, + outstandingFrames: 0, + )), + throwsA(isA()), + ); + expect(activeClaims, 0); + expect(fixture.timeline.snapshot().nextOrdinal, 0); + }); + + test('validates the complete batch before copying or advancing the timeline', + () { + final fixture = _makeFixture(); + var copyCalls = 0; + final factory = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: _CatalogView( + fixture.catalog, + copySample: (rendition, unit, localFrame) { + copyCalls += 1; + return fixture.catalog.copySample(rendition, unit, localFrame); + }, + ), + timeline: fixture.timeline, + rendition: 'opaque', + limits: limits, + )); + final before = fixture.timeline.snapshot(); + + expect( + () => factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0), frame('missing', 0)], + pendingSamples: 0, + outstandingFrames: 0, + )), + throwsA(anything), + ); + expect(copyCalls, 0); + expect(fixture.timeline.snapshot(), before); + + expect( + () => factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 1)], + pendingSamples: 0, + outstandingFrames: 0, + )), + throwsA(isA().having( + (error) => error.toString(), + 'message', + contains('frame zero'), + )), + ); + expect(copyCalls, 0); + expect(fixture.timeline.snapshot(), before); + }); + + test('does not advance the timeline when a later payload allocation fails', + () { + final fixture = _makeFixture(); + var copyCalls = 0; + final factory = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: _CatalogView( + fixture.catalog, + copySample: (rendition, unit, localFrame) { + copyCalls += 1; + if (localFrame == 1) { + throw RangeError('injected sample allocation failure'); + } + return fixture.catalog.copySample(rendition, unit, localFrame); + }, + ), + timeline: fixture.timeline, + rendition: 'opaque', + limits: limits, + )); + final before = fixture.timeline.snapshot(); + + expect( + () => factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0), frame('body', 1)], + pendingSamples: 0, + outstandingFrames: 0, + )), + throwsA(isA().having( + (error) => error.toString(), + 'message', + contains('injected sample allocation failure'), + )), + ); + expect(copyCalls, 2); + expect(fixture.timeline.snapshot(), before); + }); + + test('enforces pending and outstanding credit before any payload copy', () { + final fixture = _makeFixture(); + var copyCalls = 0; + final factory = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: _CatalogView( + fixture.catalog, + copySample: (rendition, unit, localFrame) { + copyCalls += 1; + return fixture.catalog.copySample(rendition, unit, localFrame); + }, + ), + timeline: fixture.timeline, + rendition: 'opaque', + limits: limits, + )); + final frames = [frame('body', 0), frame('body', 1)]; + + final overCreditInputs = [ + CreateWorkerSampleBatchInput( + frames: frames, pendingSamples: 11, outstandingFrames: 0), + CreateWorkerSampleBatchInput( + frames: frames, pendingSamples: 0, outstandingFrames: 11), + CreateWorkerSampleBatchInput( + frames: alternatingBodyFrames(13), + pendingSamples: 0, + outstandingFrames: 0), + ]; + for (final input in overCreditInputs) { + expect( + () => factory.createBatch(input), + throwsA(isA().having( + (error) => error.toString(), + 'message', + contains('limit'), + )), + ); + } + expect(copyCalls, 0); + expect(fixture.timeline.snapshot().nextOrdinal, 0); + + expect( + factory + .createBatch(CreateWorkerSampleBatchInput( + frames: frames, + pendingSamples: 10, + outstandingFrames: 10, + )) + .samples, + hasLength(2), + ); + }); + + test('rejects hostile record lengths before copying sample bytes', () { + final fixture = _makeFixture(); + final firstRecord = fixture.catalog.records.require('opaque', 'body', 0); + var copyCalls = 0; + final hostile = _CatalogView( + fixture.catalog, + records: _StaticRecordIndex(RuntimeCatalogAccessUnit( + rendition: firstRecord.rendition, + unit: firstRecord.unit, + localFrame: firstRecord.localFrame, + ordinal: firstRecord.ordinal, + record: AccessUnitRecord( + payloadOffset: firstRecord.record.payloadOffset, + payloadLength: maxSafeInteger + 1, + unitIndex: firstRecord.record.unitIndex, + renditionIndex: firstRecord.record.renditionIndex, + key: firstRecord.record.key, + frameIndex: firstRecord.record.frameIndex, + ), + range: ByteRange( + offset: firstRecord.range.offset, + length: maxSafeInteger + 1, + ), + blobKey: firstRecord.blobKey, + blobRange: firstRecord.blobRange, + relativeRange: firstRecord.relativeRange, + )), + copySample: (rendition, unit, localFrame) { + copyCalls += 1; + return fixture.catalog.copySample(rendition, unit, localFrame); + }, + ); + final factory = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: hostile, + timeline: fixture.timeline, + rendition: 'opaque', + limits: limits, + )); + + expect( + () => factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0)], + pendingSamples: 0, + outstandingFrames: 0, + )), + throwsA(isA().having( + (error) => error.toString(), + 'message', + contains('sample byte length'), + )), + ); + expect(copyCalls, 0); + expect(fixture.timeline.snapshot().nextOrdinal, 0); + }); + + test( + 'resets occurrence identity but not ordinal or time on generation change', + () { + final fixture = _makeFixture(); + final first = fixture.factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0), frame('body', 1)], + pendingSamples: 0, + outstandingFrames: 0, + )); + expect(fixture.timeline.activateNextGeneration(), 2); + final beforeBad = fixture.timeline.snapshot(); + + expect( + () => fixture.factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 1)], + pendingSamples: 0, + outstandingFrames: 0, + )), + throwsA(isA().having( + (error) => error.toString(), + 'message', + contains('frame zero'), + )), + ); + expect(fixture.timeline.snapshot(), beforeBad); + + final second = fixture.factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0)], + pendingSamples: 0, + outstandingFrames: 0, + )); + expect(second.generation, 2); + expect(second.samples.map(identity), [ + [2, 'body', 0, 0], + ]); + expect( + second.samples[0].timestamp, + greaterThan(first.samples.last.timestamp), + ); + }); + + test('rejects a copied buffer whose runtime length differs from its record', + () { + final fixture = _makeFixture(); + final factory = WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: _CatalogView( + fixture.catalog, + copySample: (rendition, unit, localFrame) => Uint8List(1).buffer, + ), + timeline: fixture.timeline, + rendition: 'opaque', + limits: limits, + )); + + expect( + () => factory.createBatch(CreateWorkerSampleBatchInput( + frames: [frame('body', 0)], + pendingSamples: 0, + outstandingFrames: 0, + )), + throwsA(isA().having( + (error) => error.toString(), + 'message', + contains('exact record length'), + )), + ); + expect(fixture.timeline.snapshot().nextOrdinal, 0); + }); + }); +} + +class _Fixture { + _Fixture(this.catalog, this.timeline, this.factory); + + final RuntimeAssetCatalog catalog; + final DecodeTimeline timeline; + final WorkerSampleFactory factory; +} + +_Fixture _makeFixture() { + final catalog = installRuntimeAssetCatalog(createOpaqueTestAsset()); + final timeline = DecodeTimeline(RationalFrameRate( + numerator: catalog.manifest.frameRate.numerator, + denominator: catalog.manifest.frameRate.denominator, + )); + timeline.activateNextGeneration(); + return _Fixture(catalog, timeline, _createFactory(catalog, timeline)); +} + +WorkerSampleFactory _createFactory( + WorkerSampleCatalog catalog, + DecodeTimeline timeline, +) { + return WorkerSampleFactory(WorkerSampleFactoryOptions( + catalog: catalog, + timeline: timeline, + rendition: 'opaque', + limits: limits, + )); +} + +WorkerSampleFrameRequest frame(String unitId, int unitFrame) => + WorkerSampleFrameRequest(unitId: unitId, unitFrame: unitFrame); + +List alternatingBodyFrames(int length) => + List.generate(length, (index) => frame('body', index % 2)); + +List identity(DecoderWorkerSample sample) => + [sample.ordinal, sample.unitId, sample.unitInstance, sample.unitFrame]; + +/// A [WorkerSampleCatalog] delegating to a base with optional overrides. +class _CatalogView implements WorkerSampleCatalog { + _CatalogView( + this._base, { + ByteBuffer Function(String, String, int)? copySample, + RuntimeCatalogRecordIndex? records, + }) : _copySampleOverride = copySample, + _recordsOverride = records; + + final WorkerSampleCatalog _base; + final ByteBuffer Function(String, String, int)? _copySampleOverride; + final RuntimeCatalogRecordIndex? _recordsOverride; + + @override + RuntimeCatalogIdIndex get renditions => _base.renditions; + + @override + RuntimeCatalogIdIndex get units => _base.units; + + @override + RuntimeCatalogRecordIndex get records => _recordsOverride ?? _base.records; + + @override + ByteBuffer copySample(String rendition, String unit, int localFrame) => + (_copySampleOverride ?? _base.copySample)(rendition, unit, localFrame); +} + +/// A [WorkerSampleCatalog] whose only working lookup is `renditions.require`. +class _StaticCatalog implements WorkerSampleCatalog { + _StaticCatalog({required RenditionV01 rendition}) + : renditions = _StaticIdIndex(rendition); + + @override + final RuntimeCatalogIdIndex renditions; + + @override + RuntimeCatalogIdIndex get units => + _ThrowingIdIndex('unused'); + + @override + RuntimeCatalogRecordIndex get records => _ThrowingRecordIndex(); + + @override + ByteBuffer copySample(String rendition, String unit, int localFrame) => + Uint8List(0).buffer; +} + +class _StaticIdIndex implements RuntimeCatalogIdIndex { + _StaticIdIndex(this._value); + + final T _value; + + @override + int get size => 1; + @override + T? get(String id) => _value; + @override + T require(String id) => _value; + @override + List keys() => throw UnimplementedError(); + @override + List values() => throw UnimplementedError(); +} + +class _ThrowingIdIndex implements RuntimeCatalogIdIndex { + _ThrowingIdIndex(this._message); + final String _message; + @override + int get size => throw UnimplementedError(); + @override + T? get(String id) => throw StateError(_message); + @override + T require(String id) => throw StateError(_message); + @override + List keys() => throw UnimplementedError(); + @override + List values() => throw UnimplementedError(); +} + +class _StaticRecordIndex implements RuntimeCatalogRecordIndex { + _StaticRecordIndex(this._value); + + final RuntimeCatalogAccessUnit _value; + + @override + int get size => 1; + @override + RuntimeCatalogAccessUnit? get(String rendition, String unit, int localFrame) => + _value; + @override + RuntimeCatalogAccessUnit require( + String rendition, String unit, int localFrame) => + _value; + @override + List values() => throw UnimplementedError(); +} + +class _ThrowingRecordIndex implements RuntimeCatalogRecordIndex { + @override + int get size => throw UnimplementedError(); + @override + RuntimeCatalogAccessUnit? get(String rendition, String unit, int localFrame) => + throw StateError('unused'); + @override + RuntimeCatalogAccessUnit require( + String rendition, String unit, int localFrame) => + throw StateError('unused'); + @override + List values() => throw UnimplementedError(); +} + +class _InlineResourceHost implements WorkerSampleResourceHost { + _InlineResourceHost(this._claim); + + final WorkerSampleTransferLease Function(int) _claim; + + @override + WorkerSampleTransferLease claim(int byteLength) => _claim(byteLength); +} + +class _InlineTransferLease implements WorkerSampleTransferLease { + _InlineTransferLease(this._release); + + final void Function() _release; + + @override + void release() => _release(); +} diff --git a/flutter/rust/aval_decode/Cargo.lock b/flutter/rust/aval_decode/Cargo.lock new file mode 100644 index 0000000..fbb79cb --- /dev/null +++ b/flutter/rust/aval_decode/Cargo.lock @@ -0,0 +1,178 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aval_decode" +version = "0.1.0" +dependencies = [ + "cc", + "openh264", +] + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "log", +] + +[[package]] +name = "openh264" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63c9c38f992322e3c8c604571d084d418fde947e720f3f88302eb842c0834c93" +dependencies = [ + "openh264-sys2", + "wide", +] + +[[package]] +name = "openh264-sys2" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a8867e48183bbd9147380227448c065fe456eb30b0ebc68929809c36c30985" +dependencies = [ + "cc", + "nasm-rs", + "walkdir", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/flutter/rust/aval_decode/Cargo.toml b/flutter/rust/aval_decode/Cargo.toml new file mode 100644 index 0000000..eb928cb --- /dev/null +++ b/flutter/rust/aval_decode/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "aval_decode" +version = "0.1.0" +edition = "2021" +rust-version = "1.88" +license = "BSD-2-Clause" +description = """ +AVAL AVC (H.264 Constrained Baseline) decode core for the Flutter port: wraps \ +the openh264 crate, converts decoded I420 to RGBA8888 with BT.709 limited-range \ +coefficients, ports the web player's frame-credit ledger, and exposes a C ABI \ +for Dart FFI. See flutter/ARCHITECTURE.md sections 2-4 and 6. +""" +publish = false + +# cdylib -> native shared library for platform FFI (iOS/Android/macOS/Windows/Linux via dart:ffi) +# staticlib -> static linking option for platforms that prefer it (e.g. iOS) +# rlib -> ordinary Rust library, so this crate can also be used/tested as a normal Rust dependency +[lib] +name = "aval_decode" +crate-type = ["cdylib", "staticlib", "rlib"] + +[dependencies] +# Pinned to the exact version whose declared MSRV (1.88) matches the toolchain available in +# this environment (`rustc 1.88.0`). Newer 0.9.x releases pull in `wide`/`safe_arch` versions +# that require rustc 1.89. Revisit this pin when the build toolchain is upgraded. +openh264 = "=0.9.1" + +[build-dependencies] +# Compiles the Objective-C VideoToolbox decode backend (src/vt/videotoolbox_decoder.m) +# into the staticlib on Apple targets. See build.rs and ARCHITECTURE.md §2(c). +cc = "1" + +[dev-dependencies] +# (none) - unit/integration tests use only std, plus the fixture file under tests/fixtures. + +[profile.release] +panic = "unwind" # keep unwind so the FFI boundary can catch_unwind rather than abort the host process diff --git a/flutter/rust/aval_decode/build.rs b/flutter/rust/aval_decode/build.rs new file mode 100644 index 0000000..353695d --- /dev/null +++ b/flutter/rust/aval_decode/build.rs @@ -0,0 +1,34 @@ +//! Build script: on Apple targets, compile the Objective-C VideoToolbox decode +//! backend into the staticlib and link the required system frameworks. +//! +//! The ObjC source (`src/vt/videotoolbox_decoder.m`) exposes a tiny C ABI +//! (`aval_vt_*`) that the Rust `VideoToolboxAdapter` (`src/adapter.rs`, also +//! Apple-gated) calls. On non-Apple targets this is a no-op and only the +//! software OpenH264 backend exists. See ARCHITECTURE.md §2(c). + +use std::env; + +fn main() { + let target_vendor = + env::var("CARGO_CFG_TARGET_VENDOR").unwrap_or_default(); + if target_vendor != "apple" { + return; + } + + let src = "src/vt/videotoolbox_decoder.m"; + println!("cargo:rerun-if-changed={src}"); + + cc::Build::new() + .file(src) + // ARC manages the ObjC object; CoreFoundation/CoreMedia handles are + // released explicitly (CFRelease) since CF types are not ARC-managed. + .flag("-fobjc-arc") + .compile("aval_vt"); + + // Frameworks the decode path pulls in. On the macOS host cdylib build, + // cargo applies these at link time directly; for the iOS staticlib they + // must also be present in the final Xcode link (Runner OTHER_LDFLAGS). + for framework in ["VideoToolbox", "CoreMedia", "CoreVideo", "CoreFoundation"] { + println!("cargo:rustc-link-lib=framework={framework}"); + } +} diff --git a/flutter/rust/aval_decode/src/adapter.rs b/flutter/rust/aval_decode/src/adapter.rs new file mode 100644 index 0000000..b29a09d --- /dev/null +++ b/flutter/rust/aval_decode/src/adapter.rs @@ -0,0 +1,244 @@ +//! Codec/backend-generic decode seam (`ARCHITECTURE.md` §2). +//! +//! [`DecoderSession`](crate::decoder::DecoderSession) owns the protocol — +//! frame-credit ledger, generation activation, unit/decode-order continuity, +//! the ready-frame table, metrics, and fatal-failure latching. It does *not* +//! own a codec: the actual "encoded access unit in, owned RGBA picture out" +//! step lives behind the [`DecoderAdapter`] trait so a hardware backend +//! (VideoToolbox on iOS/macOS, MediaCodec on Android — `ARCHITECTURE.md` +//! §2(c), Phase 13/14) can replace the software [`OpenH264Adapter`] without +//! touching the session, the C ABI (`ffi.rs`), or the Dart/shader layers. +//! +//! The trait is deliberately narrow: everything the session needs from a +//! backend is "decode one Annex-B access unit; give me `None` while priming +//! or an owned RGBA8888 [`DecodedRgbaFrame`] when a picture is produced." +//! Geometry validation against the configured coded surface, credit leasing, +//! ordinal/timestamp derivation, and the priming/hidden-chunk rules all stay +//! in the session, so every adapter inherits identical protocol behavior. + +use crate::error::AvalDecodeError; + +/// A decoded picture converted to owned, tightly-packed RGBA8888 bytes. +/// +/// `rgba.len()` is exactly `width * height * 4`; the session cross-checks +/// `width`/`height` against the configured coded surface before accepting it. +pub struct DecodedRgbaFrame { + /// Decoded picture width in pixels. + pub width: usize, + /// Decoded picture height in pixels. + pub height: usize, + /// Owned RGBA8888 bytes, row-major, no stride padding. + pub rgba: Vec, +} + +/// The decode backend behind a [`DecoderSession`](crate::decoder::DecoderSession). +/// +/// One access unit in, at most one displayed picture out (AVAL H.264 is +/// Constrained Baseline: decode order equals display order, one chunk yields +/// at most one frame). `Send` so the session — and the `flutter_rust_bridge` +/// worker thread that will host it (§4) — can move a boxed adapter across +/// threads. +pub trait DecoderAdapter: Send { + /// Decodes one encoded Annex-B access unit. + /// + /// Returns `Ok(Some(frame))` when a displayed picture is produced, + /// `Ok(None)` while the decoder is still priming (no output yet), and + /// `Err(..)` when the backend rejects the bitstream. + /// + /// # Errors + /// + /// - [`AvalDecodeError::DecodeFailed`] if the backend rejects the access + /// unit. + /// - [`AvalDecodeError::DecoderOutputInvalid`] if the decoded picture's + /// geometry cannot be represented as a valid RGBA buffer. + fn decode( + &mut self, + access_unit: &[u8], + ) -> Result, AvalDecodeError>; +} + +pub use openh264_adapter::OpenH264Adapter; + +#[cfg(target_vendor = "apple")] +pub use videotoolbox_adapter::VideoToolboxAdapter; + +/// The default software backend: Cisco's BSD-2-Clause OpenH264 decoder plus +/// this crate's SIMD-free I420→RGBA conversion (`ARCHITECTURE.md` §2(a)/§3.3). +mod openh264_adapter { + use openh264::decoder::{Decoder, DecoderConfig, Flush}; + use openh264::formats::YUVSource; + use openh264::OpenH264API; + + use super::{DecodedRgbaFrame, DecoderAdapter}; + use crate::error::AvalDecodeError; + use crate::yuv; + + /// Wraps a single `openh264` [`Decoder`]. Created once per configured + /// session; never reconfigured (matching the session's configure-once rule). + pub struct OpenH264Adapter { + decoder: Decoder, + } + + impl OpenH264Adapter { + /// Creates the openh264 decoder. + /// + /// # Errors + /// + /// [`AvalDecodeError::DecodeFailed`] if openh264 cannot construct a + /// decoder (TS `DECODER_CONFIGURE_FAILED`). + pub fn new() -> Result { + // Default openh264 flush-after-decode OOMs (`dsOutOfMemory`) on + // High-profile streams with B-frames (mansion-woman, etc.): forced + // flush corrupts the DPB. NoFlush matches DecodeFrameNoDelay usage + // and leaves reordering to the codec. + let decoder = Decoder::with_api_config( + OpenH264API::from_source(), + DecoderConfig::new().flush_after_decode(Flush::NoFlush), + ) + .map_err(|error| { + AvalDecodeError::DecodeFailed(format!( + "failed to create openh264 decoder: {error}" + )) + })?; + Ok(Self { decoder }) + } + } + + impl DecoderAdapter for OpenH264Adapter { + fn decode( + &mut self, + access_unit: &[u8], + ) -> Result, AvalDecodeError> { + match self.decoder.decode(access_unit) { + Ok(Some(yuv)) => { + let (width, height) = yuv.dimensions(); + let (y_stride, uv_stride, _) = yuv.strides(); + let len = yuv::rgba_len(width, height) + .ok_or(AvalDecodeError::DecoderOutputInvalid)?; + let mut rgba = vec![0u8; len]; + yuv::i420_to_rgba( + yuv.y(), + yuv.u(), + yuv.v(), + width, + height, + y_stride, + uv_stride, + &mut rgba, + )?; + Ok(Some(DecodedRgbaFrame { width, height, rgba })) + } + Ok(None) => Ok(None), + Err(error) => Err(AvalDecodeError::DecodeFailed(format!( + "openh264 rejected the chunk: {error}" + ))), + } + } + } +} + +/// The hardware backend on Apple platforms: VideoToolbox (`ARCHITECTURE.md` +/// §2(c)). Thin Rust wrapper over the Objective-C decoder in +/// `src/vt/videotoolbox_decoder.m`, compiled into this crate by `build.rs`. +#[cfg(target_vendor = "apple")] +mod videotoolbox_adapter { + use std::os::raw::{c_int, c_void}; + + use super::{DecodedRgbaFrame, DecoderAdapter}; + use crate::error::AvalDecodeError; + + // C ABI from videotoolbox_decoder.m. + extern "C" { + fn aval_vt_create() -> *mut c_void; + fn aval_vt_decode( + dec: *mut c_void, + data: *const u8, + len: usize, + out_rgba: *mut *mut u8, + out_len: *mut usize, + out_width: *mut u32, + out_height: *mut u32, + ) -> c_int; + fn aval_vt_free_frame(rgba: *mut u8); + fn aval_vt_destroy(dec: *mut c_void); + } + + /// Owns the opaque VideoToolbox decoder handle for one session. + pub struct VideoToolboxAdapter { + handle: *mut c_void, + } + + // The handle is used only from the session's single owning thread; the + // adapter never shares it. `Send` mirrors `OpenH264Adapter` so the session + // can move a boxed adapter to its worker thread (§4). + unsafe impl Send for VideoToolboxAdapter {} + + impl VideoToolboxAdapter { + /// Creates the VideoToolbox-backed decoder. The decompression session + /// itself is created lazily on the first access unit that carries + /// SPS/PPS, so this only allocates the wrapper. + /// + /// # Errors + /// + /// [`AvalDecodeError::DecodeFailed`] if the wrapper cannot be allocated. + pub fn new() -> Result { + let handle = unsafe { aval_vt_create() }; + if handle.is_null() { + return Err(AvalDecodeError::DecodeFailed( + "failed to create VideoToolbox decoder".to_string(), + )); + } + Ok(Self { handle }) + } + } + + impl DecoderAdapter for VideoToolboxAdapter { + fn decode( + &mut self, + access_unit: &[u8], + ) -> Result, AvalDecodeError> { + let mut out_rgba: *mut u8 = std::ptr::null_mut(); + let mut out_len: usize = 0; + let mut out_width: u32 = 0; + let mut out_height: u32 = 0; + let rc = unsafe { + aval_vt_decode( + self.handle, + access_unit.as_ptr(), + access_unit.len(), + &mut out_rgba, + &mut out_len, + &mut out_width, + &mut out_height, + ) + }; + match rc { + 1 => { + if out_rgba.is_null() { + return Err(AvalDecodeError::DecoderOutputInvalid); + } + // Copy the C-owned buffer into an owned Vec, then free it. + let rgba = + unsafe { std::slice::from_raw_parts(out_rgba, out_len) } + .to_vec(); + unsafe { aval_vt_free_frame(out_rgba) }; + Ok(Some(DecodedRgbaFrame { + width: out_width as usize, + height: out_height as usize, + rgba, + })) + } + 0 => Ok(None), // Priming. + _ => Err(AvalDecodeError::DecodeFailed( + "VideoToolbox rejected the access unit".to_string(), + )), + } + } + } + + impl Drop for VideoToolboxAdapter { + fn drop(&mut self) { + unsafe { aval_vt_destroy(self.handle) }; + } + } +} diff --git a/flutter/rust/aval_decode/src/decoder.rs b/flutter/rust/aval_decode/src/decoder.rs new file mode 100644 index 0000000..cfc79cf --- /dev/null +++ b/flutter/rust/aval_decode/src/decoder.rs @@ -0,0 +1,889 @@ +//! Protocol-shaped decode session. +//! +//! Ports the command/event vocabulary of +//! `packages/player-web/src/decoder-worker/{core.ts, protocol.ts}` (format 1.0, +//! upstream merge `67c4c0e`) onto the synchronous `openh264` decoder. The +//! `DecoderWorkerCore` class owns a single `VideoDecoder`, gates input with the +//! [`FrameCreditLedger`] and [`DecoderSampleSequence`], and emits +//! frame/ack/error events over a Worker port. Here the same lifecycle is +//! expressed as ordinary methods ([`DecoderSession::configure`], +//! [`DecoderSession::activate_generation`], [`DecoderSession::submit_chunk`], +//! [`DecoderSession::take_frame`], [`DecoderSession::release_frame`], +//! [`DecoderSession::abort_generation`], [`DecoderSession::snapshot`], +//! [`DecoderSession::dispose`]). +//! +//! ## Codec-generic seam, H.264-only native decode +//! +//! Format 1.0 made the worker codec-neutral: [`SessionConfig`] now declares a +//! [`VideoCodec`] family and a bit depth, mirroring `DecoderWorkerVideoProfile` +//! (`protocol.ts:39-46`). This crate keeps the declaration generic but only +//! *decodes* H.264 — [`DecoderSession::configure`] rejects H.265/VP9/AV1 with +//! [`AvalDecodeError::Unsupported`]. The `DecoderAdapter` seam stays codec-generic +//! (ARCHITECTURE.md §2); a future dav1d/libvpx/openh265 crate slots in behind the +//! same session shape. +//! +//! ## Decode-chunk vs displayed-frame distinction +//! +//! A submission is now an encoded **chunk** ([`DecodeChunk`]), not a "sample". A +//! chunk carries `displayed_frame_count` outputs (0 hidden, 1 for H.264, N for a +//! VP9 superframe) each mapped to a `presentation_index` inside its unit. Because +//! AVAL H.264 is Constrained Baseline (no B-frames, one reference, closed GOP), +//! decode order equals display order and one chunk in produces at most one frame +//! out synchronously, so the async event pump / dequeue-callback machinery +//! collapses away. The session API carries the general chunk shape regardless so +//! the Dart caller matches the web protocol; a chunk asserting more than one +//! displayed frame is rejected as [`AvalDecodeError::Unsupported`] (openh264 is +//! strictly 1:1). +//! +//! What is faithfully preserved from `core.ts`: +//! - configure-once semantics (`ALREADY_CONFIGURED`), +//! - the exact derived decoded-byte budget rule (`validateConfiguration`), +//! - monotonic generation activation and generation-scoped submit, +//! - the outstanding-frame credit gate before accepting a submission +//! (now denominated in displayed frames, TS `sumDisplayedFrames`), +//! - chunk unit/decode-index continuity via [`DecoderSampleSequence`], +//! - the frame-credit lease/release lifecycle across the boundary, +//! - the metrics snapshot vocabulary (`DecoderWorkerMetrics`), +//! - fatal-failure latching (a fatal error tears the session down; further calls +//! report it), mirroring `#fail`. +//! +//! What is intentionally *not* ported (WebCodecs/Worker-only, see the report): +//! the async support probe / `probe-config`, `decodeQueueSize`/dequeue callbacks, +//! request-id monotonicity, boundary-flush plumbing (`flushCalls`), `VideoFrame` +//! transfer, and the WebCodecs colour-space echo checks in `core-validation.ts` +//! (`avc1.*` codec parsing and level-limit math live in `aval_format`, not this +//! crate — ARCHITECTURE.md §6). + +use std::collections::{HashMap, VecDeque}; + +use crate::adapter::{DecoderAdapter, OpenH264Adapter}; +use crate::error::AvalDecodeError; +use crate::ledger::FrameCreditLedger; +use crate::sample_sequence::{expected_timestamp, DecodeChunk, DecoderSampleSequence}; +use crate::yuv; +use crate::DECODER_WORKER_HARD_LIMITS; + +/// Declared codec family. Mirrors `VideoCodec` from `@pixel-point/aval-format` +/// (`"h264" | "h265" | "vp9" | "av1"`). Only [`VideoCodec::H264`] is decodable by +/// this crate; the rest are accepted as a declaration and rejected at configure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VideoCodec { + /// H.264 / AVC — the only natively decoded family (openh264). + H264, + /// H.265 / HEVC — declared only (future licensing-gated crate). + H265, + /// VP9 — declared only (future libvpx crate). + Vp9, + /// AV1 — declared only (future dav1d crate). + Av1, +} + +impl VideoCodec { + /// Maps the C ABI codec discriminant (`0=h264, 1=h265, 2=vp9, 3=av1`). + #[must_use] + pub const fn from_u32(value: u32) -> Option { + match value { + 0 => Some(Self::H264), + 1 => Some(Self::H265), + 2 => Some(Self::Vp9), + 3 => Some(Self::Av1), + _ => None, + } + } +} + +/// Session configuration. A decode-relevant port of +/// `DecoderWorkerConfigureCommand` (`protocol.ts:106-114`): the declared codec +/// family/bit-depth ([`DecoderWorkerVideoProfile`]), coded surface geometry, and +/// the two limits that gate frame credit. The full WebCodecs codec string, +/// level-limit math, and colour-space expectation are validated upstream in +/// `aval_format`/`aval_player` before the bytes reach this crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionConfig { + /// Declared codec family (must be [`VideoCodec::H264`] to configure). + pub codec: VideoCodec, + /// Declared luma bit depth (must be `8` for H.264; only AV1 allows `10`). + pub bit_depth: u8, + /// Coded (decoder-surface) width in pixels. + pub coded_width: usize, + /// Coded (decoder-surface) height in pixels. + pub coded_height: usize, + /// Combined submitted-output and leased-frame ceiling + /// (`DecoderWorkerLimits.maxOutstandingFrames`, 1..=12). + pub max_outstanding_frames: usize, + /// Logical RGBA bytes leased at once + /// (`DecoderWorkerLimits.maxDecodedBytes`); must equal the exact derived + /// budget, see [`maximum_decoded_rgba_bytes`]. + pub max_decoded_bytes: u64, +} + +/// Exact per-surface decoded RGBA byte count for a coded surface. +/// +/// Local stand-in for `aval-format`'s decoded-surface budget for the unpadded +/// case: `width * height * 4`. Returns `None` on overflow. +#[must_use] +pub fn maximum_decoded_rgba_bytes(coded_width: usize, coded_height: usize) -> Option { + yuv::rgba_len(coded_width, coded_height).map(|len| len as u64) +} + +/// Outcome of [`DecoderSession::submit_chunk`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubmitOutcome { + /// A displayed frame became available and was leased under this `frame_id`. + /// Retrieve it with [`DecoderSession::take_frame`]. + Frame { + /// Ledger frame id of the produced frame. + frame_id: u64, + }, + /// No displayed frame this call — the decoder is priming (`decode()` returned + /// `None`), or the chunk was hidden (`displayed_frame_count == 0`). + Priming, +} + +/// A decoded, converted, RGBA frame held by the session until released. +#[derive(Debug)] +struct StoredFrame { + frame_id: u64, + generation: u64, + ordinal: u64, + unit_instance: u64, + unit_frame: u64, + decode_index: u64, + timestamp: u64, + duration: u64, + width: usize, + height: usize, + rgba: Vec, +} + +/// A borrowed view of a frame returned by [`DecoderSession::take_frame`]. +/// +/// The backing bytes stay owned by the session (and valid) until +/// [`DecoderSession::release_frame`] is called with the same `frame_id`. +#[derive(Debug, Clone, Copy)] +pub struct FrameView<'a> { + /// Ledger frame id; pass to [`DecoderSession::release_frame`] when done. + pub frame_id: u64, + /// Global presentation ordinal (`presentation_ordinal_base + presentation_index`). + pub ordinal: u64, + /// Source unit instance. + pub unit_instance: u64, + /// Displayed-frame index within the unit (the chunk's `presentation_index`). + pub unit_frame: u64, + /// Decode-order index of the chunk that produced this frame. + pub decode_index: u64, + /// Presentation timestamp. + pub timestamp: u64, + /// Frame duration. + pub duration: u64, + /// Frame width in pixels. + pub width: usize, + /// Frame height in pixels. + pub height: usize, + /// Tightly-packed RGBA8888 bytes (`width * height * 4`). + pub rgba: &'a [u8], +} + +/// Metrics snapshot. Ports the meaningful subset of `DecoderWorkerMetrics` +/// (`protocol.ts:211-233`). WebCodecs/async-only counters (`flushCalls`, +/// `boundaryFlushCalls`, `resetCalls`, `decodeQueueSize`) are omitted because the +/// synchronous openh264 path has no input queue or boundary flush; see the report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DecoderMetrics { + /// Successful `configure` calls (0 or 1). + pub configure_calls: u64, + /// Accepted chunks (TS `acceptedSamples`). + pub accepted_samples: u64, + /// Chunks actually handed to the decoder (TS `submittedChunks`). + pub submitted_chunks: u64, + /// Displayed frames produced by the decoder (TS `outputFrames`). + pub output_frames: u64, + /// Frames handed to the caller via `take_frame` (TS `deliveredFrames`). + pub delivered_frames: u64, + /// Frames released by the caller (TS `releasedFrames`). + pub released_frames: u64, + /// Frames dropped by generation abort/retire before delivery (TS `staleFrames`). + pub stale_frames: u64, + /// Frames whose backing buffer was closed/discarded (TS `closedFrames`). + pub closed_frames: u64, + /// Chunks accepted but not yet handed to the decoder. Always `0` here — the + /// synchronous path has no pending queue (TS `pendingSamples`). + pub pending_samples: u64, + /// In-flight display obligation not yet delivered (pending + expected + + /// buffered). Always `0` here — decode delivers synchronously + /// (TS `submittedFrames`). + pub submitted_frames: u64, + /// Currently leased (outstanding) frames (TS `leasedFrames`). + pub leased_frames: u64, + /// Currently leased decoded bytes (TS `leasedDecodedBytes`). + pub leased_decoded_bytes: u64, + /// Active generation, or `None` if no generation is active. + pub active_generation: Option, + /// Next submission ordinal = accepted chunks (TS `nextSubmissionOrdinal`). + pub next_submission_ordinal: u64, + /// Next output ordinal = delivered + stale frames (TS `nextOutputOrdinal`). + pub next_output_ordinal: u64, + /// Fatal errors latched by the session. + pub errors: u64, + /// Whether the session has been disposed. + pub disposed: bool, +} + +/// Owner of the sole decode backend for a session. +pub struct DecoderSession { + decoder: Option>, + config: Option, + credits: FrameCreditLedger, + sequence: DecoderSampleSequence, + + active_generation: Option, + last_generation: u64, + + ready: VecDeque, + frames: HashMap, + + configure_calls: u64, + accepted_samples: u64, + submitted_chunks: u64, + output_frames: u64, + delivered_frames: u64, + released_frames: u64, + stale_frames: u64, + closed_frames: u64, + errors: u64, + + failure: Option, + disposed: bool, +} + +impl Default for DecoderSession { + fn default() -> Self { + Self::new() + } +} + +impl DecoderSession { + /// Creates a new, unconfigured session. + #[must_use] + pub fn new() -> Self { + Self { + decoder: None, + config: None, + credits: FrameCreditLedger::new(), + sequence: DecoderSampleSequence::new(), + active_generation: None, + last_generation: 0, + ready: VecDeque::new(), + frames: HashMap::new(), + configure_calls: 0, + accepted_samples: 0, + submitted_chunks: 0, + output_frames: 0, + delivered_frames: 0, + released_frames: 0, + stale_frames: 0, + closed_frames: 0, + errors: 0, + failure: None, + disposed: false, + } + } + + /// Configures the session exactly once (TS `#configure`). + /// + /// # Errors + /// + /// - [`AvalDecodeError::Unsupported`] if the declared codec is not H.264 + /// (openh264 is the only native decoder). + /// - [`AvalDecodeError::InvalidArgument`] if already configured, disposed, + /// the geometry/limits are out of range, the bit depth is not 8, or + /// `max_decoded_bytes` is not the exact derived budget (TS + /// `ALREADY_CONFIGURED` / `PROTOCOL_ERROR`). + /// - [`AvalDecodeError::DecodeFailed`] if the `openh264` decoder cannot be + /// created (TS `DECODER_CONFIGURE_FAILED`). + pub fn configure(&mut self, config: SessionConfig) -> Result<(), AvalDecodeError> { + self.check_usable()?; + if self.configure_calls != 0 || self.decoder.is_some() { + return Err(AvalDecodeError::InvalidArgument( + "decoder session may be configured only once", + )); + } + Self::validate_config(&config)?; + + // Backend selection (ARCHITECTURE.md §2(c)). `configure` already + // rejected every non-H.264 codec, so only H.264 reaches this point. + // On Apple targets the hardware VideoToolbox backend is the default; + // set AVAL_DECODE_OPENH264=1 to force the software backend for A/B + // debugging. Every other platform uses OpenH264. + let decoder = match self.build_decoder() { + Ok(decoder) => decoder, + Err(error) => return Err(self.fail(error)), + }; + + self.decoder = Some(decoder); + self.config = Some(config); + self.configure_calls += 1; + Ok(()) + } + + /// Constructs the decode backend for this session (ARCHITECTURE.md §2(c)). + /// + /// Apple targets default to the hardware VideoToolbox backend, overridable + /// to software with `AVAL_DECODE_OPENH264=1`; all other platforms use + /// OpenH264. Kept out of [`configure`] so the selection policy lives in one + /// place. + fn build_decoder(&self) -> Result, AvalDecodeError> { + #[cfg(target_vendor = "apple")] + { + let force_software = std::env::var_os("AVAL_DECODE_OPENH264") + .is_some_and(|value| value == "1"); + if !force_software { + return Ok(Box::new(crate::adapter::VideoToolboxAdapter::new()?)); + } + } + Ok(Box::new(OpenH264Adapter::new()?)) + } + + /// Activates a new generation (TS `#activateGeneration`). Generations must + /// increase monotonically and be positive. + /// + /// # Errors + /// + /// [`AvalDecodeError::InvalidArgument`] if not configured, disposed, or the + /// generation is not strictly greater than the last activated generation. + pub fn activate_generation(&mut self, generation: u64) -> Result<(), AvalDecodeError> { + self.assert_configured()?; + if generation == 0 { + return Err(AvalDecodeError::InvalidArgument( + "generation must be a positive integer", + )); + } + if generation <= self.last_generation { + return Err(AvalDecodeError::InvalidArgument( + "decoder generations must increase monotonically", + )); + } + self.sequence.activate(generation); + self.active_generation = Some(generation); + self.last_generation = generation; + Ok(()) + } + + /// Aborts the active generation (TS `#abortGeneration`), dropping any decoded + /// frames that are queued but not yet taken. Frames already handed out by + /// [`DecoderSession::take_frame`] stay leased until the caller releases them, + /// so their FFI pointer never dangles. + /// + /// # Errors + /// + /// [`AvalDecodeError::InvalidArgument`] if not configured/disposed or + /// `generation` is not the active generation. + pub fn abort_generation(&mut self, generation: u64) -> Result<(), AvalDecodeError> { + self.assert_configured()?; + if self.active_generation != Some(generation) { + return Err(AvalDecodeError::InvalidArgument( + "only the active decoder generation can be aborted", + )); + } + self.active_generation = None; + self.sequence.abort(generation); + // Drop only queued-but-untaken frames of this generation (TS retire of + // buffered, not-yet-transferred frames). Delivered-unreleased frames stay. + let stale: Vec = self + .ready + .iter() + .copied() + .filter(|id| { + self.frames + .get(id) + .is_some_and(|frame| frame.generation == generation) + }) + .collect(); + for frame_id in stale { + self.drop_frame(frame_id); + self.stale_frames += 1; + self.closed_frames += 1; + } + Ok(()) + } + + /// Submits one encoded chunk (TS `#submit` + `#pump` + `handleOutput`, + /// collapsed into a synchronous decode). + /// + /// # Errors + /// + /// - [`AvalDecodeError::Unsupported`] if `displayed_frame_count > 1` (openh264 + /// is 1:1; superframes need a VP9/AV1 decoder). + /// - [`AvalDecodeError::InvalidArgument`] if not configured/disposed, the + /// generation is not active, the outstanding-frame credit is exhausted + /// (TS `BACKPRESSURE_LIMIT`), or the chunk fails unit/continuity validation. + /// - [`AvalDecodeError::DecodeFailed`] if `openh264` rejects the bitstream. + /// - [`AvalDecodeError::DecoderOutputInvalid`] if the decoded geometry is + /// inconsistent, or a hidden chunk unexpectedly produced a frame (fatal, + /// TS `DECODER_OUTPUT_INVALID`). + /// - [`AvalDecodeError::DecodedByteBudgetExceeded`] if leasing the frame would + /// exceed the byte budget (fatal). + pub fn submit_chunk( + &mut self, + generation: u64, + chunk: &DecodeChunk<'_>, + ) -> Result { + self.assert_configured()?; + if self.active_generation != Some(generation) { + return Err(AvalDecodeError::InvalidArgument( + "decode submission does not target the active generation", + )); + } + let config = self.config.expect("configured session has a config"); + + // openh264 yields at most one displayed frame per decode(); a chunk that + // asserts a superframe cannot be decoded here (configure already rejects + // the codecs that produce them, but guard the chunk shape too). + if chunk.displayed_frame_count > 1 { + return Err(AvalDecodeError::Unsupported( + "openh264 yields one displayed frame per chunk; superframes need a VP9/AV1 decoder", + )); + } + + // Outstanding-frame credit gate (TS `#submit` denominated in displayed + // frames). In the synchronous collapse there is no pending queue and no + // in-flight decoder callback, so outstanding == leased. A hidden chunk + // (0 displayed frames) needs no credit. + if chunk.displayed_frame_count > 0 + && !self + .credits + .has_submission_credit(0, config.max_outstanding_frames) + { + return Err(AvalDecodeError::InvalidArgument( + "decode submission exceeds the outstanding-frame budget", + )); + } + + // Unit/decode-order continuity (advances the sequence on success). + self.sequence + .accept(generation, std::slice::from_ref(chunk))?; + self.accepted_samples += 1; + self.submitted_chunks += 1; + + // Decode one access unit through the configured backend, which returns + // an owned RGBA picture (or `None` while priming). The backend owns the + // codec + colorspace conversion; the session owns everything else. + let decoder = self.decoder.as_mut().expect("configured session decoder"); + let converted = decoder.decode(chunk.data)?; + + // Hidden chunk: no displayed output is expected. + if chunk.displayed_frame_count == 0 { + if converted.is_some() { + // TS: "fails closed when a hidden chunk unexpectedly produces a frame". + self.closed_frames += 1; + return Err(self.fail(AvalDecodeError::DecoderOutputInvalid)); + } + return Ok(SubmitOutcome::Priming); + } + + // From here `displayed_frame_count == 1`. + let Some(frame) = converted else { + return Ok(SubmitOutcome::Priming); + }; + let (width, height, rgba) = (frame.width, frame.height, frame.rgba); + + // Geometry must match the configured coded surface (TS validateDecodedFrame). + if width != config.coded_width || height != config.coded_height { + return Err(self.fail(AvalDecodeError::DecoderOutputInvalid)); + } + + let presentation_index = chunk.presentation_indices[0]; + // `validate_chunk_shape` guarantees `base + unit_frame_count <= MAX_SAFE` + // and `presentation_index < unit_frame_count`, so this cannot overflow. + let ordinal = chunk.presentation_ordinal_base + presentation_index; + let timestamp = expected_timestamp(chunk, 0)?; + + let decoded_bytes = rgba.len() as u64; + let frame_id = match self + .credits + .lease(generation, decoded_bytes, config.max_decoded_bytes) + { + Ok(id) => id, + Err(error) => return Err(self.fail(error)), + }; + self.output_frames += 1; + + self.frames.insert( + frame_id, + StoredFrame { + frame_id, + generation, + ordinal, + unit_instance: chunk.unit_instance, + unit_frame: presentation_index, + decode_index: chunk.decode_index, + timestamp, + duration: chunk.duration, + width, + height, + rgba, + }, + ); + self.ready.push_back(frame_id); + Ok(SubmitOutcome::Frame { frame_id }) + } + + /// Removes and returns the next ready frame in FIFO order (TS frame event + /// delivery). `Ok(None)` means no frame is currently queued. + /// + /// # Errors + /// + /// [`AvalDecodeError::InvalidArgument`] if disposed, or a latched fatal + /// failure is replayed. + pub fn take_frame(&mut self) -> Result>, AvalDecodeError> { + self.check_usable()?; + let Some(frame_id) = self.ready.pop_front() else { + return Ok(None); + }; + self.delivered_frames += 1; + let frame = self + .frames + .get(&frame_id) + .expect("ready frame id is present in the frame table"); + Ok(Some(FrameView { + frame_id: frame.frame_id, + ordinal: frame.ordinal, + unit_instance: frame.unit_instance, + unit_frame: frame.unit_frame, + decode_index: frame.decode_index, + timestamp: frame.timestamp, + duration: frame.duration, + width: frame.width, + height: frame.height, + rgba: &frame.rgba, + })) + } + + /// Releases a frame, freeing its buffer and replenishing credit (TS + /// `#releaseFrame`). + /// + /// # Errors + /// + /// [`AvalDecodeError::FrameReleaseInvalid`] if `frame_id` is unknown, zero, + /// or already released (fatal — treated as ownership corruption, matching + /// the TS worker which fails the session on a bad release). + pub fn release_frame(&mut self, frame_id: u64) -> Result<(), AvalDecodeError> { + self.check_usable()?; + if let Err(error) = self.credits.release(frame_id) { + return Err(self.fail(error)); + } + self.frames.remove(&frame_id); + // A frame is normally taken before release, but tolerate release of an + // untaken frame by removing it from the ready queue too. + self.ready.retain(|&id| id != frame_id); + self.released_frames += 1; + Ok(()) + } + + /// Returns a metrics snapshot (TS `snapshotMetrics`). Always succeeds, even + /// after failure or disposal, matching the TS `snapshot` command. + #[must_use] + pub fn snapshot(&self) -> DecoderMetrics { + DecoderMetrics { + configure_calls: self.configure_calls, + accepted_samples: self.accepted_samples, + submitted_chunks: self.submitted_chunks, + output_frames: self.output_frames, + delivered_frames: self.delivered_frames, + released_frames: self.released_frames, + stale_frames: self.stale_frames, + closed_frames: self.closed_frames, + // No pending queue and synchronous delivery in the openh264 collapse. + pending_samples: 0, + submitted_frames: 0, + leased_frames: self.credits.count() as u64, + leased_decoded_bytes: self.credits.decoded_bytes(), + active_generation: self.active_generation, + next_submission_ordinal: self.sequence.accepted_chunks(), + next_output_ordinal: self.delivered_frames + self.stale_frames, + errors: self.errors, + disposed: self.disposed, + } + } + + /// Tears the session down (TS `#dispose`). Idempotent. + pub fn dispose(&mut self) { + if self.disposed { + return; + } + self.disposed = true; + self.active_generation = None; + self.ready.clear(); + self.frames.clear(); + self.sequence.clear_active(); + self.credits.clear(); + self.decoder = None; + } + + /// Whether a fatal error has been latched. + #[must_use] + pub fn has_failed(&self) -> bool { + self.failure.is_some() + } + + // --- internal helpers ------------------------------------------------- + + fn validate_config(config: &SessionConfig) -> Result<(), AvalDecodeError> { + // Codec-generic seam, H.264-only native decode (ARCHITECTURE.md §2). + if config.codec != VideoCodec::H264 { + return Err(AvalDecodeError::Unsupported( + "only H.264 is decodable natively (openh264); H.265/VP9/AV1 are declared-only", + )); + } + // TS validateConfiguration: only AV1 supports a 10-bit worker profile. + if config.bit_depth != 8 { + return Err(AvalDecodeError::InvalidArgument( + "H.264 profile must be 8-bit", + )); + } + if config.coded_width == 0 || config.coded_height == 0 { + return Err(AvalDecodeError::InvalidArgument( + "coded dimensions must be positive", + )); + } + if config.max_outstanding_frames < 1 + || config.max_outstanding_frames > DECODER_WORKER_HARD_LIMITS.max_outstanding_frames + { + return Err(AvalDecodeError::InvalidArgument( + "maxOutstandingFrames must be between 1 and the hard cap (12)", + )); + } + // TS validateConfiguration: `maxDecodedBytes` must exactly match the + // derived decoded-surface budget. + let per_surface = maximum_decoded_rgba_bytes(config.coded_width, config.coded_height) + .ok_or(AvalDecodeError::InvalidArgument( + "decoded-surface byte count overflows", + ))?; + let exact = per_surface + .checked_mul(config.max_outstanding_frames as u64) + .ok_or(AvalDecodeError::InvalidArgument( + "decoded-surface budget overflows", + ))?; + if config.max_decoded_bytes != exact { + return Err(AvalDecodeError::InvalidArgument( + "maxDecodedBytes must exactly match the decoded-surface budget", + )); + } + Ok(()) + } + + /// TS `#assertConfigured`: reject if not yet configured (also checks usable). + fn assert_configured(&mut self) -> Result<(), AvalDecodeError> { + self.check_usable()?; + if self.decoder.is_none() || self.config.is_none() { + return Err(AvalDecodeError::InvalidArgument( + "decoder session must be configured before use", + )); + } + Ok(()) + } + + /// Reject if disposed, or replay a latched fatal failure. + fn check_usable(&self) -> Result<(), AvalDecodeError> { + if self.disposed { + return Err(AvalDecodeError::InvalidArgument( + "decoder session is disposed", + )); + } + if let Some(failure) = &self.failure { + return Err(failure.clone()); + } + Ok(()) + } + + /// TS `#fail`: latch a fatal error and tear down decode state, returning the + /// same error for convenient `return Err(self.fail(err))` use. + fn fail(&mut self, error: AvalDecodeError) -> AvalDecodeError { + if self.failure.is_none() && !self.disposed { + self.failure = Some(error.clone()); + self.errors += 1; + self.active_generation = None; + self.ready.clear(); + self.frames.clear(); + self.sequence.clear_active(); + self.credits.clear(); + self.decoder = None; + } + error + } + + fn drop_frame(&mut self, frame_id: u64) { + // Best-effort release of a stale/aborted frame; ignore ledger errors so + // an already-consistent state is not turned fatal. + let _ = self.credits.release(frame_id); + self.frames.remove(&frame_id); + self.ready.retain(|&id| id != frame_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_2x2(max_outstanding: usize) -> SessionConfig { + SessionConfig { + codec: VideoCodec::H264, + bit_depth: 8, + coded_width: 2, + coded_height: 2, + max_outstanding_frames: max_outstanding, + max_decoded_bytes: maximum_decoded_rgba_bytes(2, 2).unwrap() * max_outstanding as u64, + } + } + + fn key_chunk(unit_instance: u64) -> DecodeChunk<'static> { + DecodeChunk { + unit_id: "unit", + unit_instance, + decode_index: 0, + unit_chunk_count: 1, + unit_frame_count: 1, + presentation_ordinal_base: unit_instance, + presentation_indices: &[0], + presentation_timestamp: unit_instance * 16_667 + 1, + duration: 16_667, + random_access: true, + displayed_frame_count: 1, + data: &[0x00, 0x00, 0x00, 0x01, 0x67], + } + } + + #[test] + fn configure_is_once_only() { + let mut session = DecoderSession::new(); + session.configure(config_2x2(4)).unwrap(); + let err = session.configure(config_2x2(4)).unwrap_err(); + assert!(matches!(err, AvalDecodeError::InvalidArgument(_))); + assert_eq!(session.snapshot().configure_calls, 1); + } + + #[test] + fn configure_rejects_non_h264_codec() { + for codec in [VideoCodec::H265, VideoCodec::Vp9, VideoCodec::Av1] { + let mut session = DecoderSession::new(); + let cfg = SessionConfig { + codec, + ..config_2x2(4) + }; + let err = session.configure(cfg).unwrap_err(); + assert!(matches!(err, AvalDecodeError::Unsupported(_))); + assert!(err.is_fatal()); + assert_eq!(session.snapshot().configure_calls, 0); + } + } + + #[test] + fn configure_rejects_non_8bit_depth() { + let mut session = DecoderSession::new(); + let cfg = SessionConfig { + bit_depth: 10, + ..config_2x2(4) + }; + assert!(matches!( + session.configure(cfg).unwrap_err(), + AvalDecodeError::InvalidArgument(_) + )); + } + + #[test] + fn configure_requires_exact_decoded_byte_budget() { + let mut session = DecoderSession::new(); + let mut cfg = config_2x2(4); + cfg.max_decoded_bytes += 1; + let err = session.configure(cfg).unwrap_err(); + assert!(matches!(err, AvalDecodeError::InvalidArgument(_))); + + let mut under = config_2x2(4); + under.max_decoded_bytes -= 1; + assert!(DecoderSession::new().configure(under).is_err()); + } + + #[test] + fn configure_rejects_out_of_range_outstanding_frames() { + let mut zero = config_2x2(4); + zero.max_outstanding_frames = 0; + zero.max_decoded_bytes = 0; + assert!(DecoderSession::new().configure(zero).is_err()); + let mut over = config_2x2(4); + over.max_outstanding_frames = 13; // above the hard cap of 12 + over.max_decoded_bytes = maximum_decoded_rgba_bytes(2, 2).unwrap() * 13; + assert!(DecoderSession::new().configure(over).is_err()); + } + + #[test] + fn submit_before_configure_is_rejected() { + let mut session = DecoderSession::new(); + let err = session.submit_chunk(1, &key_chunk(0)).unwrap_err(); + assert!(matches!(err, AvalDecodeError::InvalidArgument(_))); + } + + #[test] + fn submit_requires_the_active_generation() { + let mut session = DecoderSession::new(); + session.configure(config_2x2(4)).unwrap(); + // No generation activated yet. + assert!(session.submit_chunk(1, &key_chunk(0)).is_err()); + session.activate_generation(1).unwrap(); + // Submitting to a non-active generation is rejected. + assert!(session.submit_chunk(2, &key_chunk(0)).is_err()); + } + + #[test] + fn submit_rejects_superframe_chunks_as_unsupported() { + let mut session = DecoderSession::new(); + session.configure(config_2x2(4)).unwrap(); + session.activate_generation(1).unwrap(); + let superframe = DecodeChunk { + unit_frame_count: 2, + presentation_indices: &[0, 1], + displayed_frame_count: 2, + ..key_chunk(0) + }; + let err = session.submit_chunk(1, &superframe).unwrap_err(); + assert!(matches!(err, AvalDecodeError::Unsupported(_))); + } + + #[test] + fn generations_must_increase_monotonically() { + let mut session = DecoderSession::new(); + session.configure(config_2x2(4)).unwrap(); + session.activate_generation(2).unwrap(); + assert!(session.activate_generation(2).is_err()); + assert!(session.activate_generation(1).is_err()); + session.activate_generation(3).unwrap(); + } + + #[test] + fn take_frame_on_empty_queue_returns_none() { + let mut session = DecoderSession::new(); + session.configure(config_2x2(4)).unwrap(); + session.activate_generation(1).unwrap(); + assert!(session.take_frame().unwrap().is_none()); + } + + #[test] + fn dispose_is_idempotent_and_blocks_further_use() { + let mut session = DecoderSession::new(); + session.configure(config_2x2(4)).unwrap(); + session.dispose(); + session.dispose(); + assert!(session.snapshot().disposed); + assert!(session.activate_generation(1).is_err()); + } + + #[test] + fn release_of_unknown_frame_is_fatal() { + let mut session = DecoderSession::new(); + session.configure(config_2x2(4)).unwrap(); + let err = session.release_frame(42).unwrap_err(); + assert_eq!(err, AvalDecodeError::FrameReleaseInvalid); + assert!(session.has_failed()); + // Session is latched failed now. + assert!(session.activate_generation(1).is_err()); + } +} diff --git a/flutter/rust/aval_decode/src/error.rs b/flutter/rust/aval_decode/src/error.rs new file mode 100644 index 0000000..dd47431 --- /dev/null +++ b/flutter/rust/aval_decode/src/error.rs @@ -0,0 +1,140 @@ +//! Error/status vocabulary shared by the internal session logic and the C ABI. +//! +//! [`AvalDecodeStatus`] is `#[repr(C)]` and is the *only* error representation that crosses the +//! FFI boundary (see `ffi.rs`); every `extern "C"` function returns one. The variants intentionally +//! mirror the `DecoderWorkerErrorCode` values used by +//! `packages/player-web/src/decoder-worker/core-validation.ts` / +//! `frame-credit-ledger.ts` (`DECODED_BYTE_BUDGET_EXCEEDED`, `DECODER_OUTPUT_INVALID`, +//! `FRAME_RELEASE_INVALID`) so a Dart-side error mapping can reuse the same taxonomy the web +//! player already documents. + +use std::fmt; + +/// Status code returned by every `extern "C"` function in this crate. +/// +/// `Ok` (0) means the call did what its name says. Every other value is a non-panicking error +/// result - the crate never aborts the host process for an ordinary decode/ledger error (a Rust +/// panic inside FFI-called code is still caught at the boundary and reported as `Panicked`, see +/// `ffi::catch_ffi`). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AvalDecodeStatus { + /// The call completed successfully. + Ok = 0, + /// A required pointer argument was null. + NullPointer = 1, + /// An argument was structurally invalid (e.g. zero-length buffer where data was required). + InvalidArgument = 2, + /// The underlying OpenH264 decoder rejected or failed to decode the access unit. + DecodeFailed = 3, + /// `take_frame` was called but no decoded frame is currently queued. + NoFrameAvailable = 4, + /// Ledger parity with `FrameCreditLedger#lease`'s `DECODED_BYTE_BUDGET_EXCEEDED`: leasing the + /// newly decoded frame would exceed the session's configured decoded-byte budget. Treat as + /// fatal for the session, matching the TS ledger's `fatal: true` on this error. + DecodedByteBudgetExceeded = 5, + /// Ledger parity with `FrameCreditLedger#lease`'s `DECODER_OUTPUT_INVALID`: the internal + /// frame-id space was exhausted (practically unreachable, kept for parity). + DecoderOutputInvalid = 6, + /// Ledger parity with `FrameCreditLedger#release`/`#revoke`'s `FRAME_RELEASE_INVALID`: + /// `release_frame` was called with a `frame_id` that is zero, or that does not correspond to + /// a currently-outstanding lease (including a double release). + FrameReleaseInvalid = 7, + /// A Rust panic was caught at the FFI boundary; the session is left in a defined-but-unusable + /// state and should be destroyed. + Panicked = 8, + /// The declared codec configuration is not decodable by this crate. openh264/H.264 is the only + /// native decoder; H.265/VP9/AV1 are declared-but-rejected at configure time (see + /// [`AvalDecodeError::Unsupported`] and the `DecoderAdapter` seam in ARCHITECTURE.md §2). + Unsupported = 9, +} + +impl AvalDecodeStatus { + #[must_use] + pub const fn is_ok(self) -> bool { + matches!(self, Self::Ok) + } +} + +impl fmt::Display for AvalDecodeStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Ok => "ok", + Self::NullPointer => "null pointer argument", + Self::InvalidArgument => "invalid argument", + Self::DecodeFailed => "decode failed", + Self::NoFrameAvailable => "no frame available", + Self::DecodedByteBudgetExceeded => "decoded byte budget exceeded", + Self::DecoderOutputInvalid => "decoder output invalid (frame id space exhausted)", + Self::FrameReleaseInvalid => "frame release invalid (unknown or already-released frame id)", + Self::Panicked => "internal panic caught at FFI boundary", + Self::Unsupported => "unsupported codec configuration (openh264/H.264 only)", + }; + f.write_str(s) + } +} + +/// Internal (non-FFI) error type used by `ledger.rs` and `decoder.rs`. Every variant carries a +/// direct mapping to an [`AvalDecodeStatus`] via [`AvalDecodeError::status`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AvalDecodeError { + InvalidArgument(&'static str), + DecodeFailed(String), + DecodedByteBudgetExceeded, + DecoderOutputInvalid, + FrameReleaseInvalid, + /// A declared but non-decodable codec configuration. Emitted at configure + /// time for any `codecFamily` other than H.264 (openh264 is the only native + /// decoder). Fatal in the TS sense — the session cannot proceed. + Unsupported(&'static str), +} + +impl AvalDecodeError { + #[must_use] + pub const fn status(&self) -> AvalDecodeStatus { + match self { + Self::InvalidArgument(_) => AvalDecodeStatus::InvalidArgument, + Self::DecodeFailed(_) => AvalDecodeStatus::DecodeFailed, + Self::DecodedByteBudgetExceeded => AvalDecodeStatus::DecodedByteBudgetExceeded, + Self::DecoderOutputInvalid => AvalDecodeStatus::DecoderOutputInvalid, + Self::FrameReleaseInvalid => AvalDecodeStatus::FrameReleaseInvalid, + Self::Unsupported(_) => AvalDecodeStatus::Unsupported, + } + } + + /// Whether this error is "fatal" in the same sense the TS `DecoderWorkerCoreError.fatal` flag + /// is: the session should be torn down rather than continued. Kept as a method (rather than + /// baked into the status enum) so callers that only need the status code don't have to reason + /// about fatality, mirroring how `core-validation.ts` call sites branch on `.fatal` separately + /// from `.code`. + #[must_use] + pub const fn is_fatal(&self) -> bool { + matches!( + self, + Self::DecodedByteBudgetExceeded + | Self::DecoderOutputInvalid + | Self::FrameReleaseInvalid + | Self::Unsupported(_) + ) + } +} + +impl fmt::Display for AvalDecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidArgument(msg) => write!(f, "invalid argument: {msg}"), + Self::DecodeFailed(msg) => write!(f, "decode failed: {msg}"), + Self::DecodedByteBudgetExceeded => { + write!(f, "decoded output exceeds the session frame-byte budget") + } + Self::DecoderOutputInvalid => write!(f, "decoder frame id space was exhausted"), + Self::FrameReleaseInvalid => write!( + f, + "released frame id is not owned by this decoder session, or is not a positive id" + ), + Self::Unsupported(msg) => write!(f, "unsupported codec configuration: {msg}"), + } + } +} + +impl std::error::Error for AvalDecodeError {} diff --git a/flutter/rust/aval_decode/src/ffi.rs b/flutter/rust/aval_decode/src/ffi.rs new file mode 100644 index 0000000..9e59d01 --- /dev/null +++ b/flutter/rust/aval_decode/src/ffi.rs @@ -0,0 +1,481 @@ +//! C ABI for `dart:ffi`. +//! +//! Every entry point is `extern "C"`, takes/returns `#[repr(C)]` data, and wraps +//! its body in [`std::panic::catch_unwind`] (`Cargo.toml` sets +//! `panic = "unwind"` precisely so a panic becomes [`AvalDecodeStatus::Panicked`] +//! instead of aborting the host process). Errors are reported through the +//! [`AvalDecodeStatus`] return code; there is no other error channel across the +//! boundary (see `error.rs`). +//! +//! Frame ownership: a decoded frame's RGBA bytes stay owned by the Rust session. +//! [`aval_decode_take_frame`] hands out a raw pointer + length that remains valid +//! until [`aval_decode_release_frame`] is called with the same `frame_id`. On the +//! Dart side wrap the pointer with `Pointer.asTypedList(len)` and register +//! a `NativeFinalizer` that calls `aval_decode_release_frame`, so the native +//! allocation is freed exactly once, GC-safe (ARCHITECTURE.md §4). +//! +//! Lifecycle: [`aval_decode_session_create`] -> configure/activate/submit/take/ +//! release/... -> [`aval_decode_dispose`] (idempotent logical teardown) -> +//! [`aval_decode_session_destroy`] (frees the handle). Passing a null handle to +//! any call returns [`AvalDecodeStatus::NullPointer`]. +//! +//! Format-1.0 surface: submissions are **chunks** ([`AvalDecodeChunk`], the port +//! of `DecoderWorkerSample`), configuration declares a codec family + bit depth +//! ([`AvalDecodeConfig`]), and non-H.264 configs are rejected with +//! [`AvalDecodeStatus::Unsupported`]. + +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::slice; + +use crate::decoder::{DecoderSession, SessionConfig, SubmitOutcome, VideoCodec}; +use crate::error::{AvalDecodeError, AvalDecodeStatus}; +use crate::sample_sequence::DecodeChunk; + +/// Opaque session handle. Created by [`aval_decode_session_create`], freed by +/// [`aval_decode_session_destroy`]. +pub struct AvalDecoder { + session: DecoderSession, +} + +/// Configuration passed to [`aval_decode_configure`]. Mirrors [`SessionConfig`]. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct AvalDecodeConfig { + /// Declared codec family (`0=h264, 1=h265, 2=vp9, 3=av1`). Only `0` configures; + /// the rest return [`AvalDecodeStatus::Unsupported`]. + pub codec: u32, + /// Declared luma bit depth (must be `8` for H.264). + pub bit_depth: u32, + /// Coded surface width in pixels. + pub coded_width: u32, + /// Coded surface height in pixels. + pub coded_height: u32, + /// Outstanding-frame ceiling (1..=12). + pub max_outstanding_frames: u32, + /// Exact derived decoded-byte budget. + pub max_decoded_bytes: u64, +} + +/// One encoded chunk passed to [`aval_decode_submit_chunk`]. Port of the wire-1.0 +/// `DecoderWorkerSample` (`protocol.ts:91-104`). +/// +/// `data`/`unit_id`/`presentation_indices` are borrowed for the duration of the +/// call only; the callee copies whatever it retains. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct AvalDecodeChunk { + /// Which occurrence of the unit this chunk belongs to. + pub unit_instance: u64, + /// This chunk's zero-based decode index within its unit occurrence. + pub decode_index: u64, + /// Total chunks in the unit occurrence. + pub unit_chunk_count: u64, + /// Total displayed frames in the unit occurrence. + pub unit_frame_count: u64, + /// Presentation-ordinal base for the unit. + pub presentation_ordinal_base: u64, + /// Presentation timestamp of the chunk's first displayed output. + pub presentation_timestamp: u64, + /// Frame duration. + pub duration: u64, + /// Number of displayed frames this chunk yields (0 hidden, 1 for H.264). + pub displayed_frame_count: u64, + /// Non-zero if this chunk begins at random access (IDR for H.264). + pub random_access: u8, + /// Pointer to the chunk's Annex-B / elementary bytes. + pub data: *const u8, + /// Length of `data` in bytes. + pub data_len: usize, + /// Pointer to UTF-8 unit-id bytes (1..=128 bytes). + pub unit_id: *const u8, + /// Length of `unit_id` in bytes. + pub unit_id_len: usize, + /// Pointer to `u64` presentation indices (`presentation_indices_len` entries). + /// May be null iff `presentation_indices_len == 0` (a hidden chunk). + pub presentation_indices: *const u64, + /// Number of presentation indices; must equal `displayed_frame_count`. + pub presentation_indices_len: usize, +} + +/// Written by [`aval_decode_submit_chunk`] to report whether a frame was produced. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct AvalSubmitResult { + /// Non-zero if a displayed frame became available (retrievable via `take_frame`). + pub produced_frame: u8, + /// Frame id of the produced frame (only meaningful if `produced_frame != 0`). + pub frame_id: u64, +} + +/// Written by [`aval_decode_take_frame`]. `data` is valid until the matching +/// [`aval_decode_release_frame`]. (`*const u8` defaults to null via `Default`.) +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct AvalDecodeFrame { + /// Ledger frame id; pass to [`aval_decode_release_frame`]. + pub frame_id: u64, + /// Pointer to RGBA8888 bytes (Rust-owned). + pub data: *const u8, + /// Length of `data` (`width * height * 4`). + pub len: usize, + /// Frame width in pixels. + pub width: u32, + /// Frame height in pixels. + pub height: u32, + /// Global presentation ordinal (`presentation_ordinal_base + presentation_index`). + pub ordinal: u64, + /// Presentation timestamp. + pub timestamp: u64, + /// Frame duration. + pub duration: u64, + /// Source unit instance. + pub unit_instance: u64, + /// Displayed-frame index within the unit (the chunk's `presentation_index`). + pub unit_frame: u64, + /// Decode-order index of the chunk that produced this frame. + pub decode_index: u64, +} + +/// Metrics written by [`aval_decode_snapshot`]. Mirrors +/// [`crate::decoder::DecoderMetrics`]; `active_generation` uses `-1` for "none". +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct AvalDecodeMetrics { + /// Successful `configure` calls (0 or 1). + pub configure_calls: u64, + /// Accepted chunks. + pub accepted_samples: u64, + /// Chunks handed to the decoder. + pub submitted_chunks: u64, + /// Displayed frames produced by the decoder. + pub output_frames: u64, + /// Frames delivered via `take_frame`. + pub delivered_frames: u64, + /// Frames released by the caller. + pub released_frames: u64, + /// Frames dropped by abort before delivery. + pub stale_frames: u64, + /// Frames whose backing buffer was closed/discarded. + pub closed_frames: u64, + /// Chunks accepted but not yet decoded (always 0 in the synchronous path). + pub pending_samples: u64, + /// In-flight, not-yet-delivered display obligation (always 0 in the sync path). + pub submitted_frames: u64, + /// Currently leased frames. + pub leased_frames: u64, + /// Currently leased decoded bytes. + pub leased_decoded_bytes: u64, + /// Active generation, or `-1` if none is active. + pub active_generation: i64, + /// Next submission ordinal (accepted chunks). + pub next_submission_ordinal: u64, + /// Next output ordinal (delivered + stale frames). + pub next_output_ordinal: u64, + /// Fatal errors latched by the session. + pub errors: u64, + /// Non-zero if the session is disposed. + pub disposed: u8, +} + +/// Creates a new, unconfigured session. Returns a handle to pass to the other +/// entry points, or null if allocation panicked. +/// +/// # Safety +/// +/// The returned pointer must eventually be freed with +/// [`aval_decode_session_destroy`]. +#[no_mangle] +pub extern "C" fn aval_decode_session_create() -> *mut AvalDecoder { + catch_unwind(|| { + Box::into_raw(Box::new(AvalDecoder { + session: DecoderSession::new(), + })) + }) + .unwrap_or(std::ptr::null_mut()) +} + +/// Destroys a session handle created by [`aval_decode_session_create`]. +/// +/// # Safety +/// +/// `handle` must be a pointer previously returned by +/// [`aval_decode_session_create`] and not already destroyed. Passing null is a +/// no-op. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_session_destroy(handle: *mut AvalDecoder) { + if handle.is_null() { + return; + } + let _ = catch_unwind(AssertUnwindSafe(|| { + // Reclaim and drop the box. + drop(unsafe { Box::from_raw(handle) }); + })); +} + +/// Configures the session. See [`DecoderSession::configure`]. +/// +/// # Safety +/// +/// `handle` must be valid; `config` must point to a readable [`AvalDecodeConfig`]. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_configure( + handle: *mut AvalDecoder, + config: *const AvalDecodeConfig, +) -> AvalDecodeStatus { + with_session(handle, |session| { + let Some(config) = (unsafe { config.as_ref() }) else { + return AvalDecodeStatus::NullPointer; + }; + let Some(codec) = VideoCodec::from_u32(config.codec) else { + return AvalDecodeStatus::Unsupported; + }; + status_of(session.configure(SessionConfig { + codec, + bit_depth: config.bit_depth as u8, + coded_width: config.coded_width as usize, + coded_height: config.coded_height as usize, + max_outstanding_frames: config.max_outstanding_frames as usize, + max_decoded_bytes: config.max_decoded_bytes, + })) + }) +} + +/// Activates a generation. See [`DecoderSession::activate_generation`]. +/// +/// # Safety +/// +/// `handle` must be valid. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_activate_generation( + handle: *mut AvalDecoder, + generation: u64, +) -> AvalDecodeStatus { + with_session(handle, |session| { + status_of(session.activate_generation(generation)) + }) +} + +/// Aborts the active generation. See [`DecoderSession::abort_generation`]. +/// +/// # Safety +/// +/// `handle` must be valid. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_abort_generation( + handle: *mut AvalDecoder, + generation: u64, +) -> AvalDecodeStatus { + with_session(handle, |session| { + status_of(session.abort_generation(generation)) + }) +} + +/// Submits one encoded chunk for `generation`. See +/// [`DecoderSession::submit_chunk`]. +/// +/// On success, `out_result` (if non-null) reports whether a displayed frame was +/// produced and its `frame_id`. +/// +/// # Safety +/// +/// `handle` must be valid; `chunk` must point to a readable [`AvalDecodeChunk`] +/// whose `data`/`unit_id` pointers are readable for their declared lengths and +/// whose `presentation_indices` pointer is readable for `presentation_indices_len` +/// `u64`s (or null when that length is 0); `out_result` may be null. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_submit_chunk( + handle: *mut AvalDecoder, + generation: u64, + chunk: *const AvalDecodeChunk, + out_result: *mut AvalSubmitResult, +) -> AvalDecodeStatus { + with_session(handle, |session| { + let Some(chunk) = (unsafe { chunk.as_ref() }) else { + return AvalDecodeStatus::NullPointer; + }; + if chunk.data.is_null() || chunk.unit_id.is_null() { + return AvalDecodeStatus::NullPointer; + } + if chunk.data_len == 0 { + return AvalDecodeStatus::InvalidArgument; + } + // Presentation indices: null is only valid for an empty (hidden) chunk. + let presentation_indices: &[u64] = if chunk.presentation_indices_len == 0 { + &[] + } else if chunk.presentation_indices.is_null() { + return AvalDecodeStatus::NullPointer; + } else { + unsafe { + slice::from_raw_parts(chunk.presentation_indices, chunk.presentation_indices_len) + } + }; + let data = unsafe { slice::from_raw_parts(chunk.data, chunk.data_len) }; + let unit_id_bytes = unsafe { slice::from_raw_parts(chunk.unit_id, chunk.unit_id_len) }; + let Ok(unit_id) = std::str::from_utf8(unit_id_bytes) else { + return AvalDecodeStatus::InvalidArgument; + }; + let decode_chunk = DecodeChunk { + unit_id, + unit_instance: chunk.unit_instance, + decode_index: chunk.decode_index, + unit_chunk_count: chunk.unit_chunk_count, + unit_frame_count: chunk.unit_frame_count, + presentation_ordinal_base: chunk.presentation_ordinal_base, + presentation_indices, + presentation_timestamp: chunk.presentation_timestamp, + duration: chunk.duration, + random_access: chunk.random_access != 0, + displayed_frame_count: chunk.displayed_frame_count, + data, + }; + match session.submit_chunk(generation, &decode_chunk) { + Ok(outcome) => { + if !out_result.is_null() { + let result = match outcome { + SubmitOutcome::Frame { frame_id } => AvalSubmitResult { + produced_frame: 1, + frame_id, + }, + SubmitOutcome::Priming => AvalSubmitResult::default(), + }; + unsafe { out_result.write(result) }; + } + AvalDecodeStatus::Ok + } + Err(error) => error.status(), + } + }) +} + +/// Removes and returns the next ready frame. See [`DecoderSession::take_frame`]. +/// +/// Returns [`AvalDecodeStatus::NoFrameAvailable`] when the ready queue is empty. +/// +/// # Safety +/// +/// `handle` must be valid; `out_frame` must point to a writable +/// [`AvalDecodeFrame`]. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_take_frame( + handle: *mut AvalDecoder, + out_frame: *mut AvalDecodeFrame, +) -> AvalDecodeStatus { + with_session(handle, |session| { + if out_frame.is_null() { + return AvalDecodeStatus::NullPointer; + } + match session.take_frame() { + Ok(Some(frame)) => { + let out = AvalDecodeFrame { + frame_id: frame.frame_id, + data: frame.rgba.as_ptr(), + len: frame.rgba.len(), + width: frame.width as u32, + height: frame.height as u32, + ordinal: frame.ordinal, + timestamp: frame.timestamp, + duration: frame.duration, + unit_instance: frame.unit_instance, + unit_frame: frame.unit_frame, + decode_index: frame.decode_index, + }; + unsafe { out_frame.write(out) }; + AvalDecodeStatus::Ok + } + Ok(None) => AvalDecodeStatus::NoFrameAvailable, + Err(error) => error.status(), + } + }) +} + +/// Releases a frame. See [`DecoderSession::release_frame`]. This is the call a +/// Dart `NativeFinalizer` should invoke. +/// +/// # Safety +/// +/// `handle` must be valid. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_release_frame( + handle: *mut AvalDecoder, + frame_id: u64, +) -> AvalDecodeStatus { + with_session(handle, |session| status_of(session.release_frame(frame_id))) +} + +/// Writes a metrics snapshot. See [`DecoderSession::snapshot`]. +/// +/// # Safety +/// +/// `handle` must be valid; `out_metrics` must point to a writable +/// [`AvalDecodeMetrics`]. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_snapshot( + handle: *mut AvalDecoder, + out_metrics: *mut AvalDecodeMetrics, +) -> AvalDecodeStatus { + with_session(handle, |session| { + if out_metrics.is_null() { + return AvalDecodeStatus::NullPointer; + } + let metrics = session.snapshot(); + let out = AvalDecodeMetrics { + configure_calls: metrics.configure_calls, + accepted_samples: metrics.accepted_samples, + submitted_chunks: metrics.submitted_chunks, + output_frames: metrics.output_frames, + delivered_frames: metrics.delivered_frames, + released_frames: metrics.released_frames, + stale_frames: metrics.stale_frames, + closed_frames: metrics.closed_frames, + pending_samples: metrics.pending_samples, + submitted_frames: metrics.submitted_frames, + leased_frames: metrics.leased_frames, + leased_decoded_bytes: metrics.leased_decoded_bytes, + active_generation: metrics + .active_generation + .map_or(-1, |generation| generation as i64), + next_submission_ordinal: metrics.next_submission_ordinal, + next_output_ordinal: metrics.next_output_ordinal, + errors: metrics.errors, + disposed: u8::from(metrics.disposed), + }; + unsafe { out_metrics.write(out) }; + AvalDecodeStatus::Ok + }) +} + +/// Logical teardown. See [`DecoderSession::dispose`]. Idempotent; the handle is +/// still valid (metrics remain readable) until [`aval_decode_session_destroy`]. +/// +/// # Safety +/// +/// `handle` must be valid. +#[no_mangle] +pub unsafe extern "C" fn aval_decode_dispose(handle: *mut AvalDecoder) -> AvalDecodeStatus { + with_session(handle, |session| { + session.dispose(); + AvalDecodeStatus::Ok + }) +} + +/// Runs `body` with a `&mut DecoderSession` behind the handle, catching panics. +fn with_session(handle: *mut AvalDecoder, body: F) -> AvalDecodeStatus +where + F: FnOnce(&mut DecoderSession) -> AvalDecodeStatus, +{ + if handle.is_null() { + return AvalDecodeStatus::NullPointer; + } + // Safety: the caller contract requires `handle` to be a live pointer from + // `aval_decode_session_create`; we hold the only reference for this call. + let decoder = unsafe { &mut *handle }; + catch_unwind(AssertUnwindSafe(|| body(&mut decoder.session))) + .unwrap_or(AvalDecodeStatus::Panicked) +} + +fn status_of(result: Result<(), AvalDecodeError>) -> AvalDecodeStatus { + match result { + Ok(()) => AvalDecodeStatus::Ok, + Err(error) => error.status(), + } +} diff --git a/flutter/rust/aval_decode/src/ledger.rs b/flutter/rust/aval_decode/src/ledger.rs new file mode 100644 index 0000000..ba10af4 --- /dev/null +++ b/flutter/rust/aval_decode/src/ledger.rs @@ -0,0 +1,253 @@ +//! Frame-credit backpressure ledger. +//! +//! A near-1:1 port of `packages/player-web/src/decoder-worker/frame-credit-ledger.ts` +//! (89 LOC). It accounts every decoded frame handed to the caller as a +//! [`FrameLease`] keyed by an incrementing `frame_id`, gating both the number of +//! outstanding frames ([`FrameCreditLedger::has_submission_credit`]) and a +//! decoded-byte budget ([`FrameCreditLedger::lease`]). The caller replenishes +//! credit with [`FrameCreditLedger::release`] once it is done with a frame. +//! +//! Error parity: the three TypeScript `DecoderWorkerCoreError` codes raised here +//! (`DECODED_BYTE_BUDGET_EXCEEDED`, `DECODER_OUTPUT_INVALID`, `FRAME_RELEASE_INVALID`) +//! map to the identically-named [`AvalDecodeError`] variants (see `error.rs`), +//! all of which are `is_fatal() == true`, matching the `fatal: true` flag the +//! TS ledger sets on every throw. + +use std::collections::HashMap; + +use crate::error::AvalDecodeError; + +/// One accounted decoded frame. Mirrors the TS `FrameLease` interface +/// (`frame-credit-ledger.ts:3-6`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FrameLease { + generation: u64, + decoded_bytes: u64, +} + +/// Accounts decoded frames until the caller releases them. +/// +/// Direct port of the TS `FrameCreditLedger` class. `frame_id`s start at 1 and +/// increase monotonically (never reused), so a released id can never be +/// confused with a live one. +#[derive(Debug, Default)] +pub struct FrameCreditLedger { + leases: HashMap, + next_frame_id: u64, + decoded_bytes: u64, +} + +impl FrameCreditLedger { + /// Creates an empty ledger with `next_frame_id == 1` (TS `#nextFrameId = 1`). + #[must_use] + pub fn new() -> Self { + Self { + leases: HashMap::new(), + next_frame_id: 1, + decoded_bytes: 0, + } + } + + /// Number of currently outstanding leases (TS `get count`). + #[must_use] + pub fn count(&self) -> usize { + self.leases.len() + } + + /// Total leased decoded bytes (TS `get decodedBytes`). + #[must_use] + pub fn decoded_bytes(&self) -> u64 { + self.decoded_bytes + } + + /// Whether another chunk may be submitted (TS `hasSubmissionCredit`). + /// + /// `submitted_frames + leases.len() < maximum_outstanding_frames`. + #[must_use] + pub fn has_submission_credit( + &self, + submitted_frames: usize, + maximum_outstanding_frames: usize, + ) -> bool { + submitted_frames + self.leases.len() < maximum_outstanding_frames + } + + /// Leases a newly decoded frame, returning its `frame_id` (TS `lease`). + /// + /// # Errors + /// + /// - [`AvalDecodeError::DecodedByteBudgetExceeded`] if adding `decoded_bytes` + /// would exceed `maximum_decoded_bytes` (TS `DECODED_BYTE_BUDGET_EXCEEDED`). + /// - [`AvalDecodeError::DecoderOutputInvalid`] if the `frame_id` space is + /// exhausted — practically unreachable, kept for parity with the TS + /// `Number.isSafeInteger` guard (`DECODER_OUTPUT_INVALID`). + pub fn lease( + &mut self, + generation: u64, + decoded_bytes: u64, + maximum_decoded_bytes: u64, + ) -> Result { + // TS: `this.#decodedBytes + decodedBytes > maximumDecodedBytes`. Use a + // checked add so an arithmetic overflow is reported as the same fatal + // budget error rather than panicking. + let projected = self + .decoded_bytes + .checked_add(decoded_bytes) + .ok_or(AvalDecodeError::DecodedByteBudgetExceeded)?; + if projected > maximum_decoded_bytes { + return Err(AvalDecodeError::DecodedByteBudgetExceeded); + } + let frame_id = self.next_frame_id; + // TS: `!Number.isSafeInteger(frameId)` -> DECODER_OUTPUT_INVALID. + let next = frame_id + .checked_add(1) + .ok_or(AvalDecodeError::DecoderOutputInvalid)?; + self.next_frame_id = next; + self.leases.insert( + frame_id, + FrameLease { + generation, + decoded_bytes, + }, + ); + self.decoded_bytes = projected; + Ok(frame_id) + } + + /// Releases a lease, replenishing credit (TS `release`). + /// + /// # Errors + /// + /// [`AvalDecodeError::FrameReleaseInvalid`] if `frame_id` is `0` or does not + /// correspond to a live lease (including a double release). + pub fn release(&mut self, frame_id: u64) -> Result<(), AvalDecodeError> { + let lease = self.require_lease(frame_id)?; + self.leases.remove(&frame_id); + // Cannot underflow: `decoded_bytes` always includes every live lease. + self.decoded_bytes -= lease.decoded_bytes; + Ok(()) + } + + /// Rolls back a transfer that failed before ownership changed (TS `revoke`). + /// + /// # Errors + /// + /// Same as [`FrameCreditLedger::release`]. + pub fn revoke(&mut self, frame_id: u64) -> Result<(), AvalDecodeError> { + self.release(frame_id) + } + + /// The generation that leased `frame_id`, if it is live. Not present in the + /// TS source; used by the session to detect stale releases across + /// generations without exposing the internal map. + #[must_use] + pub fn lease_generation(&self, frame_id: u64) -> Option { + self.leases.get(&frame_id).map(|lease| lease.generation) + } + + /// Drops every lease and resets the byte counter (TS `clear`). + /// + /// Note: `next_frame_id` is deliberately *not* reset, matching the TS class + /// (only `#leases`/`#decodedBytes` are cleared), so ids stay unique for the + /// lifetime of the ledger. + pub fn clear(&mut self) { + self.leases.clear(); + self.decoded_bytes = 0; + } + + /// TS `#requireLease`: `frame_id` must be a positive, currently-owned id. + fn require_lease(&self, frame_id: u64) -> Result { + if frame_id == 0 { + return Err(AvalDecodeError::FrameReleaseInvalid); + } + self.leases + .get(&frame_id) + .copied() + .ok_or(AvalDecodeError::FrameReleaseInvalid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Mirrors decoder-worker.test.ts's credit-holding assertions and the + // frame-credit-ledger.ts semantics directly. + + #[test] + fn has_submission_credit_matches_ts_boundary() { + let mut ledger = FrameCreditLedger::new(); + // No leases: submitted + 0 < max. + assert!(ledger.has_submission_credit(0, 2)); + assert!(ledger.has_submission_credit(1, 2)); + assert!(!ledger.has_submission_credit(2, 2)); + // One outstanding lease shifts the boundary down by one. + ledger.lease(1, 16, 1_000).unwrap(); + assert!(ledger.has_submission_credit(0, 2)); + assert!(!ledger.has_submission_credit(1, 2)); + } + + #[test] + fn lease_assigns_incrementing_ids_from_one_and_tracks_bytes() { + let mut ledger = FrameCreditLedger::new(); + assert_eq!(ledger.lease(1, 24, 1_000).unwrap(), 1); + assert_eq!(ledger.lease(1, 24, 1_000).unwrap(), 2); + assert_eq!(ledger.count(), 2); + assert_eq!(ledger.decoded_bytes(), 48); + } + + #[test] + fn lease_rejects_budget_overflow_as_fatal() { + let mut ledger = FrameCreditLedger::new(); + ledger.lease(1, 40, 48).unwrap(); + let err = ledger.lease(1, 24, 48).unwrap_err(); + assert_eq!(err, AvalDecodeError::DecodedByteBudgetExceeded); + assert!(err.is_fatal()); + // The rejected lease left no residue. + assert_eq!(ledger.count(), 1); + assert_eq!(ledger.decoded_bytes(), 40); + } + + #[test] + fn release_replenishes_and_rejects_bad_ids() { + let mut ledger = FrameCreditLedger::new(); + let id = ledger.lease(1, 24, 1_000).unwrap(); + ledger.release(id).unwrap(); + assert_eq!(ledger.count(), 0); + assert_eq!(ledger.decoded_bytes(), 0); + // Double release is fatal ownership corruption (TS FRAME_RELEASE_INVALID). + assert_eq!( + ledger.release(id).unwrap_err(), + AvalDecodeError::FrameReleaseInvalid + ); + // Zero and unknown ids are equally invalid. + assert_eq!( + ledger.release(0).unwrap_err(), + AvalDecodeError::FrameReleaseInvalid + ); + assert_eq!( + ledger.release(999).unwrap_err(), + AvalDecodeError::FrameReleaseInvalid + ); + } + + #[test] + fn clear_drops_leases_but_keeps_id_monotonicity() { + let mut ledger = FrameCreditLedger::new(); + let first = ledger.lease(1, 8, 1_000).unwrap(); + ledger.clear(); + assert_eq!(ledger.count(), 0); + assert_eq!(ledger.decoded_bytes(), 0); + // Ids never rewind, so a cleared id can never collide with a live one. + let next = ledger.lease(2, 8, 1_000).unwrap(); + assert!(next > first); + } + + #[test] + fn revoke_is_release() { + let mut ledger = FrameCreditLedger::new(); + let id = ledger.lease(1, 8, 1_000).unwrap(); + ledger.revoke(id).unwrap(); + assert_eq!(ledger.count(), 0); + } +} diff --git a/flutter/rust/aval_decode/src/lib.rs b/flutter/rust/aval_decode/src/lib.rs new file mode 100644 index 0000000..1336110 --- /dev/null +++ b/flutter/rust/aval_decode/src/lib.rs @@ -0,0 +1,66 @@ +//! AVAL AVC (H.264 Constrained Baseline) decode core for the Flutter port. +//! +//! This crate is a Rust port of the web player's decoder-worker +//! (`packages/player-web/src/decoder-worker/`), wrapping the `openh264` decoder +//! behind a small, protocol-shaped session API and a C ABI suitable for +//! `dart:ffi`. See `flutter/ARCHITECTURE.md` sections 2, 3.3, 4, and 6. +//! +//! Module map (mirrors the TypeScript sources it ports): +//! - [`ledger`] <- `frame-credit-ledger.ts` (backpressure / decoded-byte budget; +//! unchanged across the format-1.0 sync `67c4c0e`) +//! - [`sample_sequence`] <- `sample-sequence.ts` (reworked unit/chunk continuity) + +//! the structural slice of `core-validation.ts::validateSampleShape` +//! - [`decoder`] <- `core.ts` + `protocol.ts` (configure / submit_chunk / +//! take_frame / release_frame / snapshot / dispose) +//! - [`yuv`] <- the new I420 -> RGBA8888 (BT.709 limited-range) conversion +//! step (ARCHITECTURE.md 3.3, risk register #13) +//! - [`ffi`] <- the `extern "C"` boundary +//! - [`error`] <- the shared status/error taxonomy + +pub mod adapter; +pub mod decoder; +pub mod error; +pub mod ffi; +pub mod ledger; +pub mod sample_sequence; +pub mod yuv; + +pub use adapter::{DecodedRgbaFrame, DecoderAdapter, OpenH264Adapter}; +pub use decoder::{DecoderSession, SessionConfig, SubmitOutcome, VideoCodec}; +pub use error::{AvalDecodeError, AvalDecodeStatus}; +pub use ledger::FrameCreditLedger; +pub use sample_sequence::{DecodeChunk, DecoderSampleSequence}; + +/// JavaScript `Number.MAX_SAFE_INTEGER` (2^53 - 1). +/// +/// The web ledger/validation code uses `Number.isSafeInteger` bounds; the Rust +/// port keeps the same numeric ceiling where parity matters (see +/// `frame-credit-ledger.ts` and `core-validation.ts`). +pub const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + +/// Ported verbatim from `DECODER_WORKER_HARD_LIMITS` (`protocol.ts:10-16`). +/// +/// `max_sample_bytes` / `max_decoded_bytes` are `Number.MAX_SAFE_INTEGER` in the +/// TypeScript source; they are represented here with [`MAX_SAFE_INTEGER`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecoderHardLimits { + /// Maximum native decoder input queue depth. + pub max_decode_queue_size: usize, + /// Maximum accepted samples waiting to enter the decoder. + pub max_pending_samples: usize, + /// Combined submitted-output and transferred-frame credit ceiling. + pub max_outstanding_frames: usize, + /// Maximum accepted encoded access-unit size, in bytes. + pub max_sample_bytes: u64, + /// Maximum logical RGBA bytes leased to the caller at once. + pub max_decoded_bytes: u64, +} + +/// The single frozen limits table (`Object.freeze(DECODER_WORKER_HARD_LIMITS)`). +pub const DECODER_WORKER_HARD_LIMITS: DecoderHardLimits = DecoderHardLimits { + max_decode_queue_size: 12, + max_pending_samples: 24, + max_outstanding_frames: 12, + max_sample_bytes: MAX_SAFE_INTEGER, + max_decoded_bytes: MAX_SAFE_INTEGER, +}; diff --git a/flutter/rust/aval_decode/src/sample_sequence.rs b/flutter/rust/aval_decode/src/sample_sequence.rs new file mode 100644 index 0000000..e70d2e3 --- /dev/null +++ b/flutter/rust/aval_decode/src/sample_sequence.rs @@ -0,0 +1,570 @@ +//! Generation-local independent-unit and decode-order continuity for submitted +//! encoded chunks. +//! +//! Ports `packages/player-web/src/decoder-worker/sample-sequence.ts` (the +//! reworked `DecoderSampleSequence` class, format-1.0 chunk vocabulary) together +//! with the structural slice of `core-validation.ts::validateSampleShape` +//! (lines 195-290) that the sequence relies on to reject a malformed batch +//! before advancing. +//! +//! ## Vocabulary change vs the pre-1.0 fork (samples -> chunks) +//! +//! The old protocol carried a global decode `ordinal` and a single +//! `type: EncodedVideoChunkType` per *sample*. Format 1.0 (upstream merge +//! `67c4c0e`) renames the wire unit to a **chunk** and reshapes it around +//! *independent units*: +//! - `decodeIndex` / `unitChunkCount` — a unit occurrence spans `unitChunkCount` +//! chunks in decode order (`decodeIndex` `0..unitChunkCount-1`); the old global +//! `ordinal` is gone and is now derived as [`DecoderSampleSequence::accepted_chunks`] +//! (TS `nextSubmissionOrdinal = sequence.acceptedChunks`, `core.ts:246`). +//! - `presentationOrdinalBase` + `presentationIndices` — the **decode-chunk vs +//! displayed-frame distinction**: one chunk maps `displayedFrameCount` outputs +//! to authored frame indices inside the unit. A hidden VP9/AV1 chunk carries +//! `displayedFrameCount: 0` and an empty `presentationIndices`; a VP9 superframe +//! carries N. For H.264 this is always 1:1 (`unitChunkCount == unitFrameCount`, +//! `decodeIndex == presentationIndices[0]`, `displayedFrameCount == 1`), but the +//! struct carries the general shape so the Dart caller matches the web protocol. +//! - `randomAccess: boolean` replaces `type: EncodedVideoChunkType`. +//! - `presentationTimestamp` replaces `timestamp`. +//! +//! Error mapping: the TS code throws `PROTOCOL_ERROR` / `GENERATION_MISMATCH` +//! `DecoderWorkerCoreError`s here. Those codes are not part of this crate's C ABI +//! status enum (`error.rs`), so every validation failure is reported as +//! [`AvalDecodeError::InvalidArgument`] with a descriptive `&'static str` — the +//! one intentional error-taxonomy narrowing versus TypeScript. + +use std::collections::HashSet; + +use crate::error::AvalDecodeError; +use crate::MAX_SAFE_INTEGER; + +/// Maximum encoded unit-id length, from `core-validation.ts:215` +/// (`unitId.length` must be between 1 and 128). +pub const MAX_UNIT_ID_LEN: usize = 128; + +/// One owned wire-1.0 encoded chunk in decoder submission order. Mirrors the +/// fields of the TS `DecoderWorkerSample` interface (`protocol.ts:91-104`) that +/// carry decode semantics; `data: ArrayBuffer` becomes a byte slice. +/// +/// `presentation_indices` maps every displayed output carried by this chunk to +/// its authored frame index inside the unit. Hidden chunks use an empty slice +/// and `displayed_frame_count == 0`. For H.264 there is exactly one entry. +#[derive(Debug, Clone, Copy)] +pub struct DecodeChunk<'a> { + /// Stable id for the source unit (1..=128 bytes when interpreted as UTF-8). + pub unit_id: &'a str, + /// Which occurrence of the unit this chunk belongs to (monotonic per generation). + pub unit_instance: u64, + /// This chunk's zero-based index within its unit occurrence's decode order. + pub decode_index: u64, + /// Total chunks in this unit occurrence (`decode_index < unit_chunk_count`). + pub unit_chunk_count: u64, + /// Total displayed frames in this unit occurrence. + pub unit_frame_count: u64, + /// Presentation-ordinal base for the unit; a displayed frame's global ordinal + /// is `presentation_ordinal_base + presentation_index`. + pub presentation_ordinal_base: u64, + /// Authored frame indices (within the unit) for the outputs this chunk yields. + /// Length must equal `displayed_frame_count`; empty for a hidden chunk. + pub presentation_indices: &'a [u64], + /// Presentation timestamp of the chunk's first displayed output. + pub presentation_timestamp: u64, + /// Frame duration in the same units as `presentation_timestamp`. + pub duration: u64, + /// Whether this chunk begins at random access (TS `randomAccess`, IDR for H.264). + pub random_access: bool, + /// Number of displayed frames this chunk yields (0 hidden, 1 for H.264, N for + /// a VP9/AV1 superframe). + pub displayed_frame_count: u64, + /// Encoded chunk bytes (non-empty). + pub data: &'a [u8], +} + +/// Presentation timestamp for the `displayed_index`-th output carried by a chunk +/// (TS `expectedTimestamp` / `checkedTimestamp`): `presentation_timestamp + +/// duration * displayed_index`, rejected if it leaves the safe-integer range. +/// +/// # Errors +/// +/// [`AvalDecodeError::InvalidArgument`] if the timeline overflows `MAX_SAFE_INTEGER`. +pub fn expected_timestamp( + chunk: &DecodeChunk<'_>, + displayed_index: u64, +) -> Result { + checked_timestamp(chunk.presentation_timestamp, chunk.duration, displayed_index) +} + +fn checked_timestamp(timestamp: u64, duration: u64, index: u64) -> Result { + duration + .checked_mul(index) + .and_then(|offset| timestamp.checked_add(offset)) + .filter(|&ts| ts <= MAX_SAFE_INTEGER) + .ok_or(AvalDecodeError::InvalidArgument( + "decode chunk presentation timeline exceeds safe integers", + )) +} + +/// Validates one chunk's structural shape (TS `validateSampleShape`, +/// `core-validation.ts:195-290`). +/// +/// Purely structural WebCodecs/protocol-shape checks that have no meaning for a +/// typed Rust struct (`hasExactKeys`, `data instanceof ArrayBuffer`, JS number +/// integrality) are omitted; every value check that guards decode correctness is +/// kept. +/// +/// # Errors +/// +/// [`AvalDecodeError::InvalidArgument`] describing the first failed check. +pub fn validate_chunk_shape(chunk: &DecodeChunk<'_>) -> Result<(), AvalDecodeError> { + let id_len = chunk.unit_id.len(); + if !(1..=MAX_UNIT_ID_LEN).contains(&id_len) { + return Err(AvalDecodeError::InvalidArgument( + "decode chunk unitId length must be between 1 and 128", + )); + } + // `unit_instance` / `decode_index` are unsigned, so the "non-negative" checks + // hold by construction. + if chunk.unit_chunk_count < 1 { + return Err(AvalDecodeError::InvalidArgument( + "decode chunk unitChunkCount must be a positive integer", + )); + } + if chunk.decode_index >= chunk.unit_chunk_count { + return Err(AvalDecodeError::InvalidArgument( + "decode chunk decodeIndex exceeds unitChunkCount", + )); + } + if chunk.unit_frame_count < 1 { + return Err(AvalDecodeError::InvalidArgument( + "decode chunk unitFrameCount must be a positive integer", + )); + } + if chunk.presentation_ordinal_base > MAX_SAFE_INTEGER - chunk.unit_frame_count { + return Err(AvalDecodeError::InvalidArgument( + "presentation ordinal range exceeds safe integers", + )); + } + // TS: `presentationIndices.length !== displayedFrameCount`. + if chunk.presentation_indices.len() as u64 != chunk.displayed_frame_count { + return Err(AvalDecodeError::InvalidArgument( + "presentationIndices must match displayedFrameCount", + )); + } + if chunk.displayed_frame_count > 0 && chunk.duration == 0 { + return Err(AvalDecodeError::InvalidArgument( + "displayed chunks must have a positive duration", + )); + } + let mut local_indices = HashSet::new(); + for (index, &presentation_index) in chunk.presentation_indices.iter().enumerate() { + if presentation_index >= chunk.unit_frame_count { + return Err(AvalDecodeError::InvalidArgument( + "presentation index exceeds unitFrameCount", + )); + } + if !local_indices.insert(presentation_index) { + return Err(AvalDecodeError::InvalidArgument( + "presentation indices must be unique within a chunk", + )); + } + checked_timestamp(chunk.presentation_timestamp, chunk.duration, index as u64)?; + } + if chunk.data.is_empty() { + return Err(AvalDecodeError::InvalidArgument( + "decode chunk data must not be empty", + )); + } + Ok(()) +} + +/// In-flight validation state for one independent-unit occurrence. Mirrors the TS +/// `UnitSequence` interface (`sample-sequence.ts:8-18`). +#[derive(Debug, Clone)] +struct UnitSequence { + unit_id: String, + unit_instance: u64, + unit_chunk_count: u64, + unit_frame_count: u64, + presentation_ordinal_base: u64, + seen_presentation_indices: HashSet, + seen_timestamps: HashSet, + next_decode_index: u64, + displayed_frame_count: u64, +} + +/// Owns generation-local independent-unit and decode-order continuity. +/// +/// Direct port of the reworked TS `DecoderSampleSequence` class. +#[derive(Debug, Default)] +pub struct DecoderSampleSequence { + active_generation: Option, + next_unit_instance: u64, + active_unit: Option, + accepted_chunks: u64, +} + +impl DecoderSampleSequence { + /// Creates a fresh sequence (no active generation, zero accepted chunks). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Number of chunks accepted since construction — the global decode ordinal + /// counter that `core.ts` reports as `nextSubmissionOrdinal` + /// (TS `get acceptedChunks`). + #[must_use] + pub fn accepted_chunks(&self) -> u64 { + self.accepted_chunks + } + + /// Marks `generation` active and resets the per-generation unit cursor + /// (TS `activate`). + pub fn activate(&mut self, generation: u64) { + self.active_generation = Some(generation); + self.next_unit_instance = 0; + self.active_unit = None; + } + + /// Clears the active generation and its open unit iff it currently equals + /// `generation` (TS `abort`). + pub fn abort(&mut self, generation: u64) { + if self.active_generation == Some(generation) { + self.active_generation = None; + self.active_unit = None; + } + } + + /// Unconditionally clears the active generation and its open unit + /// (TS `clearActive`). + pub fn clear_active(&mut self) { + self.active_generation = None; + self.active_unit = None; + } + + /// Validates the entire batch atomically, then advances the sequence + /// (TS `accept`). Nothing is mutated unless every chunk passes: the working + /// unit/instance cursor is a clone that is committed only on success. + /// + /// # Errors + /// + /// - [`AvalDecodeError::InvalidArgument`] if `generation` is not the active + /// generation (TS `GENERATION_MISMATCH`), any chunk fails + /// [`validate_chunk_shape`], or the unit continuity / completeness rules are + /// violated (TS `PROTOCOL_ERROR`). + pub fn accept( + &mut self, + generation: u64, + chunks: &[DecodeChunk<'_>], + ) -> Result<(), AvalDecodeError> { + if self.active_generation != Some(generation) { + return Err(AvalDecodeError::InvalidArgument( + "decode submission does not target the active generation", + )); + } + + let mut next_unit_instance = self.next_unit_instance; + let mut active_unit = self.active_unit.clone(); + for chunk in chunks { + validate_chunk_shape(chunk)?; + if active_unit.is_none() { + if chunk.decode_index != 0 { + return Err(AvalDecodeError::InvalidArgument( + "every unit occurrence must begin at decodeIndex zero", + )); + } + if chunk.unit_instance != next_unit_instance { + return Err(AvalDecodeError::InvalidArgument( + "unitInstance must equal the next unit instance", + )); + } + if !chunk.random_access { + return Err(AvalDecodeError::InvalidArgument( + "every unit occurrence must begin at random access", + )); + } + if next_unit_instance >= MAX_SAFE_INTEGER { + return Err(AvalDecodeError::InvalidArgument( + "unitInstance leaves no safe successor", + )); + } + active_unit = Some(UnitSequence { + unit_id: chunk.unit_id.to_owned(), + unit_instance: chunk.unit_instance, + unit_chunk_count: chunk.unit_chunk_count, + unit_frame_count: chunk.unit_frame_count, + presentation_ordinal_base: chunk.presentation_ordinal_base, + seen_presentation_indices: HashSet::new(), + seen_timestamps: HashSet::new(), + next_decode_index: 0, + displayed_frame_count: 0, + }); + next_unit_instance += 1; + } + + let unit = active_unit + .as_mut() + .expect("active unit was set above when absent"); + validate_unit_relation(unit, chunk)?; + for (index, &presentation_index) in chunk.presentation_indices.iter().enumerate() { + if !unit.seen_presentation_indices.insert(presentation_index) { + return Err(AvalDecodeError::InvalidArgument( + "unit presentation indices must be unique and complete", + )); + } + let timestamp = expected_timestamp(chunk, index as u64)?; + if !unit.seen_timestamps.insert(timestamp) { + return Err(AvalDecodeError::InvalidArgument( + "unit presentation timestamps must be unique", + )); + } + } + unit.displayed_frame_count = unit + .displayed_frame_count + .checked_add(chunk.displayed_frame_count) + .ok_or(AvalDecodeError::InvalidArgument( + "unit displayed-frame count is unsafe", + ))?; + unit.next_decode_index += 1; + if unit.next_decode_index == unit.unit_chunk_count { + if unit.displayed_frame_count != unit.unit_frame_count + || unit.seen_presentation_indices.len() as u64 != unit.unit_frame_count + { + return Err(AvalDecodeError::InvalidArgument( + "unit displayed-frame metadata is incomplete", + )); + } + active_unit = None; + } + } + + let chunk_count = chunks.len() as u64; + if self.accepted_chunks > MAX_SAFE_INTEGER - chunk_count { + return Err(AvalDecodeError::InvalidArgument( + "accepted chunk count exceeds safe integers", + )); + } + self.next_unit_instance = next_unit_instance; + self.active_unit = active_unit; + self.accepted_chunks += chunk_count; + Ok(()) + } +} + +fn validate_unit_relation( + unit: &UnitSequence, + chunk: &DecodeChunk<'_>, +) -> Result<(), AvalDecodeError> { + if chunk.unit_id != unit.unit_id + || chunk.unit_instance != unit.unit_instance + || chunk.unit_chunk_count != unit.unit_chunk_count + || chunk.unit_frame_count != unit.unit_frame_count + || chunk.presentation_ordinal_base != unit.presentation_ordinal_base + { + return Err(AvalDecodeError::InvalidArgument( + "decode chunks in one unit occurrence must share exact unit metadata", + )); + } + if chunk.decode_index != unit.next_decode_index { + return Err(AvalDecodeError::InvalidArgument( + "decodeIndex must equal the unit's next decode index", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 1:1 H.264-style chunk: one chunk == one displayed frame, forming a + /// complete single-chunk unit. + fn h264_chunk<'a>(unit_id: &'a str, unit_instance: u64, timestamp: u64) -> DecodeChunk<'a> { + DecodeChunk { + unit_id, + unit_instance, + decode_index: 0, + unit_chunk_count: 1, + unit_frame_count: 1, + presentation_ordinal_base: 0, + presentation_indices: &[0], + presentation_timestamp: timestamp, + duration: 16_667, + random_access: true, + displayed_frame_count: 1, + data: &[0x01], + } + } + + /// Builds one chunk of a multi-chunk unit (mirrors the TS `unitChunk` helper): + /// three-chunk / three-frame unit, `decodeIndex == presentationIndex`. + fn unit_chunk<'a>(unit_id: &'a str, decode_index: u64) -> DecodeChunk<'a> { + const INDICES: [&[u64]; 3] = [&[0], &[1], &[2]]; + DecodeChunk { + unit_id, + unit_instance: 0, + decode_index, + unit_chunk_count: 3, + unit_frame_count: 3, + presentation_ordinal_base: 0, + presentation_indices: INDICES[decode_index as usize], + presentation_timestamp: decode_index * 1_000, + duration: 1_000, + random_access: decode_index == 0, + displayed_frame_count: 1, + data: &[0x01], + } + } + + #[test] + fn accept_advances_accepted_chunks_and_unit_instance() { + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + // Two complete single-chunk units in one batch: instances 0 then 1. + seq.accept(1, &[h264_chunk("idle", 0, 0), h264_chunk("idle", 1, 16_667)]) + .unwrap(); + assert_eq!(seq.accepted_chunks(), 2); + // A third unit continues the instance counter from 2. + seq.accept(1, &[h264_chunk("idle", 2, 40_000)]).unwrap(); + assert_eq!(seq.accepted_chunks(), 3); + } + + #[test] + fn accept_rejects_inactive_generation() { + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + let err = seq.accept(2, &[h264_chunk("idle", 0, 0)]).unwrap_err(); + assert!(matches!(err, AvalDecodeError::InvalidArgument(_))); + assert_eq!(seq.accepted_chunks(), 0); + } + + #[test] + fn accept_requires_units_to_begin_at_decode_index_zero_and_random_access() { + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + // A first chunk with decode_index 1 has no open unit -> rejected. + let mut bad = h264_chunk("idle", 0, 0); + bad.decode_index = 1; + bad.unit_chunk_count = 2; + assert!(seq.accept(1, &[bad]).is_err()); + // A non-key first chunk is rejected. + let mut not_key = h264_chunk("idle", 0, 0); + not_key.random_access = false; + assert!(seq.accept(1, &[not_key]).is_err()); + assert_eq!(seq.accepted_chunks(), 0); + } + + #[test] + fn accept_requires_monotonic_unit_instance() { + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + seq.accept(1, &[h264_chunk("idle", 0, 0)]).unwrap(); + // Next unit must be instance 1, not 0 again. + assert!(seq.accept(1, &[h264_chunk("idle", 0, 16_667)]).is_err()); + assert_eq!(seq.accepted_chunks(), 1); + } + + #[test] + fn accept_keeps_partial_unit_state_across_submits() { + // Mirrors the TS "keeps partial unit state across submits" case: a + // three-chunk unit fed one chunk per accept, completing on the third. + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + seq.accept(1, &[unit_chunk("u", 0)]).unwrap(); + seq.accept(1, &[unit_chunk("u", 1)]).unwrap(); + seq.accept(1, &[unit_chunk("u", 2)]).unwrap(); + assert_eq!(seq.accepted_chunks(), 3); + // The unit closed, so the next occurrence must be instance 1. + assert!(seq.accept(1, &[unit_chunk("u", 0)]).is_err()); + } + + #[test] + fn accept_rejects_wrong_decode_index_within_a_unit() { + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + seq.accept(1, &[unit_chunk("u", 0)]).unwrap(); + // Skipping to decode_index 2 mid-unit is rejected. + assert!(seq.accept(1, &[unit_chunk("u", 2)]).is_err()); + assert_eq!(seq.accepted_chunks(), 1); + } + + #[test] + fn accept_rejects_incomplete_unit_frame_metadata() { + // A unit that claims 2 frames but only presents 1 across its chunks. + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + let chunk = DecodeChunk { + unit_frame_count: 2, + ..h264_chunk("u", 0, 0) + }; + assert!(seq.accept(1, &[chunk]).is_err()); + assert_eq!(seq.accepted_chunks(), 0); + } + + #[test] + fn accept_is_atomic_on_a_mid_batch_failure() { + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + // First chunk is a valid unit; second reuses instance 0 (must be 1). + let err = seq + .accept(1, &[h264_chunk("idle", 0, 0), h264_chunk("idle", 0, 20_000)]) + .unwrap_err(); + assert!(matches!(err, AvalDecodeError::InvalidArgument(_))); + assert_eq!(seq.accepted_chunks(), 0); + } + + #[test] + fn accept_maps_multiple_displayed_outputs_from_one_chunk() { + // A superframe-style chunk: one chunk, two displayed frames. The sequence + // is codec-neutral and accepts it (native decode rejects non-H.264 at + // configure; that gate lives in the session, not here). + let mut seq = DecoderSampleSequence::new(); + seq.activate(1); + let chunk = DecodeChunk { + unit_frame_count: 2, + presentation_indices: &[0, 1], + displayed_frame_count: 2, + ..h264_chunk("sf", 0, 0) + }; + seq.accept(1, &[chunk]).unwrap(); + assert_eq!(seq.accepted_chunks(), 1); + } + + #[test] + fn abort_only_clears_the_matching_generation() { + let mut seq = DecoderSampleSequence::new(); + seq.activate(5); + seq.abort(4); // different generation: no-op + seq.accept(5, &[h264_chunk("idle", 0, 0)]).unwrap(); + seq.abort(5); // matching generation clears active + assert!(seq.accept(5, &[h264_chunk("idle", 1, 20_000)]).is_err()); + } + + #[test] + fn validate_chunk_shape_field_checks() { + // presentationIndices length must equal displayedFrameCount. + let mut mismatch = h264_chunk("idle", 0, 0); + mismatch.displayed_frame_count = 2; + assert!(validate_chunk_shape(&mismatch).is_err()); + + // presentation index must be < unit_frame_count. + let out_of_range = DecodeChunk { + presentation_indices: &[3], + ..h264_chunk("idle", 0, 0) + }; + assert!(validate_chunk_shape(&out_of_range).is_err()); + + // empty unit_id rejected. + assert!(validate_chunk_shape(&h264_chunk("", 0, 0)).is_err()); + + // empty data rejected. + let empty_data = DecodeChunk { + data: &[], + ..h264_chunk("idle", 0, 0) + }; + assert!(validate_chunk_shape(&empty_data).is_err()); + + // a well-formed 1:1 chunk passes. + assert!(validate_chunk_shape(&h264_chunk("idle", 0, 0)).is_ok()); + } +} diff --git a/flutter/rust/aval_decode/src/vt/videotoolbox_decoder.m b/flutter/rust/aval_decode/src/vt/videotoolbox_decoder.m new file mode 100644 index 0000000..c6c1e28 --- /dev/null +++ b/flutter/rust/aval_decode/src/vt/videotoolbox_decoder.m @@ -0,0 +1,359 @@ +// VideoToolbox H.264 decode backend for aval_decode (ARCHITECTURE.md §2(c)). +// +// Exposes a tiny C ABI consumed by the Rust `VideoToolboxAdapter` +// (src/adapter.rs). One Annex-B access unit in, at most one decoded RGBA8888 +// picture out, in DECODE order (temporal processing disabled) — matching the +// exact contract the OpenH264 backend already satisfies, so the session's +// frame-credit ledger, decode-order continuity, and container-driven +// presentation ordering are all unaffected by the backend swap. +// +// Why Objective-C rather than raw CoreMedia FFI from Rust: the CoreMedia / +// VideoToolbox / CoreVideo call sequence (format-description creation, sample +// buffer construction, decompression session lifecycle, pixel-buffer lock and +// stride-aware readback) is far less error-prone here, where the SDK headers +// and CF ownership rules are first-class. CF handles are released explicitly; +// only the ObjC wrapper object is ARC-managed. + +#import +#import +#import +#import +#import +#import +#import + +// --------------------------------------------------------------------------- +// C ABI (mirrored by extern "C" decls in src/adapter.rs). +// --------------------------------------------------------------------------- +typedef struct AvalVtDecoder AvalVtDecoder; + +AvalVtDecoder *aval_vt_create(void); +// Returns: 1 = frame produced, 0 = priming (no output), -1 = error. +int aval_vt_decode(AvalVtDecoder *dec, const uint8_t *data, size_t len, + uint8_t **out_rgba, size_t *out_len, uint32_t *out_width, + uint32_t *out_height); +void aval_vt_free_frame(uint8_t *rgba); +void aval_vt_destroy(AvalVtDecoder *dec); + +// --------------------------------------------------------------------------- +// Decoder object. +// --------------------------------------------------------------------------- +@interface AvalVtDecoderObjc : NSObject +@end + +@implementation AvalVtDecoderObjc { + CMVideoFormatDescriptionRef _format; + VTDecompressionSessionRef _session; + + uint8_t *_sps; + size_t _spsLen; + uint8_t *_pps; + size_t _ppsLen; + + // Set by the synchronous decode output handler. + CVImageBufferRef _captured; +} + +- (void)dealloc { + [self teardownSession]; + free(_sps); + free(_pps); +} + +- (void)teardownSession { + if (_session) { + VTDecompressionSessionInvalidate(_session); + CFRelease(_session); + _session = NULL; + } + if (_format) { + CFRelease(_format); + _format = NULL; + } +} + +// Replace a cached parameter set; returns YES if the bytes changed. +static BOOL replaceParam(uint8_t **slot, size_t *slotLen, const uint8_t *src, + size_t len) { + if (*slot && *slotLen == len && memcmp(*slot, src, len) == 0) { + return NO; + } + free(*slot); + *slot = malloc(len); + memcpy(*slot, src, len); + *slotLen = len; + return YES; +} + +// (Re)create the format description + decompression session from cached +// SPS/PPS. Returns YES on success. +- (BOOL)ensureSession { + if (_session && _format) { + return YES; + } + if (!_sps || !_pps) { + return NO; // No parameter sets seen yet. + } + + const uint8_t *const paramPtrs[2] = {_sps, _pps}; + const size_t paramSizes[2] = {_spsLen, _ppsLen}; + CMVideoFormatDescriptionRef format = NULL; + OSStatus status = CMVideoFormatDescriptionCreateFromH264ParameterSets( + kCFAllocatorDefault, 2, paramPtrs, paramSizes, + /*NALUnitHeaderLength=*/4, &format); + if (status != noErr || !format) { + return NO; + } + + // Request BGRA output so the readback path is a fixed, well-supported format + // on every Apple GPU; the Rust side swizzles nothing — this ObjC writes RGBA. + const int32_t pixelFormat = kCVPixelFormatType_32BGRA; + CFNumberRef pixelFormatNum = + CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &pixelFormat); + const void *keys[] = {kCVPixelBufferPixelFormatTypeKey}; + const void *values[] = {pixelFormatNum}; + CFDictionaryRef destAttrs = CFDictionaryCreate( + kCFAllocatorDefault, keys, values, 1, &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + CFRelease(pixelFormatNum); + + VTDecompressionSessionRef session = NULL; + status = VTDecompressionSessionCreate(kCFAllocatorDefault, format, + /*decoderSpecification=*/NULL, destAttrs, + /*outputCallback=*/NULL, &session); + CFRelease(destAttrs); + if (status != noErr || !session) { + CFRelease(format); + return NO; + } + + _format = format; + _session = session; + return YES; +} + +// Decode one Annex-B access unit. Fills out params on a produced frame. +// Returns 1 / 0 / -1 as documented on aval_vt_decode. +- (int)decode:(const uint8_t *)data + len:(size_t)len + outRgba:(uint8_t **)outRgba + outLen:(size_t *)outLen + outWidth:(uint32_t *)outWidth + outHeight:(uint32_t *)outHeight { + // --- Scan Annex-B NAL units; cache SPS/PPS, collect VCL payloads as AVCC. --- + BOOL paramsChanged = NO; + // AVCC output buffer grows as VCL NALs are appended (4-byte length + payload). + uint8_t *avcc = NULL; + size_t avccLen = 0; + + size_t i = 0; + while (i + 3 < len) { + // Find a start code (00 00 01 or 00 00 00 01). + if (!(data[i] == 0 && data[i + 1] == 0 && + (data[i + 2] == 1 || (data[i + 2] == 0 && i + 3 < len && data[i + 3] == 1)))) { + i++; + continue; + } + size_t startCodeLen = (data[i + 2] == 1) ? 3 : 4; + size_t nalStart = i + startCodeLen; + if (nalStart >= len) { + break; + } + // Find the next start code to bound this NAL. + size_t j = nalStart; + while (j + 2 < len && + !(data[j] == 0 && data[j + 1] == 0 && + (data[j + 2] == 1 || + (data[j + 2] == 0 && j + 3 < len && data[j + 3] == 1)))) { + j++; + } + size_t nalEnd = (j + 2 < len) ? j : len; + size_t nalLen = nalEnd - nalStart; + if (nalLen == 0) { + i = nalEnd; + continue; + } + uint8_t nalType = data[nalStart] & 0x1f; + + if (nalType == 7) { + paramsChanged |= replaceParam(&_sps, &_spsLen, &data[nalStart], nalLen); + } else if (nalType == 8) { + paramsChanged |= replaceParam(&_pps, &_ppsLen, &data[nalStart], nalLen); + } else if (nalType >= 1 && nalType <= 5) { + // VCL slice: append as a 4-byte-big-endian length-prefixed AVCC unit. + uint8_t *grown = realloc(avcc, avccLen + 4 + nalLen); + if (!grown) { + free(avcc); + return -1; + } + avcc = grown; + avcc[avccLen + 0] = (uint8_t)((nalLen >> 24) & 0xff); + avcc[avccLen + 1] = (uint8_t)((nalLen >> 16) & 0xff); + avcc[avccLen + 2] = (uint8_t)((nalLen >> 8) & 0xff); + avcc[avccLen + 3] = (uint8_t)(nalLen & 0xff); + memcpy(avcc + avccLen + 4, &data[nalStart], nalLen); + avccLen += 4 + nalLen; + } + // SEI (6), AUD (9), and everything else are dropped. + i = nalEnd; + } + + // If parameter sets changed, rebuild the session before decoding. + if (paramsChanged) { + [self teardownSession]; + } + if (![self ensureSession]) { + free(avcc); + // No session yet (parameter sets not seen) and no VCL to decode: priming. + return 0; + } + if (avccLen == 0) { + free(avcc); + return 0; // Parameter-set-only access unit: priming, no picture. + } + + // --- Wrap the AVCC bytes in a CMSampleBuffer. --- + CMBlockBufferRef blockBuffer = NULL; + OSStatus status = CMBlockBufferCreateWithMemoryBlock( + kCFAllocatorDefault, /*memoryBlock=*/NULL, avccLen, + kCFAllocatorDefault, /*customBlockSource=*/NULL, 0, avccLen, 0, + &blockBuffer); + if (status != kCMBlockBufferNoErr || !blockBuffer) { + free(avcc); + return -1; + } + status = CMBlockBufferReplaceDataBytes(avcc, blockBuffer, 0, avccLen); + free(avcc); + if (status != kCMBlockBufferNoErr) { + CFRelease(blockBuffer); + return -1; + } + + CMSampleBufferRef sampleBuffer = NULL; + const size_t sampleSize = avccLen; + status = CMSampleBufferCreateReady(kCFAllocatorDefault, blockBuffer, _format, + 1, 0, NULL, 1, &sampleSize, &sampleBuffer); + CFRelease(blockBuffer); + if (status != noErr || !sampleBuffer) { + return -1; + } + + // --- Decode synchronously, in decode order (no temporal reordering). --- + _captured = NULL; + VTDecodeInfoFlags infoFlags = 0; + // flags = 0: synchronous (no kVTDecodeFrame_EnableAsynchronousDecompression) + // and no kVTDecodeFrame_EnableTemporalProcessing → output in decode order, + // one picture per frame, delivered before this call returns. + status = VTDecompressionSessionDecodeFrameWithOutputHandler( + _session, sampleBuffer, /*decodeFlags=*/0, &infoFlags, + ^(OSStatus handlerStatus, VTDecodeInfoFlags handlerInfoFlags, + CVImageBufferRef imageBuffer, CMTime pts, CMTime dur) { + (void)handlerInfoFlags; + (void)pts; + (void)dur; + if (handlerStatus == noErr && imageBuffer) { + _captured = CVPixelBufferRetain(imageBuffer); + } + }); + CFRelease(sampleBuffer); + if (status != noErr) { + if (_captured) { + CVPixelBufferRelease(_captured); + _captured = NULL; + } + return -1; + } + if (!_captured) { + return 0; // Decoder accepted the frame but produced no picture (priming). + } + + // --- Read back BGRA → RGBA, stride-aware. --- + CVPixelBufferRef pixelBuffer = _captured; + _captured = NULL; + CVReturn lock = CVPixelBufferLockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly); + if (lock != kCVReturnSuccess) { + CVPixelBufferRelease(pixelBuffer); + return -1; + } + const size_t width = CVPixelBufferGetWidth(pixelBuffer); + const size_t height = CVPixelBufferGetHeight(pixelBuffer); + const size_t srcStride = CVPixelBufferGetBytesPerRow(pixelBuffer); + const uint8_t *src = (const uint8_t *)CVPixelBufferGetBaseAddress(pixelBuffer); + + int result = -1; + const size_t rgbaLen = width * height * 4; + uint8_t *rgba = malloc(rgbaLen); + if (rgba && src) { + for (size_t y = 0; y < height; y++) { + const uint8_t *srcRow = src + y * srcStride; + uint8_t *dstRow = rgba + y * width * 4; + for (size_t x = 0; x < width; x++) { + // Source is BGRA; write RGBA. + dstRow[x * 4 + 0] = srcRow[x * 4 + 2]; // R + dstRow[x * 4 + 1] = srcRow[x * 4 + 1]; // G + dstRow[x * 4 + 2] = srcRow[x * 4 + 0]; // B + dstRow[x * 4 + 3] = srcRow[x * 4 + 3]; // A + } + } + *outRgba = rgba; + *outLen = rgbaLen; + *outWidth = (uint32_t)width; + *outHeight = (uint32_t)height; + result = 1; + } else { + free(rgba); + } + + CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly); + CVPixelBufferRelease(pixelBuffer); + return result; +} + +@end + +// --------------------------------------------------------------------------- +// C ABI shims. +// --------------------------------------------------------------------------- +struct AvalVtDecoder { + void *obj; // retained AvalVtDecoderObjc * +}; + +AvalVtDecoder *aval_vt_create(void) { + AvalVtDecoder *dec = malloc(sizeof(AvalVtDecoder)); + if (!dec) { + return NULL; + } + AvalVtDecoderObjc *obj = [[AvalVtDecoderObjc alloc] init]; + dec->obj = (__bridge_retained void *)obj; + return dec; +} + +int aval_vt_decode(AvalVtDecoder *dec, const uint8_t *data, size_t len, + uint8_t **out_rgba, size_t *out_len, uint32_t *out_width, + uint32_t *out_height) { + if (!dec || !dec->obj) { + return -1; + } + AvalVtDecoderObjc *obj = (__bridge AvalVtDecoderObjc *)dec->obj; + return [obj decode:data + len:len + outRgba:out_rgba + outLen:out_len + outWidth:out_width + outHeight:out_height]; +} + +void aval_vt_free_frame(uint8_t *rgba) { free(rgba); } + +void aval_vt_destroy(AvalVtDecoder *dec) { + if (!dec) { + return; + } + if (dec->obj) { + // __bridge_transfer hands the +1 retain back to ARC; the temporary is + // released at the end of this statement, deallocating the decoder. + (void)(__bridge_transfer AvalVtDecoderObjc *)dec->obj; + dec->obj = NULL; + } + free(dec); +} diff --git a/flutter/rust/aval_decode/src/yuv.rs b/flutter/rust/aval_decode/src/yuv.rs new file mode 100644 index 0000000..6d89959 --- /dev/null +++ b/flutter/rust/aval_decode/src/yuv.rs @@ -0,0 +1,243 @@ +//! Planar I420 (YUV 4:2:0) -> RGBA8888 conversion, BT.709 limited-range. +//! +//! There is no analog to this step in the web player: WebCodecs' `VideoFrame` +//! performed YUV->RGB invisibly before the renderer ran (ARCHITECTURE.md 3.3). +//! With the `openh264` core the decoder yields planar I420, so this crate must +//! convert explicitly, immediately after decode, so the buffer that crosses the +//! FFI boundary is already RGBA (matching what WebCodecs produced in v1). +//! +//! Per ARCHITECTURE.md risk register #13 this is a first-class, independently +//! tested unit: [`i420_to_rgba`] is a pure function and [`yuv_to_rgb`] exposes +//! the single-pixel kernel so the coefficients can be pinned against known +//! test patterns with exact expected RGBA output. +//! +//! # Coefficients +//! +//! BT.709, limited ("studio") range: luma in `[16, 235]`, chroma in `[16, 240]`. +//! With `Kr = 0.2126`, `Kb = 0.0722`, `Kg = 1 - Kr - Kb = 0.7152`: +//! +//! ```text +//! c = Y - 16, d = U - 128, e = V - 128 +//! Yp = (255 / 219) * c = 1.164383 * c +//! R = Yp + (255 / 224) * 2 * (1 - Kr) * e = Yp + 1.792741 * e +//! G = Yp - (255 / 224) * 2 * (1 - Kb) * (Kb / Kg) * d +//! - (255 / 224) * 2 * (1 - Kr) * (Kr / Kg) * e = Yp - 0.213249 * d - 0.532909 * e +//! B = Yp + (255 / 224) * 2 * (1 - Kb) * d = Yp + 2.112402 * d +//! ``` +//! +//! Each channel is rounded to nearest and clamped to `[0, 255]`; alpha is always +//! `255` (the decoded picture is opaque — the AVAL packed-alpha layout carries +//! its alpha as a *second luma pane* within the same picture, handled downstream +//! by the renderer, not here). + +use crate::error::AvalDecodeError; + +/// Bytes per output pixel (R, G, B, A). +pub const RGBA_BYTES_PER_PIXEL: usize = 4; + +// BT.709 limited-range coefficients (see module docs for derivation). +const Y_MUL: f32 = 1.164_383; +const RV_MUL: f32 = 1.792_741; +const GU_MUL: f32 = -0.213_249; +const GV_MUL: f32 = -0.532_909; +const BU_MUL: f32 = 2.112_402; + +/// Required RGBA output length for a `width` x `height` image, or `None` on +/// overflow. +#[must_use] +pub fn rgba_len(width: usize, height: usize) -> Option { + width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(RGBA_BYTES_PER_PIXEL)) +} + +/// Converts a single limited-range BT.709 YUV triple to non-premultiplied RGB. +/// +/// Exposed so the conversion kernel can be tested and reasoned about +/// independently of plane/stride bookkeeping. +#[must_use] +pub fn yuv_to_rgb(y: u8, u: u8, v: u8) -> [u8; 3] { + let c = f32::from(y) - 16.0; + let d = f32::from(u) - 128.0; + let e = f32::from(v) - 128.0; + let yp = Y_MUL * c; + let r = RV_MUL.mul_add(e, yp); + let g = GV_MUL.mul_add(e, GU_MUL.mul_add(d, yp)); + let b = BU_MUL.mul_add(d, yp); + [clamp_round(r), clamp_round(g), clamp_round(b)] +} + +#[inline] +fn clamp_round(value: f32) -> u8 { + // Round half away from zero, then clamp into the u8 range. + let rounded = value.round(); + if rounded <= 0.0 { + 0 + } else if rounded >= 255.0 { + 255 + } else { + rounded as u8 + } +} + +/// Converts a planar I420 image to a tightly-packed RGBA8888 buffer. +/// +/// `y_stride` is the byte stride of the luma plane; `uv_stride` is the byte +/// stride of *each* chroma plane. Strides may exceed the visible width (as they +/// do in `openh264`'s decoded buffers) — only the visible `width` x `height` +/// region is read and written. The output is written row-major, top-down, with +/// no padding (`width * height * 4` bytes) and alpha fixed at `255`. +/// +/// # Errors +/// +/// [`AvalDecodeError::InvalidArgument`] if the dimensions are zero, if a stride +/// is narrower than its plane's visible width, if `out` is not exactly +/// `width * height * 4` bytes, or if any input plane is too small for the +/// declared dimensions and strides. +#[allow(clippy::too_many_arguments)] +pub fn i420_to_rgba( + y_plane: &[u8], + u_plane: &[u8], + v_plane: &[u8], + width: usize, + height: usize, + y_stride: usize, + uv_stride: usize, + out: &mut [u8], +) -> Result<(), AvalDecodeError> { + if width == 0 || height == 0 { + return Err(AvalDecodeError::InvalidArgument( + "i420_to_rgba requires non-zero width and height", + )); + } + // 4:2:0 chroma is half-resolution, rounded up for odd dimensions. + let chroma_width = width.div_ceil(2); + let chroma_height = height.div_ceil(2); + if y_stride < width || uv_stride < chroma_width { + return Err(AvalDecodeError::InvalidArgument( + "i420_to_rgba stride is narrower than the plane width", + )); + } + let wanted = rgba_len(width, height).ok_or(AvalDecodeError::InvalidArgument( + "i420_to_rgba output length overflows", + ))?; + if out.len() != wanted { + return Err(AvalDecodeError::InvalidArgument( + "i420_to_rgba output buffer length does not match width * height * 4", + )); + } + // Last byte read from each plane must be in bounds. + let y_needed = (height - 1) * y_stride + width; + let uv_needed = (chroma_height - 1) * uv_stride + chroma_width; + if y_plane.len() < y_needed || u_plane.len() < uv_needed || v_plane.len() < uv_needed { + return Err(AvalDecodeError::InvalidArgument( + "i420_to_rgba input plane is too small for the declared geometry", + )); + } + + for row in 0..height { + let y_row = row * y_stride; + let uv_row = (row / 2) * uv_stride; + let out_row = row * width * RGBA_BYTES_PER_PIXEL; + for col in 0..width { + let y = y_plane[y_row + col]; + let chroma_col = col / 2; + let u = u_plane[uv_row + chroma_col]; + let v = v_plane[uv_row + chroma_col]; + let [r, g, b] = yuv_to_rgb(y, u, v); + let base = out_row + col * RGBA_BYTES_PER_PIXEL; + out[base] = r; + out[base + 1] = g; + out[base + 2] = b; + out[base + 3] = 255; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `(input YUV, expected RGB)` pair. + type Vector = ((u8, u8, u8), (u8, u8, u8)); + + // Expected RGB values were derived independently from the BT.709 limited-range + // formula in the module docs (rounded to nearest, clamped to [0, 255]); they + // are hardcoded here rather than recomputed with the same code so the test is + // not tautological. See risk register #13. + const VECTORS: &[Vector] = &[ + ((16, 128, 128), (0, 0, 0)), // limited-range black + ((235, 128, 128), (255, 255, 255)), // limited-range white + ((126, 128, 128), (128, 128, 128)), // neutral mid-gray + ((90, 240, 16), (0, 122, 255)), + ((81, 90, 240), (255, 24, 0)), + ((145, 54, 34), (0, 216, 0)), + ]; + + #[test] + fn single_pixel_kernel_matches_known_vectors() { + for &((y, u, v), (r, g, b)) in VECTORS { + assert_eq!(yuv_to_rgb(y, u, v), [r, g, b], "yuv_to_rgb({y}, {u}, {v})"); + } + } + + #[test] + fn converts_a_uniform_2x2_patch_for_every_vector() { + // A 2x2 image where all four luma samples share one chroma sample; every + // output pixel must equal the vector's expected RGBA, alpha == 255. + for &((y, u, v), (r, g, b)) in VECTORS { + let y_plane = [y; 4]; + let u_plane = [u; 1]; + let v_plane = [v; 1]; + let mut out = [0u8; 16]; + i420_to_rgba(&y_plane, &u_plane, &v_plane, 2, 2, 2, 1, &mut out).unwrap(); + for pixel in out.chunks_exact(4) { + assert_eq!(pixel, [r, g, b, 255]); + } + } + } + + #[test] + fn maps_luma_positions_through_stride_correctly() { + // Neutral chroma so output is grayscale; distinct luma per pixel verifies + // row/column indexing. Luma stride (4) is wider than width (2). + // Y=16 -> 0, Y=235 -> 255, Y=126 -> 128. + let y_plane = [ + 16, 235, 0xEE, 0xEE, // row 0 visible: 16, 235; padding beyond width + 126, 235, 0xEE, 0xEE, // row 1 visible: 126, 235 + ]; + let u_plane = [128u8; 1]; + let v_plane = [128u8; 1]; + let mut out = [0u8; 16]; + i420_to_rgba(&y_plane, &u_plane, &v_plane, 2, 2, 4, 1, &mut out).unwrap(); + assert_eq!(&out[0..4], &[0, 0, 0, 255]); // (0,0) Y=16 + assert_eq!(&out[4..8], &[255, 255, 255, 255]); // (1,0) Y=235 + assert_eq!(&out[8..12], &[128, 128, 128, 255]); // (0,1) Y=126 + assert_eq!(&out[12..16], &[255, 255, 255, 255]); // (1,1) Y=235 + } + + #[test] + fn rejects_bad_geometry_and_buffers() { + let y = [16u8; 4]; + let u = [128u8; 1]; + let v = [128u8; 1]; + let mut out = [0u8; 16]; + // zero dimension + assert!(i420_to_rgba(&y, &u, &v, 0, 2, 2, 1, &mut out).is_err()); + // stride narrower than width + assert!(i420_to_rgba(&y, &u, &v, 2, 2, 1, 1, &mut out).is_err()); + // wrong output length + let mut short = [0u8; 8]; + assert!(i420_to_rgba(&y, &u, &v, 2, 2, 2, 1, &mut short).is_err()); + // input plane too small + let tiny = [16u8; 1]; + assert!(i420_to_rgba(&tiny, &u, &v, 2, 2, 2, 1, &mut out).is_err()); + } + + #[test] + fn rgba_len_computes_and_detects_overflow() { + assert_eq!(rgba_len(64, 48), Some(64 * 48 * 4)); + assert_eq!(rgba_len(usize::MAX, 2), None); + } +} diff --git a/flutter/rust/aval_decode/tests/decode_fixture.rs b/flutter/rust/aval_decode/tests/decode_fixture.rs new file mode 100644 index 0000000..10b3566 --- /dev/null +++ b/flutter/rust/aval_decode/tests/decode_fixture.rs @@ -0,0 +1,209 @@ +//! Integration test: decode one real IDR chunk (and a run of pictures) end to end. +//! +//! Drives the public [`DecoderSession`] API against `tests/fixtures/sample_cbp.h264` +//! (a Constrained Baseline, cabac=0, 64x48 Annex-B clip: SPS/PPS/SEI/IDR followed +//! by P-frame slices). It submits the first chunk (everything up to the first +//! non-IDR slice), takes the produced frame, and asserts geometry and pixel +//! sanity — the Phase 3 exit criterion that `openh264` decodes one IDR chunk and +//! the Rust-side I420 -> RGBA conversion produces a plausible RGBA surface. +//! +//! Format-1.0 chunk vocabulary: submissions are [`DecodeChunk`]s. For H.264 each +//! chunk is 1:1 (`displayed_frame_count == 1`, `decode_index == presentation_index`). + +use aval_decode::decoder::maximum_decoded_rgba_bytes; +use aval_decode::{DecodeChunk, DecoderSession, SessionConfig, SubmitOutcome, VideoCodec}; + +const FIXTURE: &[u8] = include_bytes!("fixtures/sample_cbp.h264"); + +// The fixture SPS declares a 64x48 coded surface (verified out of band). +const CODED_WIDTH: usize = 64; +const CODED_HEIGHT: usize = 48; + +fn h264_config(max_outstanding: usize) -> SessionConfig { + SessionConfig { + codec: VideoCodec::H264, + bit_depth: 8, + coded_width: CODED_WIDTH, + coded_height: CODED_HEIGHT, + max_outstanding_frames: max_outstanding, + max_decoded_bytes: maximum_decoded_rgba_bytes(CODED_WIDTH, CODED_HEIGHT).unwrap() + * max_outstanding as u64, + } +} + +/// Returns the byte length of the first chunk (the IDR access unit): everything +/// from the start of the stream up to (not including) the start code of the first +/// coded slice of a *subsequent* picture (NAL type 1). The first chunk therefore +/// contains SPS(7) + PPS(8) + SEI(6) + IDR(5). +fn first_chunk_len(stream: &[u8]) -> usize { + let mut i = 0; + while i + 3 < stream.len() { + let (payload, next) = if stream[i] == 0 && stream[i + 1] == 0 && stream[i + 2] == 1 { + (i + 3, i + 3) + } else if i + 4 < stream.len() + && stream[i] == 0 + && stream[i + 1] == 0 + && stream[i + 2] == 0 + && stream[i + 3] == 1 + { + (i + 4, i + 4) + } else { + i += 1; + continue; + }; + let nal_type = stream[payload] & 0x1f; + // A type-1 (non-IDR coded slice) marks the second picture: cut here. + if nal_type == 1 { + return i; + } + i = next; + } + stream.len() +} + +#[test] +fn decodes_the_fixture_idr_chunk() { + let chunk_len = first_chunk_len(FIXTURE); + assert!( + chunk_len > 0 && chunk_len < FIXTURE.len(), + "chunk boundary not found" + ); + let bytes = &FIXTURE[..chunk_len]; + + let mut session = DecoderSession::new(); + session.configure(h264_config(4)).expect("configure"); + session.activate_generation(1).expect("activate"); + + let chunk = DecodeChunk { + unit_id: "idle", + unit_instance: 0, + decode_index: 0, + unit_chunk_count: 1, + unit_frame_count: 1, + presentation_ordinal_base: 0, + presentation_indices: &[0], + presentation_timestamp: 0, + duration: 16_667, + random_access: true, + displayed_frame_count: 1, + data: bytes, + }; + + let outcome = session + .submit_chunk(1, &chunk) + .expect("submit should decode the IDR"); + let frame_id = match outcome { + SubmitOutcome::Frame { frame_id } => frame_id, + SubmitOutcome::Priming => panic!("IDR chunk should not prime with no_delay decode"), + }; + + let frame = session + .take_frame() + .expect("take_frame") + .expect("a frame is ready"); + assert_eq!(frame.frame_id, frame_id); + assert_eq!(frame.width, CODED_WIDTH); + assert_eq!(frame.height, CODED_HEIGHT); + assert_eq!(frame.ordinal, 0); + assert_eq!(frame.unit_frame, 0); + assert_eq!(frame.decode_index, 0); + assert_eq!(frame.rgba.len(), CODED_WIDTH * CODED_HEIGHT * 4); + + // Pixel sanity: every alpha byte is 255 (opaque), and the image is not a + // single flat colour (a real decoded picture has variation). + assert!( + frame.rgba.chunks_exact(4).all(|pixel| pixel[3] == 255), + "alpha channel must be fully opaque" + ); + let first = &frame.rgba[0..3]; + let has_variation = frame + .rgba + .chunks_exact(4) + .any(|pixel| pixel[0..3] != *first); + assert!(has_variation, "decoded frame should not be a flat colour"); + + // Metrics reflect one decoded, one delivered, one still leased. + let metrics = session.snapshot(); + assert_eq!(metrics.output_frames, 1); + assert_eq!(metrics.delivered_frames, 1); + assert_eq!(metrics.leased_frames, 1); + assert_eq!(metrics.next_submission_ordinal, 1); + + session.release_frame(frame_id).expect("release"); + assert_eq!(session.snapshot().leased_frames, 0); + + session.dispose(); +} + +#[test] +fn decodes_multiple_pictures_in_order() { + // Feed the IDR chunk then subsequent P-slice pictures as one independent unit, + // one submit each, and confirm decode indices stay contiguous and each yields + // exactly one frame — the "one chunk in -> one frame out, decode order == + // display order" invariant. + let mut boundaries = vec![0usize]; + let mut i = 0; + while i + 3 < FIXTURE.len() { + let (payload, next) = if FIXTURE[i] == 0 && FIXTURE[i + 1] == 0 && FIXTURE[i + 2] == 1 { + (i + 3, i + 3) + } else if i + 4 < FIXTURE.len() + && FIXTURE[i] == 0 + && FIXTURE[i + 1] == 0 + && FIXTURE[i + 2] == 0 + && FIXTURE[i + 3] == 1 + { + (i + 4, i + 4) + } else { + i += 1; + continue; + }; + let nal_type = FIXTURE[payload] & 0x1f; + // Split only on subsequent (type-1) coded slices: the leading SPS/PPS/SEI + // and the IDR (type 5) form the first chunk together. + if nal_type == 1 { + boundaries.push(i); + } + i = next; + } + boundaries.push(FIXTURE.len()); + + let picture_count = (boundaries.len() - 1) as u64; + + let mut session = DecoderSession::new(); + session.configure(h264_config(12)).expect("configure"); + session.activate_generation(1).expect("activate"); + + let mut decoded = 0u64; + for (ordinal, window) in boundaries.windows(2).enumerate() { + let bytes = &FIXTURE[window[0]..window[1]]; + let ordinal = ordinal as u64; + let indices = [ordinal]; + let chunk = DecodeChunk { + unit_id: "idle", + unit_instance: 0, + decode_index: ordinal, + unit_chunk_count: picture_count, + unit_frame_count: picture_count, + presentation_ordinal_base: 0, + presentation_indices: &indices, + presentation_timestamp: ordinal * 16_667 + 1, + duration: 16_667, + random_access: ordinal == 0, + displayed_frame_count: 1, + data: bytes, + }; + if let SubmitOutcome::Frame { frame_id } = session.submit_chunk(1, &chunk).expect("submit") { + let frame = session.take_frame().expect("take").expect("frame"); + assert_eq!(frame.ordinal, decoded); + assert_eq!(frame.decode_index, ordinal); + assert_eq!(frame.width, CODED_WIDTH); + assert_eq!(frame.height, CODED_HEIGHT); + session.release_frame(frame_id).expect("release"); + decoded += 1; + } + } + + // The clip has 12 coded pictures (1 IDR + 11 P); every one should decode. + assert!(decoded >= 2, "expected multiple decoded frames, got {decoded}"); + assert_eq!(session.snapshot().leased_frames, 0); +} diff --git a/flutter/rust/aval_decode/tests/fixtures/sample_cbp.h264 b/flutter/rust/aval_decode/tests/fixtures/sample_cbp.h264 new file mode 100644 index 0000000..3e7cada Binary files /dev/null and b/flutter/rust/aval_decode/tests/fixtures/sample_cbp.h264 differ diff --git a/flutter/rust/aval_graph/Cargo.lock b/flutter/rust/aval_graph/Cargo.lock new file mode 100644 index 0000000..d6ccd21 --- /dev/null +++ b/flutter/rust/aval_graph/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aval_graph" +version = "0.1.0" diff --git a/flutter/rust/aval_graph/Cargo.toml b/flutter/rust/aval_graph/Cargo.toml new file mode 100644 index 0000000..a4f5ad3 --- /dev/null +++ b/flutter/rust/aval_graph/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "aval_graph" +version = "0.1.0" +edition = "2021" +description = "Pure Rust port of AVAL ring arc planning (packages/graph ring-plan.ts)" +license = "MIT OR Apache-2.0" + +[lib] +name = "aval_graph" +path = "src/lib.rs" + +[dependencies] diff --git a/flutter/rust/aval_graph/src/lib.rs b/flutter/rust/aval_graph/src/lib.rs new file mode 100644 index 0000000..411135e --- /dev/null +++ b/flutter/rust/aval_graph/src/lib.rs @@ -0,0 +1,218 @@ +//! Pure ring arc planning — port of `packages/graph/src/ring-plan.ts`. +//! +//! Graph install / tick reducer remains in Dart (`aval_graph` package) for now; +//! this crate locks the ring geometry math for Rust hosts (WASM / native). + +use std::collections::HashMap; + +/// Which arc a ring prefers when both directions are equally long. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TieBreak { + Forward, + Backward, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RingDefinition { + pub id: String, + pub states: Vec, + pub cyclic: bool, + pub tie_break: TieBreak, + pub max_chained_steps: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RingArc { + pub direction: TieBreak, + /// Ordered landings; last entry is the requested target. + pub states: Vec, +} + +/// Choose the shorter arc between two members of one ring. +pub fn plan_ring_arc(ring: &RingDefinition, from: &str, to: &str) -> Option { + let length = ring.states.len(); + let from_index = ring.states.iter().position(|s| s == from)?; + let to_index = ring.states.iter().position(|s| s == to)?; + if from_index == to_index { + return None; + } + + let (forward, backward) = if ring.cyclic { + ( + (to_index + length - from_index) % length, + (from_index + length - to_index) % length, + ) + } else { + let f = if to_index > from_index { + to_index - from_index + } else { + usize::MAX + }; + let b = if from_index > to_index { + from_index - to_index + } else { + usize::MAX + }; + (f, b) + }; + if forward == usize::MAX && backward == usize::MAX { + return None; + } + + let direction = if forward < backward { + TieBreak::Forward + } else if backward < forward { + TieBreak::Backward + } else { + ring.tie_break + }; + let distance = if direction == TieBreak::Forward { + forward + } else { + backward + }; + let offset: isize = if direction == TieBreak::Forward { 1 } else { -1 }; + let mut states = Vec::with_capacity(distance); + for step in 1..=distance { + let index = ((from_index as isize + step as isize * offset).rem_euclid(length as isize)) + as usize; + states.push(ring.states[index].clone()); + } + Some(RingArc { direction, states }) +} + +/// Direct neighbour edge map: from → (to → edge_id). +pub type DirectEdges = HashMap>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RingRoute { + None, + TooLong { + ring_id: String, + distance: usize, + }, + Arc { + ring_id: String, + direction: TieBreak, + states: Vec, + /// Edge ids along the arc, one per landing. + step_edge_ids: Vec, + }, +} + +/// Resolve authored step edges that walk `from` → `to` along the first capable ring. +pub fn resolve_ring_route( + rings_by_state: &HashMap>, + direct_edges: &DirectEdges, + from: &str, + to: &str, +) -> RingRoute { + let mut refused: Option<(String, usize)> = None; + let Some(rings) = rings_by_state.get(from) else { + return RingRoute::None; + }; + for ring in rings { + let Some(arc) = plan_ring_arc(ring, from, to) else { + continue; + }; + if arc.states.len() > ring.max_chained_steps { + refused.get_or_insert_with(|| (ring.id.clone(), arc.states.len())); + continue; + } + let mut step_edge_ids = Vec::with_capacity(arc.states.len()); + let mut cursor = from.to_string(); + let mut ok = true; + for state in &arc.states { + match direct_edges.get(&cursor).and_then(|m| m.get(state)) { + Some(edge_id) => { + step_edge_ids.push(edge_id.clone()); + cursor = state.clone(); + } + None => { + ok = false; + break; + } + } + } + if !ok { + continue; + } + return RingRoute::Arc { + ring_id: ring.id.clone(), + direction: arc.direction, + states: arc.states, + step_edge_ids, + }; + } + match refused { + Some((ring_id, distance)) => RingRoute::TooLong { ring_id, distance }, + None => RingRoute::None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facing_ring(tie_break: TieBreak, cyclic: bool, max_chained: usize) -> RingDefinition { + RingDefinition { + id: "facing.walk".into(), + states: vec![ + "walk_n".into(), + "walk_ne".into(), + "walk_e".into(), + "walk_se".into(), + "walk_s".into(), + "walk_sw".into(), + "walk_w".into(), + "walk_nw".into(), + ], + cyclic, + tie_break, + max_chained_steps: max_chained, + } + } + + #[test] + fn shorter_arc_and_landings() { + let ring = facing_ring(TieBreak::Forward, true, 4); + let arc = plan_ring_arc(&ring, "walk_n", "walk_e").unwrap(); + assert_eq!(arc.direction, TieBreak::Forward); + assert_eq!(arc.states, vec!["walk_ne", "walk_e"]); + let arc = plan_ring_arc(&ring, "walk_n", "walk_w").unwrap(); + assert_eq!(arc.direction, TieBreak::Backward); + assert_eq!(arc.states, vec!["walk_nw", "walk_w"]); + } + + #[test] + fn half_turn_tie_break() { + let forward = facing_ring(TieBreak::Forward, true, 4); + let backward = facing_ring(TieBreak::Backward, true, 4); + assert_eq!( + plan_ring_arc(&forward, "walk_n", "walk_s") + .unwrap() + .direction, + TieBreak::Forward + ); + assert_eq!( + plan_ring_arc(&backward, "walk_n", "walk_s") + .unwrap() + .direction, + TieBreak::Backward + ); + } + + #[test] + fn non_cyclic_no_wrap() { + let line = facing_ring(TieBreak::Forward, false, 16); + assert!(plan_ring_arc(&line, "walk_n", "walk_n").is_none()); + assert!(plan_ring_arc(&line, "walk_n", "sit").is_none()); + let arc = plan_ring_arc(&line, "walk_nw", "walk_ne").unwrap(); + assert_eq!( + arc.states, + vec![ + "walk_w", "walk_sw", "walk_s", "walk_se", "walk_e", "walk_ne" + ] + ); + } +} diff --git a/flutter/scripts/analyze.sh b/flutter/scripts/analyze.sh new file mode 100755 index 0000000..d06c8c3 --- /dev/null +++ b/flutter/scripts/analyze.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Static analysis across all Dart packages and the Rust crate (clippy). +set -euo pipefail + +FLUTTER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +failed=() + +for pkg in "$FLUTTER_DIR"/packages/*/; do + [ -f "$pkg/pubspec.yaml" ] || continue + name="$(basename "$pkg")" + echo "==> dart analyze: $name" + (cd "$pkg" && dart analyze) || failed+=("$name") +done + +for crate in "$FLUTTER_DIR"/rust/*/; do + [ -f "$crate/Cargo.toml" ] || continue + name="$(basename "$crate")" + echo "==> cargo clippy: $name" + (cd "$crate" && cargo clippy --all-targets -- -D warnings) || failed+=("$name") +done + +if [ ${#failed[@]} -gt 0 ]; then + echo "ANALYSIS FAILED: ${failed[*]}" >&2 + exit 1 +fi +echo "all packages analyze clean" diff --git a/flutter/scripts/run.sh b/flutter/scripts/run.sh new file mode 100755 index 0000000..cd466ba --- /dev/null +++ b/flutter/scripts/run.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# Build the Rust decode core, then run a Flutter example app. +# Usage: run.sh [example] [flutter-run-args...] +# run.sh # grass_rabbit on macOS +# run.sh grass_rabbit -d macos # explicit macOS +# run.sh grass_rabbit -d ios # iOS simulator +# run.sh grass_rabbit -d "John’s iPhone" --release # physical device, release +set -euo pipefail + +FLUTTER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EXAMPLE="${1:-grass_rabbit}" +shift || true + +EXAMPLE_DIR="$FLUTTER_DIR/examples/$EXAMPLE" +if [ ! -d "$EXAMPLE_DIR" ]; then + echo "unknown example '$EXAMPLE' — available:" >&2 + ls "$FLUTTER_DIR/examples" 2>/dev/null >&2 || echo " (none yet)" >&2 + exit 1 +fi + +# Detect -d / --device-id from remaining args. +DEVICE_HINT="" +ARGS=("$@") +for ((i = 0; i < ${#ARGS[@]}; i++)); do + if [ "${ARGS[$i]}" = "-d" ] || [ "${ARGS[$i]}" = "--device-id" ]; then + DEVICE_HINT="${ARGS[$((i + 1))]:-}" + break + fi +done + +# Default to macOS on Darwin when no -d is given. +if [ -z "$DEVICE_HINT" ] && [ "$(uname -s)" = Darwin ]; then + ARGS=(-d macos "${ARGS[@]}") + DEVICE_HINT="macos" +fi + +# Classify the target: host / ios-sim / ios-device. +TARGET_KIND="host" +case "$DEVICE_HINT" in + macos|"") TARGET_KIND="host" ;; + ios|simulator) TARGET_KIND="ios-sim" ;; + iPhone*|iPad*|iphone*|ipad*) + # Physical devices show up in `flutter devices` as ios (not simulator). + if flutter devices 2>/dev/null | grep -F "$DEVICE_HINT" | grep -qi simulator; then + TARGET_KIND="ios-sim" + elif [[ "$DEVICE_HINT" == *Simulator* ]]; then + TARGET_KIND="ios-sim" + else + # UDID-like or named phone: prefer physical if listed as non-simulator. + if flutter devices 2>/dev/null | grep -F "$DEVICE_HINT" | grep -qi 'ios' && \ + ! flutter devices 2>/dev/null | grep -F "$DEVICE_HINT" | grep -qi simulator; then + TARGET_KIND="ios-device" + else + # Ambiguous name — check simctl for a match first. + if xcrun simctl list devices available 2>/dev/null | grep -qF "$DEVICE_HINT"; then + TARGET_KIND="ios-sim" + else + TARGET_KIND="ios-device" + fi + fi + fi + ;; + *) + # Raw UDID: physical devices are 25+ hex/dash; simulators are UUID-shaped. + if flutter devices 2>/dev/null | grep -F "$DEVICE_HINT" | grep -qi simulator; then + TARGET_KIND="ios-sim" + elif flutter devices 2>/dev/null | grep -F "$DEVICE_HINT" | grep -qi 'ios'; then + TARGET_KIND="ios-device" + fi + ;; +esac + +# When user said -d ios and a physical phone is connected, prefer it for --release. +if [ "$DEVICE_HINT" = "ios" ] && printf '%s\n' "${ARGS[@]}" | grep -qx -- '--release'; then + PHYS="$(flutter devices 2>/dev/null | awk -F '•' '/ios/ && !/simulator/ {gsub(/^ +| +$/,"",$2); print $2; exit}')" + if [ -n "$PHYS" ]; then + TARGET_KIND="ios-device" + DEVICE_HINT="$PHYS" + NEW_ARGS=() + for ((i = 0; i < ${#ARGS[@]}; i++)); do + if [ "${ARGS[$i]}" = "-d" ] || [ "${ARGS[$i]}" = "--device-id" ]; then + NEW_ARGS+=("${ARGS[$i]}" "$PHYS") + i=$((i + 1)) + continue + fi + NEW_ARGS+=("${ARGS[$i]}") + done + ARGS=("${NEW_ARGS[@]}") + fi +fi + +RUST_TARGET="" +LIB_NAME="" +USE_PROCESS=0 +case "$TARGET_KIND" in + host) + case "$(uname -s)" in + Darwin) LIB_NAME="libaval_decode.dylib" ;; + Linux) LIB_NAME="libaval_decode.so" ;; + *) LIB_NAME="aval_decode.dll" ;; + esac + ;; + ios-sim) + if [ "$(uname -m)" = arm64 ]; then + RUST_TARGET="aarch64-apple-ios-sim" + else + RUST_TARGET="x86_64-apple-ios" + fi + LIB_NAME="libaval_decode.a" + USE_PROCESS=1 + ;; + ios-device) + RUST_TARGET="aarch64-apple-ios" + LIB_NAME="libaval_decode.a" + USE_PROCESS=1 + ;; +esac + +write_ios_xcconfig() { + local static_lib="$1" + local out="$EXAMPLE_DIR/ios/Flutter/AvalDecode.local.xcconfig" + mkdir -p "$(dirname "$out")" + # Escape spaces for xcconfig. + local escaped="${static_lib// /\\ }" + cat >"$out" < wrote $out" +} + +# Copy staticlib into ios/Native for Xcode force_load (relative path in pbxproj). +install_ios_native_lib() { + local static_lib="$1" + local native_dir="$EXAMPLE_DIR/ios/Native" + mkdir -p "$native_dir" + cp -f "$static_lib" "$native_dir/libaval_decode.a" + echo "==> installed $native_dir/libaval_decode.a" +} + +if [ -n "$RUST_TARGET" ]; then + echo "==> cargo build --release --target $RUST_TARGET: aval_decode" + export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-13.0}" + export CFLAGS_aarch64_apple_ios="${CFLAGS_aarch64_apple_ios:--miphoneos-version-min=13.0}" + export CXXFLAGS_aarch64_apple_ios="${CXXFLAGS_aarch64_apple_ios:--miphoneos-version-min=13.0}" + export CFLAGS_aarch64_apple_ios_sim="${CFLAGS_aarch64_apple_ios_sim:--miphonesimulator-version-min=13.0}" + export CXXFLAGS_aarch64_apple_ios_sim="${CXXFLAGS_aarch64_apple_ios_sim:--miphonesimulator-version-min=13.0}" + # Device cdylib link fails on older min versions; staticlib is what we embed. + (cd "$FLUTTER_DIR/rust/aval_decode" && \ + cargo rustc --release --target "$RUST_TARGET" --crate-type staticlib) + export AVAL_DECODE_LIB="$FLUTTER_DIR/rust/aval_decode/target/$RUST_TARGET/release/$LIB_NAME" + write_ios_xcconfig "$AVAL_DECODE_LIB" + install_ios_native_lib "$AVAL_DECODE_LIB" +else + echo "==> cargo build --release: aval_decode" + (cd "$FLUTTER_DIR/rust/aval_decode" && cargo build --release) + export AVAL_DECODE_LIB="$FLUTTER_DIR/rust/aval_decode/target/release/$LIB_NAME" +fi + +[ -f "$AVAL_DECODE_LIB" ] || { echo "missing $AVAL_DECODE_LIB" >&2; exit 1; } + +if [ "$(uname -s)" = Darwin ] && [[ "$AVAL_DECODE_LIB" == *.dylib ]]; then + codesign -s - --force "$AVAL_DECODE_LIB" >/dev/null 2>&1 || true +fi + +# Boot / resolve iOS simulator when needed. +if [ "$TARGET_KIND" = "ios-sim" ]; then + pick_sim_udid() { + local udid + udid="$(xcrun simctl list devices booted 2>/dev/null | \ + awk -F '[()]' '/iPhone / {print $2; exit}')" + if [ -n "$udid" ]; then echo "$udid"; return; fi + for pattern in 'iPhone 17 Pro (' 'iPhone 16 Pro (' 'iPhone 17 (' 'iPhone 16 (' 'iPhone '; do + udid="$(xcrun simctl list devices available 2>/dev/null | \ + awk -F '[()]' -v p="$pattern" 'index($0, p) {print $2; exit}')" + if [ -n "$udid" ]; then echo "$udid"; return; fi + done + } + SIM_UDID="$(pick_sim_udid)" + if [ -z "$SIM_UDID" ]; then + echo "no iOS simulator found" >&2 + exit 1 + fi + if ! xcrun simctl list devices booted 2>/dev/null | grep -q "$SIM_UDID"; then + echo "==> booting simulator $SIM_UDID" + xcrun simctl boot "$SIM_UDID" 2>/dev/null || true + fi + open -a Simulator 2>/dev/null || true + if [ "$DEVICE_HINT" = "ios" ] || [ "$DEVICE_HINT" = "simulator" ]; then + NEW_ARGS=() + for ((i = 0; i < ${#ARGS[@]}; i++)); do + if [ "${ARGS[$i]}" = "-d" ] || [ "${ARGS[$i]}" = "--device-id" ]; then + NEW_ARGS+=("${ARGS[$i]}" "$SIM_UDID") + i=$((i + 1)) + continue + fi + NEW_ARGS+=("${ARGS[$i]}") + done + ARGS=("${NEW_ARGS[@]}") + fi +fi + +DART_DEFINES=(--dart-define=AVAL_DECODE_LIB="$AVAL_DECODE_LIB") +if [ "$USE_PROCESS" -eq 1 ]; then + DART_DEFINES+=(--dart-define=AVAL_DECODE_USE_PROCESS=true) +fi + +echo "==> flutter run: $EXAMPLE (target=$TARGET_KIND lib=$AVAL_DECODE_LIB process=$USE_PROCESS)" +cd "$EXAMPLE_DIR" +flutter pub get +exec flutter run "${ARGS[@]}" "${DART_DEFINES[@]}" diff --git a/flutter/scripts/setup.sh b/flutter/scripts/setup.sh new file mode 100755 index 0000000..a193b17 --- /dev/null +++ b/flutter/scripts/setup.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Fetch dependencies for every Dart package and the Rust crate. +set -euo pipefail + +FLUTTER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +for pkg in "$FLUTTER_DIR"/packages/*/; do + [ -f "$pkg/pubspec.yaml" ] || continue + echo "==> dart pub get: $(basename "$pkg")" + (cd "$pkg" && dart pub get) +done + +for crate in "$FLUTTER_DIR"/rust/*/; do + [ -f "$crate/Cargo.toml" ] || continue + echo "==> cargo fetch: $(basename "$crate")" + (cd "$crate" && cargo fetch) +done + +echo "setup complete" diff --git a/flutter/scripts/test.sh b/flutter/scripts/test.sh new file mode 100755 index 0000000..4bade72 --- /dev/null +++ b/flutter/scripts/test.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Run every test suite: all Dart packages + the Rust decode crate. +# Usage: test.sh [package-name ...] e.g. `test.sh aval_format aval_decode` +set -euo pipefail + +FLUTTER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +only=("$@") +failed=() + +want() { + [ ${#only[@]} -eq 0 ] && return 0 + for o in "${only[@]}"; do [ "$o" = "$1" ] && return 0; done + return 1 +} + +for pkg in "$FLUTTER_DIR"/packages/*/; do + [ -f "$pkg/pubspec.yaml" ] || continue + [ -d "$pkg/test" ] || continue + name="$(basename "$pkg")" + want "$name" || continue + echo "==> dart test: $name" + (cd "$pkg" && dart test) || failed+=("$name") +done + +for crate in "$FLUTTER_DIR"/rust/*/; do + [ -f "$crate/Cargo.toml" ] || continue + name="$(basename "$crate")" + want "$name" || continue + echo "==> cargo test: $name" + (cd "$crate" && cargo test) || failed+=("$name") +done + +if [ ${#failed[@]} -gt 0 ]; then + echo "TESTS FAILED: ${failed[*]}" >&2 + exit 1 +fi +echo "all test suites green" diff --git a/packages/compiler/src/commands/asset.ts b/packages/compiler/src/commands/asset.ts index c39471b..c454dfa 100644 --- a/packages/compiler/src/commands/asset.ts +++ b/packages/compiler/src/commands/asset.ts @@ -28,6 +28,22 @@ export interface AssetInspection { readonly frameRate: string; readonly initialState: string; readonly states: readonly string[]; + /** Every compiled edge, with the ring provenance of the turn steps. */ + readonly edges: readonly { + readonly id: string; + readonly from: string; + readonly to: string; + readonly ring?: string; + readonly step?: 1 | -1; + readonly derived?: true; + }[]; + readonly rings: readonly { + readonly id: string; + readonly states: readonly string[]; + readonly cyclic: boolean; + readonly tieBreak: "forward" | "backward"; + readonly maxChainedSteps: number; + }[]; readonly renditions: readonly { readonly id: string; readonly codec: string; @@ -110,6 +126,25 @@ export async function inspectAssetFile( frameRate: `${String(frameRate.numerator)}/${String(frameRate.denominator)}`, initialState: front.manifest.initialState, states: Object.freeze(front.manifest.states.map(({ id }) => id)), + edges: Object.freeze(front.manifest.edges.map((edge) => + Object.freeze({ + id: edge.id, + from: edge.from, + to: edge.to, + ...(edge.ring === undefined ? {} : { ring: edge.ring }), + ...(edge.step === undefined ? {} : { step: edge.step }), + ...(edge.derived === undefined ? {} : { derived: edge.derived }) + }) + )), + rings: Object.freeze((front.manifest.rings ?? []).map((ring) => + Object.freeze({ + id: ring.id, + states: Object.freeze([...ring.states]), + cyclic: ring.cyclic, + tieBreak: ring.tieBreak, + maxChainedSteps: ring.maxChainedSteps + }) + )), renditions: Object.freeze(front.manifest.renditions.map((rendition) => Object.freeze({ id: rendition.id, diff --git a/packages/compiler/src/compile/direct-project.ts b/packages/compiler/src/compile/direct-project.ts index 984a283..2fd9bc6 100644 --- a/packages/compiler/src/compile/direct-project.ts +++ b/packages/compiler/src/compile/direct-project.ts @@ -302,7 +302,8 @@ function directProject(input: { ...(intro === undefined ? {} : { initialUnit: intro.id }) })]), edges: Object.freeze([]), - bindings: Object.freeze([]) + bindings: Object.freeze([]), + rings: Object.freeze([]) }); } diff --git a/packages/compiler/src/compile/project-compiler.ts b/packages/compiler/src/compile/project-compiler.ts index cd313bb..0e8f9ce 100644 --- a/packages/compiler/src/compile/project-compiler.ts +++ b/packages/compiler/src/compile/project-compiler.ts @@ -223,6 +223,7 @@ export async function buildNormalizedProjectBundleArtifact( allInvocations.push(...toolchainInvocations("verify")); const warnings = Object.freeze([...new Set([ ...(options.warnings ?? []), + ...(project.ringNotes ?? []), ...alphaPolicy.warnings, ...continuity.warnings, ...[...sources.values()].flatMap(({ warnings }) => warnings) diff --git a/packages/compiler/src/compile/project-encoding-compiler.ts b/packages/compiler/src/compile/project-encoding-compiler.ts index c79d1c2..4a25679 100644 --- a/packages/compiler/src/compile/project-encoding-compiler.ts +++ b/packages/compiler/src/compile/project-encoding-compiler.ts @@ -8,6 +8,7 @@ import { type CompiledManifestInput, type EncodedChunkInput, type ProductionRendition, + type Ring, type UnitInput, type VideoBitDepth, type VideoLayout, @@ -18,6 +19,7 @@ import { CompilerError } from "../diagnostics.js"; import type { NormalizedSourceProject, NormalizedVideoEncoding, + SourceRing, SourceUnit } from "../model.js"; import { sha256Concat, sha256Hex } from "./hash.js"; @@ -122,8 +124,13 @@ export function compileProjectEncoding( units: Object.freeze(units), initialState: input.project.initialState, states: input.project.states, - edges: input.project.edges, + edges: Object.freeze(input.project.edges.map(({ kind: _k, ring: _r, step: _s, derived: _d, ...edge }) => + Object.freeze(edge) + )), bindings: input.project.bindings, + ...(input.project.rings === undefined || input.project.rings.length === 0 + ? {} + : { rings: input.project.rings.map(lowerRing) }), readiness: deriveReadiness(input.project), limits: estimateRuntimeLimits( input.project, @@ -309,6 +316,17 @@ function lowerUnit( return Object.freeze({ id: unit.id, kind: unit.kind, frameCount, chunks }); } +/** Strip the authoring-only turn defaults; the asset keeps only ring identity. */ +function lowerRing(ring: Readonly): Ring { + return Object.freeze({ + id: ring.id, + states: Object.freeze([...ring.states]), + cyclic: ring.cyclic, + tieBreak: ring.tieBreak, + maxChainedSteps: ring.maxChainedSteps + }); +} + function checkedAdd(left: number, right: number, label: string): number { if (left > Number.MAX_SAFE_INTEGER - right) { throw new CompilerError("OUTPUT_LIMIT", `${label} exceeds safe arithmetic`); diff --git a/packages/compiler/src/model.ts b/packages/compiler/src/model.ts index 90259fb..186e0fc 100644 --- a/packages/compiler/src/model.ts +++ b/packages/compiler/src/model.ts @@ -131,8 +131,21 @@ export type SourceTransition = readonly reverseOf?: string; }; +/** + * Ring membership of a turn edge. + * + * Authors write `kind: "turn"` with the ring and signed step; expansion emits the + * same shape with `derived: true` for the edges it generated. + */ +export interface SourceTurnMembership { + readonly kind?: "turn"; + readonly ring?: string; + readonly step?: 1 | -1; + readonly derived?: true; +} + export type SourceEdge = - | { + | (SourceTurnMembership & { readonly id: string; readonly from: string; readonly to: string; @@ -141,8 +154,8 @@ export type SourceEdge = readonly transition?: SourceTransition; readonly continuity: "exact-authored" | "exact-reverse"; readonly targetRunwayFrames?: never; - } - | { + }) + | (SourceTurnMembership & { readonly id: string; readonly from: string; readonly to: string; @@ -151,7 +164,38 @@ export type SourceEdge = readonly transition?: never; readonly continuity: "cut"; readonly targetRunwayFrames: number; - }; + }); + +/** + * One authored departure from the ring default, applied to a single ordered + * neighbour pair. An override is the only way to give a step its own bridge + * unit, and it is rejected unless its pair is actually adjacent. + */ +export interface SourceRingOverride { + readonly from: string; + readonly to: string; + readonly mode: "cut" | "unit"; + readonly unit?: string; + readonly direction?: "forward" | "reverse"; + readonly continuity?: "exact-authored" | "exact-reverse"; +} + +/** How every step of a ring departs and what it plays, before overrides. */ +export interface SourceRingTurn { + readonly mode: "cut" | "unit"; + readonly start: Exclude; + readonly continuity: "exact-authored" | "exact-reverse"; +} + +export interface SourceRing { + readonly id: string; + readonly states: readonly string[]; + readonly cyclic: boolean; + readonly tieBreak: "forward" | "backward"; + readonly turn: SourceRingTurn; + readonly maxChainedSteps: number; + readonly overrides: readonly SourceRingOverride[]; +} export type SourceBindingName = | "activate" @@ -249,6 +293,8 @@ export interface SourceProject { readonly states: readonly SourceState[]; readonly edges: readonly SourceEdge[]; readonly bindings: readonly SourceBinding[]; + /** Absent in projects which author no ring. */ + readonly rings?: readonly SourceRing[]; } export type NormalizedVideoEncoding = VideoEncoding; @@ -263,8 +309,13 @@ export interface NormalizedSourceProject { readonly units: readonly SourceUnit[]; readonly initialState: string; readonly states: readonly SourceState[]; + /** Authored edges plus every step expansion derived from a ring. */ readonly edges: readonly SourceEdge[]; readonly bindings: readonly SourceBinding[]; + /** Absent in projects which author no ring. */ + readonly rings?: readonly SourceRing[]; + /** Author-facing notes from ring expansion, such as a shadowed step. */ + readonly ringNotes?: readonly string[]; } export interface AlphaPixelLocation { diff --git a/packages/compiler/src/source-graph-preflight.ts b/packages/compiler/src/source-graph-preflight.ts index ea921cc..0026a9c 100644 --- a/packages/compiler/src/source-graph-preflight.ts +++ b/packages/compiler/src/source-graph-preflight.ts @@ -21,7 +21,7 @@ import type { */ export function preflightSourceGraph(project: Pick< SourceProject, - "initialState" | "states" | "edges" | "units" + "initialState" | "states" | "edges" | "units" | "rings" >): void { const units = new Map(project.units.map((unit) => [unit.id, unit])); let definition: MotionGraphDefinition; @@ -39,12 +39,28 @@ export function preflightSourceGraph(project: Pick< to: edge.to, start: edge.start, continuity: edge.continuity, - ...(edge.trigger === undefined ? {} : { trigger: edge.trigger }) + ...(edge.trigger === undefined ? {} : { trigger: edge.trigger }), + ...(edge.ring === undefined + ? {} + : { ring: edge.ring, step: edge.step ?? 1 }) }; return Object.freeze( transition === undefined ? base : { ...base, transition } ) as GraphEdgeDefinition; - }) + }), + ...(project.rings === undefined || project.rings.length === 0 + ? {} + : { + rings: project.rings!.map((ring) => + Object.freeze({ + id: ring.id, + states: Object.freeze([...ring.states]), + cyclic: ring.cyclic, + tieBreak: ring.tieBreak, + maxChainedSteps: ring.maxChainedSteps + }) + ) + }) }; validateMotionGraphDefinition(definition); } catch (error) { diff --git a/packages/compiler/src/source-graph-schema.ts b/packages/compiler/src/source-graph-schema.ts index cc54ea9..b3be241 100644 --- a/packages/compiler/src/source-graph-schema.ts +++ b/packages/compiler/src/source-graph-schema.ts @@ -4,7 +4,8 @@ import type { SourceEdge, SourceStart, SourceTransition, - SourceTrigger + SourceTrigger, + SourceTurnMembership } from "./model.js"; import { @@ -32,6 +33,9 @@ const BINDING_SOURCES = [ "visible" ] as const satisfies readonly SourceBindingName[]; +/** Author-facing ring membership; `derived` is emitted by expansion, not read. */ +const TURN_KEYS = ["kind", "ring", "step"] as const; + export function cloneSourceEdges( value: unknown, maximum: number @@ -77,7 +81,7 @@ function cloneEdge(value: unknown, path: string): SourceEdge { `${path}.start.type` ); const commonKeys = ["id", "from", "to", "start", "continuity"]; - const optionalCommon = ["trigger"]; + const optionalCommon = ["trigger", ...TURN_KEYS]; if (startType === "cut") { exactKeys(input, [...commonKeys, "targetRunwayFrames"], path, optionalCommon); } else { @@ -88,6 +92,7 @@ function cloneEdge(value: unknown, path: string): SourceEdge { const to = identifier(input.to, `${path}.to`); const trigger = cloneTrigger(input.trigger, `${path}.trigger`); const start = cloneStart(startInput, startType, `${path}.start`); + const turn = cloneTurnMembership(input, path); if (startType === "cut") { literal(input.continuity, "cut", `${path}.continuity`); @@ -96,6 +101,7 @@ function cloneEdge(value: unknown, path: string): SourceEdge { from, to, ...(trigger === undefined ? {} : { trigger }), + ...turn, start: start as Extract, continuity: "cut", targetRunwayFrames: integer( @@ -117,12 +123,33 @@ function cloneEdge(value: unknown, path: string): SourceEdge { from, to, ...(trigger === undefined ? {} : { trigger }), + ...turn, start: start as Exclude, ...(transition === undefined ? {} : { transition }), continuity }); } +/** + * Read the authored turn marker. `kind: "turn"` is the author-facing spelling of + * ring membership, so it always travels with the ring and the signed step. + */ +function cloneTurnMembership( + input: Record, + path: string +): SourceTurnMembership { + const declared = TURN_KEYS.filter((key) => + Object.prototype.hasOwnProperty.call(input, key) + ); + if (declared.length === 0) return {}; + literal(input.kind, "turn", `${path}.kind`); + const ring = identifier(input.ring, `${path}.ring`); + if (input.step !== 1 && input.step !== -1) { + invalid(`${path}.step`, "must be 1 or -1"); + } + return Object.freeze({ kind: "turn" as const, ring, step: input.step }); +} + function cloneTrigger(value: unknown, path: string): SourceTrigger | undefined { if (value === undefined) return undefined; const input = record(value, path); diff --git a/packages/compiler/src/source-project-normalize.ts b/packages/compiler/src/source-project-normalize.ts index 52abcb0..c1495cd 100644 --- a/packages/compiler/src/source-project-normalize.ts +++ b/packages/compiler/src/source-project-normalize.ts @@ -17,6 +17,8 @@ export function normalizeSourceProject( initialState: project.initialState, states: project.states, edges: project.edges, - bindings: project.bindings + bindings: project.bindings, + ...(project.rings === undefined ? {} : { rings: project.rings }), + ...(project.ringNotes === undefined ? {} : { ringNotes: project.ringNotes }) }); } diff --git a/packages/compiler/src/source-project-schema.ts b/packages/compiler/src/source-project-schema.ts index 5ccaf5f..cf2499a 100644 --- a/packages/compiler/src/source-project-schema.ts +++ b/packages/compiler/src/source-project-schema.ts @@ -31,6 +31,8 @@ import { cloneSourceBindings, cloneSourceEdges } from "./source-graph-schema.js"; +import { expandSourceRings } from "./source-ring-expansion.js"; +import { cloneSourceRings } from "./source-ring-schema.js"; import { preflightSourceGraph } from "./source-graph-preflight.js"; import { cloneVideoEncodings } from "./compile/video-encoding-policy.js"; import { normalizeSourceProject } from "./source-project-normalize.js"; @@ -48,6 +50,8 @@ const PROJECT_KEYS = [ "edges", "bindings" ] as const; +/** Projects which author no ring omit the key, keeping their output unchanged. */ +const OPTIONAL_PROJECT_KEYS = ["rings"] as const; const PNG_DIMENSION_MAX = 0xffff_ffff; /** Parse strict JSON and validate the sole project format. */ @@ -71,18 +75,35 @@ export function validateSourceProject( value: unknown ): Readonly { const input = record(value, "project"); - exactKeys(input, PROJECT_KEYS, "project"); + exactKeys(input, PROJECT_KEYS, "project", OPTIONAL_PROJECT_KEYS); literal(input.projectVersion, "1.0", "project.projectVersion"); const canvas = cloneCanvas(input.canvas); const frameRate = cloneSourceFrameRate(input.frameRate); const sources = cloneSourceDescriptors(input.sources); const units = cloneSourceUnits(input.units, sources); const states = cloneSourceStates(input.states, units); - const edges = cloneSourceEdges(input.edges, FORMAT_DEFAULT_BUDGETS.maxEdges); + const authoredEdges = cloneSourceEdges( + input.edges, + FORMAT_DEFAULT_BUDGETS.maxEdges + ); const bindings = cloneSourceBindings( input.bindings, FORMAT_DEFAULT_BUDGETS.maxBindings ); + const rings = cloneSourceRings(input.rings); + const expansion = expandSourceRings({ + rings, + states, + units, + edges: authoredEdges + }); + const edges = expansion.edges; + if (edges.length > FORMAT_DEFAULT_BUDGETS.maxEdges) { + invalid( + "project.edges", + `expand to ${String(edges.length)} entries, above the ${String(FORMAT_DEFAULT_BUDGETS.maxEdges)} edge budget` + ); + } const initialState = identifier(input.initialState, "project.initialState"); validateSourceReferences({ initialState, @@ -107,7 +128,12 @@ export function validateSourceProject( initialState, states, edges, - bindings + bindings, + // Omitted, not emptied: a project without rings normalizes exactly as it + // did before rings existed. + ...(rings.length === 0 + ? {} + : { rings, ringNotes: expansion.notes }) }) satisfies Readonly); preflightSourceGraph(project); return project; diff --git a/packages/compiler/src/source-ring-expansion.ts b/packages/compiler/src/source-ring-expansion.ts new file mode 100644 index 0000000..3502f1e --- /dev/null +++ b/packages/compiler/src/source-ring-expansion.ts @@ -0,0 +1,401 @@ +import type { + SourceEdge, + SourceRing, + SourceRingOverride, + SourceState, + SourceTransition, + SourceUnit +} from "./model.js"; +import { identifier, invalid } from "./schema-validation.js"; + +export interface RingExpansionInput { + readonly rings: readonly SourceRing[]; + readonly states: readonly SourceState[]; + readonly units: readonly SourceUnit[]; + readonly edges: readonly SourceEdge[]; +} + +export interface RingExpansion { + /** Authored edges first, then every derived step, sorted by id. */ + readonly edges: readonly SourceEdge[]; + readonly notes: readonly string[]; +} + +interface StepPair { + readonly from: string; + readonly to: string; + readonly step: 1 | -1; +} + +/** + * Expand every ring into the turn edges which walk it. + * + * A ring is authoring shorthand: the compiled asset still contains one ordinary + * edge per ordered neighbour pair, so nothing downstream has to understand rings + * to play them. Derived edges are marked so `inspect` can tell them apart from + * authored ones, and an authored edge always wins over the step it shadows. + */ +export function expandSourceRings( + input: Readonly +): Readonly { + if (input.rings.length === 0) { + return Object.freeze({ edges: input.edges, notes: Object.freeze([]) }); + } + + const stateIds = new Set(input.states.map(({ id }) => id)); + const portsByState = indexPortsByState(input.states, input.units); + const authoredByPair = new Map(); + for (const edge of input.edges) { + authoredByPair.set(pairKey(edge.from, edge.to), edge); + } + + const notes: string[] = []; + const derived: SourceEdge[] = []; + const owners = new Map(); + const ringsById = new Map(input.rings.map((ring) => [ring.id, ring])); + + for (const ring of input.rings) { + validateRingMembers(ring, stateIds); + const pairs = neighbourPairs(ring); + const pairSet = new Set(pairs.map(({ from, to }) => pairKey(from, to))); + validateOverrides(ring, pairSet); + validateUnitMode(ring, pairs); + + for (const pair of pairs) { + const key = pairKey(pair.from, pair.to); + const owner = owners.get(key); + if (owner !== undefined) { + // V7: one pair, one edge; two owners would make routing ambiguous. + invalid( + `rings.${ring.id}`, + `steps from ${pair.from} to ${pair.to}, which ring ${owner} already derives` + ); + } + owners.set(key, ring.id); + + const authored = authoredByPair.get(key); + if (authored !== undefined) { + notes.push( + `ring ${ring.id} step ${pair.from} to ${pair.to} is shadowed by authored edge ${authored.id}` + ); + continue; + } + derived.push(deriveStep(ring, pair, portsByState)); + } + } + + reconcileReversibleOverrides(input.rings, derived); + for (const edge of input.edges) { + validateAuthoredTurnEdge(edge, ringsById, owners); + } + + return Object.freeze({ + edges: Object.freeze(sortEdgesById([...input.edges, ...derived])), + notes: Object.freeze(notes) + }); +} + +/** V1, V2, V3: the ring's own members must be usable before anything derives. */ +function validateRingMembers(ring: SourceRing, stateIds: ReadonlySet): void { + const seen = new Set(); + for (const state of ring.states) { + if (seen.has(state)) { + invalid(`rings.${ring.id}`, `duplicates state ${state}`); + } + seen.add(state); + if (!stateIds.has(state)) { + invalid(`rings.${ring.id}`, `references unknown state ${state}`); + } + } + if (ring.states.length < 2) { + invalid( + `rings.${ring.id}`, + `must contain at least 2 states, not ${String(ring.states.length)}` + ); + } + if (ring.cyclic && ring.states.length < 3) { + invalid( + `rings.${ring.id}`, + `is cyclic and must contain at least 3 states, not ${String(ring.states.length)}` + ); + } + if (ring.maxChainedSteps > ring.states.length) { + invalid( + `rings.${ring.id}`, + `maxChainedSteps ${String(ring.maxChainedSteps)} exceeds its ${String(ring.states.length)} states` + ); + } +} + +/** V5: an override only means something on a pair the ring actually steps. */ +function validateOverrides( + ring: SourceRing, + pairs: ReadonlySet +): void { + for (const override of ring.overrides) { + if (!pairs.has(pairKey(override.from, override.to))) { + invalid( + `rings.${ring.id}`, + `override ${override.from} to ${override.to} is not an adjacent step` + ); + } + } +} + +/** V4: ring-level unit mode needs a unit for every single step. */ +function validateUnitMode( + ring: SourceRing, + pairs: readonly StepPair[] +): void { + if (ring.turn.mode !== "unit") return; + const supplied = new Map( + ring.overrides + .filter((override) => override.mode === "unit") + .map((override) => [pairKey(override.from, override.to), override]) + ); + const missing = pairs.filter( + ({ from, to }) => !supplied.has(pairKey(from, to)) + ); + if (missing.length > 0) { + const first = missing[0]!; + invalid( + `rings.${ring.id}`, + `turn mode unit needs a per-step unit override; ${String(missing.length)} step(s) have none, starting at ${first.from} to ${first.to}` + ); + } +} + +/** V6: an authored turn edge must name a real ring adjacency. */ +function validateAuthoredTurnEdge( + edge: SourceEdge, + ringsById: ReadonlyMap, + owners: ReadonlyMap +): void { + if (edge.kind === undefined && edge.ring === undefined) return; + const ring = edge.ring === undefined ? undefined : ringsById.get(edge.ring); + if (ring === undefined) { + invalid( + `edges.${edge.id}`, + `references unknown ring ${String(edge.ring)}` + ); + } + const owner = owners.get(pairKey(edge.from, edge.to)); + if (owner !== ring.id) { + invalid( + `edges.${edge.id}`, + `is not an adjacent step of ring ${ring.id} from ${edge.from} to ${edge.to}` + ); + } +} + +/** Every ordered adjacency of a ring: forward around first, then backward. */ +function neighbourPairs(ring: SourceRing): readonly StepPair[] { + const forward: StepPair[] = []; + const backward: StepPair[] = []; + const length = ring.states.length; + const adjacencies = ring.cyclic ? length : length - 1; + for (let index = 0; index < adjacencies; index += 1) { + const from = ring.states[index]!; + const to = ring.states[(index + 1) % length]!; + forward.push({ from, to, step: 1 }); + backward.push({ from: to, to: from, step: -1 }); + } + return Object.freeze([...forward, ...backward]); +} + +function deriveStep( + ring: SourceRing, + pair: StepPair, + portsByState: ReadonlyMap> +): SourceEdge { + const override = ring.overrides.find( + (candidate) => candidate.from === pair.from && candidate.to === pair.to + ); + const mode = override?.mode ?? ring.turn.mode; + const start = ring.turn.start; + validatePorts(ring, pair, start, portsByState); + + const base = { + id: stepEdgeId(ring, pair), + from: pair.from, + to: pair.to, + kind: "turn" as const, + ring: ring.id, + step: pair.step, + derived: true as const, + start, + continuity: override?.continuity ?? ring.turn.continuity + }; + if (mode === "cut") return Object.freeze(base); + return Object.freeze({ + ...base, + transition: unitTransition(ring, pair, override!) + }); +} + +function unitTransition( + ring: SourceRing, + pair: StepPair, + override: SourceRingOverride +): SourceTransition { + const unit = override.unit; + if (unit === undefined) { + invalid( + `rings.${ring.id}`, + `step ${pair.from} to ${pair.to} uses mode unit without a unit` + ); + } + if (override.direction === undefined) { + return Object.freeze({ kind: "locked", unit }); + } + return Object.freeze({ + kind: "reversible", + unit, + direction: override.direction + }); +} + +/** + * Complete the reversible step pairs an override asked for. + * + * A reversible unit is shared by exactly two inverse edges, one of which points + * at the other, so the pairing can only be resolved once both steps exist. + */ +function reconcileReversibleOverrides( + rings: readonly SourceRing[], + derived: SourceEdge[] +): void { + const byId = new Map(derived.map((edge, index) => [edge.id, index])); + for (const ring of rings) { + for (const override of ring.overrides) { + if (override.direction !== "reverse") continue; + const forward = ring.overrides.find( + (candidate) => + candidate.from === override.to && + candidate.to === override.from && + candidate.direction === "forward" + ); + if (forward === undefined || forward.unit !== override.unit) { + invalid( + `rings.${ring.id}`, + `reversible step ${override.from} to ${override.to} needs a forward override sharing unit ${String(override.unit)}` + ); + } + const reverseIndex = byId.get( + stepEdgeId(ring, { from: override.from, to: override.to }) + ); + const forwardId = stepEdgeId(ring, { + from: forward.from, + to: forward.to + }); + if (reverseIndex === undefined || !byId.has(forwardId)) { + invalid( + `rings.${ring.id}`, + `reversible step ${override.from} to ${override.to} is shadowed on only one side` + ); + } + const edge = derived[reverseIndex]!; + derived[reverseIndex] = Object.freeze({ + ...edge, + continuity: "exact-reverse", + transition: Object.freeze({ + kind: "reversible", + unit: override.unit!, + direction: "reverse", + reverseOf: forwardId + }) + }) as SourceEdge; + } + } +} + +/** V8: a step can only depart and land through ports both bodies declare. */ +function validatePorts( + ring: SourceRing, + pair: StepPair, + start: SourceRing["turn"]["start"], + portsByState: ReadonlyMap> +): void { + const target = portsByState.get(pair.to); + if (target?.has(start.targetPort) !== true) { + invalid( + `rings.${ring.id}`, + `step ${pair.from} to ${pair.to} needs port ${start.targetPort} on ${pair.to}` + ); + } + if (start.type !== "portal") return; + const source = portsByState.get(pair.from); + if (source?.has(start.sourcePort) !== true) { + invalid( + `rings.${ring.id}`, + `step ${pair.from} to ${pair.to} needs port ${start.sourcePort} on ${pair.from}` + ); + } +} + +/** + * The id of a derived step: `..`, with the prefix every ring + * member shares removed so `facing.walk` over `walk_n`/`walk_ne` reads + * `facing.walk.n.ne` instead of repeating the axis in all three positions. + */ +export function stepEdgeId( + ring: SourceRing, + pair: Readonly<{ from: string; to: string }> +): string { + const prefix = sharedMemberPrefix(ring.states); + const from = pair.from.slice(prefix.length); + const to = pair.to.slice(prefix.length); + const id = `${ring.id}.${from === "" ? pair.from : from}.${to === "" ? pair.to : to}`; + return identifier(id, `rings.${ring.id}.steps`); +} + +/** + * The longest prefix shared by every member, cut back to a separator so a + * remainder never begins mid-word. Returns "" when nothing is shared. + */ +function sharedMemberPrefix(states: readonly string[]): string { + const first = states[0] ?? ""; + let length = first.length; + for (const state of states) { + let index = 0; + while (index < length && index < state.length && state[index] === first[index]) { + index += 1; + } + length = index; + } + const candidate = first.slice(0, length); + const boundary = Math.max( + candidate.lastIndexOf("_"), + candidate.lastIndexOf("."), + candidate.lastIndexOf("-") + ); + if (boundary < 0) return ""; + const prefix = candidate.slice(0, boundary + 1); + return states.every((state) => state.length > prefix.length) ? prefix : ""; +} + +function indexPortsByState( + states: readonly SourceState[], + units: readonly SourceUnit[] +): ReadonlyMap> { + const unitsById = new Map(units.map((unit) => [unit.id, unit])); + const portsByState = new Map>(); + for (const state of states) { + const unit = unitsById.get(state.bodyUnit); + portsByState.set( + state.id, + new Set(unit?.kind === "body" ? unit.ports.map(({ id }) => id) : []) + ); + } + return portsByState; +} + +function sortEdgesById(edges: readonly SourceEdge[]): SourceEdge[] { + return [...edges].sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0 + ); +} + +function pairKey(from: string, to: string): string { + return `${from} ${to}`; +} diff --git a/packages/compiler/src/source-ring-schema.ts b/packages/compiler/src/source-ring-schema.ts new file mode 100644 index 0000000..7770dc2 --- /dev/null +++ b/packages/compiler/src/source-ring-schema.ts @@ -0,0 +1,178 @@ +import type { SourceRing, SourceRingOverride, SourceStart } from "./model.js"; + +import { + boundedArray, + exactKeys, + identifier, + integer, + invalid, + oneOf, + optionalIdentifier, + record, + sortUniqueById +} from "./schema-validation.js"; + +export const MAX_RINGS = 8; +export const MAX_RING_STATES = 32; + +/** + * Validate the authored rings. + * + * Only the ring array is sorted; member order is the axis itself and must be + * preserved exactly as authored. Structural rules which depend on the rest of + * the project (member existence, adjacency, port compatibility) belong to ring + * expansion, which owns the V1-V8 diagnostics. + */ +export function cloneSourceRings(value: unknown): readonly SourceRing[] { + if (value === undefined) return Object.freeze([]); + const inputs = boundedArray(value, "rings", 0, MAX_RINGS); + return sortUniqueById( + inputs.map((entry, index) => cloneRing(entry, `rings[${String(index)}]`)), + "rings" + ); +} + +function cloneRing(value: unknown, path: string): SourceRing { + const input = record(value, path); + exactKeys( + input, + ["id", "states", "cyclic", "tieBreak", "turn", "maxChainedSteps"], + path, + ["overrides"] + ); + const id = identifier(input.id, `${path}.id`); + // Length rules live in expansion, which can name the ring in its diagnostic. + const stateInputs = boundedArray( + input.states, + `${path}.states`, + 0, + MAX_RING_STATES + ); + const states = stateInputs.map((state, index) => + identifier(state, `${path}.states[${String(index)}]`) + ); + const cyclic = boolean(input.cyclic, `${path}.cyclic`); + return Object.freeze({ + id, + states: Object.freeze(states), + cyclic, + tieBreak: oneOf( + input.tieBreak, + ["forward", "backward"] as const, + `${path}.tieBreak` + ), + turn: cloneTurn(input.turn, `${path}.turn`), + maxChainedSteps: integer( + input.maxChainedSteps, + `${path}.maxChainedSteps`, + 1, + MAX_RING_STATES + ), + overrides: cloneOverrides(input.overrides, `${path}.overrides`) + }); +} + +function cloneTurn(value: unknown, path: string): SourceRing["turn"] { + const input = record(value, path); + exactKeys(input, ["mode", "start", "continuity"], path); + return Object.freeze({ + mode: oneOf(input.mode, ["cut", "unit"] as const, `${path}.mode`), + start: cloneStart(input.start, `${path}.start`), + continuity: oneOf( + input.continuity, + ["exact-authored", "exact-reverse"] as const, + `${path}.continuity` + ) + }); +} + +/** + * Every step departs at an authored boundary, so a ring's start policy is a + * portal or a finish. The compiled step is a cut only in the sense that it plays + * no bridge unit, which `turn.mode` decides. + */ +function cloneStart( + value: unknown, + path: string +): Exclude { + const input = record(value, path); + const type = oneOf(input.type, ["portal", "finish"] as const, `${path}.type`); + if (type === "portal") { + exactKeys(input, ["type", "sourcePort", "targetPort", "maxWaitFrames"], path); + return Object.freeze({ + type, + sourcePort: identifier(input.sourcePort, `${path}.sourcePort`), + targetPort: identifier(input.targetPort, `${path}.targetPort`), + maxWaitFrames: integer(input.maxWaitFrames, `${path}.maxWaitFrames`, 0) + }); + } + exactKeys(input, ["type", "targetPort", "maxWaitFrames"], path); + return Object.freeze({ + type, + targetPort: identifier(input.targetPort, `${path}.targetPort`), + maxWaitFrames: integer(input.maxWaitFrames, `${path}.maxWaitFrames`, 0) + }); +} + +function cloneOverrides( + value: unknown, + path: string +): readonly SourceRingOverride[] { + if (value === undefined) return Object.freeze([]); + const inputs = boundedArray(value, path, 0, MAX_RING_STATES * 2); + const seen = new Set(); + const overrides = inputs.map((entry, index) => { + const overridePath = `${path}[${String(index)}]`; + const input = record(entry, overridePath); + exactKeys(input, ["from", "to", "mode"], overridePath, [ + "unit", + "direction", + "continuity" + ]); + const from = identifier(input.from, `${overridePath}.from`); + const to = identifier(input.to, `${overridePath}.to`); + const key = `${from} ${to}`; + if (seen.has(key)) { + invalid(overridePath, `duplicates the step ${from} to ${to}`); + } + seen.add(key); + const mode = oneOf(input.mode, ["cut", "unit"] as const, `${overridePath}.mode`); + const unit = optionalIdentifier(input.unit, `${overridePath}.unit`); + if (mode === "unit" && unit === undefined) { + invalid(`${overridePath}.unit`, "is required by mode unit"); + } + if (mode === "cut" && unit !== undefined) { + invalid(`${overridePath}.unit`, "is not allowed by mode cut"); + } + return Object.freeze({ + from, + to, + mode, + ...(unit === undefined ? {} : { unit }), + ...(input.direction === undefined + ? {} + : { + direction: oneOf( + input.direction, + ["forward", "reverse"] as const, + `${overridePath}.direction` + ) + }), + ...(input.continuity === undefined + ? {} + : { + continuity: oneOf( + input.continuity, + ["exact-authored", "exact-reverse"] as const, + `${overridePath}.continuity` + ) + }) + }); + }); + return Object.freeze(overrides); +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") invalid(path, "must be a boolean"); + return value; +} diff --git a/packages/compiler/test/source-ring-expansion.test.ts b/packages/compiler/test/source-ring-expansion.test.ts new file mode 100644 index 0000000..06062db --- /dev/null +++ b/packages/compiler/test/source-ring-expansion.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from "vitest"; + +import { CompilerError } from "../src/diagnostics.js"; +import { validateSourceProject } from "../src/source-project-schema.js"; + +const FACINGS = Object.freeze([ + "walk_n", + "walk_ne", + "walk_e", + "walk_se", + "walk_s", + "walk_sw", + "walk_w", + "walk_nw" +]); + +/** An eight-way facing ring over one shared source, in cut mode. */ +function ringProject(): any { + return { + projectVersion: "1.0", + alpha: "auto", + canvas: { + width: 256, + height: 256, + fit: "contain", + pixelAspect: [1, 1], + colorSpace: "srgb" + }, + frameRate: { numerator: 30, denominator: 1 }, + sources: [{ + id: "render", + type: "video", + path: "render.mov", + timing: { mode: "exact" } + }], + encodings: [{ + codec: "h264", + preset: "slow", + renditions: [{ id: "video.1x", width: 256, height: 256, crf: 20 }] + }], + units: FACINGS.map((facing, index) => ({ + id: `${facing}.body`, + kind: "body", + source: "render", + range: [index * 8, index * 8 + 8], + playback: "loop", + ports: [{ id: "default", entryFrame: 0, portalFrames: [0, 4] }] + })), + initialState: "walk_n", + states: FACINGS.map((facing) => ({ + id: facing, + bodyUnit: `${facing}.body` + })), + edges: [], + bindings: [], + rings: [{ + id: "facing.walk", + states: [...FACINGS], + cyclic: true, + tieBreak: "forward", + turn: { + mode: "cut", + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 8 + }, + continuity: "exact-authored" + }, + maxChainedSteps: 4 + }] + }; +} + +function expectInvalid(value: unknown, pattern: RegExp): CompilerError { + try { + validateSourceProject(value); + } catch (error) { + expect(error).toBeInstanceOf(CompilerError); + expect((error as CompilerError).message).toMatch(pattern); + return error as CompilerError; + } + throw new Error("expected project validation to fail"); +} + +describe("ring expansion", () => { + it("derives two turn edges per adjacency of a cyclic ring", () => { + const project = validateSourceProject(ringProject()); + + // AC1: eight members, eight adjacencies, both directions. + expect(project.edges).toHaveLength(16); + expect(project.edges.every((edge) => edge.derived === true)).toBe(true); + expect(project.edges.every((edge) => edge.kind === "turn")).toBe(true); + expect(project.edges.every((edge) => edge.ring === "facing.walk")).toBe(true); + expect(project.edges.filter((edge) => edge.step === 1)).toHaveLength(8); + expect(project.edges.filter((edge) => edge.step === -1)).toHaveLength(8); + expect(project.ringNotes).toEqual([]); + }); + + it("names a derived step by its ring and the members' distinct suffixes", () => { + const project = validateSourceProject(ringProject()); + + expect(project.edges.map(({ id }) => id)).toContain("facing.walk.n.ne"); + expect(project.edges.map(({ id }) => id)).toContain("facing.walk.nw.n"); + expect(project.edges.find(({ id }) => id === "facing.walk.n.ne")).toEqual({ + id: "facing.walk.n.ne", + from: "walk_n", + to: "walk_ne", + kind: "turn", + ring: "facing.walk", + step: 1, + derived: true, + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 8 + }, + continuity: "exact-authored" + }); + expect(project.edges.map(({ id }) => id)) + .toEqual([...project.edges.map(({ id }) => id)].sort()); + }); + + it("derives one edge per adjacency direction on a non-cyclic ring", () => { + const value = ringProject(); + value.rings[0].cyclic = false; + const project = validateSourceProject(value); + + expect(project.edges).toHaveLength(14); + expect(project.edges.map(({ id }) => id)).not.toContain("facing.walk.nw.n"); + }); + + it("lets an authored edge shadow the step it replaces and notes it", () => { + const value = ringProject(); + value.units.push({ + id: "pivot.nw.n", + kind: "bridge", + source: "render", + range: [64, 68] + }); + value.edges.push({ + id: "pivot.nw.n.edge", + from: "walk_nw", + to: "walk_n", + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 8 + }, + transition: { kind: "locked", unit: "pivot.nw.n" }, + continuity: "exact-authored" + }); + const project = validateSourceProject(value); + + // AC6: the authored edge wins and the shadowed step is reported. + expect(project.edges).toHaveLength(16); + expect(project.edges.map(({ id }) => id)).toContain("pivot.nw.n.edge"); + expect(project.edges.map(({ id }) => id)).not.toContain("facing.walk.nw.n"); + expect(project.ringNotes).toEqual([ + "ring facing.walk step walk_nw to walk_n is shadowed by authored edge pivot.nw.n.edge" + ]); + }); + + it("gives a step its own bridge unit through an override", () => { + const value = ringProject(); + value.units.push({ + id: "pivot.n.ne", + kind: "bridge", + source: "render", + range: [64, 68] + }); + value.rings[0].overrides = [ + { from: "walk_n", to: "walk_ne", mode: "unit", unit: "pivot.n.ne" } + ]; + const project = validateSourceProject(value); + + expect(project.edges.find(({ id }) => id === "facing.walk.n.ne")) + .toMatchObject({ transition: { kind: "locked", unit: "pivot.n.ne" } }); + expect(project.edges.find(({ id }) => id === "facing.walk.ne.e")) + .not.toHaveProperty("transition"); + }); + + it("accepts an authored turn edge which names its ring adjacency", () => { + const value = ringProject(); + value.rings[0].states = ["walk_n", "walk_ne", "walk_e"]; + value.rings[0].cyclic = false; + value.rings[0].maxChainedSteps = 2; + value.states = value.states.slice(0, 3); + value.units = value.units.slice(0, 3); + value.edges.push({ + id: "explicit.e.se", + kind: "turn", + ring: "facing.walk", + step: 1, + from: "walk_ne", + to: "walk_e", + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 8 + }, + continuity: "exact-authored" + }); + const project = validateSourceProject(value); + + expect(project.edges).toHaveLength(4); + expect(project.rings?.[0]?.states).toEqual(["walk_n", "walk_ne", "walk_e"]); + }); + + it("rejects every unusable ring at compile time", () => { + // V1: a member which is not a state. + const unknownState = ringProject(); + unknownState.rings[0].states = [...FACINGS, "sprint"]; + expectInvalid( + unknownState, + /rings\.facing\.walk references unknown state sprint/u + ); + + // V2: a member repeated inside one ring. + const duplicated = ringProject(); + duplicated.rings[0].states = [...FACINGS, "walk_n"]; + expectInvalid(duplicated, /rings\.facing\.walk duplicates state walk_n/u); + + // V3: too few members to be an axis at all, or to close a cycle. + const single = ringProject(); + single.rings[0].states = ["walk_n"]; + expectInvalid( + single, + /rings\.facing\.walk must contain at least 2 states, not 1/u + ); + const openPair = ringProject(); + openPair.rings[0].states = ["walk_n", "walk_ne"]; + openPair.rings[0].maxChainedSteps = 1; + expectInvalid( + openPair, + /rings\.facing\.walk is cyclic and must contain at least 3 states, not 2/u + ); + + // V4: ring-level unit mode with no per-step units. + const unitMode = ringProject(); + unitMode.rings[0].turn.mode = "unit"; + expectInvalid( + unitMode, + /rings\.facing\.walk turn mode unit needs a per-step unit override; 16 step\(s\) have none, starting at walk_n to walk_ne/u + ); + + // V5: an override on a pair which is not adjacent. + const strayOverride = ringProject(); + strayOverride.rings[0].overrides = [ + { from: "walk_n", to: "walk_s", mode: "cut" } + ]; + expectInvalid( + strayOverride, + /rings\.facing\.walk override walk_n to walk_s is not an adjacent step/u + ); + + // V6: an authored turn edge whose ring or adjacency does not exist. + const unknownRing = ringProject(); + unknownRing.edges.push({ + id: "explicit.turn", + kind: "turn", + ring: "facing.absent", + step: 1, + from: "walk_n", + to: "walk_ne", + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 8 + }, + continuity: "exact-authored" + }); + expectInvalid( + unknownRing, + /edges\.explicit\.turn references unknown ring facing\.absent/u + ); + const nonAdjacent = ringProject(); + nonAdjacent.edges.push({ + id: "explicit.turn", + kind: "turn", + ring: "facing.walk", + step: 1, + from: "walk_n", + to: "walk_s", + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 8 + }, + continuity: "exact-authored" + }); + expectInvalid( + nonAdjacent, + /edges\.explicit\.turn is not an adjacent step of ring facing\.walk from walk_n to walk_s/u + ); + + // V7: two rings deriving a step between the same pair. + const overlapping = ringProject(); + overlapping.rings.push({ + ...structuredClone(overlapping.rings[0]), + id: "facing.other", + states: ["walk_n", "walk_ne", "walk_e"], + cyclic: false, + maxChainedSteps: 2 + }); + expectInvalid( + overlapping, + /rings\.facing\.walk steps from walk_n to walk_ne, which ring facing\.other already derives/u + ); + + // V8: a cut-mode step whose bodies share no compatible port. + const missingPort = ringProject(); + missingPort.units[2].ports = [ + { id: "handoff", entryFrame: 0, portalFrames: [0, 4] } + ]; + expectInvalid( + missingPort, + /rings\.facing\.walk step walk_ne to walk_e needs port default on walk_e/u + ); + }); + + it("leaves a project which authors no ring untouched", () => { + const value = ringProject(); + delete value.rings; + value.states = value.states.slice(0, 1); + value.units = value.units.slice(0, 1); + + const project = validateSourceProject(value); + + // AC10: no rings authored, nothing added, nothing to report. + expect(project.edges).toEqual([]); + expect(project.rings).toBeUndefined(); + expect(project.ringNotes).toBeUndefined(); + }); +}); diff --git a/packages/compiler/test/source-ring-fixture.test.ts b/packages/compiler/test/source-ring-fixture.test.ts new file mode 100644 index 0000000..ffd1b7f --- /dev/null +++ b/packages/compiler/test/source-ring-fixture.test.ts @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { MotionGraphEngine } from "@pixel-point/aval-graph"; + +import { parseSourceProject } from "../src/source-project-schema.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = resolve( + HERE, + "../../../fixtures/rings/v1-eight-way-facing/motion.json" +); +const PRE_RINGS_FIXTURE = resolve( + HERE, + "../../../fixtures/compiler/v1/source/motion.json" +); + +describe("eight-way facing ring fixture", () => { + it("expands into the whole ring without authoring one edge", () => { + const project = parseSourceProject(readFileSync(FIXTURE)); + + expect(project.states.map(({ id }) => id)).toEqual([ + "walk_e", + "walk_n", + "walk_ne", + "walk_nw", + "walk_s", + "walk_se", + "walk_sw", + "walk_w" + ]); + // AC1: eight adjacencies, both directions, all derived. + expect(project.edges).toHaveLength(16); + expect(project.edges.every(({ derived }) => derived === true)).toBe(true); + expect(project.edges.map(({ id }) => id)).toContain("facing.walk.n.ne"); + expect(project.edges.map(({ id }) => id)).toContain("facing.walk.n.nw"); + expect(project.ringNotes).toEqual([]); + }); + + it("plans and walks the ring once installed in the graph", () => { + const project = parseSourceProject(readFileSync(FIXTURE)); + const engine = new MotionGraphEngine(); + engine.install({ + initialState: project.initialState, + states: project.states.map((state) => { + const unit = project.units.find(({ id }) => id === state.bodyUnit)!; + if (unit.kind !== "body") throw new Error("body unit expected"); + return { + id: state.id, + body: { + unitId: unit.id, + kind: unit.playback, + frameCount: unit.range[1] - unit.range[0], + ports: unit.ports + } + }; + }), + edges: project.edges.map((edge) => ({ + id: edge.id, + from: edge.from, + to: edge.to, + start: edge.start, + continuity: edge.continuity, + ...(edge.ring === undefined + ? {} + : { ring: edge.ring, step: edge.step ?? 1 }) + })), + rings: (project.rings ?? []).map((ring) => ({ + id: ring.id, + states: [...ring.states], + cyclic: ring.cyclic, + tieBreak: ring.tieBreak, + maxChainedSteps: ring.maxChainedSteps + })) + }); + engine.beginAnimated(); + + // AC2, AC3: the shorter arc, one step at a time. + expect(engine.planFor("walk_e")).toEqual(["walk_ne", "walk_e"]); + expect(engine.planFor("walk_w")).toEqual(["walk_nw", "walk_w"]); + // Half a turn is the longest arc the ring allows. + expect(engine.planFor("walk_s")).toEqual([ + "walk_ne", + "walk_e", + "walk_se", + "walk_s" + ]); + + engine.request("walk_e"); + let landed: string[] = []; + for (let tick = 0; tick < 32; tick += 1) { + const result = engine.tick({ contentOrdinal: BigInt(tick) }); + for (const effect of result.effects) { + if (effect.type === "turnstep") landed.push(effect.to); + } + if (result.snapshot.phase === "stable") break; + } + expect(landed).toEqual(["walk_ne", "walk_e"]); + }); +}); + +describe("projects authored before rings existed", () => { + it("normalize with no ring keys and no expansion at all", () => { + const raw = readFileSync(PRE_RINGS_FIXTURE); + const project = parseSourceProject(raw); + + // AC10: the checked-in pre-rings project is untouched by expansion, so its + // compiled output stays byte-identical. + expect("rings" in project).toBe(false); + expect("ringNotes" in project).toBe(false); + const authored = JSON.parse(new TextDecoder().decode(raw)) as { + readonly edges: readonly { readonly id: string }[]; + }; + expect(project.edges.map(({ id }) => id)) + .toEqual([...authored.edges.map(({ id }) => id)].sort()); + expect(project.edges.some((edge) => edge.derived === true)).toBe(false); + }); +}); diff --git a/packages/element/src/asset-generation.ts b/packages/element/src/asset-generation.ts index e180f1a..cc2363b 100644 --- a/packages/element/src/asset-generation.ts +++ b/packages/element/src/asset-generation.ts @@ -192,6 +192,11 @@ export class ElementAssetGeneration { return !this.#terminal && this.#runtime?.readyFor(state) === true; } + public planFor(state: string): readonly string[] | null { + if (this.#terminal) return null; + return this.#runtime?.planFor(state) ?? null; + } + public pause(): void { this.#runtime?.pause(); } diff --git a/packages/element/src/aval-element.ts b/packages/element/src/aval-element.ts index 0d57249..d9f49e6 100644 --- a/packages/element/src/aval-element.ts +++ b/packages/element/src/aval-element.ts @@ -20,7 +20,8 @@ import type { AvalElementConstructor, AvalFit, AvalMode, - AvalMotion + AvalMotion, + AvalRing } from "./public-types.js"; /** Browser reflection facade. All coordination and effects live in ElementReconciler. */ @@ -92,6 +93,9 @@ export function createAvalElementClass( public get inputBindings(): readonly Readonly[] { return this.#reconciler.inputBindings; } + public get rings(): readonly Readonly[] { + return this.#reconciler.rings; + } public prepare( options: Readonly<{ signal?: AbortSignal; timeoutMs?: number }> = {} @@ -101,6 +105,9 @@ export function createAvalElementClass( public setState(name: string): Promise { return this.#reconciler.setState(name); } public send(event: string): boolean { return this.#reconciler.send(event); } public readyFor(state: string): boolean { return this.#reconciler.readyFor(state); } + public planFor(state: string): readonly string[] | null { + return this.#reconciler.planFor(state); + } public pause(): void { this.#reconciler.pause(); } public resume(): Promise { return this.#reconciler.resume(); } public getDiagnostics( diff --git a/packages/element/src/browser-runtime-contracts.ts b/packages/element/src/browser-runtime-contracts.ts index f0707a1..1043103 100644 --- a/packages/element/src/browser-runtime-contracts.ts +++ b/packages/element/src/browser-runtime-contracts.ts @@ -9,13 +9,15 @@ import type { RuntimeVisibilityState } from "@pixel-point/aval-player-web"; -import type { AvalRuntimeTraceRecord } from "./public-types.js"; +import type { AvalRing, AvalRuntimeTraceRecord } from "./public-types.js"; export interface BrowserRuntimeMetadata { readonly initialState: string; readonly stateNames: readonly string[]; readonly eventNames: readonly string[]; readonly bindings: readonly Readonly[]; + /** Absent when the asset declares no ring. */ + readonly rings?: readonly Readonly[]; readonly renditions: readonly Readonly<{ id: string; codec: string; @@ -64,6 +66,7 @@ export interface BrowserRuntimePlayer { canSend(event: string): boolean; send(event: string): boolean; readyFor(state: string): boolean; + planFor(state: string): readonly string[] | null; pause(): void; resume(): Promise; setMotionPolicy(policy: MotionPolicy): Promise; diff --git a/packages/element/src/browser-runtime-factory.ts b/packages/element/src/browser-runtime-factory.ts index 200a745..d10e404 100644 --- a/packages/element/src/browser-runtime-factory.ts +++ b/packages/element/src/browser-runtime-factory.ts @@ -29,6 +29,7 @@ import { } from "./cleanup-receipt.js"; import type { AvalCleanupReceipt, + AvalRing, AvalSourceCandidate } from "./public-types.js"; import { RuntimeAcquisitionCleanupError } from "./runtime-acquisition-error.js"; @@ -242,6 +243,7 @@ function captureMetadata(manifest: Readonly<{ trigger?: Readonly<{ type: string; name?: string }>; }>[]; bindings: readonly Readonly[]; + rings?: readonly Readonly[]; renditions: readonly Readonly<{ id: string; codec: string; @@ -273,6 +275,13 @@ function captureMetadata(manifest: Readonly<{ bindings: Object.freeze(manifest.bindings.map((binding) => Object.freeze({ source: binding.source, event: binding.event }) )), + rings: Object.freeze((manifest.rings ?? []).map((ring) => + Object.freeze({ + id: ring.id, + states: Object.freeze([...ring.states]), + cyclic: ring.cyclic + }) + )), renditions: Object.freeze(manifest.renditions.map((rendition) => Object.freeze({ id: rendition.id, diff --git a/packages/element/src/browser-runtime-player.ts b/packages/element/src/browser-runtime-player.ts index f989905..bfd4d60 100644 --- a/packages/element/src/browser-runtime-player.ts +++ b/packages/element/src/browser-runtime-player.ts @@ -106,6 +106,9 @@ export class BrowserRuntimePlayerOwner implements BrowserRuntimePlayer { public canSend(event: string): boolean { return this.#player.canSend(event); } public send(event: string): boolean { return this.#player.send(event); } public readyFor(state: string): boolean { return this.#player.readyFor(state); } + public planFor(state: string): readonly string[] | null { + return this.#player.planFor(state); + } public pause(): void { this.#player.pauseRealtime(); } public resume(): Promise { return this.#player.resumeRealtime(); } public setMotionPolicy(policy: MotionPolicy): Promise { diff --git a/packages/element/src/diagnostics.ts b/packages/element/src/diagnostics.ts index c312808..4399851 100644 --- a/packages/element/src/diagnostics.ts +++ b/packages/element/src/diagnostics.ts @@ -17,6 +17,7 @@ import type { AvalMode, AvalMotion, AvalPublicFailure, + AvalRing, AvalTerminalCleanupProof } from "./public-types.js"; @@ -41,6 +42,7 @@ export interface ElementDiagnosticState { readonly stateNames: readonly string[]; readonly eventNames: readonly string[]; readonly inputBindings: readonly Readonly[]; + readonly rings?: readonly Readonly[]; readonly configuredMotion: AvalMotion; readonly hostReducedMotion: boolean | null; readonly autoplay: AvalAutoplay; @@ -98,6 +100,7 @@ export function createElementDiagnostics( paused: state.paused, effectivelyVisible: state.effectivelyVisible, stateNames: Object.freeze([...state.stateNames]), + rings: Object.freeze([...(state.rings ?? [])]), eventNames: Object.freeze([...state.eventNames]), inputBindings: Object.freeze(state.inputBindings.map((binding) => Object.freeze({ source: binding.source, event: binding.event }) diff --git a/packages/element/src/dom-event-bridge.ts b/packages/element/src/dom-event-bridge.ts index 186dbc2..a762b32 100644 --- a/packages/element/src/dom-event-bridge.ts +++ b/packages/element/src/dom-event-bridge.ts @@ -94,6 +94,15 @@ export class DomEventBridge { to: event.to })); return; + case "turnstep": + this.#dispatch("turnstep", freezeEventDetail({ + generation: this.#generation, + ring: event.ring, + from: event.from, + to: event.to, + remaining: event.remaining + })); + return; case "fallback": { const snapshot = this.#stage.snapshot(); const reason = normalizeStaticReason(event.reason); diff --git a/packages/element/src/element-public-state.ts b/packages/element/src/element-public-state.ts index bcaf829..41eaed0 100644 --- a/packages/element/src/element-public-state.ts +++ b/packages/element/src/element-public-state.ts @@ -8,7 +8,8 @@ import type { import type { AvalMode, AvalMotion, - AvalPublicFailure + AvalPublicFailure, + AvalRing } from "./public-types.js"; /** Sole mutable authority for the element's public playback state. */ @@ -24,6 +25,7 @@ export class ElementPublicState { #stateNames: readonly string[] = Object.freeze([]); #eventNames: readonly string[] = Object.freeze([]); #inputBindings: readonly Readonly[] = Object.freeze([]); + #rings: readonly Readonly[] = Object.freeze([]); #lastFailure: Readonly | null = null; public get readiness(): RuntimeReadiness { return this.#readiness; } @@ -37,6 +39,7 @@ export class ElementPublicState { public get stateNames(): readonly string[] { return this.#stateNames; } public get eventNames(): readonly string[] { return this.#eventNames; } public get inputBindings(): readonly Readonly[] { return this.#inputBindings; } + public get rings(): readonly Readonly[] { return this.#rings; } public get lastFailure(): Readonly | null { return this.#lastFailure; } @@ -62,6 +65,7 @@ export class ElementPublicState { stateNames: readonly string[]; eventNames: readonly string[]; bindings: readonly Readonly[]; + rings?: readonly Readonly[]; }>): void { this.#initialState = metadata.initialState; this.#requestedState = metadata.initialState; @@ -71,6 +75,13 @@ export class ElementPublicState { this.#inputBindings = Object.freeze(metadata.bindings.map((binding) => Object.freeze({ source: binding.source, event: binding.event }) )); + this.#rings = Object.freeze((metadata.rings ?? []).map((ring) => + Object.freeze({ + id: ring.id, + states: Object.freeze([...ring.states]), + cyclic: ring.cyclic + }) + )); } public prepared(result: Readonly): void { @@ -121,5 +132,6 @@ export class ElementPublicState { this.#stateNames = Object.freeze([]); this.#eventNames = Object.freeze([]); this.#inputBindings = Object.freeze([]); + this.#rings = Object.freeze([]); } } diff --git a/packages/element/src/element-reconciler-diagnostics.ts b/packages/element/src/element-reconciler-diagnostics.ts index bfa6f72..de6e31b 100644 --- a/packages/element/src/element-reconciler-diagnostics.ts +++ b/packages/element/src/element-reconciler-diagnostics.ts @@ -60,6 +60,7 @@ export function createReconcilerDiagnostics( stateNames: state.stateNames, eventNames: state.eventNames, inputBindings: state.inputBindings, + rings: state.rings, configuredMotion: configuration.motion, hostReducedMotion: desired.hostReducedMotion, autoplay: configuration.autoplay, diff --git a/packages/element/src/element-reconciler.ts b/packages/element/src/element-reconciler.ts index a1e5ea5..a51dabc 100644 --- a/packages/element/src/element-reconciler.ts +++ b/packages/element/src/element-reconciler.ts @@ -70,6 +70,7 @@ import { assertInteractionTarget } from "./interaction-target.js"; import type { AvalDiagnostics, AvalMode, + AvalRing, AvalTerminalCleanupProof } from "./public-types.js"; @@ -167,6 +168,7 @@ export class ElementReconciler implements ElementOwnerAuthority { public get stateNames(): readonly string[] { return this.#publicState.stateNames; } public get eventNames(): readonly string[] { return this.#publicState.eventNames; } public get inputBindings(): readonly Readonly[] { return this.#publicState.inputBindings; } + public get rings(): readonly Readonly[] { return this.#publicState.rings; } public get interactionTarget(): Element | null { return this.#desired.snapshot().interactionTarget; } public ownerFailureContext() { return elementFailureContext( this.#owners.lifecycle.terminal, @@ -325,6 +327,15 @@ export class ElementReconciler implements ElementOwnerAuthority { } catch { return false; } } + /** Dry-run route for a state request; null when there is no route today. */ + public planFor(state: string): readonly string[] | null { + try { + const checked = normalizeState(state); + if (checked === null) return null; + return this.#owners.controller.active?.planFor(checked) ?? null; + } catch { return null; } + } + public pause(): void { if (this.#owners.lifecycle.terminal) return; if (this.#owners.events.active) { diff --git a/packages/element/src/public-types.ts b/packages/element/src/public-types.ts index f4c90d0..4b11997 100644 --- a/packages/element/src/public-types.ts +++ b/packages/element/src/public-types.ts @@ -83,6 +83,22 @@ export interface AvalFallbackDetail { readonly visualState: string | null; } +/** One ring axis declared by the loaded asset. */ +export interface AvalRing { + readonly id: string; + readonly states: readonly string[]; + readonly cyclic: boolean; +} + +export interface AvalTurnStepDetail { + readonly generation: number; + readonly ring: string; + readonly from: string; + readonly to: string; + /** Steps still queued after this landing. */ + readonly remaining: number; +} + export interface AvalErrorDetail { readonly generation: number; readonly failure: Readonly; @@ -97,6 +113,7 @@ export interface AvalElementEventMap { readonly transitionend: CustomEvent>; readonly underflow: CustomEvent>; readonly fallback: CustomEvent>; + readonly turnstep: CustomEvent>; readonly error: CustomEvent>; } @@ -243,6 +260,7 @@ export interface AvalDiagnostics { readonly stateNames: readonly string[]; readonly eventNames: readonly string[]; readonly inputBindings: readonly Readonly[]; + readonly rings: readonly Readonly[]; readonly configuredMotion: AvalMotion; readonly hostReducedMotion: boolean | null; readonly autoplay: AvalAutoplay; @@ -345,11 +363,13 @@ export interface AvalElement extends HTMLElement { readonly stateNames: readonly string[]; readonly eventNames: readonly string[]; readonly inputBindings: readonly Readonly[]; + readonly rings: readonly Readonly[]; prepare(options?: Readonly): Promise; setState(name: string): Promise; send(event: string): boolean; readyFor(state: string): boolean; + planFor(state: string): readonly string[] | null; pause(): void; resume(): Promise; getDiagnostics(options?: Readonly<{ readonly trace?: boolean }>): Readonly; diff --git a/packages/element/test/asset-generation.test.ts b/packages/element/test/asset-generation.test.ts index a802745..f3cc1ab 100644 --- a/packages/element/test/asset-generation.test.ts +++ b/packages/element/test/asset-generation.test.ts @@ -454,6 +454,7 @@ function runtimeFixture(dispose: () => Promise): BrowserRuntimePlayer { canSend: () => false, send: () => false, readyFor: () => false, + planFor: () => null, pause: () => undefined, resume: async () => undefined, setMotionPolicy: async () => undefined, diff --git a/packages/element/test/turn-step-events.test.ts b/packages/element/test/turn-step-events.test.ts new file mode 100644 index 0000000..c2b645f --- /dev/null +++ b/packages/element/test/turn-step-events.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { DomEventBridge } from "../src/dom-event-bridge.js"; +import { ElementPublicState } from "../src/element-public-state.js"; + +class DetailEvent extends Event { + public readonly detail: Readonly; + public constructor(type: string, detail: Readonly) { + super(type); + this.detail = detail; + } +} + +function bridge(target: EventTarget): DomEventBridge { + return new DomEventBridge({ + target, + generation: 2, + stage: { + readiness: () => undefined, + requestedState: () => undefined, + visualState: () => undefined, + transitioning: () => undefined, + snapshot: () => ({ requestedState: "walk_n", visualState: "walk_n" }) + }, + createEvent: (type, detail) => + new DetailEvent(type, detail) as unknown as CustomEvent + }); +} + +describe("turnstep DOM event", () => { + it("publishes one immutable landing per step boundary", () => { + const target = new EventTarget(); + const observed: unknown[] = []; + target.addEventListener("turnstep", (event) => { + observed.push((event as DetailEvent).detail); + }); + const publisher = bridge(target); + + publisher.runtime({ + type: "turnstep", + ring: "facing.walk", + from: "walk_n", + to: "walk_ne", + remaining: 1 + }); + publisher.runtime({ + type: "turnstep", + ring: "facing.walk", + from: "walk_ne", + to: "walk_e", + remaining: 0 + }); + + expect(observed).toEqual([ + { + generation: 2, + ring: "facing.walk", + from: "walk_n", + to: "walk_ne", + remaining: 1 + }, + { + generation: 2, + ring: "facing.walk", + from: "walk_ne", + to: "walk_e", + remaining: 0 + } + ]); + expect(Object.isFrozen(observed[0])).toBe(true); + }); + + it("stops publishing after the bridge closes with its generation", () => { + const target = new EventTarget(); + const observed: unknown[] = []; + target.addEventListener("turnstep", () => observed.push(true)); + const publisher = bridge(target); + + publisher.close(); + publisher.runtime({ + type: "turnstep", + ring: "facing.walk", + from: "walk_n", + to: "walk_ne", + remaining: 0 + }); + + expect(observed).toEqual([]); + }); +}); + +describe("element ring metadata", () => { + it("publishes the asset's rings and detaches their member lists", () => { + const state = new ElementPublicState(); + const states = ["walk_n", "walk_ne", "walk_e"]; + + state.metadataReady({ + initialState: "walk_n", + stateNames: states, + eventNames: [], + bindings: [], + rings: [{ id: "facing.walk", states, cyclic: true }] + }); + + expect(state.rings).toEqual([ + { id: "facing.walk", states: ["walk_n", "walk_ne", "walk_e"], cyclic: true } + ]); + expect(Object.isFrozen(state.rings)).toBe(true); + expect(Object.isFrozen(state.rings[0]?.states)).toBe(true); + expect(state.rings[0]?.states).not.toBe(states); + }); + + it("reports no rings for an asset which declares none, and clears on reset", () => { + const state = new ElementPublicState(); + + state.metadataReady({ + initialState: "idle", + stateNames: ["idle"], + eventNames: [], + bindings: [] + }); + expect(state.rings).toEqual([]); + + state.metadataReady({ + initialState: "walk_n", + stateNames: ["walk_n", "walk_ne"], + eventNames: [], + bindings: [], + rings: [{ id: "facing.walk", states: ["walk_n", "walk_ne"], cyclic: false }] + }); + state.reset(); + expect(state.rings).toEqual([]); + }); +}); diff --git a/packages/format/src/constants.ts b/packages/format/src/constants.ts index c8e7592..7732217 100644 --- a/packages/format/src/constants.ts +++ b/packages/format/src/constants.ts @@ -34,6 +34,8 @@ export const FORMAT_DEFAULT_BUDGETS: Readonly = Object.freeze({ maxJsonStringBytes: 4_096, maxStates: 32, maxEdges: 64, + maxRings: 8, + maxRingStates: 32, maxUnits: 96, maxRenditions: 4, maxBindings: 32, diff --git a/packages/format/src/graph-adapter.ts b/packages/format/src/graph-adapter.ts index d22fc9b..9955d67 100644 --- a/packages/format/src/graph-adapter.ts +++ b/packages/format/src/graph-adapter.ts @@ -25,7 +25,20 @@ export function adaptManifestToMotionGraph( const definition: MotionGraphDefinition = { initialState: manifest.initialState, states: manifest.states.map((state) => adaptState(state, unitsById)), - edges: manifest.edges.map((edge) => adaptEdge(edge, unitsById)) + edges: manifest.edges.map((edge) => adaptEdge(edge, unitsById)), + ...(manifest.rings === undefined + ? {} + : { + rings: manifest.rings.map((ring) => + Object.freeze({ + id: ring.id, + states: Object.freeze([...ring.states]), + cyclic: ring.cyclic, + tieBreak: ring.tieBreak, + maxChainedSteps: ring.maxChainedSteps + }) + ) + }) }; return validateMotionGraphDefinition(definition); } catch (error) { @@ -103,7 +116,11 @@ function adaptEdge( from: edge.from, to: edge.to, start, - continuity: edge.continuity + continuity: edge.continuity, + // `derived` is provenance for tooling; the runtime only needs the axis. + ...(edge.ring === undefined + ? {} + : { ring: edge.ring, step: edge.step ?? 1 }) }; if (trigger === undefined && transition === undefined) { return Object.freeze(base); diff --git a/packages/format/src/index.ts b/packages/format/src/index.ts index 1bef033..3f9c0d3 100644 --- a/packages/format/src/index.ts +++ b/packages/format/src/index.ts @@ -143,6 +143,7 @@ export type { Readiness, Rect, ResidencyEndpoint, + Ring, Sha256Hex, Start, State, diff --git a/packages/format/src/manifest-graph-schema.ts b/packages/format/src/manifest-graph-schema.ts index 3161aef..7df4ff7 100644 --- a/packages/format/src/manifest-graph-schema.ts +++ b/packages/format/src/manifest-graph-schema.ts @@ -23,6 +23,7 @@ import type { Edge, FormatBudgets, Readiness, + Ring, Start, State, Transition, @@ -80,6 +81,8 @@ export function cloneEdges( return Object.freeze(edges); } +const TURN_KEYS = ["ring", "step", "derived"] as const; + function cloneEdge(value: unknown, path: string): Edge { const input = record(value, path); const startProbe = record(input.start, `${path}.start`); @@ -90,8 +93,9 @@ function cloneEdge(value: unknown, path: string): Edge { ? ["id", "from", "to", "start", "continuity", "targetRunwayFrames"] : ["id", "from", "to", "start", "continuity"], path, - cut ? ["trigger"] : ["trigger", "transition"] + cut ? ["trigger", ...TURN_KEYS] : ["trigger", "transition", ...TURN_KEYS] ); + const turn = cloneTurnMembership(input, path); const id = identifier(input.id, `${path}.id`); const from = identifier(input.from, `${path}.from`); const to = identifier(input.to, `${path}.to`); @@ -111,7 +115,15 @@ function cloneEdge(value: unknown, path: string): Edge { MIN_RUNWAY_FRAMES, MAX_RUNWAY_FRAMES ); - const base = { id, from, to, start, continuity: "cut", targetRunwayFrames } as const; + const base = { + id, + from, + to, + start, + continuity: "cut", + targetRunwayFrames, + ...turn + } as const; return trigger === undefined ? Object.freeze(base) : Object.freeze({ ...base, trigger }); @@ -125,7 +137,7 @@ function cloneEdge(value: unknown, path: string): Edge { const transition = owns(input, "transition") ? cloneTransition(input.transition, `${path}.transition`) : undefined; - const base = { id, from, to, start, continuity } as const; + const base = { id, from, to, start, continuity, ...turn } as const; if (trigger === undefined && transition === undefined) { return Object.freeze(base); } @@ -138,6 +150,95 @@ function cloneEdge(value: unknown, path: string): Edge { return Object.freeze({ ...base, trigger, transition }); } +/** + * Read the optional ring membership of a turn edge. `ring` and `step` travel + * together: a step is meaningless without the axis it steps along. + */ +function cloneTurnMembership( + input: Record, + path: string +): { + readonly ring?: string; + readonly step?: 1 | -1; + readonly derived?: true; +} { + const hasRing = owns(input, "ring"); + if (!hasRing && !owns(input, "step")) { + if (owns(input, "derived")) { + invalid(`${path}.derived`, "requires ring membership"); + } + return {}; + } + if (!hasRing) invalid(`${path}.ring`, "is required by step"); + const ring = identifier(input.ring, `${path}.ring`); + if (input.step !== 1 && input.step !== -1) { + invalid(`${path}.step`, "must be 1 or -1"); + } + if (!owns(input, "derived")) return { ring, step: input.step }; + if (input.derived !== true) { + invalid(`${path}.derived`, "must be true when present"); + } + return { ring, step: input.step, derived: true }; +} + +/** + * Validate the manifest's rings. Member order is authored, so it is preserved; + * only the ring array itself must be sorted, which keeps the canonical bytes + * independent of authoring order. + */ +export function cloneRings( + value: unknown, + budgets: FormatBudgets, + path: string +): readonly Ring[] { + const inputs = boundedArray(value, path, 1, budgets.maxRings); + const rings = inputs.map((entry, index) => { + const ringPath = `${path}[${String(index)}]`; + const input = record(entry, ringPath); + exactKeys( + input, + ["id", "states", "cyclic", "tieBreak", "maxChainedSteps"], + ringPath + ); + const id = identifier(input.id, `${ringPath}.id`); + const states = boundedArray( + input.states, + `${ringPath}.states`, + 2, + budgets.maxRingStates + ).map((state, stateIndex) => + identifier(state, `${ringPath}.states[${String(stateIndex)}]`) + ); + if (new Set(states).size !== states.length) { + invalid(`${ringPath}.states`, "must be unique"); + } + if (typeof input.cyclic !== "boolean") { + invalid(`${ringPath}.cyclic`, "must be a boolean"); + } + if (input.cyclic && states.length < 3) { + invalid(`${ringPath}.states`, "must contain 3 states when cyclic"); + } + return Object.freeze({ + id, + states: Object.freeze(states), + cyclic: input.cyclic, + tieBreak: oneOf( + input.tieBreak, + ["forward", "backward"], + `${ringPath}.tieBreak` + ), + maxChainedSteps: integerInRange( + input.maxChainedSteps, + `${ringPath}.maxChainedSteps`, + 1, + budgets.maxRingStates + ) + }); + }); + requireIdOrder(rings, path); + return Object.freeze(rings); +} + function cloneTrigger(value: unknown, path: string): Trigger { const input = record(value, path); if (input.type === "completion") { diff --git a/packages/format/src/manifest-relations.ts b/packages/format/src/manifest-relations.ts index 8f2ad8a..4e98ec7 100644 --- a/packages/format/src/manifest-relations.ts +++ b/packages/format/src/manifest-relations.ts @@ -5,6 +5,7 @@ import type { FormatBudgets, Readiness, ProductionRendition, + Ring, State, Unit } from "./model.js"; @@ -16,6 +17,7 @@ export interface ManifestRelationInput { readonly states: readonly State[]; readonly edges: readonly Edge[]; readonly bindings: readonly Binding[]; + readonly rings?: readonly Ring[]; readonly readiness: Readiness; } @@ -73,6 +75,7 @@ export function validateManifestRelations(input: ManifestRelationInput): void { ); } } + validateRings(input.rings ?? [], statesById, input.edges); validateReadiness( input.readiness, input.initialState, @@ -82,6 +85,95 @@ export function validateManifestRelations(input: ManifestRelationInput): void { ); } +/** + * A compiled ring must be fully walkable: every member resolves to a state and + * every ordered adjacency has an edge, in both directions. A player that has to + * discover a missing step at request time cannot honour the ring at all. + */ +function validateRings( + rings: readonly Ring[], + statesById: ReadonlyMap, + edges: readonly Edge[] +): void { + const byPair = new Map(); + for (const edge of edges) byPair.set(pairKey(edge.from, edge.to), edge); + const owners = new Map(); + + for (let index = 0; index < rings.length; index += 1) { + const ring = rings[index]!; + const path = `rings[${String(index)}]`; + for (const state of ring.states) { + if (!statesById.has(state)) { + invalid( + `${path}.states`, + `ring ${quote(ring.id)} references unknown state ${quote(state)}` + ); + } + } + const length = ring.states.length; + const adjacencies = ring.cyclic ? length : length - 1; + for (let position = 0; position < adjacencies; position += 1) { + const from = ring.states[position]!; + const to = ring.states[(position + 1) % length]!; + for (const [source, target, step] of [ + [from, to, 1], + [to, from, -1] + ] as const) { + const key = pairKey(source, target); + const owner = owners.get(key); + if (owner !== undefined) { + invalid( + path, + `rings ${quote(owner)} and ${quote(ring.id)} both step from ${quote(source)} to ${quote(target)}` + ); + } + owners.set(key, ring.id); + const edge = byPair.get(key); + if (edge === undefined) { + invalid( + path, + `ring ${quote(ring.id)} has no edge from ${quote(source)} to ${quote(target)}` + ); + } + if (edge.ring !== undefined && edge.ring !== ring.id) { + invalid( + path, + `edge ${quote(edge.id)} declares ring ${quote(edge.ring)} inside ring ${quote(ring.id)}` + ); + } + if (edge.step !== undefined && edge.step !== step) { + invalid( + path, + `edge ${quote(edge.id)} must declare step ${String(step)} in ring ${quote(ring.id)}` + ); + } + } + } + } + + const ringIds = new Set(rings.map(({ id }) => id)); + for (let index = 0; index < edges.length; index += 1) { + const edge = edges[index]!; + if (edge.ring === undefined) continue; + if (!ringIds.has(edge.ring)) { + invalid( + `edges[${String(index)}].ring`, + `does not reference a ring (${quote(edge.ring)})` + ); + } + if (owners.get(pairKey(edge.from, edge.to)) !== edge.ring) { + invalid( + `edges[${String(index)}]`, + `is not an adjacency of ring ${quote(edge.ring)}` + ); + } + } +} + +function pairKey(from: string, to: string): string { + return `${from}\u0000${to}`; +} + export function validateBlobCount( units: readonly Unit[], renditions: readonly ProductionRendition[], diff --git a/packages/format/src/manifest-schema.ts b/packages/format/src/manifest-schema.ts index b65496e..4dc14cf 100644 --- a/packages/format/src/manifest-schema.ts +++ b/packages/format/src/manifest-schema.ts @@ -4,6 +4,7 @@ import { cloneBindings, cloneEdges, cloneReadiness, + cloneRings, cloneStates } from "./manifest-graph-schema.js"; import { cloneDeclaredLimits } from "./manifest-limits-schema.js"; @@ -53,6 +54,8 @@ const TOP_LEVEL_KEYS = [ "readiness", "limits" ] as const; +/** Assets which author no ring omit the key entirely. */ +const OPTIONAL_TOP_LEVEL_KEYS = ["rings"] as const; /** Validate, detach, and recursively freeze the sole production manifest. */ export function validateCompiledManifest( @@ -62,7 +65,7 @@ export function validateCompiledManifest( try { const budgets = resolveFormatBudgets(options); const input = record(value, "manifest"); - exactKeys(input, TOP_LEVEL_KEYS, "manifest"); + exactKeys(input, TOP_LEVEL_KEYS, "manifest", OPTIONAL_TOP_LEVEL_KEYS); literal(input.formatVersion, "1.0", "formatVersion"); const generator = generatorString(input.generator, "generator"); const codec = oneOf(input.codec, VIDEO_CODECS, "codec"); @@ -94,6 +97,9 @@ export function validateCompiledManifest( const states = cloneStates(input.states, budgets, "states"); const edges = cloneEdges(input.edges, budgets, "edges"); const bindings = cloneBindings(input.bindings, budgets, "bindings"); + const rings = Object.prototype.hasOwnProperty.call(input, "rings") + ? cloneRings(input.rings, budgets, "rings") + : undefined; const readiness = cloneReadiness(input.readiness, budgets, "readiness"); const limits = cloneDeclaredLimits( input.limits, @@ -110,7 +116,8 @@ export function validateCompiledManifest( states, edges, bindings, - readiness + readiness, + ...(rings === undefined ? {} : { rings }) }); return Object.freeze({ @@ -127,6 +134,7 @@ export function validateCompiledManifest( states, edges, bindings, + ...(rings === undefined ? {} : { rings }), readiness, limits }); diff --git a/packages/format/src/model.ts b/packages/format/src/model.ts index 6bb18d3..6570f72 100644 --- a/packages/format/src/model.ts +++ b/packages/format/src/model.ts @@ -25,6 +25,8 @@ export interface FormatBudgets { readonly maxJsonStringBytes: number; readonly maxStates: number; readonly maxEdges: number; + readonly maxRings: number; + readonly maxRingStates: number; readonly maxUnits: number; readonly maxRenditions: number; readonly maxBindings: number; @@ -158,7 +160,15 @@ export type Transition = readonly reverseOf?: Id; }; -interface NonCutEdge { +/** Ring membership carried by a turn edge: one signed step along `ring`. */ +interface TurnMembership { + readonly ring?: Id; + readonly step?: 1 | -1; + /** True when the compiler expanded this edge from a ring rather than authoring. */ + readonly derived?: true; +} + +interface NonCutEdge extends TurnMembership { readonly id: Id; readonly from: Id; readonly to: Id; @@ -169,7 +179,7 @@ interface NonCutEdge { readonly targetRunwayFrames?: never; } -interface CutEdge { +interface CutEdge extends TurnMembership { readonly id: Id; readonly from: Id; readonly to: Id; @@ -182,6 +192,18 @@ interface CutEdge { export type Edge = NonCutEdge | CutEdge; +/** + * An ordered axis of states joined by turn edges. Adjacent members are one step + * apart; a cyclic ring also joins its last member back to its first. + */ +export interface Ring { + readonly id: Id; + readonly states: readonly Id[]; + readonly cyclic: boolean; + readonly tieBreak: "forward" | "backward"; + readonly maxChainedSteps: number; +} + export type BindingSource = | "activate" | "engagement.off" @@ -226,6 +248,8 @@ export interface CompiledManifest { readonly states: readonly State[]; readonly edges: readonly Edge[]; readonly bindings: readonly Binding[]; + /** Omitted entirely by assets which author no ring. */ + readonly rings?: readonly Ring[]; readonly readiness: Readiness; readonly limits: DeclaredLimits; } diff --git a/packages/format/src/writer-normalize.ts b/packages/format/src/writer-normalize.ts index a8eb483..6416220 100644 --- a/packages/format/src/writer-normalize.ts +++ b/packages/format/src/writer-normalize.ts @@ -51,6 +51,7 @@ const MANIFEST_INPUT_KEYS = [ "readiness", "limits" ] as const; +const OPTIONAL_MANIFEST_INPUT_KEYS = ["rings"] as const; export interface NormalizedWriterInput { readonly manifest: CompiledManifest; @@ -74,7 +75,12 @@ export function normalizeWriterInput( const root = record(input, "writer input"); exactKeys(root, ["manifest", "chunks"], "writer input"); const sourceManifest = record(root.manifest, "manifest input"); - exactKeys(sourceManifest, MANIFEST_INPUT_KEYS, "manifest input"); + exactKeys( + sourceManifest, + MANIFEST_INPUT_KEYS, + "manifest input", + OPTIONAL_MANIFEST_INPUT_KEYS + ); const sourceRenditions = boundedInputArray( sourceManifest.renditions, "manifest.renditions", @@ -171,6 +177,9 @@ export function normalizeWriterInput( ...unit.value, chunks: unitSpans[index] })); + const sourceRings = owns(sourceManifest, "rings") + ? boundedInputArray(sourceManifest.rings, "manifest.rings", budgets.maxRings, 1) + : null; const manifestCandidate = { ...sourceManifest, renditions: sourceRenditions, @@ -178,6 +187,7 @@ export function normalizeWriterInput( states: sortById(sourceStates, "states"), edges: sortById(sourceEdges, "edges"), bindings: normalizeBindings(sourceBindings), + ...(sourceRings === null ? {} : { rings: sortById(sourceRings, "rings") }), readiness: normalizeReadiness(sourceManifest.readiness, budgets) }; const manifest = validateCompiledManifest(manifestCandidate, options); diff --git a/packages/format/test/manifest-rings.test.ts b/packages/format/test/manifest-rings.test.ts new file mode 100644 index 0000000..9ba36ec --- /dev/null +++ b/packages/format/test/manifest-rings.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { FormatError } from "../src/errors.js"; +import { adaptManifestToMotionGraph } from "../src/graph-adapter.js"; +import { validateCompiledManifest } from "../src/manifest-schema.js"; +import { validManifest } from "./manifest-fixture.js"; + +/** The fixture graph plus the one edge a fully cyclic three-state ring needs. */ +function ringManifest(): Record { + const manifest = structuredClone(validManifest()) as Record; + // Edges stay sorted by id, so the new step belongs before "edge-cb". + manifest.edges.splice(4, 0, { + id: "edge-ca", + from: "a-c", + to: "a-a", + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 0 + }, + continuity: "exact-authored", + ring: "facing", + step: 1, + derived: true + }); + manifest.edges[0].ring = "facing"; + manifest.edges[0].step = 1; + manifest.rings = [ + { + id: "facing", + states: ["a-a", "a-b", "a-c"], + cyclic: true, + tieBreak: "forward", + maxChainedSteps: 2 + } + ]; + return manifest; +} + +function expectInvalid(value: unknown, pattern: RegExp): FormatError { + try { + validateCompiledManifest(value); + } catch (error) { + expect(error).toBeInstanceOf(FormatError); + expect((error as FormatError).code).toBe("MANIFEST_INVALID"); + expect((error as FormatError).message).toMatch(pattern); + return error as FormatError; + } + throw new Error("expected manifest validation to fail"); +} + +describe("compiled manifest rings", () => { + it("accepts a cyclic ring and preserves its authored member order", () => { + const manifest = validateCompiledManifest(ringManifest()); + + expect(manifest.rings).toEqual([ + { + id: "facing", + states: ["a-a", "a-b", "a-c"], + cyclic: true, + tieBreak: "forward", + maxChainedSteps: 2 + } + ]); + expect(Object.isFrozen(manifest.rings)).toBe(true); + expect(Object.isFrozen(manifest.rings?.[0]?.states)).toBe(true); + expect(manifest.edges.find(({ id }) => id === "edge-ca")).toMatchObject({ + ring: "facing", + step: 1, + derived: true + }); + }); + + it("omits the key entirely for a manifest which authors no ring", () => { + const manifest = validateCompiledManifest(validManifest()); + + // AC10: an asset without rings is byte-identical to one compiled before + // rings existed, so the key must be absent rather than empty. + expect("rings" in manifest).toBe(false); + expect(manifest.rings).toBeUndefined(); + }); + + it("carries the ring and its steps into the motion graph", () => { + const graph = adaptManifestToMotionGraph( + validateCompiledManifest(ringManifest()) + ); + + expect(graph.definition.rings).toEqual([ + { + id: "facing", + states: ["a-a", "a-b", "a-c"], + cyclic: true, + tieBreak: "forward", + maxChainedSteps: 2 + } + ]); + expect(graph.definition.edges.find(({ id }) => id === "edge-ca")) + .toMatchObject({ ring: "facing", step: 1 }); + // Provenance stays in the asset; the runtime only needs the axis. + expect(graph.definition.edges.find(({ id }) => id === "edge-ca")) + .not.toHaveProperty("derived"); + }); + + it("rejects a ring whose members or adjacencies are unusable", () => { + const unknownState = ringManifest(); + unknownState.rings[0].states = ["a-a", "a-b", "missing"]; + expectInvalid(unknownState, /ring "facing" references unknown state "missing"/u); + + const duplicated = ringManifest(); + duplicated.rings[0].states = ["a-a", "a-b", "a-a"]; + expectInvalid(duplicated, /rings\[0\]\.states must be unique/u); + + const tooShort = ringManifest(); + tooShort.rings[0].states = ["a-a", "a-b"]; + expectInvalid(tooShort, /must contain 3 states when cyclic/u); + + const missingStep = ringManifest(); + missingStep.edges = missingStep.edges.filter( + (edge: Record) => edge.id !== "edge-ca" + ); + expectInvalid( + missingStep, + /ring "facing" has no edge from "a-c" to "a-a"/u + ); + + const emptyRings = ringManifest(); + emptyRings.rings = []; + expectInvalid(emptyRings, /rings/u); + }); + + it("rejects turn membership which contradicts the ring", () => { + const conflictingRing = ringManifest(); + conflictingRing.edges[1].ring = "other"; + conflictingRing.edges[1].step = -1; + expectInvalid( + conflictingRing, + /edge "edge-ac" declares ring "other" inside ring "facing"/u + ); + + const strayRing = ringManifest(); + delete strayRing.rings; + expectInvalid(strayRing, /does not reference a ring/u); + + const wrongStep = ringManifest(); + wrongStep.edges[2].ring = "facing"; + wrongStep.edges[2].step = 1; + expectInvalid(wrongStep, /must declare step -1 in ring "facing"/u); + + const stepWithoutRing = ringManifest(); + delete stepWithoutRing.edges[0].ring; + expectInvalid(stepWithoutRing, /is required by step/u); + + const derivedWithoutRing = ringManifest(); + delete derivedWithoutRing.edges[0].ring; + delete derivedWithoutRing.edges[0].step; + derivedWithoutRing.edges[0].derived = true; + expectInvalid(derivedWithoutRing, /requires ring membership/u); + + const badStep = ringManifest(); + badStep.edges[0].step = 2; + expectInvalid(badStep, /must be 1 or -1/u); + }); +}); diff --git a/packages/graph/src/engine-state.ts b/packages/graph/src/engine-state.ts index 09f24ee..0808243 100644 --- a/packages/graph/src/engine-state.ts +++ b/packages/graph/src/engine-state.ts @@ -13,6 +13,7 @@ import type { MotionGraphTraceRecord, ValidatedMotionGraph } from "./model.js"; +import type { TurnChainPlan } from "./intent-router.js"; import { OperationJournal, type OperationJournalCheckpoint, @@ -43,6 +44,7 @@ interface MotionGraphEngineCheckpoint { readonly ledger: Readonly; readonly journal: Readonly; readonly routes: Readonly; + readonly turn: Readonly | null; } /** Package-private mechanical storage for the canonical graph reducer. */ @@ -57,6 +59,8 @@ export class MotionGraphEngineState { public requestedState: GraphStateId | null = null; public visualState: GraphStateId | null = null; public presentation: Readonly | null = null; + /** Remainder of the ring arc a chained turn is walking, if any. */ + public turn: Readonly | null = null; #graph: ValidatedMotionGraph | null = null; #indexes: ValidatedGraphIndexes | null = null; @@ -88,6 +92,8 @@ export class MotionGraphEngineState { pendingEdgeId: this.routes.pending?.edge.id ?? null, activeEdgeId: this.routes.active?.edge.id ?? null, followOnEdgeId: this.routes.followOn?.edge.id ?? null, + turnRing: this.turn?.ring ?? null, + turnStepsRemaining: this.turn?.remaining.length ?? 0, direction: this.presentation?.kind === "reversible" ? this.presentation.direction @@ -110,7 +116,8 @@ export class MotionGraphEngineState { presentation: this.presentation, ledger: this.ledger.checkpoint(), journal: this.journal.checkpoint(), - routes: this.routes.checkpoint() + routes: this.routes.checkpoint(), + turn: this.turn }); } @@ -124,6 +131,7 @@ export class MotionGraphEngineState { this.ledger.restore(checkpoint.ledger); this.journal.restore(checkpoint.journal); this.routes.restore(checkpoint.routes); + this.turn = checkpoint.turn; } public record( diff --git a/packages/graph/src/engine.ts b/packages/graph/src/engine.ts index 29bbdbb..62a6ff9 100644 --- a/packages/graph/src/engine.ts +++ b/packages/graph/src/engine.ts @@ -24,7 +24,8 @@ import { planStateIntent, type EventIntentPlan, type IntentContext, - type StateIntentPlan + type StateIntentPlan, + type TurnChainPlan } from "./intent-router.js"; import { findFinishBoundary, @@ -32,6 +33,27 @@ import { nextBodyFrame } from "./portal-search.js"; import type { RequestAdmission } from "./request-ledger.js"; +import { resolveRingRoute } from "./ring-plan.js"; +import type { ActiveRouteCompletion } from "./route-plan.js"; + +/** + * How a multi-step ring request is served. + * + * `chain` walks every intermediate state, which is the only policy that keeps + * frame continuity across the whole arc. `direct` collapses the arc into its + * departure boundary and lands in the target, for hosts honouring a reduced + * motion preference. + */ +export type MotionGraphTurnPolicy = "chain" | "direct"; + +export interface MotionGraphEngineOptions { + readonly turnPolicy?: MotionGraphTurnPolicy; +} + +interface RoutedTurn { + readonly edge: GraphEdgeDefinition; + readonly turn: Readonly | null; +} /** * Pure version-0 graph reducer. It owns authored cursors and emits abstract @@ -39,6 +61,28 @@ import type { RequestAdmission } from "./request-ledger.js"; */ export class MotionGraphEngine { readonly #runtime = new MotionGraphEngineState(); + readonly #turnPolicy: MotionGraphTurnPolicy; + + public constructor(options: Readonly = {}) { + if (options === null || typeof options !== "object") { + throw new MotionGraphError( + "GRAPH_VALIDATION", + "graph engine options must be an object" + ); + } + const policy = options.turnPolicy ?? "chain"; + if (policy !== "chain" && policy !== "direct") { + throw new MotionGraphError( + "GRAPH_VALIDATION", + "turnPolicy must be chain or direct" + ); + } + this.#turnPolicy = policy; + } + + public get turnPolicy(): MotionGraphTurnPolicy { + return this.#turnPolicy; + } public install( definition: MotionGraphDefinition | ValidatedMotionGraph @@ -156,6 +200,7 @@ export class MotionGraphEngine { ); } else { this.#runtime.presentation = this.#runtime.staticPresentation(visual); + this.#runtime.turn = null; this.#runtime.routes.clear(); } return this.#runtime.record("begin-static", effects); @@ -229,6 +274,7 @@ export class MotionGraphEngine { } else { this.#runtime.presentation = this.#runtime.staticPresentation(visual); } + this.#runtime.turn = null; this.#runtime.routes.clear(); this.#runtime.phase = "static"; return this.#runtime.record("recover-static", effects); @@ -272,6 +318,7 @@ export class MotionGraphEngine { if (settlement !== null) { effects.push(settlement); } + this.#runtime.turn = null; this.#runtime.routes.clear(); this.#runtime.phase = "error"; return this.#runtime.record("fail-static", effects); @@ -352,6 +399,33 @@ export class MotionGraphEngine { return planEventIntent(this.#intentContext(), event).kind !== "reject"; } + /** + * The landings `request(target)` would visit now, in order, or null when the + * target is unreachable. An empty plan means the target is already held. The + * graph is not advanced and no input is allocated. + */ + public planFor(target: GraphStateId): readonly GraphStateId[] | null { + if ( + typeof target !== "string" || + this.#runtime.readiness === "unready" || + this.#runtime.readiness === "disposed" || + this.#runtime.readiness === "error" || + !this.#runtime.hasState(target) + ) return null; + + const source = this.#departureState(); + if (source === null) return null; + if (source === target) return Object.freeze([]); + if (this.#runtime.edgeDirect(source, target) !== null) { + return Object.freeze([target]); + } + const route = resolveRingRoute(this.#runtime.indexes(), source, target); + if (route.kind !== "arc") return null; + return this.#turnPolicy === "direct" + ? Object.freeze([target]) + : Object.freeze([...route.states]); + } + public tick(options: MotionGraphTickOptions): Readonly { this.#runtime.assertInstalled("tick"); if (this.#runtime.readiness === "disposed" || this.#runtime.readiness === "error") { @@ -440,6 +514,7 @@ export class MotionGraphEngine { this.#changeReadiness("disposed", effects); this.#runtime.phase = "disposed"; this.#runtime.presentation = null; + this.#runtime.turn = null; this.#runtime.routes.clear(); return this.#runtime.record("dispose", effects); } @@ -472,6 +547,7 @@ export class MotionGraphEngine { this.#appendSuperseded(admission, effects); if (plan.kind === "cancel-before-stable" || plan.kind === "cancel-pending") { + this.#runtime.turn = null; this.#runtime.routes.cancelPending(); if (plan.kind === "cancel-pending") this.#runtime.phase = "stable"; const settled = this.#runtime.ledger.settlePending({ @@ -484,32 +560,74 @@ export class MotionGraphEngine { } switch (plan.kind) { - case "replace-pending": - this.#runtime.routes.replacePending(plan.edge, sequence); + case "replace-pending": { + const routed = this.#routeTurn(plan.edge, plan.turn); + this.#runtime.turn = routed.turn; + this.#runtime.routes.replacePending(routed.edge, sequence); if (this.#runtime.phase !== "preparing" && this.#runtime.phase !== "intro") { this.#runtime.phase = "waiting"; } break; + } case "continue-active-target": + this.#runtime.turn = null; this.#runtime.routes.clearFollowOn(); this.#runtime.routes.clearReversal(); break; case "continue-reversal-target": + this.#runtime.turn = null; this.#runtime.routes.clearFollowOn(); break; case "queue-reversal": + this.#runtime.turn = null; this.#runtime.routes.queueReversal(plan.edge, sequence); break; - case "queue-follow-on": - this.#runtime.routes.queueFollowOn(plan.edge, sequence); + case "queue-follow-on": { + const routed = this.#routeTurn(plan.edge, plan.turn); + this.#runtime.turn = routed.turn; + this.#runtime.routes.queueFollowOn(routed.edge, sequence); break; + } case "static-commit": + this.#runtime.turn = null; this.#commitStaticEdge(plan.edge, sequence, effects, false); break; } return this.#acceptedRequest(admission, sequence, effects); } + /** + * Apply the turn policy to a routed step. `direct` collapses the whole arc + * into one departure so no intermediate body is presented, while still + * reporting one landing on the ring. + */ + #routeTurn( + edge: GraphEdgeDefinition, + turn: Readonly | undefined + ): RoutedTurn { + if (turn === undefined) return { edge, turn: null }; + if (this.#turnPolicy === "chain" || turn.remaining.length === 0) { + return { edge, turn }; + } + const last = turn.remaining[turn.remaining.length - 1]!; + const collapsed = Object.freeze({ + id: `${turn.ring}.${edge.from}.${last.to}`, + from: edge.from, + to: last.to, + start: edge.start, + continuity: edge.continuity, + ring: turn.ring + }) as GraphEdgeDefinition; + return { + edge: collapsed, + turn: Object.freeze({ + ring: turn.ring, + after: collapsed.id, + remaining: Object.freeze([]) + }) + }; + } + #applyEventIntent( plan: Exclude, { readonly kind: "reject" }>, sequence: number, @@ -517,6 +635,8 @@ export class MotionGraphEngine { ): void { if (plan.kind === "accept-noop") return; + // Event routes are authored point-to-point; they never continue a ring arc. + this.#runtime.turn = null; if (plan.kind === "cancel-pending") { this.#setRequestedState(plan.edge.to, sequence, effects); this.#abortPendingForEvent(effects); @@ -574,10 +694,22 @@ export class MotionGraphEngine { visualState: this.#runtime.requireVisualState(), routes: this.#runtime.routes, indexes: this.#runtime.indexes(), - hasPendingRequests: this.#runtime.ledger.pendingRequestCount > 0 + hasPendingRequests: this.#runtime.ledger.pendingRequestCount > 0, + turnInFlight: this.#runtime.turn !== null }); } + /** The state a new request would depart from, mirroring intent routing. */ + #departureState(): GraphStateId | null { + const phase = this.#runtime.phase; + if (phase === "locked" || phase === "reversible") { + const active = this.#runtime.routes.active; + if (active === null) return null; + return (this.#runtime.routes.reversal ?? active).edge.to; + } + return this.#runtime.visualState; + } + #tickIntro(): void { const presentation = this.#runtime.presentation; if (presentation?.kind !== "intro") { @@ -747,6 +879,7 @@ export class MotionGraphEngine { this.#setVisualState(edge.to, effects); effects.push(this.#transitionEnd(edge)); const completion = this.#runtime.routes.completeActive(); + if (this.#continueTurn(edge, completion, effects)) return; if (completion.promoted !== null) { this.#runtime.phase = "waiting"; @@ -764,6 +897,54 @@ export class MotionGraphEngine { } } + /** + * Report a ring landing and, when the chain still owes steps, arm the next + * one as the pending route. Returns whether the chain took over the phase. + * + * The chain is only advanced when the landing is the one it was planned from, + * so a route queued by a newer request replans instead of resuming a + * superseded arc. + */ + #continueTurn( + edge: GraphEdgeDefinition, + completion: Readonly, + effects: MotionGraphEffect[] + ): boolean { + const turn = this.#runtime.turn; + if (edge.ring === undefined && turn?.after !== edge.id) return false; + + const owned = turn !== null && turn.after === edge.id; + const continues = owned && completion.promoted === null && + turn.remaining.length > 0 && + turn.remaining[0]!.from === edge.to; + const queued = completion.promoted === null + ? 0 + : 1 + (turn !== null && turn.after === completion.promoted.edge.id + ? turn.remaining.length + : 0); + effects.push(freezeEffect({ + type: "turnstep", + ring: turn?.ring ?? edge.ring!, + from: edge.from, + to: edge.to, + remaining: continues ? turn.remaining.length : queued + })); + if (!continues) { + if (owned) this.#runtime.turn = null; + return false; + } + + const [next, ...rest] = turn.remaining; + this.#runtime.turn = Object.freeze({ + ring: turn.ring, + after: next!.id, + remaining: Object.freeze(rest) + }); + this.#runtime.routes.replacePending(next!, completion.completed.sequence); + this.#runtime.phase = "waiting"; + return true; + } + #commitStaticEdge( edge: GraphEdgeDefinition, sequence: number, @@ -780,6 +961,7 @@ export class MotionGraphEngine { reason: preparationCommit ? "static-recovery" : "target-committed" }); if (settlement !== null) effects.push(settlement); + this.#runtime.turn = null; this.#runtime.routes.clear(); this.#runtime.phase = "static"; } diff --git a/packages/graph/src/index.ts b/packages/graph/src/index.ts index 833b1b5..923afba 100644 --- a/packages/graph/src/index.ts +++ b/packages/graph/src/index.ts @@ -4,7 +4,17 @@ export { type MotionGraphErrorCode } from "./errors.js"; export { GRAPH_IDENTIFIER_PATTERN, GRAPH_LIMITS } from "./limits.js"; -export { MotionGraphEngine } from "./engine.js"; +export { + MotionGraphEngine, + type MotionGraphEngineOptions, + type MotionGraphTurnPolicy +} from "./engine.js"; +export { + planRingArc, + resolveRingRoute, + type RingArc, + type RingRoute +} from "./ring-plan.js"; export { findFinishBoundary, findNextPortalBoundary, @@ -25,12 +35,16 @@ export type { GraphInitialUnitDefinition, GraphPortDefinition, GraphPresentation, + GraphRingDefinition, + GraphRingId, + GraphRingTieBreak, GraphSettlement, GraphSettlementError, GraphStartPolicy, GraphStateDefinition, GraphStateId, GraphTransitionDefinition, + GraphTurnStep, GraphUnitId, MotionGraphDefinition, MotionGraphDisposeOptions, diff --git a/packages/graph/src/intent-router.ts b/packages/graph/src/intent-router.ts index 6c61322..2c8b4f1 100644 --- a/packages/graph/src/intent-router.ts +++ b/packages/graph/src/intent-router.ts @@ -1,8 +1,11 @@ import type { GraphEdgeDefinition, + GraphEdgeId, + GraphRingId, GraphStateId, MotionGraphPhase } from "./model.js"; +import { resolveRingRoute } from "./ring-plan.js"; import type { RoutePlanView } from "./route-plan.js"; import type { ValidatedGraphIndexes } from "./validate.js"; @@ -17,6 +20,22 @@ export interface IntentContext { readonly routes: RoutePlanView; readonly indexes: ValidatedGraphIndexes; readonly hasPendingRequests: boolean; + /** Whether a chained turn is in flight, which makes pending routes provisional. */ + readonly turnInFlight: boolean; +} + +/** + * The steps a chained turn still owes after the routed edge. + * + * A plan carries the whole remainder so the engine never has to re-derive an arc + * it already chose; every step boundary can still replan from what actually + * landed. + */ +export interface TurnChainPlan { + readonly ring: GraphRingId; + /** Edge the remainder continues from; it identifies the chain's own landing. */ + readonly after: GraphEdgeId; + readonly remaining: readonly Readonly[]; } export type StateIntentPlan = @@ -28,6 +47,7 @@ export type StateIntentPlan = | { readonly kind: "replace-pending"; readonly edge: Readonly; + readonly turn?: Readonly; } | { readonly kind: "continue-active-target" } | { readonly kind: "continue-reversal-target" } @@ -38,6 +58,7 @@ export type StateIntentPlan = | { readonly kind: "queue-follow-on"; readonly edge: Readonly; + readonly turn?: Readonly; } | { readonly kind: "static-commit"; @@ -97,7 +118,11 @@ export function planStateIntent( if (phase === "waiting") { const pending = requireSlot(context.routes.pending, "waiting pending edge"); - if (target === pending.edge.to) return freezePlan({ kind: "join-pending" }); + // A pending step of a chained turn is an intermediate landing, never the + // caller's intent, so it cannot absorb a new request by joining it. + if (target === pending.edge.to && !context.turnInFlight) { + return freezePlan({ kind: "join-pending" }); + } if (target === visualState) return freezePlan({ kind: "cancel-pending" }); return pendingOrReject(context, visualState, target); } @@ -125,9 +150,15 @@ export function planStateIntent( } } const followOn = directEdge(context.indexes, effective.edge.to, target); - return followOn === null + if (followOn !== null) { + return freezePlan({ kind: "queue-follow-on", edge: followOn }); + } + // Replanning happens from the landing state, so an in-flight step always + // completes before the newly chosen arc begins. + const turn = turnPlan(context, effective.edge.to, target); + return turn === null ? freezePlan({ kind: "reject" }) - : freezePlan({ kind: "queue-follow-on", edge: followOn }); + : freezePlan({ kind: "queue-follow-on", edge: turn.edge, turn: turn.turn }); } /** Resolve and decide an event without mutating semantic state. */ @@ -226,9 +257,40 @@ function pendingOrReject( target: GraphStateId ): Readonly { const edge = directEdge(context.indexes, from, target); - return edge === null + if (edge !== null) return freezePlan({ kind: "replace-pending", edge }); + const turn = turnPlan(context, from, target); + return turn === null ? freezePlan({ kind: "reject" }) - : freezePlan({ kind: "replace-pending", edge }); + : freezePlan({ kind: "replace-pending", edge: turn.edge, turn: turn.turn }); +} + +/** + * Resolve a multi-step ring arc into its first step plus the queued remainder. + * + * An arc longer than the ring's `maxChainedSteps` resolves to no plan, which the + * caller reports as a route failure rather than silently walking further than + * the ring allows. + */ +function turnPlan( + context: Readonly, + from: GraphStateId, + target: GraphStateId +): Readonly<{ + edge: Readonly; + turn: Readonly; +}> | null { + const route = resolveRingRoute(context.indexes, from, target); + if (route.kind !== "arc") return null; + const first = route.steps[0]; + if (first === undefined) return null; + return Object.freeze({ + edge: first, + turn: Object.freeze({ + ring: route.ring.id, + after: first.id, + remaining: Object.freeze(route.steps.slice(1)) + }) + }); } function directEdge( diff --git a/packages/graph/src/limits.ts b/packages/graph/src/limits.ts index dbb269e..43393de 100644 --- a/packages/graph/src/limits.ts +++ b/packages/graph/src/limits.ts @@ -3,6 +3,9 @@ export const GRAPH_IDENTIFIER_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/; export const GRAPH_LIMITS = Object.freeze({ maxStates: 32, maxEdges: 64, + maxRings: 8, + maxRingStates: 32, + maxChainedSteps: 16, maxPortsPerBody: 16, maxInputsPerTick: 32, maxRoutingOperationsPerTick: 64, diff --git a/packages/graph/src/model.ts b/packages/graph/src/model.ts index e694430..dff0dfd 100644 --- a/packages/graph/src/model.ts +++ b/packages/graph/src/model.ts @@ -1,6 +1,7 @@ export type GraphStateId = string; export type GraphEdgeId = string; export type GraphUnitId = string; +export type GraphRingId = string; export interface GraphPortDefinition { readonly id: string; @@ -66,6 +67,12 @@ export type GraphEdgeTrigger = export type GraphContinuity = "exact-authored" | "exact-reverse" | "cut"; +/** Which arc a ring prefers when both directions are equally long. */ +export type GraphRingTieBreak = "forward" | "backward"; + +/** One signed step along a ring: `1` walks forward, `-1` walks backward. */ +export type GraphTurnStep = 1 | -1; + export interface GraphEdgeDefinition { readonly id: GraphEdgeId; readonly from: GraphStateId; @@ -74,12 +81,30 @@ export interface GraphEdgeDefinition { readonly start: GraphStartPolicy; readonly transition?: GraphTransitionDefinition; readonly continuity: GraphContinuity; + /** Ring this edge steps along. Present on turn edges only. */ + readonly ring?: GraphRingId; + /** Signed adjacency offset inside `ring`. Present on turn edges only. */ + readonly step?: GraphTurnStep; +} + +/** + * An ordered set of states along one axis. Adjacent members are joined by turn + * edges, so a multi-step request is served by chaining single steps rather than + * by authoring an edge for every ordered pair. + */ +export interface GraphRingDefinition { + readonly id: GraphRingId; + readonly states: readonly GraphStateId[]; + readonly cyclic: boolean; + readonly tieBreak: GraphRingTieBreak; + readonly maxChainedSteps: number; } export interface MotionGraphDefinition { readonly initialState: GraphStateId; readonly states: readonly GraphStateDefinition[]; readonly edges: readonly GraphEdgeDefinition[]; + readonly rings?: readonly GraphRingDefinition[]; } declare const validatedMotionGraphBrand: unique symbol; @@ -190,6 +215,14 @@ export type MotionGraphEffect = readonly from: GraphStateId; readonly to: GraphStateId; } + | { + readonly type: "turnstep"; + readonly ring: GraphRingId; + readonly from: GraphStateId; + readonly to: GraphStateId; + /** Steps still queued after this landing. */ + readonly remaining: number; + } | { readonly type: "fallback"; readonly reason: string; @@ -213,6 +246,10 @@ export interface MotionGraphSnapshot { readonly pendingEdgeId: GraphEdgeId | null; readonly activeEdgeId: GraphEdgeId | null; readonly followOnEdgeId: GraphEdgeId | null; + /** Ring owning the turn chain in flight, or null outside a chained turn. */ + readonly turnRing: GraphRingId | null; + /** Steps still queued after the route currently in flight. */ + readonly turnStepsRemaining: number; readonly direction: "forward" | "reverse" | null; readonly contentOrdinal: bigint | null; readonly inputSequence: number; diff --git a/packages/graph/src/ring-plan.ts b/packages/graph/src/ring-plan.ts new file mode 100644 index 0000000..4fd15ff --- /dev/null +++ b/packages/graph/src/ring-plan.ts @@ -0,0 +1,133 @@ +import type { + GraphEdgeDefinition, + GraphRingDefinition, + GraphStateId +} from "./model.js"; +import type { ValidatedGraphIndexes } from "./validate.js"; + +/** One resolved arc along a ring, excluding the state it departs from. */ +export interface RingArc { + readonly direction: "forward" | "backward"; + /** Ordered landings; the last entry is the requested target. */ + readonly states: readonly GraphStateId[]; +} + +/** + * A ring route resolved against authored edges. + * + * `none` means the pair shares no ring, or a step between two ring neighbours + * has no authored edge. `too-long` separates a reachable-but-refused arc from an + * unreachable one so callers can report the ring which refused it. + */ +export type RingRoute = + | { readonly kind: "none" } + | { + readonly kind: "too-long"; + readonly ring: Readonly; + readonly distance: number; + } + | { + readonly kind: "arc"; + readonly ring: Readonly; + readonly direction: "forward" | "backward"; + readonly states: readonly GraphStateId[]; + readonly steps: readonly Readonly[]; + }; + +/** + * Choose the shorter arc between two members of one ring. + * + * Distances are measured in steps, wrapping only on cyclic rings. Equal-length + * arcs resolve through the ring's `tieBreak`, which keeps a half-turn on an + * even cyclic ring deterministic. The `maxChainedSteps` ceiling is not applied + * here: callers decide whether a long arc is refused or merely reported. + */ +export function planRingArc( + ring: Readonly, + from: GraphStateId, + to: GraphStateId +): Readonly | null { + const length = ring.states.length; + const fromIndex = ring.states.indexOf(from); + const toIndex = ring.states.indexOf(to); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null; + + const forward = ring.cyclic + ? (toIndex - fromIndex + length) % length + : toIndex > fromIndex + ? toIndex - fromIndex + : Number.POSITIVE_INFINITY; + const backward = ring.cyclic + ? (fromIndex - toIndex + length) % length + : fromIndex > toIndex + ? fromIndex - toIndex + : Number.POSITIVE_INFINITY; + if (!Number.isFinite(forward) && !Number.isFinite(backward)) return null; + + const direction = forward < backward + ? "forward" + : backward < forward + ? "backward" + : ring.tieBreak; + const distance = direction === "forward" ? forward : backward; + const offset = direction === "forward" ? 1 : -1; + const states: GraphStateId[] = []; + for (let step = 1; step <= distance; step += 1) { + const index = (((fromIndex + step * offset) % length) + length) % length; + states.push(ring.states[index]!); + } + return Object.freeze({ direction, states: Object.freeze(states) }); +} + +/** + * Resolve the authored step edges which walk `from` to `to` along one ring. + * + * Rings are consulted in validated (ascending id) order and the first ring that + * can serve the whole arc wins, so a state which belongs to two rings routes + * deterministically. + */ +export function resolveRingRoute( + indexes: ValidatedGraphIndexes, + from: GraphStateId, + to: GraphStateId +): Readonly { + let refused: Readonly | null = null; + for (const ring of indexes.ringsByState.get(from) ?? []) { + const arc = planRingArc(ring, from, to); + if (arc === null) continue; + if (arc.states.length > ring.maxChainedSteps) { + refused ??= Object.freeze({ + kind: "too-long" as const, + ring, + distance: arc.states.length + }); + continue; + } + const steps = collectSteps(indexes, from, arc.states); + if (steps === null) continue; + return Object.freeze({ + kind: "arc" as const, + ring, + direction: arc.direction, + states: arc.states, + steps + }); + } + return refused ?? Object.freeze({ kind: "none" as const }); +} + +function collectSteps( + indexes: ValidatedGraphIndexes, + from: GraphStateId, + states: readonly GraphStateId[] +): readonly Readonly[] | null { + const steps: Readonly[] = []; + let cursor = from; + for (const state of states) { + const edge = indexes.directEdgesByState.get(cursor)?.get(state); + if (edge === undefined) return null; + steps.push(edge); + cursor = state; + } + return Object.freeze(steps); +} diff --git a/packages/graph/src/validate.ts b/packages/graph/src/validate.ts index f9902b3..8a0f0ff 100644 --- a/packages/graph/src/validate.ts +++ b/packages/graph/src/validate.ts @@ -10,10 +10,13 @@ import type { GraphEdgeId, GraphEdgeTrigger, GraphPortDefinition, + GraphRingDefinition, + GraphRingId, GraphStartPolicy, GraphStateDefinition, GraphStateId, GraphTransitionDefinition, + GraphTurnStep, MotionGraphDefinition, ValidatedMotionGraph } from "./model.js"; @@ -38,6 +41,12 @@ export interface ValidatedGraphIndexes { GraphEdgeDefinition >; readonly inverseEdgesById: ReadonlyMap; + readonly ringsById: ReadonlyMap; + /** Rings a state belongs to, in ascending ring-id order. */ + readonly ringsByState: ReadonlyMap< + GraphStateId, + readonly GraphRingDefinition[] + >; } const indexesByGraph = new WeakMap< @@ -132,10 +141,19 @@ export function validateMotionGraphDefinition( const inverseEdgesById = validateReversiblePairs(edges, edgesById); validateImmediateCompletionCycles(completionEdgesByState, statesById); + const rings = cloneRings(input.rings, statesById); + const ringsById = new Map(rings.map((ring) => [ring.id, ring])); + const ringsByState = indexRingsByState(rings); + validateRingStepOwnership(rings, directMutable); + for (const edge of edges) { + validateTurnEdge(edge, ringsById); + } + const definition = Object.freeze({ initialState, states: Object.freeze(states), - edges: Object.freeze(edges) + edges: Object.freeze(edges), + ...(rings.length === 0 ? {} : { rings: Object.freeze(rings) }) }); const validated = Object.freeze({ definition }) as unknown as ValidatedMotionGraph; const indexes = Object.freeze({ @@ -145,7 +163,9 @@ export function validateMotionGraphDefinition( directEdgesByState: directMutable, eventEdgesByState: eventMutable, completionEdgesByState, - inverseEdgesById + inverseEdgesById, + ringsById, + ringsByState }); indexesByGraph.set(validated, indexes); @@ -336,7 +356,8 @@ function cloneEdge( } } - const base = { id, from, to, start, continuity } as const; + const turn = cloneTurn(input, path); + const base = { id, from, to, start, continuity, ...turn } as const; if (trigger === undefined) { if (transition === undefined) { // Transitionless state requests are valid. @@ -350,6 +371,19 @@ function cloneEdge( return Object.freeze({ ...base, trigger, transition }); } +/** Read the optional ring membership which marks an edge as one turn step. */ +function cloneTurn( + input: Record, + path: string +): { readonly ring?: GraphRingId; readonly step?: GraphTurnStep } { + if (input.ring === undefined && input.step === undefined) return {}; + const ring = expectIdentifier(input.ring, `${path}.ring`); + if (input.step !== 1 && input.step !== -1) { + invalid(`${path}.step must be 1 or -1`); + } + return { ring, step: input.step }; +} + function cloneTrigger(value: unknown, path: string): GraphEdgeTrigger { const input = expectRecord(value, path); if (input.type === "completion") { @@ -640,6 +674,190 @@ function validateReversiblePairs( return inverseEdgesById; } +/** + * Clone and validate the authored rings. + * + * A ring is ordered, so its member list is not sorted here; only the ring array + * itself is required to be ascending by id, which keeps multi-ring routing + * deterministic without depending on authoring order. + */ +function cloneRings( + value: unknown, + statesById: ReadonlyMap +): readonly GraphRingDefinition[] { + if (value === undefined) return []; + const inputs = expectArray(value, "rings"); + if (inputs.length > GRAPH_LIMITS.maxRings) { + invalid(`rings must contain at most ${String(GRAPH_LIMITS.maxRings)} entries`); + } + const ringIds = new Set(); + const rings = Array.from(inputs, (entry, index) => { + const path = `rings[${String(index)}]`; + const input = expectRecord(entry, path); + const id = expectIdentifier(input.id, `${path}.id`); + addUnique(ringIds, id, `${path}.id`, "ring ID"); + if (typeof input.cyclic !== "boolean") { + invalid(`ring ${quote(id)} cyclic must be a boolean`); + } + if (input.tieBreak !== "forward" && input.tieBreak !== "backward") { + invalid(`ring ${quote(id)} tieBreak must be forward or backward`); + } + const stateInputs = expectArray(input.states, `${path}.states`); + if (stateInputs.length > GRAPH_LIMITS.maxRingStates) { + invalid( + `ring ${quote(id)} must contain at most ${String(GRAPH_LIMITS.maxRingStates)} states` + ); + } + const seen = new Set(); + const states = Array.from(stateInputs, (state, stateIndex) => { + const stateId = expectIdentifier( + state, + `${path}.states[${String(stateIndex)}]` + ); + if (seen.has(stateId)) { + invalid(`ring ${quote(id)} duplicates state ${quote(stateId)}`); + } + seen.add(stateId); + if (!statesById.has(stateId)) { + invalid(`ring ${quote(id)} references unknown state ${quote(stateId)}`); + } + return stateId; + }); + if (states.length < 2) { + invalid(`ring ${quote(id)} must contain at least 2 states`); + } + if (input.cyclic && states.length < 3) { + invalid(`cyclic ring ${quote(id)} must contain at least 3 states`); + } + const maxChainedSteps = expectPositiveSafeInteger( + input.maxChainedSteps, + `ring ${quote(id)} maxChainedSteps` + ); + if (maxChainedSteps > GRAPH_LIMITS.maxChainedSteps) { + invalid( + `ring ${quote(id)} maxChainedSteps must be at most ${String(GRAPH_LIMITS.maxChainedSteps)}` + ); + } + return Object.freeze({ + id, + states: Object.freeze(states), + cyclic: input.cyclic, + tieBreak: input.tieBreak, + maxChainedSteps + }); + }); + for (let index = 1; index < rings.length; index += 1) { + if (rings[index - 1]!.id >= rings[index]!.id) { + invalid("rings must be sorted and unique by id"); + } + } + return rings; +} + +function indexRingsByState( + rings: readonly GraphRingDefinition[] +): ReadonlyMap { + const byState = new Map(); + for (const ring of rings) { + for (const state of ring.states) { + const group = byState.get(state); + if (group === undefined) byState.set(state, [ring]); + else group.push(ring); + } + } + return byState; +} + +/** + * Reject two rings which both claim the same ordered neighbour pair. The pair + * has exactly one authored edge, so two owners would make routing ambiguous. + */ +function validateRingStepOwnership( + rings: readonly GraphRingDefinition[], + directEdgesByState: ReadonlyMap< + GraphStateId, + ReadonlyMap + > +): void { + const owners = new Map(); + for (const ring of rings) { + for (const [from, to] of ringNeighbourPairs(ring)) { + const key = pairKey(from, to); + const owner = owners.get(key); + if (owner !== undefined) { + invalid( + `rings ${quote(owner.ring)} and ${quote(ring.id)} both step from ${quote(from)} to ${quote(to)}` + ); + } + owners.set(key, { ring: ring.id, from, to }); + } + } + for (const owner of owners.values()) { + const edge = directEdgesByState.get(owner.from)?.get(owner.to); + if (edge?.ring !== undefined && edge.ring !== owner.ring) { + invalid( + `edge ${quote(edge.id)} declares ring ${quote(edge.ring)} but steps inside ring ${quote(owner.ring)}` + ); + } + } +} + +/** The ring which owns one ordered neighbour pair, kept with the pair itself. */ +interface RingStepOwner { + readonly ring: GraphRingId; + readonly from: GraphStateId; + readonly to: GraphStateId; +} + +function pairKey(from: GraphStateId, to: GraphStateId): string { + return `${from}\u0000${to}`; +} + +/** Every ordered adjacency of a ring, forward first then backward. */ +function ringNeighbourPairs( + ring: GraphRingDefinition +): readonly (readonly [GraphStateId, GraphStateId])[] { + const pairs: (readonly [GraphStateId, GraphStateId])[] = []; + const length = ring.states.length; + const last = ring.cyclic ? length : length - 1; + for (let index = 0; index < last; index += 1) { + const from = ring.states[index]!; + const to = ring.states[(index + 1) % length]!; + pairs.push([from, to]); + } + for (const [from, to] of [...pairs]) pairs.push([to, from]); + return pairs; +} + +/** A turn edge must name a real ring and connect two of its neighbours. */ +function validateTurnEdge( + edge: GraphEdgeDefinition, + ringsById: ReadonlyMap +): void { + if (edge.ring === undefined) return; + const ring = ringsById.get(edge.ring); + if (ring === undefined) { + invalid(`${edgePath(edge)} references unknown ring ${quote(edge.ring)}`); + } + const fromIndex = ring.states.indexOf(edge.from); + const toIndex = ring.states.indexOf(edge.to); + if (fromIndex < 0 || toIndex < 0) { + invalid( + `${edgePath(edge)} connects states outside ring ${quote(ring.id)}` + ); + } + const length = ring.states.length; + const offset = edge.step ?? 1; + const expected = ring.cyclic + ? (((fromIndex + offset) % length) + length) % length + : fromIndex + offset; + if (expected !== toIndex) { + invalid( + `${edgePath(edge)} is not step ${String(offset)} from ${quote(edge.from)} in ring ${quote(ring.id)}` + ); + } +} + function validateImmediateCompletionCycles( completionEdgesByState: ReadonlyMap, statesById: ReadonlyMap diff --git a/packages/graph/test/ring-turns.test.ts b/packages/graph/test/ring-turns.test.ts new file mode 100644 index 0000000..3eba0dd --- /dev/null +++ b/packages/graph/test/ring-turns.test.ts @@ -0,0 +1,537 @@ +import { describe, expect, it } from "vitest"; + +import { MotionGraphEngine } from "../src/engine.js"; +import { planRingArc } from "../src/ring-plan.js"; +import type { + GraphEdgeDefinition, + GraphRingDefinition, + GraphStateDefinition, + MotionGraphDefinition, + MotionGraphEffect, + MotionGraphResult +} from "../src/model.js"; + +const FACINGS = Object.freeze([ + "walk_n", + "walk_ne", + "walk_e", + "walk_se", + "walk_s", + "walk_sw", + "walk_w", + "walk_nw" +]); + +describe("ring arc selection", () => { + it("chooses the shorter arc and reports the states it lands on", () => { + const ring = facingRing(); + + expect(planRingArc(ring, "walk_n", "walk_e")).toEqual({ + direction: "forward", + states: ["walk_ne", "walk_e"] + }); + // AC3: two steps forward beats six steps backward. + expect(planRingArc(ring, "walk_nw", "walk_ne")).toEqual({ + direction: "forward", + states: ["walk_n", "walk_ne"] + }); + expect(planRingArc(ring, "walk_n", "walk_w")).toEqual({ + direction: "backward", + states: ["walk_nw", "walk_w"] + }); + }); + + it("resolves an even-length half turn through tieBreak deterministically", () => { + const forward = facingRing(); + const backward = facingRing({ tieBreak: "backward" }); + + // AC4: an exact half turn is equidistant in both directions. + for (let attempt = 0; attempt < 100; attempt += 1) { + expect(planRingArc(forward, "walk_n", "walk_s")).toEqual({ + direction: "forward", + states: ["walk_ne", "walk_e", "walk_se", "walk_s"] + }); + expect(planRingArc(backward, "walk_n", "walk_s")).toEqual({ + direction: "backward", + states: ["walk_nw", "walk_w", "walk_sw", "walk_s"] + }); + } + }); + + it("never wraps a non-cyclic ring", () => { + const line = facingRing({ cyclic: false }); + + expect(planRingArc(line, "walk_nw", "walk_ne")).toEqual({ + direction: "backward", + states: ["walk_w", "walk_sw", "walk_s", "walk_se", "walk_e", "walk_ne"] + }); + expect(planRingArc(line, "walk_n", "walk_n")).toBeNull(); + expect(planRingArc(line, "walk_n", "sit")).toBeNull(); + }); +}); + +describe("MotionGraphEngine ring traversal", () => { + it("plans a multi-step arc without advancing the graph", () => { + const engine = animatedEngine(); + const before = engine.snapshot(); + + // AC2: the dry run names every landing, in order. + expect(engine.planFor("walk_e")).toEqual(["walk_ne", "walk_e"]); + expect(engine.planFor("walk_ne")).toEqual(["walk_ne"]); + expect(engine.planFor("walk_n")).toEqual([]); + expect(engine.planFor("sit")).toEqual(["sit"]); + expect(engine.planFor("unknown")).toBeNull(); + expect(engine.snapshot()).toEqual(before); + }); + + it("refuses an arc longer than the ring allows", () => { + const engine = animatedEngine({ maxChainedSteps: 2 }); + + expect(engine.planFor("walk_s")).toBeNull(); + const refused = engine.request("walk_s"); + expect(refused.accepted).toBe(false); + expect(settleEffects(refused)).toEqual([ + settle([refused.requestId!], "reject", "RouteError") + ]); + }); + + it("chains one step at a time and settles only on the requested landing", () => { + const engine = animatedEngine(); + const request = engine.request("walk_e"); + expect(request.snapshot).toMatchObject({ + phase: "waiting", + requestedState: "walk_e", + visualState: "walk_n", + pendingEdgeId: stepId("walk_n", "walk_ne"), + turnRing: "facing.walk", + turnStepsRemaining: 1 + }); + + // AC2: the first step lands on the intermediate facing, and the request is + // still outstanding there. + const first = engine.tick({ contentOrdinal: 0n }); + expect(first.presentation).toEqual(bodyPresentation("walk_ne", 0)); + expect(first.snapshot).toMatchObject({ + phase: "waiting", + visualState: "walk_ne", + requestedState: "walk_e", + pendingEdgeId: stepId("walk_ne", "walk_e"), + turnStepsRemaining: 0 + }); + expect(effectTypes(first)).toEqual([ + "transitionstart", + "visualstatechange", + "transitionend", + "turnstep" + ]); + expect(turnSteps(first)).toEqual([ + { + type: "turnstep", + ring: "facing.walk", + from: "walk_n", + to: "walk_ne", + remaining: 1 + } + ]); + expect(settleEffects(first)).toEqual([]); + + const second = engine.tick({ contentOrdinal: 1n }); + expect(second.presentation).toEqual(bodyPresentation("walk_e", 0)); + expect(second.snapshot).toMatchObject({ + phase: "stable", + visualState: "walk_e", + requestedState: "walk_e", + turnRing: null, + turnStepsRemaining: 0, + isTransitioning: false + }); + expect(turnSteps(second)).toEqual([ + { + type: "turnstep", + ring: "facing.walk", + from: "walk_ne", + to: "walk_e", + remaining: 0 + } + ]); + expect(settleEffects(second)).toEqual([ + settle([request.requestId!], "resolve", "target-committed") + ]); + }); + + it("keeps every seam on a body frame across a full eight-step traversal", () => { + const engine = animatedEngine(); + const clock = { ordinal: 0n }; + + // AC8: each tick either advances a body frame or lands frame 0 of the next + // body, so no seam ever seeks inside a unit. + engine.request("walk_nw"); + expect(runToStable(engine, clock, "walk_n")).toEqual(["walk_nw"]); + + // Both arcs are four steps, so tieBreak "forward" decides. + expect(engine.planFor("walk_se")).toEqual([ + "walk_n", + "walk_ne", + "walk_e", + "walk_se" + ]); + const sweep = engine.request("walk_se"); + expect(runToStable(engine, clock, "walk_nw")).toEqual([ + "walk_n", + "walk_ne", + "walk_e", + "walk_se" + ]); + expect(sweep.requestId).toBeTypeOf("number"); + + // The whole eight-way loop, one landing per step, with no seek anywhere. + engine.request("walk_e"); + engine.request("walk_s"); + engine.request("walk_w"); + expect(runToStable(engine, clock, "walk_se")).toEqual([ + "walk_s", + "walk_sw", + "walk_w" + ]); + }); + + it("replans from the landed state and aborts the superseded request", () => { + const engine = animatedEngine(); + const first = engine.request("walk_se"); + engine.tick({ contentOrdinal: 0n }); + expect(engine.snapshot()).toMatchObject({ + visualState: "walk_ne", + pendingEdgeId: stepId("walk_ne", "walk_e") + }); + + // AC5: the new intent supersedes the old one without stopping the motion. + const second = engine.request("walk_e"); + expect(second.accepted).toBe(true); + expect(settleEffects(second)).toEqual([ + settle([first.requestId!], "reject", "AbortError") + ]); + expect(second.snapshot).toMatchObject({ + phase: "waiting", + requestedState: "walk_e", + visualState: "walk_ne", + pendingEdgeId: stepId("walk_ne", "walk_e"), + turnStepsRemaining: 0 + }); + + const landed = engine.tick({ contentOrdinal: 1n }); + expect(landed.snapshot).toMatchObject({ + phase: "stable", + visualState: "walk_e", + requestedState: "walk_e" + }); + expect(settleEffects(landed)).toEqual([ + settle([second.requestId!], "resolve", "target-committed") + ]); + }); + + it("reverses a sweep mid-chain and keeps moving on the new arc", () => { + const engine = animatedEngine(); + const outbound = engine.request("walk_s"); + engine.tick({ contentOrdinal: 0n }); + engine.tick({ contentOrdinal: 1n }); + expect(engine.snapshot().visualState).toBe("walk_e"); + + const inbound = engine.request("walk_n"); + expect(settleEffects(inbound)).toEqual([ + settle([outbound.requestId!], "reject", "AbortError") + ]); + expect(inbound.snapshot).toMatchObject({ + pendingEdgeId: stepId("walk_e", "walk_ne"), + turnStepsRemaining: 1 + }); + + expect(engine.tick({ contentOrdinal: 2n }).snapshot.visualState).toBe("walk_ne"); + const home = engine.tick({ contentOrdinal: 3n }); + expect(home.snapshot).toMatchObject({ + phase: "stable", + visualState: "walk_n" + }); + expect(settleEffects(home)).toEqual([ + settle([inbound.requestId!], "resolve", "target-committed") + ]); + }); + + it("collapses the arc under a direct turn policy", () => { + const engine = animatedEngine({ turnPolicy: "direct" }); + + // AC9: reduced motion lands in the target with one reported landing. + expect(engine.planFor("walk_s")).toEqual(["walk_s"]); + const request = engine.request("walk_s"); + expect(request.snapshot).toMatchObject({ + phase: "waiting", + requestedState: "walk_s", + turnRing: "facing.walk", + turnStepsRemaining: 0 + }); + + const landed = engine.tick({ contentOrdinal: 0n }); + expect(landed.presentation).toEqual(bodyPresentation("walk_s", 0)); + expect(turnSteps(landed)).toEqual([ + { + type: "turnstep", + ring: "facing.walk", + from: "walk_n", + to: "walk_s", + remaining: 0 + } + ]); + expect(settleEffects(landed)).toEqual([ + settle([request.requestId!], "resolve", "target-committed") + ]); + expect(landed.snapshot).toMatchObject({ + phase: "stable", + visualState: "walk_s", + turnRing: null + }); + }); + + it("prefers an explicit edge over the derived ring step", () => { + const definition = facingGraph(); + const engine = new MotionGraphEngine(); + engine.install({ + ...definition, + edges: [ + ...definition.edges, + { + ...portalEdge("shortcut.n.s", "walk_n", "walk_s"), + transition: { kind: "locked", unitId: "spin-bridge", frameCount: 2 } + } + ] + }); + engine.beginAnimated(); + + expect(engine.planFor("walk_s")).toEqual(["walk_s"]); + expect(engine.request("walk_s").snapshot).toMatchObject({ + pendingEdgeId: "shortcut.n.s", + turnRing: null, + turnStepsRemaining: 0 + }); + }); + + it("rejects rings whose members, length, or steps are unusable", () => { + expect(() => install({ rings: [ring({ states: ["walk_n", "sprint"] })] })) + .toThrow(/ring "facing.walk" references unknown state "sprint"/u); + expect(() => install({ rings: [ring({ states: ["walk_n", "walk_n", "walk_e"] })] })) + .toThrow(/ring "facing.walk" duplicates state "walk_n"/u); + expect(() => install({ rings: [ring({ states: ["walk_n"] })] })) + .toThrow(/ring "facing.walk" must contain at least 2 states/u); + expect(() => + install({ rings: [ring({ states: ["walk_n", "walk_ne"], cyclic: true })] }) + ).toThrow(/cyclic ring "facing.walk" must contain at least 3 states/u); + expect(() => + install({ + rings: [ + ring({ id: "facing.a", states: ["walk_n", "walk_ne", "walk_e"] }), + ring({ id: "facing.b", states: ["walk_n", "walk_ne", "walk_e"] }) + ] + }) + ).toThrow( + /rings "facing.a" and "facing.b" both step from "walk_n" to "walk_ne"/u + ); + expect(() => + install({ + edges: [ + { + ...portalEdge("turn.n.e", "walk_n", "walk_e"), + ring: "facing.walk", + step: 1 + } + ] + }) + ).toThrow(/edge "turn.n.e" is not step 1 from "walk_n"/u); + expect(() => + install({ + edges: [ + { + ...portalEdge("turn.n.e", "walk_n", "walk_e"), + ring: "facing.turn", + step: 1 + } + ] + }) + ).toThrow(/edge "turn.n.e" references unknown ring "facing.turn"/u); + expect(() => { + const definition = facingGraph(); + const [first, ...rest] = definition.edges; + install({ + ...definition, + rings: [ + ring({ id: "facing.other", states: ["walk_n", "walk_ne", "walk_e"] }) + ], + edges: [{ ...first!, ring: "facing.walk", step: 1 }, ...rest] + }); + }).toThrow( + /declares ring "facing.walk" but steps inside ring "facing.other"/u + ); + }); +}); + +/** + * Tick until the graph settles, asserting that every visible seam is frame 0 of + * the next body. Returns the states it landed on, in order. + */ +function runToStable( + engine: MotionGraphEngine, + clock: { ordinal: bigint }, + from: string +): readonly string[] { + const landings: string[] = []; + let cursor = from; + for (let guard = 0; guard < 256; guard += 1) { + const result = engine.tick({ contentOrdinal: clock.ordinal }); + clock.ordinal += 1n; + const presentation = result.presentation; + expect(presentation?.kind).toBe("body"); + if (presentation?.kind !== "body") throw new Error("unreachable"); + if (presentation.state !== cursor) { + expect(presentation.frameIndex).toBe(0); + landings.push(presentation.state); + cursor = presentation.state; + } + if (result.snapshot.phase === "stable") return landings; + } + throw new Error("graph never settled"); +} + +function install( + overrides: Partial +): MotionGraphEngine { + const engine = new MotionGraphEngine(); + engine.install({ ...facingGraph(), ...overrides }); + return engine; +} + +function animatedEngine( + options: { + readonly maxChainedSteps?: number; + readonly turnPolicy?: "chain" | "direct"; + } = {} +): MotionGraphEngine { + const engine = new MotionGraphEngine( + options.turnPolicy === undefined ? {} : { turnPolicy: options.turnPolicy } + ); + engine.install( + options.maxChainedSteps === undefined + ? facingGraph() + : { + ...facingGraph(), + rings: [facingRing({ maxChainedSteps: options.maxChainedSteps })] + } + ); + engine.beginAnimated(); + return engine; +} + +/** An eight-way facing ring plus one off-ring state reachable by a cut. */ +function facingGraph(): MotionGraphDefinition { + const edges: GraphEdgeDefinition[] = []; + for (let index = 0; index < FACINGS.length; index += 1) { + const from = FACINGS[index]!; + const to = FACINGS[(index + 1) % FACINGS.length]!; + edges.push({ ...portalEdge(stepId(from, to), from, to), ring: "facing.walk", step: 1 }); + edges.push({ ...portalEdge(stepId(to, from), to, from), ring: "facing.walk", step: -1 }); + } + edges.push(portalEdge("walk_n.sit", "walk_n", "sit")); + return { + initialState: "walk_n", + states: [...FACINGS, "sit"].map(state), + edges, + rings: [facingRing()] + }; +} + +function facingRing( + overrides: Partial = {} +): GraphRingDefinition { + return ring(overrides); +} + +function ring(overrides: Partial = {}): GraphRingDefinition { + return { + id: "facing.walk", + states: FACINGS, + cyclic: true, + tieBreak: "forward", + maxChainedSteps: 4, + ...overrides + }; +} + +function stepId(from: string, to: string): string { + return `facing.walk.${from}.${to}`; +} + +function state(id: string): GraphStateDefinition { + return { + id, + body: { + unitId: `${id}.body`, + kind: "loop", + frameCount: 4, + ports: [{ id: "default", entryFrame: 0, portalFrames: [0, 2] }] + } + }; +} + +function portalEdge( + id: string, + from: string, + to: string +): GraphEdgeDefinition { + return { + id, + from, + to, + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 1 + }, + continuity: "exact-authored" + }; +} + +function bodyPresentation(state_: string, frameIndex: number): object { + return { + kind: "body", + state: state_, + unitId: `${state_}.body`, + frameIndex + }; +} + +function effectTypes(result: Readonly): readonly string[] { + return result.effects.map(({ type }) => type); +} + +function turnSteps( + result: Readonly +): readonly Readonly[] { + return result.effects.filter((effect) => effect.type === "turnstep"); +} + +function settleEffects( + result: Readonly +): readonly Readonly[] { + return result.effects.filter((effect) => effect.type === "settle"); +} + +function settle( + requestIds: readonly number[], + type: "resolve" | "reject", + detail: string +): object { + return { + type: "settle", + requestIds, + outcome: type === "resolve" + ? { type: "resolve", timing: "microtask", reason: detail } + : { type: "reject", timing: "microtask", error: detail } + }; +} diff --git a/packages/player-web/src/runtime/effect-host.test.ts b/packages/player-web/src/runtime/effect-host.test.ts index 80bbe05..3b22d96 100644 --- a/packages/player-web/src/runtime/effect-host.test.ts +++ b/packages/player-web/src/runtime/effect-host.test.ts @@ -409,6 +409,8 @@ function graphSnapshot(): Readonly { pendingEdgeId: null, activeEdgeId: null, followOnEdgeId: null, + turnRing: null, + turnStepsRemaining: 0, direction: null, contentOrdinal: null, inputSequence: 0, diff --git a/packages/player-web/src/runtime/effect-host.ts b/packages/player-web/src/runtime/effect-host.ts index 56af55c..2260257 100644 --- a/packages/player-web/src/runtime/effect-host.ts +++ b/packages/player-web/src/runtime/effect-host.ts @@ -436,6 +436,7 @@ export class EffectHost { this.#dispatch(cloneGraphEvent(effect), phase); return; case "fallback": + case "turnstep": this.#dispatch(cloneGraphEvent(effect), phase); return; case "settle": @@ -602,6 +603,8 @@ function isPostDrawEffect( // legitimately emits settlement before its readiness change with no pixels. return effect.type === "visualstatechange" || effect.type === "transitionend" || + // A ring landing is only real once the body it landed on has been drawn. + effect.type === "turnstep" || effect.type === "settle" && hasPresentationBarrier; } diff --git a/packages/player-web/src/runtime/grass-rabbit-golden-trace.test.ts b/packages/player-web/src/runtime/grass-rabbit-golden-trace.test.ts new file mode 100644 index 0000000..bf5976e --- /dev/null +++ b/packages/player-web/src/runtime/grass-rabbit-golden-trace.test.ts @@ -0,0 +1,551 @@ +// Golden-trace harness: drives the *real* grass-rabbit.avl through the TS +// PathScheduler (with the real WorkerSampleFactory + RuntimeAssetCatalog and a +// deterministic fake decoder worker) and serializes the full trace + takeNext +// media sequence to JSON. The committed JSON +// (flutter/packages/aval_player/test/fixtures/grass_rabbit_golden_trace.json) +// is the golden the Dart `golden_trace_test.dart` diffs against. +// +// Regenerate the fixture with: +// WRITE_GOLDEN=1 npx vitest run --config vitest.m9.config.ts \ +// packages/player-web/src/runtime/grass-rabbit-golden-trace.test.ts +// +// The scenario (deterministic; identical on the Dart side): +// 1. begin animated in idle: startBody(idle-loop) +// 2. tick 80 frames — wraps the 70-frame idle loop (unitInstance 0 -> 1) +// 3. hover.enter at tick 80: prepareRoute(idle.entering portal edge), commit +// at the portal boundary, stream into the hover-in target, promote it +// 4. hover.leave: prepareRoute(entering -> exiting via a finish edge), stream +// to the hover-out target, promote it, then run toward idle again +// The fake worker delivers exactly the frames the scheduler submits, so the +// trace records the scheduler's DECISIONS, not decoded bytes. + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { + GraphBodyDefinition, + GraphEdgeDefinition, + GraphStartPolicy +} from "@pixel-point/aval-graph"; + +import { installRuntimeAssetCatalog } from "./asset-catalog.js"; +import { DecodeTimeline } from "./decode-timeline.js"; +import { WorkerSampleFactory } from "./worker-samples.js"; +import { PathScheduler } from "./path-scheduler.js"; +import type { + DecoderWorkerMetrics, + DecoderWorkerWaitOptions, + ManagedDecoderWorkerFrame, + PathSchedulerTakeResult, + PathSchedulerWorkerAdapter +} from "./path-scheduler-model.js"; +import type { DecoderWorkerSample } from "../decoder-worker/protocol.js"; + +const LIMITS = Object.freeze({ + maxDecodeQueueSize: 8, + maxPendingSamples: 12, + maxOutstandingFrames: 12, + maxDecodedBytes: 12 * 1280 * 720 * 4 +}); + +const RING_CAPACITY = 6; +const IDLE_TICKS = 80; + +const ASSET_PATH = fileURLToPath( + new URL( + "../../../../examples/grass-rabbit/public/grass-rabbit.avl", + import.meta.url + ) +); +const FIXTURE_PATH = fileURLToPath( + new URL( + "../../../../flutter/packages/aval_player/test/fixtures/grass_rabbit_golden_trace.json", + import.meta.url + ) +); + +describe("grass-rabbit golden trace", () => { + it("produces a stable scheduler trace and media sequence", async () => { + const result = await runScenario(); + expect(result.trace.length).toBeGreaterThan(0); + expect(result.media.length).toBeGreaterThan(0); + + if (process.env.WRITE_GOLDEN === "1") { + mkdirSync(dirname(FIXTURE_PATH), { recursive: true }); + writeFileSync(FIXTURE_PATH, `${JSON.stringify(result, null, 2)}\n`); + } + }); +}); + +interface Scenario { + readonly meta: { + readonly rendition: string; + readonly frameRate: { + readonly numerator: number; + readonly denominator: number; + }; + readonly units: Record; + }; + readonly media: readonly unknown[]; + readonly trace: readonly unknown[]; +} + +interface StepBox { + value: number; +} + +async function runScenario(): Promise { + const bytes = new Uint8Array(readFileSync(ASSET_PATH)); + const catalog = installRuntimeAssetCatalog(bytes); + const manifest = catalog.manifest; + const rendition = manifest.renditions.find((candidate) => + candidate.profile.startsWith("avc-annexb") + ); + if (rendition === undefined) throw new Error("no AVC rendition"); + const timeline = new DecodeTimeline(manifest.frameRate); + const worker = new FakeWorker(); + const samples = new WorkerSampleFactory({ + catalog, + timeline, + rendition: rendition.id, + limits: LIMITS + }); + let now = 0; + const scheduler = new PathScheduler({ + timeline, + samples, + worker, + rendition: rendition.id, + ringCapacity: RING_CAPACITY, + limits: LIMITS, + clock: { now: () => ++now } + }); + + const unitFrameCounts: Record = {}; + for (const unit of manifest.units) unitFrameCounts[unit.id] = unit.frameCount; + + const media: unknown[] = []; + const step: StepBox = { value: 0 }; + const record = (label: string, result: PathSchedulerTakeResult): void => { + media.push(serializeTake(step.value, label, result)); + step.value += 1; + if (result.kind === "frame") result.frame.close(); + }; + + // 1-2. Idle loop with wrap. + await scheduler.startBody({ + state: "idle", + body: body(manifest, "idle-loop"), + outgoingStarts: [portalStart("default", "default", 139)], + path: "idle" + }); + for (let index = 0; index < IDLE_TICKS; index += 1) { + await scheduler.pump({ targetRingFrames: RING_CAPACITY }); + record("idle", scheduler.takeNext()); + } + + // 3. hover.enter -> entering (portal edge). + await routeThrough( + scheduler, + portalEdge("idle.entering", "idle", "entering", "default", "default", 139), + "entering", + body(manifest, "hover-in"), + "enter", + media, + step + ); + + // 4. hover.leave -> exiting (finish edge from the finite hover-in body). + await routeThrough( + scheduler, + finishEdge("entering.exiting", "entering", "exiting", "default", 66), + "exiting", + body(manifest, "hover-out"), + "leave", + media, + step + ); + + for (let index = 0; index < 6; index += 1) { + await scheduler.pump({ targetRingFrames: RING_CAPACITY }); + record("exiting-tail", scheduler.takeNext()); + } + + const trace = scheduler.trace().map(serializeTraceRecord); + await scheduler.dispose(); + + return { + meta: { + rendition: rendition.id, + frameRate: { + numerator: manifest.frameRate.numerator, + denominator: manifest.frameRate.denominator + }, + units: unitFrameCounts + }, + media, + trace + }; +} + +async function routeThrough( + scheduler: PathScheduler, + edge: GraphEdgeDefinition, + targetState: string, + targetBody: GraphBodyDefinition, + label: string, + media: unknown[], + step: StepBox +): Promise { + await scheduler.prepareRoute({ edge, targetState, targetBody }); + let committed = false; + for (let guard = 0; guard < 600 && !committed; guard += 1) { + await scheduler.pump({ targetRingFrames: RING_CAPACITY }); + const decision = scheduler.routeDecision(); + if (decision !== null && decision.kind === "commit-edge") { + scheduler.commitPreparedRoute(); + committed = true; + break; + } + const result = scheduler.reserveNext(true); + if (result.kind === "frame") { + scheduler.commitPreparedPresentation(result.media); + media.push(serializeTake(step.value, `${label}-source`, result)); + step.value += 1; + result.frame.close(); + } else { + media.push(serializeTake(step.value, `${label}-wait`, result)); + step.value += 1; + } + } + if (!committed) throw new Error(`${label} route never committed`); + for (let index = 0; index < 10; index += 1) { + await scheduler.pump({ targetRingFrames: RING_CAPACITY }); + media.push(serializeTake(step.value, `${label}-target`, scheduler.takeNext())); + const last = media[media.length - 1] as { kind: string }; + step.value += 1; + if (last.kind === "frame") { + // frame already closed inside serializeTake? No — close here. + } + } + scheduler.promoteTargetToSource({ + state: targetState, + body: targetBody, + outgoingStarts: [portalStart("default", "default", 139)] + }); +} + +function body( + manifest: ReturnType["manifest"], + unitId: string +): GraphBodyDefinition { + const unit = manifest.units.find((candidate) => candidate.id === unitId); + if (unit === undefined || unit.kind !== "body") { + throw new Error(`missing body unit ${unitId}`); + } + return { + unitId: unit.id, + kind: unit.playback === "loop" ? "loop" : "finite", + frameCount: unit.frameCount, + ports: unit.ports.map((port) => ({ + id: port.id, + entryFrame: 0, + portalFrames: [...port.portalFrames] + })) + }; +} + +function portalStart( + sourcePort: string, + targetPort: string, + maxWaitFrames: number +): Extract { + return { type: "portal", sourcePort, targetPort, maxWaitFrames }; +} + +function portalEdge( + id: string, + from: string, + to: string, + sourcePort: string, + targetPort: string, + maxWaitFrames: number +): GraphEdgeDefinition { + return { + id, + from, + to, + start: portalStart(sourcePort, targetPort, maxWaitFrames), + continuity: "exact-authored" + }; +} + +function finishEdge( + id: string, + from: string, + to: string, + targetPort: string, + maxWaitFrames: number +): GraphEdgeDefinition { + return { + id, + from, + to, + start: { type: "finish", targetPort, maxWaitFrames }, + continuity: "exact-authored" + }; +} + +function serializeTake( + step: number, + label: string, + result: PathSchedulerTakeResult +): unknown { + const base: Record = { step, label, kind: result.kind }; + if (result.kind === "frame") { + base.purpose = result.purpose; + Object.assign(base, mediaFields(result.media)); + result.frame.close(); + } else if (result.kind === "resident") { + Object.assign(base, mediaFields(result.media)); + } + return base; +} + +function mediaFields(media: { + readonly graphKind: string; + readonly state: string | null; + readonly edge: string | null; + readonly path: string; + readonly frame: { readonly unit: string; readonly localFrame: number }; + readonly drawSource: string; + readonly generation: number; + readonly unitInstance: number; + readonly decodeOrdinal: number; + readonly timestamp: number; + readonly intendedPresentationOrdinal: bigint; +}): Record { + return { + graphKind: media.graphKind, + state: media.state, + edge: media.edge, + path: media.path, + unit: media.frame.unit, + localFrame: media.frame.localFrame, + drawSource: media.drawSource, + generation: media.generation, + unitInstance: media.unitInstance, + decodeOrdinal: media.decodeOrdinal, + timestamp: media.timestamp, + intendedPresentationOrdinal: media.intendedPresentationOrdinal.toString() + }; +} + +function serializeTraceRecord(record: { + readonly index: number; + readonly operation: string; + readonly generation: number | null; + readonly path: string | null; + readonly unit: string | null; + readonly unitInstance: number | null; + readonly unitFrame: number | null; + readonly decodeOrdinal: number | null; + readonly intendedPresentationOrdinal: bigint | null; + readonly ringSize: number; + readonly expectedOutputs: number; + readonly reason: string | null; +}): unknown { + return { + index: record.index, + operation: record.operation, + generation: record.generation, + path: record.path, + unit: record.unit, + unitInstance: record.unitInstance, + unitFrame: record.unitFrame, + decodeOrdinal: record.decodeOrdinal, + intendedPresentationOrdinal: + record.intendedPresentationOrdinal === null + ? null + : record.intendedPresentationOrdinal.toString(), + ringSize: record.ringSize, + expectedOutputs: record.expectedOutputs, + reason: record.reason + }; +} + +// --- Fake decoder worker (from path-scheduler.test.ts) --------------------- + +interface PendingFakeSample { + readonly generation: number; + readonly sample: Omit; +} + +class FakeWorker implements PathSchedulerWorkerAdapter { + public activeGeneration: number | null = null; + public maximumSubmittedBatch = 0; + public abortCalls = 0; + readonly #outputsPerWait = 1; + readonly #pending: PendingFakeSample[] = []; + readonly #ready: FakeManagedFrame[] = []; + readonly #open = new Set(); + #acceptedSamples = 0; + #releasedFrames = 0; + + public get queuedFrames(): number { + return this.#ready.length; + } + + public get openFrames(): number { + return this.#open.size; + } + + public async activateGeneration(generation: number): Promise { + this.activeGeneration = generation; + for (const frame of [...this.#ready]) { + if (frame.generation !== generation) frame.close(); + } + this.#ready.splice( + 0, + this.#ready.length, + ...this.#ready.filter((frame) => !frame.closed) + ); + this.#pending.length = 0; + } + + public async submit( + generation: number, + samples: readonly DecoderWorkerSample[] + ): Promise { + if (generation !== this.activeGeneration) { + throw new Error("fake generation mismatch"); + } + this.maximumSubmittedBatch = Math.max( + this.maximumSubmittedBatch, + samples.length + ); + for (const sample of samples) { + const { data: _data, ...metadata } = sample; + this.#pending.push({ generation, sample: metadata }); + this.#acceptedSamples += 1; + } + } + + public async abortGeneration(generation: number): Promise { + this.abortCalls += 1; + this.#pending.splice( + 0, + this.#pending.length, + ...this.#pending.filter((item) => item.generation !== generation) + ); + for (const frame of [...this.#open]) { + if (frame.generation === generation) frame.close(); + } + this.#ready.splice( + 0, + this.#ready.length, + ...this.#ready.filter((frame) => !frame.closed) + ); + if (this.activeGeneration === generation) this.activeGeneration = null; + } + + public takeFrame(): ManagedDecoderWorkerFrame | undefined { + return this.#ready.shift(); + } + + public async waitForFrames( + minimum = 1, + _options: DecoderWorkerWaitOptions = {} + ): Promise { + let released = 0; + while ( + this.#pending.length > 0 && + (this.#ready.length < minimum || released < this.#outputsPerWait) && + released < this.#outputsPerWait + ) { + const pending = this.#pending.shift()!; + const frame = new FakeManagedFrame(pending, () => { + this.#open.delete(frame); + this.#releasedFrames += 1; + }); + this.#open.add(frame); + this.#ready.push(frame); + released += 1; + } + } + + public async snapshotMetrics(): Promise { + const activeGeneration = this.activeGeneration; + const submittedFrames = this.#pending.filter( + (item) => item.generation === activeGeneration + ).length; + const leasedFrames = [...this.#open].filter( + (frame) => frame.generation === activeGeneration + ).length; + return { + configureCalls: 1, + resetCalls: 0, + flushCalls: 0, + boundaryFlushCalls: 0, + acceptedSamples: this.#acceptedSamples, + submittedChunks: this.#acceptedSamples, + outputFrames: this.#acceptedSamples - this.#pending.length, + deliveredFrames: this.#acceptedSamples - this.#pending.length, + releasedFrames: this.#releasedFrames, + staleFrames: 0, + closedFrames: this.#releasedFrames, + pendingSamples: 0, + submittedFrames, + leasedFrames, + leasedDecodedBytes: leasedFrames * 128, + decodeQueueSize: submittedFrames, + activeGeneration, + nextSubmissionOrdinal: this.#acceptedSamples, + nextOutputOrdinal: this.#acceptedSamples - this.#pending.length, + errors: 0, + disposed: false + }; + } +} + +class FakeManagedFrame implements ManagedDecoderWorkerFrame { + public readonly frame: VideoFrame; + public readonly frameId: number; + public readonly generation: number; + public readonly ordinal: number; + public readonly unitId: string; + public readonly unitInstance: number; + public readonly unitFrame: number; + public readonly timestamp: number; + public readonly duration: number; + public readonly decodedBytes = 128; + readonly #release: () => void; + #closed = false; + + public constructor(pending: PendingFakeSample, release: () => void) { + this.frame = { close() {} } as unknown as VideoFrame; + this.frameId = pending.sample.ordinal + 1; + this.generation = pending.generation; + this.ordinal = pending.sample.ordinal; + this.unitId = pending.sample.unitId; + this.unitInstance = pending.sample.unitInstance; + this.unitFrame = pending.sample.unitFrame; + this.timestamp = pending.sample.timestamp; + this.duration = pending.sample.duration; + this.#release = release; + } + + public get closed(): boolean { + return this.#closed; + } + + public close(): void { + if (this.#closed) return; + this.#closed = true; + this.frame.close(); + this.#release(); + } +} diff --git a/packages/player-web/src/runtime/integrated-player-contracts.ts b/packages/player-web/src/runtime/integrated-player-contracts.ts index 1186622..e1dbe79 100644 --- a/packages/player-web/src/runtime/integrated-player-contracts.ts +++ b/packages/player-web/src/runtime/integrated-player-contracts.ts @@ -2,7 +2,8 @@ import type { GraphPresentation, MotionGraphResult, MotionGraphSnapshot, - MotionGraphTickOptions + MotionGraphTickOptions, + MotionGraphTurnPolicy } from "@pixel-point/aval-graph"; import type { @@ -190,6 +191,12 @@ interface IntegratedPlayerCommonOptions { readonly timers?: IntegratedTimerHost; /** Internal M5.5 clock ownership; public pause/autoplay remains M8. */ readonly realtime?: Readonly; + /** + * How a multi-step ring request is served. "chain" (default) walks every + * intermediate state; "direct" lands in the target without them, for hosts + * honouring a reduced motion preference. + */ + readonly turnPolicy?: MotionGraphTurnPolicy; } export type IntegratedPlayerOptions = IntegratedPlayerCommonOptions & ( diff --git a/packages/player-web/src/runtime/integrated-player.ts b/packages/player-web/src/runtime/integrated-player.ts index 8eb0378..9f777fd 100644 --- a/packages/player-web/src/runtime/integrated-player.ts +++ b/packages/player-web/src/runtime/integrated-player.ts @@ -1,4 +1,8 @@ -import { MotionGraphEngine, type MotionGraphResult } from "@pixel-point/aval-graph"; +import { + MotionGraphEngine, + type GraphRingDefinition as Ring, + type MotionGraphResult +} from "@pixel-point/aval-graph"; import { RuntimeAssetCatalog, type CertifiedVideoRendition @@ -49,6 +53,8 @@ import { admitIntegratedPlayerAssetSource } from "./integrated-player-resource-a import type { RuntimeCanvasResourceLease } from "./canvas-resource-plan.js"; import { IntegratedContentTicker } from "./integrated-content-ticker.js"; import { assertSelectedVideoRenditionCatalogIdentity } from "./video-rendition-inspection.js"; +const EMPTY_RINGS: readonly Readonly[] = Object.freeze([]); + export * from "./integrated-player-contracts.js"; export type { RuntimeVisibilitySnapshot, RuntimeVisibilityState } from "./model.js"; /** @@ -61,7 +67,7 @@ export class IntegratedPlayer { readonly #assetBinding: IntegratedPlayerAssetBinding; readonly #participant: IntegratedPlayerParticipantController; readonly #decoderReentry: IntegratedPlayerDecoderReentry; - readonly #graph = new MotionGraphEngine(); + readonly #graph: MotionGraphEngine; readonly #requests = new RequestPromises(); readonly #effects: EffectHost; readonly #fallbackStore: IntegratedFallbackStore; @@ -106,6 +112,11 @@ export class IntegratedPlayer { workerAvailable: candidateAvailability.workerAvailable, rendererAvailable: candidateAvailability.rendererAvailable }); + // Host option objects are read once; the turn policy is fixed for the + // player's lifetime because it shapes every route it plans. + this.#graph = new MotionGraphEngine( + options.turnPolicy === undefined ? {} : { turnPolicy: options.turnPolicy } + ); const eventSink = options.eventSink; const diagnosticsSink = options.diagnosticsSink; const hostMaxRuntimeBytesOption = options.hostMaxRuntimeBytes; @@ -683,6 +694,20 @@ export class IntegratedPlayer { ); } + /** The ring axes this asset declares, in compiled order. */ + public get rings(): readonly Readonly[] { + return this.#catalog.graph.definition.rings ?? EMPTY_RINGS; + } + + /** + * The landings a `requestState(target)` would visit now, or null when the + * target is unreachable. An empty plan means the target is already held. + */ + public planFor(target: string): readonly string[] | null { + if (this.#disposed || typeof target !== "string") return null; + return this.#graph.planFor(target); + } + /** Public M8 clock seam; logical presentation time is retained. */ public pauseRealtime(): void { if (this.#disposed) throw disposedError(); diff --git a/packages/player-web/src/runtime/turn-step-effects.test.ts b/packages/player-web/src/runtime/turn-step-effects.test.ts new file mode 100644 index 0000000..f908a10 --- /dev/null +++ b/packages/player-web/src/runtime/turn-step-effects.test.ts @@ -0,0 +1,114 @@ +import { + MotionGraphEngine, + type GraphEdgeDefinition, + type MotionGraphDefinition +} from "@pixel-point/aval-graph"; +import { describe, expect, it } from "vitest"; + +import { EffectHost, type EffectHostEvent } from "./effect-host.js"; +import { RequestPromises } from "./request-promises.js"; + +const FACINGS = Object.freeze(["walk_n", "walk_ne", "walk_e", "walk_w"]); + +describe("chained turn effects through the staged host", () => { + it("publishes one landing per step, after the pixels for it", async () => { + const engine = new MotionGraphEngine(); + const requests = new RequestPromises(); + const install = engine.install(ringGraph()); + const observed: EffectHostEvent[] = []; + const order: string[] = []; + const host = new EffectHost({ + initialGraphSnapshot: install.snapshot, + requestPromises: requests, + eventSink: (event) => { + observed.push(event); + order.push(`event:${event.type}`); + } + }); + host.publishMetadataReady(); + host.apply(install, () => undefined); + host.publishVisualReady(); + host.apply(engine.beginAnimated(), () => undefined); + + const request = engine.request("walk_e"); + const settled = requests.register(request.requestId!); + host.apply(request); + order.length = 0; + observed.length = 0; + + host.apply(engine.tick({ contentOrdinal: 0n }), () => order.push("draw")); + host.apply(engine.tick({ contentOrdinal: 1n }), () => order.push("draw")); + + expect(observed.filter((event) => event.type === "turnstep")).toEqual([ + { + type: "turnstep", + ring: "facing.walk", + from: "walk_n", + to: "walk_ne", + remaining: 1 + }, + { + type: "turnstep", + ring: "facing.walk", + from: "walk_ne", + to: "walk_e", + remaining: 0 + } + ]); + // A landing is reported only once its pixels have been drawn. + expect(order.indexOf("draw")).toBeLessThan(order.indexOf("event:turnstep")); + expect(host.snapshot()).toMatchObject({ + visualState: "walk_e", + requestedState: "walk_e", + isTransitioning: false + }); + await expect(settled).resolves.toBeUndefined(); + }); +}); + +/** A four-state cyclic facing ring reachable by portal steps. */ +function ringGraph(): MotionGraphDefinition { + const edges: GraphEdgeDefinition[] = []; + for (let index = 0; index < FACINGS.length; index += 1) { + const from = FACINGS[index]!; + const to = FACINGS[(index + 1) % FACINGS.length]!; + edges.push(step(from, to, 1), step(to, from, -1)); + } + return { + initialState: "walk_n", + states: FACINGS.map((id) => ({ + id, + body: { + unitId: `${id}.body`, + kind: "loop", + frameCount: 2, + ports: [{ id: "default", entryFrame: 0, portalFrames: [0] }] + } + })), + edges, + rings: [{ + id: "facing.walk", + states: [...FACINGS], + cyclic: true, + tieBreak: "forward", + maxChainedSteps: 2 + }] + }; +} + +function step(from: string, to: string, offset: 1 | -1): GraphEdgeDefinition { + return { + id: `facing.walk.${from}.${to}`, + from, + to, + start: { + type: "portal", + sourcePort: "default", + targetPort: "default", + maxWaitFrames: 1 + }, + continuity: "exact-authored", + ring: "facing.walk", + step: offset + }; +}