chore(cli): sync latest main skills manifest

This commit is contained in:
James
2026-10-29 04:26:10 -04:00
18 changed files with 219 additions and 62 deletions
+1 -1
View File
@@ -46,8 +46,8 @@ jobs:
file: Dockerfile.test
load: true
tags: hyperframes-producer:test
# regression.yml owns this cache's writes.
cache-from: type=gha,scope=regression-test-image
cache-to: type=gha,mode=max,scope=regression-test-image
- name: Validate fast-capture video (drawElement + BeginFrame)
run: |
+3 -4
View File
@@ -156,10 +156,9 @@ jobs:
load: true
tags: hyperframes-producer:test
cache-from: type=gha,scope=regression-test-image
# PR matrices can fan out across many stacked branches at once. Let
# them consume the shared cache, but keep a single writer on main so
# concurrent exports cannot exhaust the Actions cache service.
cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max,scope=regression-test-image' || '' }}
# One writer: the first shard of a main push. Every shard rebuilds the same layers with
# different digests, so each export added ~1.3 GB and evicted the rest of the 10 GB cache.
cache-to: ${{ github.event_name == 'push' && strategy.job-index == 0 && 'type=gha,mode=max,scope=regression-test-image' || '' }}
- name: "Run regression shard: ${{ matrix.shard }} (${{ matrix.mode }})"
run: |
+5 -4
View File
@@ -1310,10 +1310,11 @@ stops the browser pass, and remote sources are not fetched.
| `audio_check_unavailable` | info | The audio check itself failed, and the message says why, so the clips were not checked. | Re-run; if it repeats, run `hyperframes doctor`. |
| `audio_src_not_found` | error | An `<audio src>` names a file that is not in the project, so the render is silent. Reported by `lint`. | Add the file or fix the path. |
Each file is probed once, with the same audio probe render uses. The check runs
alongside the browser pass, up to four files at a time. A clip longer than 3 s
is first sampled at three 1-second spots and decoded in full only if all three
are silent.
Each media probe (the audio metadata render uses, and render's media-type rule)
runs at most once per file; whether a `<video>` has sound is probed on each use.
The check runs alongside the browser pass, up to four files at a time. A clip
longer than 3 s is first sampled at three 1-second spots and decoded in full
only if all three are silent.
#### Mark intentional layout choices
+1 -4
View File
@@ -15,10 +15,7 @@ source /fetch.sh
license() { mkdir -p "$PREFIX/share/licenses/$1" && cp "${@:2}" "$PREFIX/share/licenses/$1/"; }
read -r x264_commit x264_url < <(awk '$1 == "x264" { print $2, $3 }' /build/sources.txt)
retry git clone -q "$x264_url" "$SRC/x264"
git -C "$SRC/x264" checkout -q "$x264_commit"
[ "$(git -C "$SRC/x264" rev-parse HEAD)" = "$x264_commit" ]
fetch x264
(cd "$SRC/x264" && ./configure --prefix="$DEPS" --enable-static --enable-pic --disable-cli &&
make -j"$JOBS" && make install)
license x264 "$SRC/x264/COPYING"
+15 -5
View File
@@ -10,10 +10,20 @@ retry() {
return 1
}
# Tries each URL on the source's line in order; only bytes matching its sha256 (git: its commit) are taken.
fetch() {
local name=$1 sum url
read -r sum url <<<"$(awk -v n="$name" '$1 == n { print $2, $3 }' "$SOURCES")"
curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 --connect-timeout 20 --max-time 600 -o "$SRC/$name" "$url"
echo "$sum $SRC/$name" | sha256sum -c --quiet
tar -xf "$SRC/$name" -C "$SRC"
local name=$1 sum urls url
read -r sum urls <<<"$(awk -v n="$name" '$1 == n { $1 = ""; print }' "$SOURCES")"
for url in $urls; do
if [[ $url == *.git ]]; then
retry git clone -q "$url" "$SRC/$name" && git -C "$SRC/$name" checkout -q "$sum" &&
[ "$(git -C "$SRC/$name" rev-parse HEAD)" = "$sum" ] && return 0
rm -rf "${SRC:?}/$name"
else
curl -fsSL --retry 5 --retry-all-errors --retry-delay 3 --connect-timeout 20 --max-time 600 -o "$SRC/$name" "$url" &&
echo "$sum $SRC/$name" | sha256sum -c --quiet && tar -xf "$SRC/$name" -C "$SRC" && return 0
fi
echo "fetch: $name not taken from $url" >&2
done
return 1
}
+67 -13
View File
@@ -13,26 +13,35 @@ const tarGz = (() => {
return readFileSync(out);
})();
async function fetchAfterResets(resets: number) {
// Serves `body` after resetting the first `resets` connections.
function serve(body: Buffer, resets = 0) {
let hits = 0;
const server = createServer((socket) => {
socket.once("data", () => {
if (++hits <= resets) return socket.resetAndDestroy();
socket.write(
`HTTP/1.1 200 OK\r\nContent-Length: ${tarGz.length}\r\nConnection: close\r\n\r\n`,
`HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`,
);
socket.end(tarGz);
socket.end(body);
});
}).listen(0, "127.0.0.1");
const { port } = server.address() as { port: number };
return {
url: `http://127.0.0.1:${port}/pkg.tar.gz`,
hits: () => hits,
close: () => server.close(),
};
}
async function fetchSource(line: string) {
const dir = mkdtempSync(join(tmpdir(), "fetch-run-"));
const sum = createHash("sha256").update(tarGz).digest("hex");
writeFileSync(
join(dir, "sources.txt"),
`pkg.tar.gz ${sum} http://127.0.0.1:${port}/pkg.tar.gz\n`,
);
writeFileSync(join(dir, "sources.txt"), `${line}\n`);
const proc = Bun.spawn(
["bash", "-c", `set -euo pipefail; source ${import.meta.dir}/fetch.sh; fetch pkg.tar.gz`],
[
"bash",
"-c",
`set -euo pipefail; source ${import.meta.dir}/fetch.sh; fetch ${line.split(" ")[0]}`,
],
{
env: { ...process.env, SRC: dir, SOURCES: join(dir, "sources.txt") },
stdout: "pipe",
@@ -40,13 +49,58 @@ async function fetchAfterResets(resets: number) {
},
);
const code = await proc.exited;
server.close();
return { code, hits, extracted: readFileSync(join(dir, "hello.txt"), "utf8") };
if (code !== 0) console.error(await new Response(proc.stderr).text());
return { code, dir };
}
const sum = createHash("sha256").update(tarGz).digest("hex");
function gitRepo(file: string) {
const dir = mkdtempSync(join(tmpdir(), "fetch-git-"));
const git = (...args: string[]) =>
Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t", "-C", dir, ...args])
.stdout.toString()
.trim();
git("init", "-q", "repo.git");
writeFileSync(join(dir, "repo.git", file), file);
git("-C", "repo.git", "add", ".");
git("-C", "repo.git", "commit", "-qm", file);
return { url: join(dir, "repo.git"), commit: git("-C", "repo.git", "rev-parse", "HEAD") };
}
describe("ffmpeg image fetch", () => {
it("retries a reset connection and still checks the sha256", async () => {
const r = await fetchAfterResets(2);
expect(r).toMatchObject({ code: 0, hits: 3, extracted: "hi" });
const host = serve(tarGz, 2);
const r = await fetchSource(`pkg.tar.gz ${sum} ${host.url}`);
host.close();
expect(r.code).toBe(0);
expect(host.hits()).toBe(3);
expect(readFileSync(join(r.dir, "hello.txt"), "utf8")).toBe("hi");
}, 30_000);
it("skips a mirror whose bytes do not match the sha256 for the next one", async () => {
const wrong = serve(Buffer.from("not the release"));
const right = serve(tarGz);
const r = await fetchSource(`pkg.tar.gz ${sum} ${wrong.url} ${right.url}`);
wrong.close();
right.close();
expect(r.code).toBe(0);
expect([wrong.hits(), right.hits()]).toEqual([1, 1]);
expect(readFileSync(join(r.dir, "hello.txt"), "utf8")).toBe("hi");
}, 30_000);
it("fails when no URL serves the pinned bytes", async () => {
const wrong = serve(Buffer.from("not the release"));
const r = await fetchSource(`pkg.tar.gz ${sum} ${wrong.url}`);
wrong.close();
expect(r.code).not.toBe(0);
}, 30_000);
it("takes a git source only at its pinned commit", async () => {
const stale = gitRepo("old.txt");
const pinned = gitRepo("pinned.txt");
const r = await fetchSource(`x264 ${pinned.commit} ${stale.url} ${pinned.url}`);
expect(r.code).toBe(0);
expect(readFileSync(join(r.dir, "x264", "pinned.txt"), "utf8")).toBe("pinned.txt");
}, 30_000);
});
+6 -5
View File
@@ -1,8 +1,9 @@
# name sha256 url (x264 has no stable tarballs: its line pins a git commit, r3191 as in the x86 ffmpeg-static)
ffmpeg-7.0.2.tar.xz 8646515b638a3ad303e23af6a3587734447cb8fc0a0c064ecdb8e95c4fd8b389 https://ffmpeg.org/releases/ffmpeg-7.0.2.tar.xz
x264 4613ac3c15fd75cebc4b9f65b7fb95e70a3acce1 https://code.videolan.org/videolan/x264.git
x265_4.1.tar.gz a31699c6a89806b74b0151e5e6a7df65de4b49050482fe5ebf8a4379d7af8f29 https://bitbucket.org/multicoreware/x265_git/downloads/x265_4.1.tar.gz
# name sha256 url [mirror url...]: fetch.sh tries them in order and takes only bytes matching the sha256
# (x264 has no stable tarballs: its line pins a git commit, r3191 as in the x86 ffmpeg-static)
ffmpeg-7.0.2.tar.xz 8646515b638a3ad303e23af6a3587734447cb8fc0a0c064ecdb8e95c4fd8b389 https://ffmpeg.org/releases/ffmpeg-7.0.2.tar.xz https://ftp.osuosl.org/pub/blfs/conglomeration/ffmpeg/ffmpeg-7.0.2.tar.xz http://downloads.yoctoproject.org/mirror/sources/ffmpeg-7.0.2.tar.xz
x264 4613ac3c15fd75cebc4b9f65b7fb95e70a3acce1 https://code.videolan.org/videolan/x264.git https://github.com/mirror/x264.git
x265_4.1.tar.gz a31699c6a89806b74b0151e5e6a7df65de4b49050482fe5ebf8a4379d7af8f29 https://bitbucket.org/multicoreware/x265_git/downloads/x265_4.1.tar.gz https://ftp.osuosl.org/pub/blfs/conglomeration/x265/x265_4.1.tar.gz http://downloads.yoctoproject.org/mirror/sources/x265_4.1.tar.gz
libvpx-1.15.0.tar.gz e935eded7d81631a538bfae703fd1e293aad1c7fd3407ba00440c95105d2011e https://github.com/webmproject/libvpx/archive/refs/tags/v1.15.0.tar.gz
opus-1.5.2.tar.gz 65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1 https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz
opus-1.5.2.tar.gz 65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1 https://downloads.xiph.org/releases/opus/opus-1.5.2.tar.gz https://github.com/xiph/opus/releases/download/v1.5.2/opus-1.5.2.tar.gz https://ftp.osuosl.org/pub/blfs/conglomeration/opus/opus-1.5.2.tar.gz
zimg-3.0.5.tar.gz a9a0226bf85e0d83c41a8ebe4e3e690e1348682f6a2a7838f1b8cbff1b799bcf https://github.com/sekrit-twc/zimg/archive/refs/tags/release-3.0.5.tar.gz
dav1d-1.5.1.tar.xz 401813f1f89fa8fd4295805aa5284d9aed9bc7fc1fdbe554af4292f64cbabe21 https://downloads.videolan.org/pub/videolan/dav1d/1.5.1/dav1d-1.5.1.tar.xz
+10
View File
@@ -3988,6 +3988,16 @@ describe("initSandboxRuntimeModular", () => {
expect(startedAt[0]).toBeGreaterThanOrEqual(1 - 0.02);
});
it("primes a clip that starts later inside the tap on iOS, so a late start is not refused", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
);
const { sfx } = mountLateSfx();
window.__player?.play();
expect(sfx.play).toHaveBeenCalledTimes(1);
expect(sfx.pause).toHaveBeenCalledTimes(1);
});
const setRate = (playbackRate: number) =>
window.dispatchEvent(
new MessageEvent("message", {
+2
View File
@@ -84,6 +84,7 @@ import {
MEDIA_SYNC_TOLERANCE_SECONDS,
isUnplayable,
mediaSourceTimeAt,
primeMediaForLaterStarts,
readElementPlaybackRate,
readElementPlaybackStart,
readElementRateSpec,
@@ -4722,6 +4723,7 @@ export function initSandboxRuntimeModular(): void {
}
runAdapters("play");
syncMediaForCurrentState();
primeMediaForLaterStarts(resolveMediaClipIndex().clips.map((clip) => clip.el));
colorGrading.redraw();
postState(true);
},
+29
View File
@@ -4,6 +4,7 @@ import { mediaElementTime } from "./mediaClock";
import {
evictMediaSyncState,
hasMediaSyncStateForTest,
primeMediaForLaterStarts,
readElementPlaybackRate,
readElementPlaybackStart,
refreshRuntimeMediaCache,
@@ -1822,6 +1823,34 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.muted).toBe(true);
});
describe("priming for iOS", () => {
const iPhone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148";
afterEach(() => vi.restoreAllMocks());
it("plays then pauses every idle unmuted take so iOS lets it start after the tap", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(iPhone);
const idle = createMockClip({ start: 20, end: 30 }).el;
const muted = createMockClip({ start: 20, end: 30 }).el;
const sounding = createMockClip({ start: 0, end: 10 }).el;
const order: string[] = [];
idle.play = vi.fn(() => (order.push("play"), Promise.resolve()));
idle.pause = vi.fn(() => void order.push("pause"));
muted.setAttribute("muted", "");
Object.defineProperty(sounding, "paused", { value: false });
primeMediaForLaterStarts([idle, muted, sounding]);
expect(order).toEqual(["play", "pause"]);
expect(muted.play).not.toHaveBeenCalled();
expect(sounding.pause).not.toHaveBeenCalled();
});
it("leaves every take parked where no tap is needed", () => {
const idle = createMockClip({ start: 20, end: 30 }).el;
primeMediaForLaterStarts([idle]);
expect(idle.play).not.toHaveBeenCalled();
});
});
it("fires onAutoplayBlocked when play() rejects with NotAllowedError", async () => {
const clip = createMockClip({ start: 0, end: 10 });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
+12 -1
View File
@@ -1,6 +1,6 @@
import { swallow } from "./diagnostics";
import { isClipVisibleAt, isInClipWindow } from "./clipWindow";
import { mediaElementTime } from "./mediaClock";
import { mediaElementTime, startsSoundOnlyInsideATap } from "./mediaClock";
import { sameInstant } from "../clipFacts";
import { interpolateVolumeGain } from "./mediaVolumeEnvelope.js";
import { elementVolumeLaneGain } from "./audioAutomationVolume.js";
@@ -203,6 +203,17 @@ function markPlayRequested(el: HTMLMediaElement): void {
el.addEventListener("error", clear, { once: true });
}
// iOS refuses an unmuted start ~5 s after the tap unless that element played inside a tap: play-then-pause
// each idle unmuted one from the tap's own call stack. Authored-muted clips start freely and stay deferred.
export function primeMediaForLaterStarts(els: Iterable<HTMLMediaElement>): void {
if (!startsSoundOnlyInsideATap()) return;
for (const el of els) {
if (el.defaultMuted || !el.paused || playRequested.has(el) || isUnplayable(el)) continue;
el.play().catch(() => {});
el.pause();
}
}
// HTMLMediaElement.NETWORK_NO_SOURCE — no usable source (404 / unsupported).
const MEDIA_NETWORK_NO_SOURCE = 3;
// An element that errored or has no source can't play; re-issuing play() every
+5
View File
@@ -3,3 +3,8 @@ export const MEDIA_ELEMENT_LEAD_SECONDS = 1e-5;
export function mediaElementTime(sourceSeconds: number): number {
return sourceSeconds + MEDIA_ELEMENT_LEAD_SECONDS;
}
export function startsSoundOnlyInsideATap(): boolean {
const { userAgent, maxTouchPoints } = navigator;
return /iP(hone|ad|od)/.test(userAgent) || (/Macintosh/.test(userAgent) && maxTouchPoints > 1);
}
@@ -450,6 +450,7 @@ class HyperframesPlayer extends HTMLElement {
}
}
if (this._media.audioOwner === "parent") this._media.playAll();
else this._media.primeAll();
this.controlsApi?.updatePlaying(true);
if (!queuedForReady && announce) this._emit(new Event("play"));
if (directTimelineStarted && this._directTimelineAdapter) {
+28
View File
@@ -167,6 +167,34 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
});
});
describe("ParentMediaManager priming", () => {
const iPhone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148";
afterEach(() => vi.restoreAllMocks());
function primed() {
const mgr = makeManager();
mgr.setupFromUrl("https://example.test/a.mp3");
const el = mgr.entries[0].el;
const calls: string[] = [];
const play = el.play.bind(el);
el.play = () => (calls.push("play"), play());
const pause = el.pause.bind(el);
el.pause = () => (calls.push("pause"), pause());
mgr.primeAll();
return calls;
}
it("plays then pauses every idle copy so iOS lets it start after the tap", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(iPhone);
expect(primed()).toEqual(["play", "pause"]);
});
it("leaves the copies alone where no tap is needed", () => {
expect(primed()).toEqual([]);
});
});
describe("ParentMediaManager across documents", () => {
afterEach(() => document.body.replaceChildren());
+11 -1
View File
@@ -14,7 +14,7 @@ import { isRealmElement, isRealmHtmlMediaElement } from "./media-element-guards.
import { isInClipWindow } from "@hyperframes/core/runtime/clip-window";
import { readClipTiming } from "@hyperframes/core/composition-contract";
import { readLinkTiming } from "@hyperframes/core/media-link";
import { mediaElementTime } from "@hyperframes/core/runtime/media-clock";
import { mediaElementTime, startsSoundOnlyInsideATap } from "@hyperframes/core/runtime/media-clock";
import {
MEDIA_SYNC_TOLERANCE_SECONDS,
forgetSteering,
@@ -219,6 +219,16 @@ export class ParentMediaManager {
for (const m of this._entries) this._playEntryIfActive(m);
}
/** iOS lets an element start with sound later only if it was played inside a tap; do it for every copy. */
primeAll(): void {
if (!startsSoundOnlyInsideATap()) return;
for (const m of this._entries) {
if (!m.el.paused) continue;
m.el.play().catch(() => {});
m.el.pause();
}
}
pauseAll(): void {
for (const m of this._entries) m.el.pause();
}
+21 -22
View File
@@ -10,29 +10,28 @@ const load = (path) => parse(readFileSync(path, "utf8"));
const workflows = readdirSync(workflowsDir)
.filter((file) => /\.ya?ml$/.test(file))
.map((file) => ({ file, doc: load(join(workflowsDir, file)) }));
const events = (on) =>
typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
const cacheWrites = (list) =>
list.flatMap(({ file, doc }) =>
Object.entries(doc.jobs ?? {}).flatMap(([name, job]) =>
(job.steps ?? [])
.map((step) => String(step.with?.["cache-to"] ?? ""))
.filter((to) => to.includes("type=gha"))
.map((to) => ({ id: `${file} ${name}`, to, matrix: Boolean(job.strategy?.matrix) })),
),
);
const dispatchOnly = ["fast-video-validation.yml"];
const ungatedCacheWrites = (list) =>
list
.filter(({ file }) => !dispatchOnly.includes(file))
.flatMap(({ file, doc }) =>
Object.entries(doc.jobs ?? {}).flatMap(([name, job]) =>
(job.steps ?? [])
.map((step) => String(step.with?.["cache-to"] ?? ""))
.filter((to) => to.includes("type=gha") && !to.includes("github.event_name == 'push'"))
.map(() => `${file} ${name}`),
),
);
// The Actions cache holds 10 GB; per-PR image exports evict the bun cache and leave installs cold.
test("only pushes to main write image layers to the Actions cache", () => {
for (const file of dispatchOnly) {
const { doc } = workflows.find((w) => w.file === file);
assert.deepEqual(events(doc.on), ["workflow_dispatch"], `${file} must stay dispatch-only`);
}
assert.deepEqual(ungatedCacheWrites(workflows), []);
// The Actions cache holds 10 GB. Per-PR exports evicted the bun cache; every shard of a main push
// exporting its own copy of the same layers evicted the release's prebuilt ffmpeg.
test("one job of a push to main writes each image cache", () => {
const writes = cacheWrites(workflows);
const ids = (list) => list.map((w) => w.id);
assert.deepEqual(ids(writes.filter((w) => !w.to.includes("github.event_name == 'push'"))), []);
assert.deepEqual(
ids(writes.filter((w) => w.matrix && !w.to.includes("strategy.job-index == 0"))),
[],
);
const scopes = writes.map((w) => w.to.match(/scope=([\w-]+)/)?.[1]);
assert.deepEqual(scopes, [...new Set(scopes)]);
});
test("preflight installs without downloading ffmpeg or Chrome", () => {
+1 -1
View File
@@ -10,7 +10,7 @@
"files": 77
},
"hyperframes-cli": {
"hash": "41783ac009531481",
"hash": "0f04eecc7db86c68",
"files": 13
},
"hyperframes-registry": {
@@ -61,7 +61,7 @@ One command, one Chrome boot. `check` runs the linter first and skips the browse
- `audio_none` (info): no clip plays audio and no unmuted `<video>` has an audio track. Fine for a silent film; otherwise add the `<audio>`.
- `audio_check_unavailable` (info): the audio check itself failed (the message says why), so the clips were not checked. Re-run; if it repeats, run `hyperframes doctor`.
- `audio_src_not_found` (error, from `lint`): an `<audio src>` names a file not in the project, so the render is silent. Add the file or fix the path.
- Cost: each file is probed once, with the same audio probe render uses. The check runs alongside the browser pass, up to four files at a time. A clip longer than 3 s is first sampled at three 1-second spots and decoded in full only if all three are silent.
- Cost: each media probe (the audio metadata render uses, and render's media-type rule) runs at most once per file; whether a `<video>` has sound is probed on each use. The check runs alongside the browser pass, up to four files at a time. A clip longer than 3 s is first sampled at three 1-second spots and decoded in full only if all three are silent.
Every finding carries a selector, the element's `data-*` identity, the composition source file, a bbox, and the sample time: jump straight from the JSON to the HTML you must edit and re-run.