diff --git a/.github/workflows/brave-windows-compatibility.yml b/.github/workflows/brave-windows-compatibility.yml new file mode 100644 index 00000000..e5ef4a28 --- /dev/null +++ b/.github/workflows/brave-windows-compatibility.yml @@ -0,0 +1,173 @@ +name: brave-windows-compatibility +run-name: brave-windows-${{ inputs.session_id }} + +on: + workflow_dispatch: + inputs: + tunnel_url: + description: Exact HTTPS endpoint created for this immutable compatibility run + required: true + type: string + source_commit: + description: Exact full source commit exposed by the endpoint + required: true + type: string + tunnel_created_at: + description: Exact UTC timestamp when the immutable HTTPS endpoint was created + required: true + type: string + session_id: + description: Unique UTC session identifier used in the artifact name + required: true + type: string + +concurrency: + group: brave-windows-${{ inputs.session_id }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + branded-brave: + runs-on: windows-2025 + timeout-minutes: 120 + env: + INPUT_TUNNEL_URL: ${{ inputs.tunnel_url }} + INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }} + INPUT_SESSION_ID: ${{ inputs.session_id }} + INPUT_TUNNEL_CREATED_AT: ${{ inputs.tunnel_created_at }} + POLICY_PATH: scripts/browser-compatibility/certification-policy.json + steps: + - name: Validate dispatch inputs before use + shell: pwsh + run: | + if ($env:INPUT_SOURCE_COMMIT -notmatch '^[a-f0-9]{40}$') { + throw 'source_commit must be a full lowercase Git commit' + } + if ($env:INPUT_SESSION_ID -notmatch '^[0-9]{8}T[0-9]{6}Z(?:-[a-z0-9][a-z0-9-]{0,47})?$') { + throw 'session_id must be a bounded UTC identifier' + } + $uri = $null + if (-not [Uri]::TryCreate($env:INPUT_TUNNEL_URL, [UriKind]::Absolute, [ref]$uri) -or + $uri.Scheme -ne 'https' -or $uri.UserInfo -ne '' -or $uri.Fragment -ne '' -or + $uri.Query -ne '' -or $uri.AbsolutePath -ne '/') { + throw 'tunnel_url must be an origin-only HTTPS URL without credentials, query, or fragment' + } + $tunnelCreatedAt = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParse($env:INPUT_TUNNEL_CREATED_AT, [ref]$tunnelCreatedAt) -or + $tunnelCreatedAt.Offset -ne [TimeSpan]::Zero -or + $tunnelCreatedAt -gt [DateTimeOffset]::UtcNow) { + throw 'tunnel_created_at must be a valid UTC timestamp no later than dispatch validation' + } + "SAFE_SESSION_ID=$env:INPUT_SESSION_ID" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.source_commit }} + fetch-depth: 1 + persist-credentials: false + - name: Bind checkout to the requested commit + shell: pwsh + run: | + $actual = git rev-parse HEAD + if ($actual -cne $env:INPUT_SOURCE_COMMIT) { + throw "checkout mismatch: $actual" + } + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.12.0 + cache: npm + - run: npm ci --ignore-scripts + - name: Verify official Brave resolution still matches checked-in policy + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: >- + node scripts/browser-compatibility/brave/resolve-builds.mjs + --boundary-date 2024-07-19 + --policy scripts/browser-compatibility/certification-policy.json + --check + - name: Allocate caller-owned install and evidence roots + shell: pwsh + run: | + $installRoot = Join-Path $env:RUNNER_TEMP "aval-brave-$($env:SAFE_SESSION_ID)" + $runParent = Join-Path $PWD "artifacts/browser-compatibility/runs/$($env:INPUT_SOURCE_COMMIT)" + New-Item -ItemType Directory -Path $runParent -Force -ErrorAction Stop | Out-Null + $runRoot = Join-Path $runParent $env:SAFE_SESSION_ID + New-Item -ItemType Directory -Path $runRoot -ErrorAction Stop | Out-Null + "BRAVE_INSTALL_ROOT=$installRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "BRAVE_RUN_ROOT=$runRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + [pscustomobject]@{ + schemaVersion = 1 + sourceCommit = $env:INPUT_SOURCE_COMMIT + sessionId = $env:SAFE_SESSION_ID + tunnelOrigin = ([Uri]$env:INPUT_TUNNEL_URL).GetLeftPart([UriPartial]::Authority) + hostOperatingSystem = 'Windows Server 2025' + hostKernelVersion = [System.Environment]::OSVersion.Version.ToString() + runnerImage = $env:ImageOS + runnerImageVersion = $env:ImageVersion + createdAt = [DateTime]::UtcNow.ToString('o') + } | ConvertTo-Json | Out-File -FilePath (Join-Path $runRoot 'brave-workflow-provenance-windows-2025.json') -Encoding utf8 + - name: Acquire exact official standalone Brave builds + run: >- + node scripts/browser-compatibility/brave/acquire-builds.mjs + --policy scripts/browser-compatibility/certification-policy.json + --platform windows-x64 + --output "$env:BRAVE_INSTALL_ROOT" + - name: Independently verify extracted Brave executables + shell: pwsh + run: | + $executables = @(Get-ChildItem -LiteralPath $env:BRAVE_INSTALL_ROOT -Filter brave.exe -File -Recurse) + if ($executables.Count -ne 2) { + throw "expected exactly two extracted brave.exe files; got $($executables.Count)" + } + $versions = @() + foreach ($executable in $executables) { + $signature = Get-AuthenticodeSignature -LiteralPath $executable.FullName + if ($signature.Status -ne 'Valid' -or + $signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN="?Brave Software, Inc\."?(,|$)') { + throw "invalid Brave signature: $($executable.FullName)" + } + $version = & $executable.FullName --version + if ($LASTEXITCODE -ne 0 -or $version -notmatch '\bBrave(?: Browser)?\b') { + throw "invalid branded version output: $($executable.FullName)" + } + $versions += [pscustomobject]@{ + path = $executable.FullName.Substring($env:BRAVE_INSTALL_ROOT.Length + 1).Replace('\', '/') + signer = $signature.SignerCertificate.Subject + versionOutput = [string]$version + } + } + $versions | ConvertTo-Json | Out-File -FilePath (Join-Path $env:BRAVE_RUN_ROOT 'brave-authenticode-windows-2025.json') -Encoding utf8 + Copy-Item -LiteralPath (Join-Path $env:BRAVE_INSTALL_ROOT 'manifest.json') -Destination (Join-Path $env:BRAVE_RUN_ROOT 'brave-acquisition-copy-windows-2025.json') + if (@(Get-ChildItem -LiteralPath $env:BRAVE_INSTALL_ROOT -Filter '*Setup.exe' -File -Recurse).Count -ne 0) { + throw 'standalone installers must not be retained after extraction' + } + - name: Run every demo, interaction, codec mode, and soak + shell: pwsh + run: | + node scripts/browser-compatibility/brave/run-matrix.mjs ` + --policy $env:POLICY_PATH ` + --platform windows ` + --install-root $env:BRAVE_INSTALL_ROOT ` + --base-url $env:INPUT_TUNNEL_URL ` + --run-root $env:BRAVE_RUN_ROOT ` + --source-commit $env:INPUT_SOURCE_COMMIT ` + --session-id $env:SAFE_SESSION_ID ` + --tunnel-created-at $env:INPUT_TUNNEL_CREATED_AT + - name: Remove acquired browsers and clean profiles + if: always() + shell: pwsh + run: | + if (Test-Path -LiteralPath $env:BRAVE_INSTALL_ROOT) { + Remove-Item -LiteralPath $env:BRAVE_INSTALL_ROOT -Recurse -Force + } + Get-ChildItem -LiteralPath $env:RUNNER_TEMP -Directory -Filter 'aval-brave-profile-*' -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: brave-windows-${{ env.SAFE_SESSION_ID }} + path: artifacts/browser-compatibility/runs/${{ inputs.source_commit }}/${{ env.SAFE_SESSION_ID }} + if-no-files-found: error + include-hidden-files: false + retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21a6454d..0d6c7221 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,29 @@ jobs: - run: npx playwright install --with-deps chromium firefox webkit - run: npm run test:browser:production + kinetic-orb: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci --ignore-scripts + - run: npx playwright install --with-deps chromium + - run: npm run test:kinetic-orb + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: failure() + with: + name: kinetic-orb-playwright + path: test-results + if-no-files-found: warn + include-hidden-files: false + retention-days: 30 + package: runs-on: ubuntu-24.04 timeout-minutes: 45 diff --git a/README.md b/README.md index 15cd8b6c..76a16120 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ loops, named application states, authored triggers, bounded transitions, reversals, and packed transparency. One logical animation is published as a codec bundle. Each codec gets its own -AVAL 1.0 file—AV1, VP9, H.265/HEVC, or H.264—and the browser selects the first -ordered `` with a supported rendition. The state graph and authored -timing are identical in every file. +AVAL wire 1.1 file—AV1, VP9, H.265/HEVC, or H.264—and the browser selects the +first ordered `` that decodes and passes pre-readiness output +qualification. The state graph and authored timing are identical in every +file. ## Five-minute start @@ -22,8 +23,8 @@ npm run dev Here `npx avl` resolves the `avl` executable from the compiler package installed on the preceding line. The generated starter contains its RGBA -frames, project 1.0 file, four ordered encoding policies, fallback markup, and -watch workflow. +frames, project 1.0 file, four ordered encoding policies, consumer-owned error +handling, and watch workflow. For a normal build, the compiler publishes a directory rather than a single output file: @@ -47,39 +48,70 @@ Use literal direct-child sources in preference order. The exact codec strings come from `build.json`; the values below are illustrative. ```html - - - - - - - +
+ + + + + + + +
``` ```js // motion.js, resolved by a package-aware web build -import { defineAvalElement } from "@pixel-point/aval-element"; +import { + AvalPlaybackError, + defineAvalElement +} from "@pixel-point/aval-element"; + +const motion = document.querySelector("#motion"); +const unavailable = document.querySelector("#motion-unavailable"); +function revealPlaybackUnavailable(failure) { + const diagnostics = motion.getDiagnostics(); + if ( + motion.readiness === "error" && + diagnostics.lastFailure !== null && + failure === diagnostics.lastFailure + ) { + unavailable.hidden = false; + } +} +motion.addEventListener("error", (event) => { + if (event.detail.fatal) revealPlaybackUnavailable(event.detail.failure); +}); +motion.addEventListener("readinesschange", () => { + if (motion.readiness === "interactiveReady") unavailable.hidden = true; +}); defineAvalElement(); + +try { + await motion.prepare(); +} catch (error) { + if (!(error instanceof AvalPlaybackError)) throw error; + revealPlaybackUnavailable(error.failure); +} ``` The `` host does not carry `src`; URLs belong to each codec -candidate. If no candidate is supported, the author-owned fallback remains -visible. Applications can select any authored state without media seeking -code: +candidate. AVAL raises `AvalPlaybackError` when playback cannot run. The +application decides whether to show its sibling image, another renderer, text, +or nothing. Applications can select any authored state without media seeking: ```js const motion = document.querySelector("aval-player"); @@ -107,7 +139,7 @@ obligations remain the publisher's responsibility. ## Packages - `@pixel-point/aval-graph`: deterministic state and route engine. -- `@pixel-point/aval-format`: strict AVAL wire 1.0 parser, validator, and writer. +- `@pixel-point/aval-format`: strict AVAL wire 1.0/1.1 parser, validator, and writer. - `@pixel-point/aval-compiler`: project 1.0 authoring API and bundle compiler. - `@pixel-point/aval-player-web`: bounded loader, codec probing, decoder scheduling, renderer, and page resource management. @@ -128,16 +160,19 @@ npm run build npm run test:browser:reference ``` -Browser animation is capability-probed in authored source order. Unsupported -codec candidates fall through to the next ``; when none can run, the -element keeps its optional host-owned fallback slot visible. +Browser animation is qualified in authored source order. A positive WebCodecs +configuration probe remains provisional; unsupported configurations and +codec-specific startup qualification failures fall through to the next +``. Once `interactiveReady` is published, the selected codec never +hot-switches. When no candidate qualifies, preparation rejects and one fatal +`error` event identifies the failed source generation. AVAL never selects or +reveals alternate application content. ## TODO - React dedicated component and API. - More browser tests. - Render some cool stuff in 3D for the demo instead of that AI-generated loop that I was not able to make look the way I wanted to actually showcase the uninterruptible animation. -- Runtime bundle size optimization ## Documentation diff --git a/apps/playground/fixture-routes.ts b/apps/playground/fixture-routes.ts new file mode 100644 index 00000000..46ea946d --- /dev/null +++ b/apps/playground/fixture-routes.ts @@ -0,0 +1,4 @@ +/** Public same-origin routes for the playground's distinct asset authorities. */ +export const QUALIFIED_FIXTURE_PREFIX = "/__aval_qualified__/"; +export const LEGACY_UNSUPPORTED_FIXTURE_PREFIX = + "/__aval_unsupported_v1__/"; diff --git a/apps/playground/v1-http-fixture-plugin.ts b/apps/playground/http-fixture-plugin.ts similarity index 65% rename from apps/playground/v1-http-fixture-plugin.ts rename to apps/playground/http-fixture-plugin.ts index 9c295435..7e547132 100644 --- a/apps/playground/v1-http-fixture-plugin.ts +++ b/apps/playground/http-fixture-plugin.ts @@ -11,6 +11,11 @@ import { import type { IncomingMessage, ServerResponse } from "node:http"; import type { Plugin, PreviewServer, ViteDevServer } from "vite"; +import { + LEGACY_UNSUPPORTED_FIXTURE_PREFIX, + QUALIFIED_FIXTURE_PREFIX +} from "./fixture-routes.js"; + type Codec = VideoCodec; interface FixtureAsset { @@ -33,22 +38,43 @@ interface RequestRecord { readonly status: number; } -const PREFIX = "/__aval_v1__/"; +interface FixtureAuthority { + readonly prefix: string; + readonly load: () => Promise; + readonly sessions: Map; +} + +const FATAL_BOUNDARY_PATH = "/__aval_certification__/fatal-boundary-network.avl"; const SESSION = /^[A-Za-z0-9_-]{1,64}$/u; const CODECS = Object.freeze([...VIDEO_CODECS].reverse()); -const FIXTURE_ROOT = fileURLToPath(new URL("../../fixtures/conformance/v1/", import.meta.url)); +const QUALIFIED_FIXTURE_ROOT = fileURLToPath(new URL( + "../../fixtures/certification/v1/", + import.meta.url +)); +const LEGACY_UNSUPPORTED_FIXTURE_ROOT = fileURLToPath(new URL( + "../../fixtures/conformance/v1/", + import.meta.url +)); const MAX_SESSIONS = 256; const MAX_RECORDS = 512; -/** Serves the canonical wire-1.0 codec bundle with deterministic range metrics. */ -export function v1HttpFixturePlugin(): Plugin { - let fixturePromise: Promise | null = null; - const sessions = new Map(); - const load = (): Promise => fixturePromise ??= loadFixtureSet() - .catch((error: unknown) => { - fixturePromise = null; - throw error; - }); +/** + * Serves distinct qualified and legacy-unsupported fixture authorities with + * deterministic ranges. Fatal-boundary certification uses qualified bytes so + * the injected resource failure, rather than profile rejection, is observed. + */ +export function playgroundFixturePlugin(): Plugin { + const qualified = createFixtureAuthority( + QUALIFIED_FIXTURE_PREFIX, + QUALIFIED_FIXTURE_ROOT + ); + const authorities = Object.freeze([ + qualified, + createFixtureAuthority( + LEGACY_UNSUPPORTED_FIXTURE_PREFIX, + LEGACY_UNSUPPORTED_FIXTURE_ROOT + ) + ]); function install(server: ViteDevServer | PreviewServer): void { server.middlewares.use((request, response, next) => { @@ -57,7 +83,7 @@ export function v1HttpFixturePlugin(): Plugin { response.destroy(error instanceof Error ? error : undefined); return; } - writeJson(response, 500, { error: "v1-fixture-failure" }); + writeJson(response, 500, { error: "fixture-authority-failure" }); }); }); } @@ -68,29 +94,51 @@ export function v1HttpFixturePlugin(): Plugin { next: () => void ): Promise { const url = new URL(request.url ?? "/", "http://aval.invalid"); - if (!url.pathname.startsWith(PREFIX)) { + if (url.pathname === FATAL_BOUNDARY_PATH) { + if (request.method !== "GET") return methodNotAllowed(response); + const fixture = await qualified.load(); + const asset = fixture.assets.get("h264.avl"); + if (asset === undefined) throw new Error("fatal-boundary fixture is unavailable"); + const rangeHeader = header(request, "range"); + const range = rangeHeader === null ? null : parseRange(rangeHeader, asset.bytes.byteLength); + if (range !== null && (range.start === 0 || range.start === 64)) { + const body = asset.bytes.subarray(range.start, range.end + 1); + response.setHeader("Accept-Ranges", "bytes"); + response.setHeader("Content-Range", `bytes ${String(range.start)}-${String(range.end)}/${String(asset.bytes.byteLength)}`); + sendBytes(response, 206, body, "application/vnd.aval", asset.etag); + return; + } + writeJson(response, 503, { error: "injected-network-failure" }); + return; + } + const authority = authorities.find(({ prefix }) => + url.pathname.startsWith(prefix) + ); + if (authority === undefined) { next(); return; } - if (url.pathname === `${PREFIX}metrics`) { + if (url.pathname === `${authority.prefix}metrics`) { if (request.method !== "GET") return methodNotAllowed(response); const session = requireSession(url.searchParams.get("session")); - writeJson(response, 200, { requests: sessions.get(session) ?? [] }); + writeJson(response, 200, { + requests: authority.sessions.get(session) ?? [] + }); return; } - if (url.pathname === `${PREFIX}reset`) { + if (url.pathname === `${authority.prefix}reset`) { if (request.method !== "POST") return methodNotAllowed(response); - sessions.delete(requireSession(url.searchParams.get("session"))); + authority.sessions.delete(requireSession(url.searchParams.get("session"))); response.statusCode = 204; response.end(); return; } if (request.method !== "GET") return methodNotAllowed(response); - const fixture = await load(); - const relativePath = url.pathname.slice(PREFIX.length); + const relativePath = url.pathname.slice(authority.prefix.length); if (relativePath === "build.json") { + const fixture = await authority.load(); const session = optionalSession(request.headers["x-aval-session"]); - record(sessions, session, Object.freeze({ + record(authority.sessions, session, Object.freeze({ path: relativePath, range: null, status: 200 @@ -98,15 +146,16 @@ export function v1HttpFixturePlugin(): Plugin { sendBytes(response, 200, fixture.report, "application/json; charset=utf-8", null); return; } + const session = requireSession(url.searchParams.get("session")); + const fixture = await authority.load(); const asset = fixture.assets.get(relativePath); if (asset === undefined) { writeJson(response, 404, { error: "fixture-not-found" }); return; } - const session = requireSession(url.searchParams.get("session")); const rangeHeader = header(request, "range"); if (url.searchParams.get("failure") === "network") { - record(sessions, session, Object.freeze({ + record(authority.sessions, session, Object.freeze({ path: relativePath, range: rangeHeader, status: 503 @@ -116,7 +165,7 @@ export function v1HttpFixturePlugin(): Plugin { } const range = rangeHeader === null ? null : parseRange(rangeHeader, asset.bytes.byteLength); if (rangeHeader !== null && range === null) { - record(sessions, session, Object.freeze({ + record(authority.sessions, session, Object.freeze({ path: relativePath, range: rangeHeader, status: 416 @@ -129,7 +178,7 @@ export function v1HttpFixturePlugin(): Plugin { const end = range?.end ?? asset.bytes.byteLength - 1; const body = asset.bytes.subarray(start, end + 1); const status = range === null ? 200 : 206; - record(sessions, session, Object.freeze({ + record(authority.sessions, session, Object.freeze({ path: relativePath, range: rangeHeader, status @@ -151,7 +200,7 @@ export function v1HttpFixturePlugin(): Plugin { } return { - name: "aval-v1-http-fixture", + name: "aval-http-fixture-authorities", enforce: "pre", configureServer(server) { install(server); @@ -162,20 +211,33 @@ export function v1HttpFixturePlugin(): Plugin { }; } -async function loadFixtureSet(): Promise { - const report = await readFile(join(FIXTURE_ROOT, "build.json")); +function createFixtureAuthority(prefix: string, root: string): FixtureAuthority { + let fixturePromise: Promise | null = null; + return Object.freeze({ + prefix, + load: (): Promise => fixturePromise ??= + loadFixtureSet(root).catch((error: unknown) => { + fixturePromise = null; + throw error; + }), + sessions: new Map() + }); +} + +async function loadFixtureSet(root: string): Promise { + const report = await readFile(join(root, "build.json")); const parsed = parseCompileBundleReport(JSON.parse(report.toString("utf8"))); const reportAssets = new Map(parsed.assets.map((asset) => [asset.codec, asset])); const assets = new Map(); for (const codec of CODECS) { const record = reportAssets.get(codec); if (record === undefined || record.path !== `${codec}.avl`) { - throw new TypeError(`v1 fixture report is missing ${codec}`); + throw new TypeError(`fixture authority report is missing ${codec}`); } - const bytes = await readFile(join(FIXTURE_ROOT, record.path)); + const bytes = await readFile(join(root, record.path)); const digest = createHash("sha256").update(bytes).digest("base64"); if (record.integrity !== `sha256-${digest}`) { - throw new Error(`v1 fixture integrity mismatch for ${codec}`); + throw new Error(`fixture authority integrity mismatch for ${codec}`); } assets.set(record.path, Object.freeze({ codec, diff --git a/apps/playground/index.html b/apps/playground/index.html index ce310d9f..920a6b6b 100644 --- a/apps/playground/index.html +++ b/apps/playground/index.html @@ -12,7 +12,8 @@

One motion, four codec sources

The player evaluates these direct-child sources in author order and - keeps the first exact WebCodecs configuration this browser supports. + keeps the first one that decodes, validates, and presents an initial + frame.

@@ -28,12 +29,15 @@

One motion, four codec sources

- Motion unavailable +

Try this codec first

-

Choose a source. Unsupported codecs fall through to the next browser-compatible file.

+

+ Choose a source. Unsupported configurations and codec-specific + startup failures fall through to the next authored file. +