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.
- package/dist/cli.js +941 -349
- package/dist/fontLocalizeCli.js +303 -73
- package/dist/hyperframes-player.global.js +1 -1
- package/dist/renderSetupWorker.js +10820 -42082
- package/dist/skills/hyperframes/scripts/lib/frame-packets-core.mjs +5 -23
- package/dist/skills/media-use/SKILL.md +10 -10
- package/dist/skills/media-use/audio/scripts/lib/bgm-volume.mjs +4 -0
- package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +2 -6
- package/dist/skills/media-use/references/meta.md +1 -1
- package/dist/skills/media-use/references/resolve.md +1 -1
- package/dist/skills/media-use/references/setup-providers.md +1 -1
- package/dist/skills/media-use/scripts/compatibility.test.mjs +68 -1
- package/dist/skills/media-use/scripts/lib/coverage.test.mjs +5 -1
- package/dist/skills/media-use/scripts/lib/freeze.mjs +34 -24
- package/dist/skills/media-use/scripts/lib/freeze.test.mjs +93 -1
- package/dist/skills/media-use/scripts/lib/logo-provider.mjs +110 -89
- package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +204 -60
- package/dist/skills/media-use/scripts/lib/media-fetch.mjs +14 -0
- package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +29 -1
- package/dist/skills/media-use/scripts/lib/registry.mjs +6 -12
- package/dist/skills/media-use/scripts/lib/registry.test.mjs +1 -1
- package/dist/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
- package/dist/studio/assets/{hyperframes-player-BF2Y9Rtn.js → hyperframes-player-B8bDHi2F.js} +1 -1
- package/dist/studio/assets/{index-BXCEB5uw.js → index-784N6Mag.js} +1 -1
- package/dist/studio/assets/{index-CGQ66fle.js → index-Bmd-35nQ.js} +4 -4
- package/dist/studio/assets/{index-Nnd43iYY.js → index-D-JPuFj7.js} +1 -1
- package/dist/studio/index.html +1 -1
- package/package.json +1 -1
|
@@ -1,20 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
104
|
-
//
|
|
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 (
|
|
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
|
|
|
@@ -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
|
-
|
|
24
|
-
|
|
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` (
|
|
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 |
|
|
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 |
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
//
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
153
|
+
return thesvgManifest;
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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 = {}) {
|