Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions .github/workflows/motion-blur-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: motion-blur-nightly

permissions:
contents: read

env:
HYPERFRAMES_NO_TELEMETRY: "1"

concurrency:
group: motion-blur-nightly
cancel-in-progress: false

on:
workflow_dispatch:
inputs:
seeds:
description: "Seeds to run, a comma list or a-b range (default: today's 200)."
type: string
default: ""
schedule:
# Daily at 08:41 UTC, off the :00 pile and away from regression.yml's 06:17.
- cron: "41 8 * * *"

jobs:
plan:
name: Plan seeds
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
chunks: ${{ steps.plan.outputs.chunks }}
steps:
# Today's 200 seeds are days-since-2026-01-01 * 200 onward, in 4 chunks of 50.
- id: plan
env:
SEEDS: ${{ inputs.seeds }}
run: |
if [ -n "$SEEDS" ]; then
chunks="$(jq -cn --arg seeds "$SEEDS" '[{id: "custom", seeds: $seeds}]')"
else
days=$(( ($(date -u +%s) - $(date -u -d 2026-01-01 +%s)) / 86400 ))
start=$(( days * 200 ))
chunks="$(jq -cn --argjson start "$start" '[range(0; 4) | ($start + . * 50) as $from | {id: ($from | tostring), seeds: "\($from)-\($from + 49)"}]')"
fi
echo "chunks=$chunks" >> "$GITHUB_OUTPUT"
echo "$chunks"
generated:
name: Generated compositions (${{ matrix.flavour }}, ${{ matrix.chunk.seeds }})
needs: plan
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
flavour: [pin, stable]
chunk: ${{ fromJSON(needs.plan.outputs.chunks) }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — This checkout also runs third-party code (bunx vitest and @puppeteer/browsers). Add persist-credentials: false here as well, so the contents: read token isn't left in .git/config while the job runs. The repo is public, so the risk is low, but this would match the report job.

- id: workspace
uses: ./.github/actions/setup-test-workspace
with:
engine: "true"
headless-shell: "true"
- id: pin
if: matrix.flavour == 'pin'
shell: bash
run: |
dir="$RUNNER_TEMP/headless-shell-pin"
bunx @puppeteer/browsers@3.2.2 install chrome-headless-shell@153.0.8010.36 --path "$dir"
echo "path=$(find "$dir" -type f -name chrome-headless-shell | head -n 1)" >> "$GITHUB_OUTPUT"
- name: Run the generated compositions
working-directory: packages/engine
env:
HYPERFRAMES_BROWSER_PATH: ${{ matrix.flavour == 'pin' && steps.pin.outputs.path || steps.workspace.outputs.headless-shell-path }}
HF_BLUR_SEEDS: ${{ matrix.chunk.seeds }}
HF_BLUR_FAILURES_FILE: ${{ github.workspace }}/blur-nightly-failures.json
HF_BLUR_REQUIRED: "1"
run: bunx vitest run --project browser src/services/captureMotionBlur.generated.browser.test.ts
- name: Keep the failing seeds
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: blur-nightly-failures-${{ matrix.flavour }}-${{ matrix.chunk.id }}
path: blur-nightly-failures.json
if-no-files-found: ignore

report:
name: Report failures
needs: [plan, generated]
if: always() && needs.plan.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
continue-on-error: true
with:
pattern: blur-nightly-failures-*
path: failures
- name: Open issues, warn and summarize
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
GENERATED_RESULT: ${{ needs.generated.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: >-
node scripts/ci/motion-blur-nightly-report.mjs
--failures failures
--generated-result "$GENERATED_RESULT"
--date "$(date -u +%F)"
--run-url "$RUN_URL"
--summary "$GITHUB_STEP_SUMMARY"
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/clawhub-unlist-retired.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/ci-cache-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/lefthook-order.test.mjs scripts/inline-gsap.test.mjs scripts/ci/flake-gate.test.mjs scripts/ci/base-file.test.mjs scripts/ci/release-only-diff.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs registry/examples/path-traced-3d/source/path-traced-3d.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/clawhub-unlist-retired.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/ci-cache-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/lefthook-order.test.mjs scripts/inline-gsap.test.mjs scripts/ci/flake-gate.test.mjs scripts/ci/motion-blur-nightly-report.test.mjs scripts/ci/base-file.test.mjs scripts/ci/release-only-diff.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs registry/examples/path-traced-3d/source/path-traced-3d.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,29 @@ export function firstFive(d: Differential): string {
return JSON.stringify(d.bad.slice(0, 5));
}

export interface Measured {
d: Differential;
reference: Rgba;
onFrame: Rgba;
mask: Uint8Array;
}

export async function measureOutside(
browser: Browser,
f: BlurFixture,
t: number,
tier?: Tier,
): Promise<Differential> {
): Promise<Measured> {
const offFrames = await captureInOrder(await referencePage(f), neighbourTimes(t));
const onFrame = await onFreshPage(browser, f, "on", (p) => captureFrame(p, t), tier);
const reference = offFrames[INVARIANTS.radiusFrames];
if (!reference) throw new Error("missing reference frame");
return compareTiles(reference, onFrame, trailMask(offFrames, radiusPx(f)), "outside", {
const mask = trailMask(offFrames, radiusPx(f));
const d = compareTiles(reference, onFrame, mask, "outside", {
block: INVARIANTS.block,
maxMae: INVARIANTS.outsideMaxMae,
});
return { d, reference, onFrame, mask };
}

export const caseName = (what: string, f: BlurFixture, t: number, tier?: Tier): string =>
Expand Down Expand Up @@ -114,8 +123,11 @@ async function referencePage(f: BlurFixture): Promise<Page> {
}

export async function stopChrome(): Promise<void> {
await closeReference();
await closeTestChrome(launched);
try {
await closeReference();
} finally {
await closeTestChrome(launched);
}
}

export function chrome(): Browser {
Expand All @@ -134,7 +146,7 @@ export function defineOutsideCase(f: BlurFixture, t: number, tier?: Tier): void
caseName("outside the trail, blur on equals blur off", f, t, tier),
async (ctx) => {
if (!supported) ctx.skip();
const d = await measureOutside(chrome(), f, t, tier);
const { d } = await measureOutside(chrome(), f, t, tier);
expect(d.bad, `${f.name} ${firstFive(d)}`).toEqual([]);
expect(d.checkedFraction).toBeGreaterThanOrEqual(INVARIANTS.minCheckedFraction);
},
Expand All @@ -154,22 +166,60 @@ function expectBlurredAwayFromSharp(
);
}

const shutterMean = async (page: Page, t: number): Promise<Rgba> =>
meanFrame(
await captureInOrder(page, shutterTimes(t), { subFrameDivisions: SUB_FRAME_DIVISIONS }),
);

export async function measureInside(
browser: Browser,
f: BlurFixture,
t: number,
tier?: Tier,
): Promise<Measured> {
const samples = await captureInOrder(await referencePage(f), shutterTimes(t), {
subFrameDivisions: SUB_FRAME_DIVISIONS,
});
const onFrame = await onFreshPage(browser, f, "on", (p) => captureFrame(p, t), tier);
const reference = meanFrame(samples);
const mask = trailMask(samples, radiusPx(f));
const d = compareTiles(reference, onFrame, mask, "inside", {
block: INVARIANTS.block,
maxMae: INVARIANTS.insideMaxMae,
});
return { d, reference, onFrame, mask };
}

export const REFERENCE_RECAPTURES = 3;

export async function blurOffDisagreement(
browser: Browser,
f: BlurFixture,
region: "outside" | "inside",
t: number,
first: Measured,
): Promise<number | undefined> {
const maxMae = region === "outside" ? INVARIANTS.outsideMaxMae : INVARIANTS.insideMaxMae;
for (let i = 0; i < REFERENCE_RECAPTURES; i++) {
const again = await onFreshPage(browser, f, "off", (p) =>
region === "outside" ? captureFrame(p, t) : shutterMean(p, t),
);
const d = compareTiles(first.reference, again, first.mask, region, {
block: INVARIANTS.block,
maxMae,
});
if (d.bad.length > 0) return d.worstMae;
}
return undefined;
}

export function defineInsideCase(f: BlurFixture, t: number, tier?: Tier): void {
it(
caseName("inside the trail, blur on matches the shutter average", f, t, tier),
async (ctx) => {
if (!supported) ctx.skip();
const offPage = await referencePage(f);
const samples = await captureInOrder(offPage, shutterTimes(t), {
subFrameDivisions: SUB_FRAME_DIVISIONS,
});
const sharp = await captureFrame(offPage, t);
const onFrame = await onFreshPage(chrome(), f, "on", (p) => captureFrame(p, t), tier);
const mask = trailMask(samples, radiusPx(f));
const d = compareTiles(meanFrame(samples), onFrame, mask, "inside", {
block: INVARIANTS.block,
maxMae: INVARIANTS.insideMaxMae,
});
const { d, onFrame, mask } = await measureInside(chrome(), f, t, tier);
const sharp = await captureFrame(await referencePage(f), t);
expect(d.checkedBlocks).toBeGreaterThan(0);
expect(d.bad, `${f.name} ${firstFive(d)}`).toEqual([]);
expectBlurredAwayFromSharp(f, sharp, onFrame, mask);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const SLIDE = 0.4;
const frame = (k: number): number => k / 30;
const frames = (...ks: number[]): number[] => ks.map(frame);

interface Spec {
export interface Spec {
name: string;
width?: number;
height?: number;
Expand Down Expand Up @@ -114,7 +114,7 @@ function compose(spec: Spec, blur: "on" | "off", tier?: "capture" | "filter"): s
</body></html>`;
}

function fixture(spec: Spec): BlurFixture {
export function fixture(spec: Spec): BlurFixture {
return {
name: spec.name,
width: spec.width ?? 320,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
EXCLUDED_FEATURES,
FEATURES,
generateComposition,
parseSeeds,
type Feature,
} from "./generate.js";

const SEEDS = parseSeeds("1-200");

const GSAP_SCRIPT = /<script>\/\*![\s\S]*?<\/script>/;

const withoutMatches = (text: string, pattern: RegExp): string => {
let current = text;
for (
let next = current.replace(pattern, "");
next !== current;
next = current.replace(pattern, "")
) {
current = next;
}
return current;
};

const visibleText = (html: string): string =>
withoutMatches(withoutMatches(html, /<(script|style)[\s\S]*?<\/\1>/g), /<[^>]*>/g).trim();

describe("generateComposition", () => {
it("is deterministic per seed", () => {
for (const seed of [1, 17, 200]) {
const a = generateComposition(seed);
const b = generateComposition(seed);
expect(b.features).toEqual(a.features);
expect(b.name).toBe(a.name);
expect(b.checkpoints).toEqual(a.checkpoints);
expect(b.html("on")).toBe(a.html("on"));
expect(b.html("off")).toBe(a.html("off"));
}
});

it("varies with the seed", () => {
const documents = new Set(SEEDS.map((seed) => generateComposition(seed).html("on")));
expect(documents.size).toBeGreaterThan(150);
});

it("uses every feature within 200 seeds", () => {
const seen = new Set<Feature>();
for (const seed of SEEDS)
for (const feature of generateComposition(seed).features) seen.add(feature);
expect([...FEATURES].filter((feature) => !seen.has(feature))).toEqual([]);
});

it("holds each composition to 1 to 4 features at 320x180, 2 s, 30 fps", () => {
for (const seed of SEEDS) {
const f = generateComposition(seed);
expect(f.features.length).toBeGreaterThanOrEqual(1);
expect(f.features.length).toBeLessThanOrEqual(4);
expect(new Set(f.features).size).toBe(f.features.length);
expect([f.width, f.height, f.durationSeconds, f.fps, f.dpr]).toEqual([320, 180, 2, 30, 1]);
expect(f.checkpoints).toHaveLength(2);
expect(f.moving).toHaveLength(1);
expect(f.name).toBe(`seed ${seed} [${f.features.join(", ")}]`);
}
});

it("keeps the features known wrong on the filter tier out", () => {
expect(EXCLUDED_FEATURES).toEqual({ ownClipPath: "E", visibilityToggle: "O" });
for (const seed of SEEDS) {
const html = generateComposition(seed).html("on").replace(GSAP_SCRIPT, "");
expect(html).not.toMatch(/#card[^{]*\{[^}]*(clip-path|mask)/);
expect(html).not.toMatch(/visibility/);
}
});

it("draws no text", () => {
for (const seed of SEEDS) expect(visibleText(generateComposition(seed).html("on"))).toBe("");
});

it("pins no tier on the blur-on page", () => {
for (const seed of [1, 2, 3]) {
expect(generateComposition(seed).html("on")).not.toContain("data-motion-blur-tier");
}
});
});

describe("parseSeeds", () => {
it("reads lists and ranges", () => {
expect(parseSeeds("1,2,3")).toEqual([1, 2, 3]);
expect(parseSeeds("5-8")).toEqual([5, 6, 7, 8]);
expect(parseSeeds("4,10-11")).toEqual([4, 10, 11]);
expect(() => parseSeeds("x")).toThrow("bad seed list");
});
});
Loading
Loading