hyperframes 0.8.67 → 0.8.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/cli.js +941 -349
  2. package/dist/fontLocalizeCli.js +303 -73
  3. package/dist/hyperframes-player.global.js +1 -1
  4. package/dist/renderSetupWorker.js +10820 -42082
  5. package/dist/skills/hyperframes/scripts/lib/frame-packets-core.mjs +5 -23
  6. package/dist/skills/media-use/SKILL.md +10 -10
  7. package/dist/skills/media-use/audio/scripts/lib/bgm-volume.mjs +4 -0
  8. package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +2 -6
  9. package/dist/skills/media-use/references/meta.md +1 -1
  10. package/dist/skills/media-use/references/resolve.md +1 -1
  11. package/dist/skills/media-use/references/setup-providers.md +1 -1
  12. package/dist/skills/media-use/scripts/compatibility.test.mjs +68 -1
  13. package/dist/skills/media-use/scripts/lib/coverage.test.mjs +5 -1
  14. package/dist/skills/media-use/scripts/lib/freeze.mjs +34 -24
  15. package/dist/skills/media-use/scripts/lib/freeze.test.mjs +93 -1
  16. package/dist/skills/media-use/scripts/lib/logo-provider.mjs +110 -89
  17. package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +204 -60
  18. package/dist/skills/media-use/scripts/lib/media-fetch.mjs +14 -0
  19. package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +29 -1
  20. package/dist/skills/media-use/scripts/lib/registry.mjs +6 -12
  21. package/dist/skills/media-use/scripts/lib/registry.test.mjs +1 -1
  22. package/dist/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
  23. package/dist/studio/assets/{hyperframes-player-BF2Y9Rtn.js → hyperframes-player-B8bDHi2F.js} +1 -1
  24. package/dist/studio/assets/{index-BXCEB5uw.js → index-784N6Mag.js} +1 -1
  25. package/dist/studio/assets/{index-CGQ66fle.js → index-Bmd-35nQ.js} +4 -4
  26. package/dist/studio/assets/{index-Nnd43iYY.js → index-D-JPuFj7.js} +1 -1
  27. package/dist/studio/index.html +1 -1
  28. package/package.json +1 -1
@@ -1,20 +1,6 @@
1
- // Shared frame-packet builder — the script half of the frame-worker core/delta split.
2
- //
3
- // Each narrative workflow ships a thin `scripts/frame-packets.mjs` wrapper that pins
4
- // its own paths (animation skill, role delta, design-truth resolution, extra packet
5
- // sections) and delegates everything else here, exactly as the markdown half already
6
- // does with `references/frame-worker-core.md` + each workflow's delta. One owner for
7
- // the packet-building logic; the wrappers own only what genuinely differs per workflow.
8
- //
9
- // Packet (<frame_id>.md) = project inputs + the frame's exact `## Frame N` block
10
- // + the blueprint body + every cited rule recipe, inlined — so a worker never opens
11
- // the shared STORYBOARD.md or any skill document. Cited motions are found
12
- // mechanically: the explicit `- rules:` field when present, plus every valid rule id
13
- // (a filename under the animation skill's rules/) mentioned in the block.
14
- //
15
- // _role.md = frame-worker-core.md + the workflow's sub-agents/frame-worker.md,
16
- // concatenated verbatim — the complete worker role, assembled from the two source
17
- // documents so nothing is hand-maintained twice.
1
+ // Frame packets inline the storyboard frame, blueprint and cited recipes.
2
+ // Workflow wrappers supply resource paths and workflow-specific sections.
3
+ // The role combines the shared worker contract with the workflow delta.
18
4
 
19
5
  import {
20
6
  existsSync,
@@ -100,12 +86,8 @@ export function resourceSections(block, { animationDir, ruleIds, frameId }) {
100
86
  if (blueprint) {
101
87
  const blueprintsDir = join(animationDir, "blueprints");
102
88
  const path = join(blueprintsDir, `${blueprint}.md`);
103
- // A blueprint that resolved to nothing used to inline an empty string, so the
104
- // packet shipped without the one document the frame was designed against and
105
- // the run still reported success. Name it instead — but only when the library
106
- // is actually there to be named against. The animation skill installs on
107
- // demand, so an absent blueprints/ is a missing install, not a bad id, and it
108
- // degrades with a warning exactly like an absent rules/ (see knownRuleIds).
89
+ // An installed library must contain the named blueprint.
90
+ // An absent on-demand library warns, like missing motion rules.
109
91
  if (!existsSync(blueprintsDir)) {
110
92
  console.warn(
111
93
  `frame-packets: no blueprints dir at ${blueprintsDir} — packets will inline no blueprint`,
@@ -17,16 +17,16 @@ npx hyperframes media-use resolve --type <type> --intent "<description>" --proje
17
17
 
18
18
  Returns one line: `resolved <id> → <path> (<type>, <metadata>)`. All search noise stays on disk.
19
19
 
20
- | Type | One-line intent |
21
- | ------- | ----------------------------------------------------------------------------------- |
22
- | `bgm` | background music (HeyGen catalog, 10k+ tracks) |
23
- | `sfx` | sound effects (bundled 19-file library + catalog) |
24
- | `image` | photos, backgrounds (HeyGen asset search, 75k+ vectors) |
25
- | `icon` | icons, symbols (transparent) |
26
- | `logo` | official brand marks (svgl → simple-icons → GitHub avatar → favicon; never redrawn) |
27
- | `voice` | TTS voiceover (HeyGen free-usage path; optional local Kokoro) |
28
- | `grade` | measured correction candidate; broad polish/stylization follows Media Treatments |
29
- | `lut` | user-provided or explicitly chosen reusable validated `.cube` file |
20
+ | Type | One-line intent |
21
+ | ------- | -------------------------------------------------------------------------------- |
22
+ | `bgm` | background music (HeyGen catalog, 10k+ tracks) |
23
+ | `sfx` | sound effects (bundled 19-file library + catalog) |
24
+ | `image` | photos, backgrounds (HeyGen asset search, 75k+ vectors) |
25
+ | `icon` | icons, symbols (transparent) |
26
+ | `logo` | official brand marks (theSVG → GitHub avatar → favicon; never redrawn) |
27
+ | `voice` | TTS voiceover (HeyGen free-usage path; optional local Kokoro) |
28
+ | `grade` | measured correction candidate; broad polish/stylization follows Media Treatments |
29
+ | `lut` | user-provided or explicitly chosen reusable validated `.cube` file |
30
30
 
31
31
  Before resolving fresh, list reusable candidates with `--candidates` and judge fit yourself — reuse rules, all flags, ingest (`--from`), and adopt are in `references/resolve.md`.
32
32
 
@@ -0,0 +1,4 @@
1
+ // Music sits under narration; a silent film brings music forward.
2
+ export const BGM_BED_VOLUME = 0.12;
3
+ export const BGM_SILENT_VOLUME = 0.9;
4
+ export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
@@ -20,12 +20,8 @@ import { pythonInvocation } from "./python.mjs";
20
20
  const r3 = (x) => Number(x.toFixed(3));
21
21
  const lyriaKey = () => process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
22
22
 
23
- // Default BGM level. Under narration music is a bed that must stay under the
24
- // voice — 0.12 linear ≈ -18 dB. A silent film (no voice) has no voice to duck
25
- // beneath, so BGM sits forward at 0.9. Callers may override per composition.
26
- export const BGM_BED_VOLUME = 0.12;
27
- export const BGM_SILENT_VOLUME = 0.9;
28
- export const bgmDefaultVolume = (hasVoice) => (hasVoice ? BGM_BED_VOLUME : BGM_SILENT_VOLUME);
23
+ import { bgmDefaultVolume } from "./bgm-volume.mjs";
24
+ export { BGM_BED_VOLUME, BGM_SILENT_VOLUME, bgmDefaultVolume } from "./bgm-volume.mjs";
29
25
 
30
26
  const BGM_PY_DEPS = ["transformers", "torch", "soundfile", "numpy"];
31
27
  const BGM_PY_PROBE =
@@ -9,7 +9,7 @@ HyperFrames owns media _playback_; media-use owns everything else. Each row is e
9
9
  | HyperFrames gap | media-use owns it via |
10
10
  | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
11
  | Audio-only, no image/icon | `resolve --type image\|icon` (heygen asset search) |
12
- | No third-party brand logos | `resolve --type logo` (svgl → simple-icons → GitHub org avatar → domain favicon) |
12
+ | No third-party brand logos | `resolve --type logo` (theSVG → GitHub org avatar → domain favicon) |
13
13
  | No voice / audio generation | `resolve --type voice` (HeyGen TTS free-usage path; optional local Kokoro) + the audio engine (`audio/scripts/audio.mjs`) |
14
14
  | Scattered/duplicated audio engine | one consolidated engine under `audio/` (hyperframes-media retired) |
15
15
  | No agent media-ops (cut/reframe/transform) | `references/operations.md` + `resolve --from` to register outputs |
@@ -14,7 +14,7 @@ Returns one line: `resolved <id> → <path> (<type>, <metadata>)`
14
14
  | `sfx` | Sound effects | Bundled 19-file library + HeyGen catalog |
15
15
  | `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) |
16
16
  | `icon` | Icons, symbols | HeyGen asset search (type=icon) |
17
- | `logo` | Official brand marks | svgl → simple-icons → GitHub org avatar → domain favicon |
17
+ | `logo` | Official brand marks | theSVG → GitHub org avatar → domain favicon |
18
18
  | `voice` | TTS voiceover | HeyGen TTS free-usage path; optional local Kokoro |
19
19
  | `grade` | HyperFrames color-grading blocks | Core preset → look index params/CDN LUT → deterministic cube |
20
20
  | `lut` | Reusable `.cube` LUT files | Look index params/CDN LUT → deterministic cube |
@@ -31,7 +31,7 @@ see the ladder and override.
31
31
  | image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell |
32
32
  | voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) |
33
33
  | icon | heygen asset search free-usage path |
34
- | logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) |
34
+ | logo | theSVG (thesvg.org), then GitHub org avatar, then domain favicon (all free) |
35
35
  | grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback |
36
36
  | video | heygen avatar video free-usage path (sign-in nudge on auth failure); optional local LTX (`videogen` ladder). Image-to-video / photo-avatar / dub stay manual `heygen` recipes |
37
37
 
@@ -1,8 +1,10 @@
1
- import { cpSync, mkdtempSync, rmSync } from "node:fs";
1
+ import assert from "node:assert/strict";
2
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { execFileSync } from "node:child_process";
5
6
  import test from "node:test";
7
+ import { pathToFileURL } from "node:url";
6
8
 
7
9
  function run(scriptsDir, script, args) {
8
10
  return execFileSync(process.execPath, [join(scriptsDir, script), ...args], {
@@ -25,3 +27,68 @@ test("published memory scripts run without reaching outside the skill", () => {
25
27
  rmSync(scratchDir, { recursive: true, force: true });
26
28
  }
27
29
  });
30
+
31
+ test("published audio helpers import without the CLI package tree", () => {
32
+ const root = mkdtempSync(join(tmpdir(), "media-use-audio-"));
33
+ const skill = join(root, "media-use");
34
+ try {
35
+ cpSync(new URL("..", import.meta.url), skill, { recursive: true });
36
+ for (const helper of ["heygen.mjs", "tts.mjs"])
37
+ run(join(skill, "audio/scripts/lib"), helper, []);
38
+ } finally {
39
+ rmSync(root, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ for (const name of ["faceless-explainer", "pr-to-video", "product-launch-video"]) {
44
+ test(name + " assembles from its installed directory alone", () => {
45
+ const root = mkdtempSync(join(tmpdir(), "installed-workflow-"));
46
+ try {
47
+ const skill = join(root, name);
48
+ cpSync(new URL("../../" + name, import.meta.url), skill, { recursive: true });
49
+ const project = join(root, "project");
50
+ mkdirSync(join(project, "compositions/frames"), { recursive: true });
51
+ writeFileSync(
52
+ join(project, "STORYBOARD.md"),
53
+ "---\nformat: 1920x1080\nmessage: Test\n---\n\n## Frame 1\n- duration: 3s\n- src: compositions/frames/01-a.html\n",
54
+ );
55
+ writeFileSync(
56
+ join(project, "compositions/frames/01-a.html"),
57
+ '<template><div data-composition-id="01-a" data-duration="3" data-width="1920" data-height="1080"><section class="clip" data-start="0" data-duration="3"></section></div></template>',
58
+ );
59
+ run(join(skill, "scripts"), "assemble-index.mjs", [
60
+ "--hyperframes",
61
+ project,
62
+ "--storyboard",
63
+ join(project, "STORYBOARD.md"),
64
+ ]);
65
+ assert.match(readFileSync(join(project, "index.html"), "utf8"), /01-a/);
66
+ importPacketBuilder(join(skill, "scripts/frame-packets.mjs"));
67
+ } finally {
68
+ rmSync(root, { recursive: true, force: true });
69
+ }
70
+ });
71
+ }
72
+
73
+ test("general-video packet builder starts from its installed directory alone", () => {
74
+ const root = mkdtempSync(join(tmpdir(), "installed-packets-"));
75
+ try {
76
+ cpSync(new URL("../../general-video", import.meta.url), root, { recursive: true });
77
+ importPacketBuilder(join(root, "scripts/frame-packets.mjs"));
78
+ } finally {
79
+ rmSync(root, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ function importPacketBuilder(path) {
84
+ execFileSync(
85
+ process.execPath,
86
+ [
87
+ "--input-type=module",
88
+ "-e",
89
+ 'const m = await import(process.argv[1]); if (typeof m.buildFramePackets !== "function") throw new Error("missing builder");',
90
+ pathToFileURL(path).href,
91
+ ],
92
+ { stdio: "pipe" },
93
+ );
94
+ }
@@ -29,7 +29,11 @@ test("weakness: audio-only → media-use resolves image + icon", () => {
29
29
 
30
30
  test("weakness: no third-party brand logos → media-use resolves logo", () => {
31
31
  assert.ok(listTypes().includes("logo"), "logo type missing");
32
- assert.ok(getProviders("logo").length >= 4, "logo cascade incomplete");
32
+ assert.deepEqual(
33
+ getProviders("logo").map((p) => p.name),
34
+ ["thesvg", "github.avatar", "favicon.ddg"],
35
+ "logo cascade incomplete",
36
+ );
33
37
  });
34
38
 
35
39
  test("weakness: no voice/audio gen → media-use exposes voice + the audio engine", () => {
@@ -1,41 +1,51 @@
1
- import { fetchMedia, isPublicMediaUrl } from "./media-fetch.mjs";
2
- import { writeFileSync, copyFileSync, mkdirSync } from "node:fs";
1
+ import { fetchMedia, isPublicMediaUrl, readCappedBody } from "./media-fetch.mjs";
2
+ import { sanitizeSvg } from "./svg-sanitize.mjs";
3
+ import { writeFileSync, copyFileSync, mkdirSync, readFileSync } from "node:fs";
3
4
  import { dirname } from "node:path";
4
5
 
5
6
  // ponytail: bound the download so a hostile/runaway URL can't fill the disk.
6
7
  // 256MB covers any real media asset; raise if 4K video sources ever exceed it.
7
8
  const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
9
+ // Bounds only the wait for response headers; a large video body may stream for minutes.
10
+ const FREEZE_HEADERS_TIMEOUT_MS = 10_000;
11
+
12
+ const isSvgPath = (destPath) => /\.svg$/i.test(destPath);
13
+
14
+ // Every logo/icon SVG comes from a third-party host (theSVG, a --from URL, a local file), so it
15
+ // passes the Figma import's sanitizeSvg allowlist before it touches disk.
16
+ function writeFrozen(destPath, buffer) {
17
+ mkdirSync(dirname(destPath), { recursive: true });
18
+ const bytes = isSvgPath(destPath) ? Buffer.from(sanitizeSvg(buffer.toString("utf8"))) : buffer;
19
+ writeFileSync(destPath, bytes);
20
+ return bytes.byteLength;
21
+ }
8
22
 
9
23
  export async function freezeUrl(url, destPath) {
10
24
  const where = String(url).slice(0, 80);
11
- const res = await fetchMedia(url);
25
+ const headerWait = new AbortController();
26
+ const timer = setTimeout(
27
+ () =>
28
+ headerWait.abort(
29
+ new Error(`freeze failed: no response within ${FREEZE_HEADERS_TIMEOUT_MS} ms for ${where}`),
30
+ ),
31
+ FREEZE_HEADERS_TIMEOUT_MS,
32
+ );
33
+ const res = await fetchMedia(url, { signal: headerWait.signal }).finally(() =>
34
+ clearTimeout(timer),
35
+ );
12
36
  if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${where}`);
13
37
 
14
- // Fail fast on an advertised oversize body before reading a single byte.
15
- const declared = Number(res.headers.get("content-length"));
16
- if (declared > MAX_FREEZE_BYTES)
17
- throw new Error(
18
- `freeze failed: ${declared} bytes exceeds ${MAX_FREEZE_BYTES} cap for ${where}`,
19
- );
20
-
21
- // Stream and abort once the cap is crossed, so a lying/chunked hostile URL
22
- // can't buffer the whole payload into memory before the check (M1).
23
- const chunks = [];
24
- let total = 0;
25
- for await (const chunk of res.body) {
26
- total += chunk.length;
27
- if (total > MAX_FREEZE_BYTES)
28
- throw new Error(`freeze failed: stream exceeds ${MAX_FREEZE_BYTES} cap for ${where}`);
29
- chunks.push(chunk);
30
- }
31
- if (total === 0) throw new Error(`freeze failed: empty response for ${where}`);
38
+ const body = await readCappedBody(res, MAX_FREEZE_BYTES, `freeze failed for ${where}`);
39
+ if (body.byteLength === 0) throw new Error(`freeze failed: empty response for ${where}`);
32
40
 
33
- mkdirSync(dirname(destPath), { recursive: true });
34
- writeFileSync(destPath, Buffer.concat(chunks, total));
35
- return total;
41
+ return writeFrozen(destPath, body);
36
42
  }
37
43
 
38
44
  export function freezeLocalFile(srcPath, destPath) {
45
+ if (isSvgPath(destPath)) {
46
+ writeFrozen(destPath, readFileSync(srcPath));
47
+ return;
48
+ }
39
49
  mkdirSync(dirname(destPath), { recursive: true });
40
50
  copyFileSync(srcPath, destPath);
41
51
  }
@@ -1,6 +1,14 @@
1
1
  import { strict as assert } from "node:assert";
2
2
  import { test } from "node:test";
3
- import { isDirectMediaUrl } from "./freeze.mjs";
3
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { isDirectMediaUrl, freezeUrl, freezeLocalFile } from "./freeze.mjs";
7
+
8
+ const HOSTILE_SVG =
9
+ '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script>' +
10
+ '<rect onload="alert(2)" width="1" height="1"/>' +
11
+ '<a href="javascript:alert(3)"><circle r="1"/></a></svg>';
4
12
 
5
13
  test("accepts direct public media URLs", () => {
6
14
  assert.equal(isDirectMediaUrl("https://cdn.example.com/clip.mp4"), true);
@@ -44,3 +52,87 @@ test("rejects local / private hosts (SSRF guard, m11)", () => {
44
52
  assert.equal(isDirectMediaUrl("https://172.40.0.1/a.mp4"), true, "172.40 is public");
45
53
  assert.equal(isDirectMediaUrl("https://11.example.com/a.mp4"), true);
46
54
  });
55
+
56
+ test("freezeUrl strips <script>, on* handlers and javascript: hrefs from a fetched SVG", async (t) => {
57
+ t.mock.method(
58
+ globalThis,
59
+ "fetch",
60
+ async () =>
61
+ new Response(HOSTILE_SVG, { status: 200, headers: { "content-type": "image/svg+xml" } }),
62
+ );
63
+ const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-")), "logo.svg");
64
+ await freezeUrl("https://example.com/hostile.svg", dest);
65
+ const out = readFileSync(dest, "utf8");
66
+ assert.ok(!out.includes("<script"), "script element stripped");
67
+ assert.ok(!out.includes("onload="), "event handler stripped");
68
+ assert.ok(!out.includes("javascript:"), "javascript: href stripped");
69
+ assert.ok(out.includes("<circle"), "benign markup survives");
70
+ });
71
+
72
+ // Puts setTimeout and AbortSignal.timeout on node:test's fake clock.
73
+ function fakeClock(t) {
74
+ t.mock.timers.enable({ apis: ["setTimeout"] });
75
+ t.mock.method(AbortSignal, "timeout", (ms) => {
76
+ const controller = new AbortController();
77
+ setTimeout(() => controller.abort(new DOMException("timed out", "TimeoutError")), ms);
78
+ return controller.signal;
79
+ });
80
+ }
81
+
82
+ test("freezeUrl keeps streaming a slow body past the header timeout", async (t) => {
83
+ fakeClock(t);
84
+ let body;
85
+ t.mock.method(globalThis, "fetch", async (_url, { signal }) => {
86
+ const stream = new ReadableStream({
87
+ start(controller) {
88
+ body = controller;
89
+ // undici aborts the body stream too when the request signal fires.
90
+ signal.addEventListener("abort", () => controller.error(signal.reason));
91
+ controller.enqueue(new Uint8Array([1, 2, 3]));
92
+ },
93
+ });
94
+ return new Response(stream, { status: 200 });
95
+ });
96
+ const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-")), "clip.mp4");
97
+ const frozen = freezeUrl("https://cdn.example.com/clip.mp4", dest);
98
+ await new Promise(setImmediate);
99
+ t.mock.timers.tick(60_000);
100
+ body.enqueue(new Uint8Array([4, 5]));
101
+ body.close();
102
+ assert.equal(await frozen, 5);
103
+ assert.deepEqual([...readFileSync(dest)], [1, 2, 3, 4, 5]);
104
+ });
105
+
106
+ test("freezeUrl times out when no response headers arrive", { timeout: 5_000 }, async (t) => {
107
+ fakeClock(t);
108
+ t.mock.method(
109
+ globalThis,
110
+ "fetch",
111
+ (_url, { signal }) =>
112
+ new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason))),
113
+ );
114
+ const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-")), "clip.mp4");
115
+ const frozen = freezeUrl("https://cdn.example.com/clip.mp4", dest);
116
+ t.mock.timers.tick(10_000);
117
+ await assert.rejects(frozen, /no response within 10000 ms/);
118
+ });
119
+
120
+ test("freezeLocalFile strips the same hostile SVG content from a local source", () => {
121
+ const srcDir = mkdtempSync(join(tmpdir(), "media-use-freeze-src-"));
122
+ const src = join(srcDir, "in.svg");
123
+ writeFileSync(src, HOSTILE_SVG);
124
+ const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-dest-")), "logo.svg");
125
+ freezeLocalFile(src, dest);
126
+ const out = readFileSync(dest, "utf8");
127
+ assert.ok(!out.includes("<script"));
128
+ assert.ok(!out.includes("onload="));
129
+ });
130
+
131
+ test("freezeLocalFile still byte-copies a non-SVG file untouched", () => {
132
+ const srcDir = mkdtempSync(join(tmpdir(), "media-use-freeze-src-"));
133
+ const src = join(srcDir, "in.png");
134
+ writeFileSync(src, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
135
+ const dest = join(mkdtempSync(join(tmpdir(), "media-use-freeze-dest-")), "logo.png");
136
+ freezeLocalFile(src, dest);
137
+ assert.deepEqual(readFileSync(dest), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
138
+ });
@@ -1,21 +1,11 @@
1
- import { fetchMedia } from "./media-fetch.mjs";
1
+ import { fetchMedia, readCappedBody } from "./media-fetch.mjs";
2
2
  // Official brand marks — the `logo` type's provider tiers, tried in registry
3
- // order. Every tier was verified against a 54-brand stress test (2026-07,
4
- // 100% cascade hit). Hit counts below are a snapshot of that run — they
5
- // drift as the alias/org maps grow; re-run the stress test to refresh them.
3
+ // order.
6
4
  //
7
- // 1. svgl — official full-color vector SVGs (+ wordmark variants);
8
- // 40/54 first-hits. Search is substring-based, so
9
- // entities go through alias normalization first
10
- // ("nextjs" never matches "Next.js" raw).
11
- // 2. simple-icons — monochrome official glyphs; caught the long tail the
12
- // others miss (nike, visa, toyota, wechat, bytedance).
13
- // Pinned CDN build for determinism.
14
- // 3. github avatar — the org's official logo for brands with a GitHub
15
- // presence. Known orgs only: guessing a login risks a
16
- // same-named personal account.
17
- // 4. domain favicon — small-raster last resort (DuckDuckGo ip3). Responses
18
- // under ~500B are DDG's globe placeholder, not a hit.
5
+ // 1. thesvg: one pinned manifest matched locally ("Next.js", "nextjs", "next js" hit one
6
+ // entry); default full-color mark; skips licenses outside THESVG_ACCEPTED_LICENSES.
7
+ // 2. github avatar: known orgs only; guessing a login risks a same-named personal account.
8
+ // 3. domain favicon: DuckDuckGo ip3 last resort; under ~500B is its globe placeholder.
19
9
  //
20
10
  // HeyGen asset search is deliberately absent: for brand queries it returns
21
11
  // generic look-alike icons (0/3 in testing) — worse than a miss. A total miss
@@ -26,24 +16,36 @@ import { mkdtempSync, writeFileSync } from "node:fs";
26
16
  import { tmpdir } from "node:os";
27
17
  import { join } from "node:path";
28
18
 
29
- const SVGL_API = "https://api.svgl.app";
30
- const SIMPLE_ICONS_CDN = "https://cdn.jsdelivr.net/npm/simple-icons@16.25.0/icons";
19
+ // Bump by pointing at a newer glincker/thesvg commit; the manifest and every
20
+ // icon URL resolve against the same pin, so a resolve is reproducible.
21
+ const THESVG_REV = "e5957fa742c1ebf6da07ac40665bdd01d8add35f";
22
+ const THESVG_CDN = `https://cdn.jsdelivr.net/gh/glincker/thesvg@${THESVG_REV}`;
23
+ const THESVG_MANIFEST = `${THESVG_CDN}/src/data/icons.json`;
31
24
  const FAVICON_MIN_BYTES = 500;
32
-
33
- // svgl search queries per entity, tried in order after the raw entity.
34
- const SVGL_ALIASES = {
35
- nextjs: ["next.js", "next"],
36
- aws: ["amazon web services"],
37
- huggingface: ["hugging face"],
38
- cocacola: ["coca-cola"],
39
- mcdonalds: ["mcdonald's"],
40
- };
41
-
42
- // simple-icons slugs that differ from the normalized entity.
43
- const SIMPLE_ICON_SLUGS = {
44
- nextjs: "nextdotjs",
45
- aws: "amazonwebservices",
46
- };
25
+ // ponytail: the manifest is ~3.3MB today; 20MB leaves headroom without trusting an
26
+ // unbounded body from a CDN edge.
27
+ const MAX_MANIFEST_BYTES = 20 * 1024 * 1024;
28
+
29
+ // When several entries share a normalized name (e.g. "slack" and the
30
+ // "slack-badge" auth button), the brand mark wins over cloud/infra and badge
31
+ // collections.
32
+ const THESVG_COLLECTION_RANK = ["brands", "community", "aws", "azure", "gcp", "k8s", "auth-badges"];
33
+
34
+ // The ten SPDX ids theSVG's submission form accepts and verifies before merge
35
+ // (thesvg.org LICENSING.md §5); anything else routes through their unaudited
36
+ // "Other/custom" path.
37
+ const THESVG_ACCEPTED_LICENSES = new Set([
38
+ "CC0-1.0",
39
+ "Unlicense",
40
+ "MIT",
41
+ "Apache-2.0",
42
+ "BSD-3-Clause",
43
+ "ISC",
44
+ "CC-BY-4.0",
45
+ "CC-BY-SA-4.0",
46
+ "CC-BY-ND-4.0",
47
+ "MPL-2.0",
48
+ ]);
47
49
 
48
50
  // Known GitHub orgs. Only mapped entities resolve at this tier — a brand name
49
51
  // is NOT a GitHub login, and guessing hits same-named personal accounts.
@@ -86,15 +88,32 @@ export function titleMatches(title, entity) {
86
88
  return norm(title) === norm(entity);
87
89
  }
88
90
 
89
- export function svglQueriesFor(entity) {
90
- return [entity, ...(SVGL_ALIASES[norm(entity)] || [])];
91
- }
92
-
93
- export function simpleIconSlugsFor(entity) {
94
- const slugs = [norm(entity)];
95
- const alias = SIMPLE_ICON_SLUGS[norm(entity)];
96
- if (alias) slugs.push(alias);
97
- return slugs;
91
+ /** Best theSVG entry for an entity, or null: slug > title > alias, ties to the brands
92
+ * collection; entries outside THESVG_ACCEPTED_LICENSES are never returned. */
93
+ export function thesvgMatch(icons, entity) {
94
+ const want = norm(entity);
95
+ if (!want || !Array.isArray(icons)) return null;
96
+ const collectionRank = (icon) => {
97
+ const i = THESVG_COLLECTION_RANK.indexOf(icon.collection);
98
+ return i === -1 ? THESVG_COLLECTION_RANK.length : i;
99
+ };
100
+ let best = null;
101
+ let bestScore = Infinity;
102
+ for (const icon of icons) {
103
+ if (!icon || typeof icon.slug !== "string" || !icon.variants?.default) continue;
104
+ if (!THESVG_ACCEPTED_LICENSES.has(icon.license)) continue;
105
+ let field;
106
+ if (norm(icon.slug) === want) field = 0;
107
+ else if (titleMatches(icon.title, want)) field = 1;
108
+ else if ((icon.aliases || []).some((a) => titleMatches(a, want))) field = 2;
109
+ else continue;
110
+ const score = field * 100 + collectionRank(icon);
111
+ if (score < bestScore) {
112
+ best = icon;
113
+ bestScore = score;
114
+ }
115
+ }
116
+ return best;
98
117
  }
99
118
 
100
119
  export function githubOrgFor(entity) {
@@ -108,7 +127,8 @@ export function faviconDomainFor(entity) {
108
127
  async function fetchJson(url) {
109
128
  const res = await fetchMedia(url, { signal: AbortSignal.timeout(10_000) });
110
129
  if (!res.ok) return null;
111
- return res.json();
130
+ const body = await readCappedBody(res, MAX_MANIFEST_BYTES, "fetchJson");
131
+ return JSON.parse(body.toString("utf8"));
112
132
  }
113
133
 
114
134
  async function urlExists(url) {
@@ -116,57 +136,58 @@ async function urlExists(url) {
116
136
  return res.ok;
117
137
  }
118
138
 
119
- export async function svglSearch(intent, ctx = {}) {
120
- const entity = entityFrom(intent, ctx.entity);
121
- for (const q of svglQueriesFor(entity)) {
122
- let items;
123
- try {
124
- items = await fetchJson(`${SVGL_API}?search=${encodeURIComponent(q)}`);
125
- } catch {
126
- return null; // network down — let the next tier try its own host
127
- }
128
- if (!Array.isArray(items)) continue;
129
- const hit = items.find((it) => titleMatches(it.title, q) || titleMatches(it.title, entity));
130
- if (!hit) continue;
131
- const route = typeof hit.route === "string" ? hit.route : hit.route?.light;
132
- if (!route) continue;
133
- return {
134
- url: route,
135
- ext: ".svg",
136
- source: "search",
137
- metadata: {
138
- description: `${hit.title} logo (official mark)`,
139
- provider: "svgl",
140
- provenance: { entity, query: q, route, wordmark: Boolean(hit.wordmark) },
141
- },
142
- };
139
+ // One manifest per process: a multi-logo resolve pays the download once. A
140
+ // failed load is not cached, so the next call retries.
141
+ let thesvgManifest = null;
142
+
143
+ async function loadThesvgManifest() {
144
+ if (!thesvgManifest) {
145
+ thesvgManifest = fetchJson(THESVG_MANIFEST).then((icons) => {
146
+ if (!Array.isArray(icons)) throw new Error("theSVG manifest: unexpected shape");
147
+ return icons;
148
+ });
149
+ thesvgManifest.catch(() => {
150
+ thesvgManifest = null;
151
+ });
143
152
  }
144
- return null;
153
+ return thesvgManifest;
145
154
  }
146
155
 
147
- export async function simpleIconsSearch(intent, ctx = {}) {
156
+ /** Test hook: drop the memoized manifest. */
157
+ export function resetThesvgManifest() {
158
+ thesvgManifest = null;
159
+ }
160
+
161
+ export async function thesvgSearch(intent, ctx = {}) {
148
162
  const entity = entityFrom(intent, ctx.entity);
149
- for (const slug of simpleIconSlugsFor(entity)) {
150
- const url = `${SIMPLE_ICONS_CDN}/${slug}.svg`;
151
- let ok;
152
- try {
153
- ok = await urlExists(url);
154
- } catch {
155
- return null;
156
- }
157
- if (!ok) continue;
158
- return {
159
- url,
160
- ext: ".svg",
161
- source: "search",
162
- metadata: {
163
- description: `${entity} logo (official monochrome glyph)`,
164
- provider: "simple-icons",
165
- provenance: { entity, slug, pinned: "simple-icons@16.25.0" },
166
- },
167
- };
163
+ let icons;
164
+ try {
165
+ icons = await loadThesvgManifest();
166
+ } catch {
167
+ return null; // network down or bad payload — let the next tier try its own host
168
168
  }
169
- return null;
169
+ const hit = thesvgMatch(icons, entity);
170
+ if (!hit) return null;
171
+ // theSVG also ships mono, light/dark and wordmark variants; default is the full-color mark.
172
+ const route = hit.variants.default;
173
+ return {
174
+ url: `${THESVG_CDN}/public${route.startsWith("/") ? "" : "/"}${route}`,
175
+ ext: ".svg",
176
+ source: "search",
177
+ metadata: {
178
+ description: `${hit.title} logo (official mark)`,
179
+ provider: "thesvg",
180
+ provenance: {
181
+ entity,
182
+ slug: hit.slug,
183
+ variant: "default",
184
+ variants: Object.keys(hit.variants),
185
+ collection: hit.collection,
186
+ license: hit.license,
187
+ pinned: `glincker/thesvg@${THESVG_REV}`,
188
+ },
189
+ },
190
+ };
170
191
  }
171
192
 
172
193
  export async function githubAvatarSearch(intent, ctx = {}) {