mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
chore(cli): sync latest main skills manifest
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user