dsh-hyperframes 0.10.1 → 0.11.0
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/CHANGELOG.md +13 -0
- package/README.en.md +4 -3
- package/README.md +4 -3
- package/docs/WORKBENCH.md +6 -0
- package/lib/client.js +40 -2
- package/lib/media-templates.d.ts +1 -1
- package/lib/media-templates.js +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/skills/NOTICE.md +1 -1
- package/skills/UPSTREAM.json +93 -8
- package/skills/embedded-captions/SKILL.md +1 -1
- package/skills/faceless-explainer/SKILL.md +1 -1
- package/skills/general-video/SKILL.md +1 -1
- package/skills/hyperframes-animation/SKILL.md +1 -1
- package/skills/hyperframes-audio/SKILL.md +1 -1
- package/skills/hyperframes-core/SKILL.md +1 -1
- package/skills/hyperframes-creative/SKILL.md +1 -1
- package/skills/hyperframes-keyframes/SKILL.md +1 -1
- package/skills/hyperframes-studio/SKILL.md +1 -1
- package/skills/media-use/scripts/lib/adopt.mjs +128 -0
- package/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
- package/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
- package/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
- package/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
- package/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
- package/skills/media-use/scripts/lib/cache.mjs +162 -0
- package/skills/media-use/scripts/lib/cache.test.mjs +110 -0
- package/skills/media-use/scripts/lib/candidates.mjs +91 -0
- package/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
- package/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
- package/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
- package/skills/media-use/scripts/lib/config-lock.d.mts +3 -0
- package/skills/media-use/scripts/lib/config-lock.test.mjs +79 -0
- package/skills/media-use/scripts/lib/coverage.test.mjs +136 -0
- package/skills/media-use/scripts/lib/cube-build.mjs +181 -0
- package/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
- package/skills/media-use/scripts/lib/cube-validate.mjs +197 -0
- package/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
- package/skills/media-use/scripts/lib/cutlist.test.mjs +174 -0
- package/skills/media-use/scripts/lib/duck.test.mjs +319 -0
- package/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
- package/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
- package/skills/media-use/scripts/lib/freeze.mjs +72 -0
- package/skills/media-use/scripts/lib/freeze.test.mjs +138 -0
- package/skills/media-use/scripts/lib/grade-analyzer.mjs +277 -0
- package/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
- package/skills/media-use/scripts/lib/heygen-cli.mjs +208 -0
- package/skills/media-use/scripts/lib/heygen-cli.test.mjs +315 -0
- package/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
- package/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
- package/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
- package/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
- package/skills/media-use/scripts/lib/image-provider.mjs +44 -0
- package/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
- package/skills/media-use/scripts/lib/local-models.mjs +318 -0
- package/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
- package/skills/media-use/scripts/lib/local-run.mjs +78 -0
- package/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
- package/skills/media-use/scripts/lib/logo-provider.mjs +244 -0
- package/skills/media-use/scripts/lib/logo-provider.test.mjs +298 -0
- package/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
- package/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
- package/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
- package/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
- package/skills/media-use/scripts/lib/main-module.mjs +13 -0
- package/skills/media-use/scripts/lib/manifest.d.mts +20 -0
- package/skills/media-use/scripts/lib/manifest.test.mjs +397 -0
- package/skills/media-use/scripts/lib/match.mjs +47 -0
- package/skills/media-use/scripts/lib/media-fetch.test.mjs +197 -0
- package/skills/media-use/scripts/lib/media-home.test.mjs +61 -0
- package/skills/media-use/scripts/lib/media-search.d.mts +9 -0
- package/skills/media-use/scripts/lib/media-search.mjs +107 -0
- package/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
- package/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
- package/skills/media-use/scripts/lib/misses.mjs +49 -0
- package/skills/media-use/scripts/lib/misses.test.mjs +89 -0
- package/skills/media-use/scripts/lib/npx-sync.mjs +2 -28
- package/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
- package/skills/media-use/scripts/lib/openverse.mjs +134 -0
- package/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
- package/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
- package/skills/media-use/scripts/lib/prefs-store.concurrent.test.mjs +173 -0
- package/skills/media-use/scripts/lib/prefs-store.test.mjs +245 -0
- package/skills/media-use/scripts/lib/probe.mjs +46 -0
- package/skills/media-use/scripts/lib/probe.test.mjs +221 -0
- package/skills/media-use/scripts/lib/providers.mjs +5 -0
- package/skills/media-use/scripts/lib/recipe-store.test.mjs +391 -0
- package/skills/media-use/scripts/lib/registry.mjs +214 -0
- package/skills/media-use/scripts/lib/registry.test.mjs +262 -0
- package/skills/media-use/scripts/lib/search.mjs +24 -0
- package/skills/media-use/scripts/lib/search.test.mjs +32 -0
- package/skills/media-use/scripts/lib/sfx-provider.mjs +33 -0
- package/skills/media-use/scripts/lib/specs.mjs +132 -0
- package/skills/media-use/scripts/lib/specs.test.mjs +157 -0
- package/skills/media-use/scripts/lib/stats.mjs +117 -0
- package/skills/media-use/scripts/lib/stats.test.mjs +165 -0
- package/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
- package/skills/media-use/scripts/lib/telemetry.test.mjs +437 -53
- package/skills/media-use/scripts/lib/test-home.test.mjs +10 -0
- package/skills/media-use/scripts/lib/transcriptCutFade.test.mjs +33 -0
- package/skills/media-use/scripts/lib/tts-local-provider.mjs +94 -0
- package/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
- package/skills/media-use/scripts/lib/usage.mjs +57 -0
- package/skills/media-use/scripts/lib/usage.test.mjs +54 -0
- package/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
- package/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
- package/skills/media-use/scripts/resolve.mjs +1360 -27
- package/skills/motion-graphics/SKILL.md +1 -1
- package/skills/music-to-video/SKILL.md +1 -1
- package/skills/pr-to-video/SKILL.md +1 -1
- package/skills/product-launch-video/SKILL.md +1 -1
- package/skills/talking-head-recut/SKILL.md +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ERROR_DIFFUSION_ALGORITHMS, applyErrorDiffusionRgba } from "./error-diffusion.mjs";
|
|
4
|
+
|
|
5
|
+
const EXPECTED_GRADIENTS = {
|
|
6
|
+
"floyd-steinberg": "00000101/00010101/00100101/00010111/01010101/01011011",
|
|
7
|
+
atkinson: "00000011/00001100/00010011/00010111/01001101/00111011",
|
|
8
|
+
"jarvis-judice-ninke": "00000011/00001011/00011001/00101111/00100111/01011011",
|
|
9
|
+
stucki: "00000011/00010101/00010110/00101011/00101101/01010111",
|
|
10
|
+
burkes: "00000101/00010011/00010110/00101011/01010111/00101011",
|
|
11
|
+
sierra: "00000011/00010101/00010110/00100111/00110111/00101101",
|
|
12
|
+
"sierra-lite": "00000101/00010101/00100101/00010110/01010111/01010101",
|
|
13
|
+
"two-row-sierra": "00000101/00010011/00010110/00101011/00101101/01011011",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("exposes the eight article error-diffusion algorithms", () => {
|
|
17
|
+
assert.deepEqual(Object.keys(ERROR_DIFFUSION_ALGORITHMS), Object.keys(EXPECTED_GRADIENTS));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("matches deterministic golden patterns for every diffusion kernel", () => {
|
|
21
|
+
const width = 8;
|
|
22
|
+
const height = 6;
|
|
23
|
+
const source = new Uint8ClampedArray(width * height * 4);
|
|
24
|
+
for (let y = 0; y < height; y++) {
|
|
25
|
+
for (let x = 0; x < width; x++) {
|
|
26
|
+
const value = Math.round((255 * (x + y * 0.7)) / (width - 1 + (height - 1) * 0.7));
|
|
27
|
+
const offset = (y * width + x) * 4;
|
|
28
|
+
source[offset] = value;
|
|
29
|
+
source[offset + 1] = Math.round(value * 0.8);
|
|
30
|
+
source[offset + 2] = Math.round(value * 0.55);
|
|
31
|
+
source[offset + 3] = 17 + x + y;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const [algorithm, expected] of Object.entries(EXPECTED_GRADIENTS)) {
|
|
36
|
+
const output = source.slice();
|
|
37
|
+
applyErrorDiffusionRgba(output, width, height, {
|
|
38
|
+
algorithm,
|
|
39
|
+
brightness: 1,
|
|
40
|
+
contrast: 1,
|
|
41
|
+
detail: 1,
|
|
42
|
+
palette: ["#000000", "#ffffff"],
|
|
43
|
+
pointSize: 1,
|
|
44
|
+
});
|
|
45
|
+
const rows = [];
|
|
46
|
+
for (let y = 0; y < height; y++) {
|
|
47
|
+
let row = "";
|
|
48
|
+
for (let x = 0; x < width; x++) row += output[(y * width + x) * 4] ? "1" : "0";
|
|
49
|
+
rows.push(row);
|
|
50
|
+
}
|
|
51
|
+
assert.equal(rows.join("/"), expected, algorithm);
|
|
52
|
+
for (let i = 0; i < width * height; i++) assert.equal(output[i * 4 + 3], source[i * 4 + 3]);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("fills point-size blocks from their center sample", () => {
|
|
57
|
+
const data = new Uint8ClampedArray([
|
|
58
|
+
0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0, 5, 255, 255, 255, 6, 0, 0, 0, 7, 255,
|
|
59
|
+
255, 255, 8,
|
|
60
|
+
]);
|
|
61
|
+
applyErrorDiffusionRgba(
|
|
62
|
+
data,
|
|
63
|
+
4,
|
|
64
|
+
2,
|
|
65
|
+
{
|
|
66
|
+
algorithm: "floyd-steinberg",
|
|
67
|
+
brightness: 1,
|
|
68
|
+
contrast: 1,
|
|
69
|
+
detail: 1,
|
|
70
|
+
palette: ["#000000", "#ffffff"],
|
|
71
|
+
pointSize: 2,
|
|
72
|
+
},
|
|
73
|
+
new Float32Array(6),
|
|
74
|
+
);
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
[...data],
|
|
77
|
+
[
|
|
78
|
+
255, 255, 255, 1, 255, 255, 255, 2, 255, 255, 255, 3, 255, 255, 255, 4, 255, 255, 255, 5, 255,
|
|
79
|
+
255, 255, 6, 255, 255, 255, 7, 255, 255, 255, 8,
|
|
80
|
+
],
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("preserves authored palette order and validates the public contract", () => {
|
|
85
|
+
const reversed = new Uint8ClampedArray([0, 0, 0, 255]);
|
|
86
|
+
applyErrorDiffusionRgba(reversed, 1, 1, {
|
|
87
|
+
palette: ["#ffffff", "#000000"],
|
|
88
|
+
});
|
|
89
|
+
assert.deepEqual([...reversed], [255, 255, 255, 255]);
|
|
90
|
+
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, { palette: ["#000000"] }),
|
|
93
|
+
/2 to 6 colors/,
|
|
94
|
+
);
|
|
95
|
+
assert.throws(
|
|
96
|
+
() =>
|
|
97
|
+
applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, {
|
|
98
|
+
algorithm: "ordered-bayer",
|
|
99
|
+
}),
|
|
100
|
+
/unknown error-diffusion algorithm/,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// The binaries media-use spawns: HYPERFRAMES_FFMPEG_PATH / HYPERFRAMES_FFPROBE_PATH when set, else PATH.
|
|
5
|
+
// A set path that is not a working ffmpeg/ffprobe throws, so a broken override never reads as "no metadata".
|
|
6
|
+
const runsByPath = new Map();
|
|
7
|
+
|
|
8
|
+
export class FfBinarySettingError extends Error {}
|
|
9
|
+
|
|
10
|
+
function configuredOr(name, envVar) {
|
|
11
|
+
const setting = process.env[envVar]?.trim();
|
|
12
|
+
if (!setting) return name;
|
|
13
|
+
const path = resolve(setting);
|
|
14
|
+
if (!runsByPath.has(path)) runsByPath.set(path, isWorking(path, name));
|
|
15
|
+
if (!runsByPath.get(path)) {
|
|
16
|
+
const why = `${envVar} names "${path}", which is not a working ${name}: fix it or unset it.`;
|
|
17
|
+
throw new FfBinarySettingError(why);
|
|
18
|
+
}
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isWorking(path, name) {
|
|
23
|
+
try {
|
|
24
|
+
const out = execFileSync(path, ["-version"], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
27
|
+
timeout: 15000,
|
|
28
|
+
windowsHide: true,
|
|
29
|
+
});
|
|
30
|
+
return out.startsWith(`${name} version`);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const ffmpegBinary = () => configuredOr("ffmpeg", "HYPERFRAMES_FFMPEG_PATH");
|
|
37
|
+
export const ffprobeBinary = () => configuredOr("ffprobe", "HYPERFRAMES_FFPROBE_PATH");
|
|
@@ -0,0 +1,72 @@
|
|
|
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";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
// ponytail: bound the download so a hostile/runaway URL can't fill the disk.
|
|
7
|
+
// 256MB covers any real media asset; raise if 4K video sources ever exceed it.
|
|
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
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function freezeUrl(url, destPath) {
|
|
24
|
+
const where = String(url).slice(0, 80);
|
|
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
|
+
);
|
|
36
|
+
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${where}`);
|
|
37
|
+
|
|
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}`);
|
|
40
|
+
|
|
41
|
+
return writeFrozen(destPath, body);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function freezeLocalFile(srcPath, destPath) {
|
|
45
|
+
if (isSvgPath(destPath)) {
|
|
46
|
+
writeFrozen(destPath, readFileSync(srcPath));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
50
|
+
copyFileSync(srcPath, destPath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ingest accepts a DIRECT public media URL only — not a platform page. yt-dlp is
|
|
54
|
+
// deliberately out (cloud IPs get blocked, and it's brittle); the supported case
|
|
55
|
+
// is "user points at their own file or a direct asset link". A direct URL is a
|
|
56
|
+
// non-platform host whose path ends in a known media extension.
|
|
57
|
+
const PLATFORM_HOSTS =
|
|
58
|
+
/(^|\.)(youtube\.com|youtu\.be|vimeo\.com|tiktok\.com|instagram\.com|twitter\.com|x\.com|facebook\.com|dailymotion\.com)$/i;
|
|
59
|
+
const MEDIA_EXT = /\.(mp3|wav|m4a|aac|ogg|flac|mp4|mov|webm|mkv|png|jpe?g|webp|gif|svg|avif)$/i;
|
|
60
|
+
|
|
61
|
+
export function isDirectMediaUrl(u) {
|
|
62
|
+
let url;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(u);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
69
|
+
if (PLATFORM_HOSTS.test(url.hostname)) return false;
|
|
70
|
+
if (!isPublicMediaUrl(url)) return false;
|
|
71
|
+
return MEDIA_EXT.test(url.pathname);
|
|
72
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
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>';
|
|
12
|
+
|
|
13
|
+
test("accepts direct public media URLs", () => {
|
|
14
|
+
assert.equal(isDirectMediaUrl("https://cdn.example.com/clip.mp4"), true);
|
|
15
|
+
assert.equal(isDirectMediaUrl("https://example.com/a/b/track.mp3"), true);
|
|
16
|
+
assert.equal(isDirectMediaUrl("http://example.com/logo.svg"), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rejects platform pages (no yt-dlp)", () => {
|
|
20
|
+
assert.equal(isDirectMediaUrl("https://www.youtube.com/watch?v=abc"), false);
|
|
21
|
+
assert.equal(isDirectMediaUrl("https://youtu.be/abc"), false);
|
|
22
|
+
assert.equal(isDirectMediaUrl("https://vimeo.com/12345"), false);
|
|
23
|
+
assert.equal(isDirectMediaUrl("https://x.com/u/status/1"), false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rejects non-direct / non-media URLs", () => {
|
|
27
|
+
assert.equal(isDirectMediaUrl("https://example.com/page"), false, "no media extension");
|
|
28
|
+
assert.equal(isDirectMediaUrl("ftp://example.com/a.mp4"), false, "non-http(s)");
|
|
29
|
+
assert.equal(isDirectMediaUrl("not a url"), false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rejects local / private hosts (SSRF guard, m11)", () => {
|
|
33
|
+
for (const u of [
|
|
34
|
+
"http://localhost/a.mp4",
|
|
35
|
+
"http://127.0.0.1/a.mp4",
|
|
36
|
+
"http://127.1.2.3/a.mp4",
|
|
37
|
+
"http://0.0.0.0/a.mp4",
|
|
38
|
+
"http://10.0.0.5/a.mp4",
|
|
39
|
+
"http://192.168.1.1/a.mp4",
|
|
40
|
+
"http://172.16.0.1/a.mp4",
|
|
41
|
+
"http://172.31.255.255/a.mp4",
|
|
42
|
+
"http://169.254.169.254/a.mp4", // cloud metadata endpoint
|
|
43
|
+
"http://printer.local/a.mp4",
|
|
44
|
+
"http://svc.internal/a.mp4",
|
|
45
|
+
"http://[::1]/a.mp4",
|
|
46
|
+
"http://[fe80::1]/a.mp4",
|
|
47
|
+
"http://[fd00::1]/a.mp4",
|
|
48
|
+
]) {
|
|
49
|
+
assert.equal(isDirectMediaUrl(u), false, `should block ${u}`);
|
|
50
|
+
}
|
|
51
|
+
// A public host that merely starts with similar digits is still allowed.
|
|
52
|
+
assert.equal(isDirectMediaUrl("https://172.40.0.1/a.mp4"), true, "172.40 is public");
|
|
53
|
+
assert.equal(isDirectMediaUrl("https://11.example.com/a.mp4"), true);
|
|
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
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Vendored plain-JS copy of packages/core/src/mediaGradeAnalyzer.ts.
|
|
2
|
+
// packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts guards behavior drift.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { basename, extname } from "node:path";
|
|
5
|
+
|
|
6
|
+
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"]);
|
|
7
|
+
const SAMPLE_FRAMES = 5;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
9
|
+
|
|
10
|
+
const ADJUST_LIMITS = {
|
|
11
|
+
exposure: { min: -2, max: 2 },
|
|
12
|
+
contrast: { min: -1, max: 1 },
|
|
13
|
+
whites: { min: -1, max: 1 },
|
|
14
|
+
blacks: { min: -1, max: 1 },
|
|
15
|
+
temperature: { min: -1, max: 1 },
|
|
16
|
+
tint: { min: -1, max: 1 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function clamp(value, key) {
|
|
20
|
+
const limit = ADJUST_LIMITS[key];
|
|
21
|
+
if (!Number.isFinite(value)) return 0;
|
|
22
|
+
return Math.min(limit.max, Math.max(limit.min, value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function round(value) {
|
|
26
|
+
const rounded = Math.round(value * 1000) / 1000;
|
|
27
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function average(values) {
|
|
31
|
+
return values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function probeMedia(mediaPath, ffprobePath) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = execFileSync(
|
|
37
|
+
ffprobePath,
|
|
38
|
+
[
|
|
39
|
+
"-v",
|
|
40
|
+
"error",
|
|
41
|
+
"-select_streams",
|
|
42
|
+
"v:0",
|
|
43
|
+
"-show_entries",
|
|
44
|
+
"stream=color_space,color_transfer,color_primaries,pix_fmt,duration:format=duration",
|
|
45
|
+
"-of",
|
|
46
|
+
"json",
|
|
47
|
+
"--",
|
|
48
|
+
mediaPath,
|
|
49
|
+
],
|
|
50
|
+
{ encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"] },
|
|
51
|
+
);
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
const stream = Array.isArray(parsed.streams) ? (parsed.streams[0] ?? {}) : {};
|
|
54
|
+
const duration = Number(stream.duration ?? parsed.format?.duration);
|
|
55
|
+
const text = (key) =>
|
|
56
|
+
typeof stream[key] === "string" && stream[key] ? stream[key] : "unknown";
|
|
57
|
+
return {
|
|
58
|
+
duration: Number.isFinite(duration) && duration > 0 ? duration : null,
|
|
59
|
+
colorSpace: text("color_space"),
|
|
60
|
+
transfer: text("color_transfer"),
|
|
61
|
+
primaries: text("color_primaries"),
|
|
62
|
+
pixelFormat: text("pix_fmt"),
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return {
|
|
66
|
+
duration: null,
|
|
67
|
+
colorSpace: "unknown",
|
|
68
|
+
transfer: "unknown",
|
|
69
|
+
primaries: "unknown",
|
|
70
|
+
pixelFormat: "unknown",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseMediaTreatmentSignalStats(raw) {
|
|
76
|
+
const frames = [];
|
|
77
|
+
let current = null;
|
|
78
|
+
for (const line of String(raw).split(/\r?\n/)) {
|
|
79
|
+
// One digit is enough: .* consumes the rest without overlapping repetitions.
|
|
80
|
+
const frame = line.match(/^frame:\d.*pts_time:([+-]?(?:\d+(?:\.\d+)?|\.\d+))/);
|
|
81
|
+
if (frame) {
|
|
82
|
+
if (current) frames.push(current);
|
|
83
|
+
current = { ptsTime: Number(frame[1]) };
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const stat = line.match(/lavfi\.signalstats\.([A-Z]+)=([+-]?(?:\d+(?:\.\d+)?|\.\d+))/);
|
|
87
|
+
if (!stat) continue;
|
|
88
|
+
current ??= {};
|
|
89
|
+
current[stat[1]] = Number(stat[2]);
|
|
90
|
+
}
|
|
91
|
+
if (current) frames.push(current);
|
|
92
|
+
return frames.filter(
|
|
93
|
+
(frame) =>
|
|
94
|
+
Number.isFinite(frame.YMIN) &&
|
|
95
|
+
Number.isFinite(frame.YLOW) &&
|
|
96
|
+
Number.isFinite(frame.YAVG) &&
|
|
97
|
+
Number.isFinite(frame.YHIGH) &&
|
|
98
|
+
Number.isFinite(frame.YMAX) &&
|
|
99
|
+
Number.isFinite(frame.UAVG) &&
|
|
100
|
+
Number.isFinite(frame.VAVG),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function summarizeFrames(frames) {
|
|
105
|
+
if (frames.length === 0) throw new Error("FFmpeg returned no analyzable video frames");
|
|
106
|
+
return {
|
|
107
|
+
frames: frames.length,
|
|
108
|
+
yMin: Math.min(...frames.map((frame) => frame.YMIN)),
|
|
109
|
+
yLow: average(frames.map((frame) => frame.YLOW)),
|
|
110
|
+
yAvg: average(frames.map((frame) => frame.YAVG)),
|
|
111
|
+
yHigh: average(frames.map((frame) => frame.YHIGH)),
|
|
112
|
+
yMax: Math.max(...frames.map((frame) => frame.YMAX)),
|
|
113
|
+
uAvg: average(frames.map((frame) => frame.UAVG)),
|
|
114
|
+
vAvg: average(frames.map((frame) => frame.VAVG)),
|
|
115
|
+
satAvg: average(frames.map((frame) => frame.SATAVG ?? 0)),
|
|
116
|
+
shadowClipRisk: average(frames.map((frame) => (frame.YLOW <= 16 ? 1 : 0))),
|
|
117
|
+
highlightClipRisk: average(frames.map((frame) => (frame.YHIGH >= 235 ? 1 : 0))),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function suggestedExposure(normalizedAverage, yLow, yHigh) {
|
|
122
|
+
if (normalizedAverage < 0.28 && yHigh / 255 < 0.65) {
|
|
123
|
+
return clamp((0.32 - normalizedAverage) * 1.2, "exposure");
|
|
124
|
+
}
|
|
125
|
+
if (normalizedAverage > 0.72 && yLow / 255 > 0.3) {
|
|
126
|
+
return clamp((0.68 - normalizedAverage) * 1.2, "exposure");
|
|
127
|
+
}
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function suggestedTemperature(uAverage, vAverage) {
|
|
132
|
+
const chromaWarmth = (vAverage - 128 + (128 - uAverage)) / 128;
|
|
133
|
+
return Math.abs(chromaWarmth) >= 0.08 ? clamp(-chromaWarmth * 0.25, "temperature") : 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function suggestedTint(uAverage, vAverage) {
|
|
137
|
+
const cast = uAverage + vAverage - 256;
|
|
138
|
+
return Math.abs(cast) >= 10 ? clamp(-cast / 512, "tint") : 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function statsToAdjust(stats) {
|
|
142
|
+
const yMin = Number(stats.yMin);
|
|
143
|
+
const yLow = Number(stats.yLow ?? stats.yMin);
|
|
144
|
+
const yMax = Number(stats.yMax);
|
|
145
|
+
const yHigh = Number(stats.yHigh ?? stats.yMax);
|
|
146
|
+
const yAvg = Number(stats.yAvg);
|
|
147
|
+
const uAvg = Number(stats.uAvg);
|
|
148
|
+
const vAvg = Number(stats.vAvg);
|
|
149
|
+
const shadowClipRisk = Number(stats.shadowClipRisk ?? (yLow <= 16 ? 1 : 0));
|
|
150
|
+
const highlightClipRisk = Number(stats.highlightClipRisk ?? (yHigh >= 235 ? 1 : 0));
|
|
151
|
+
const percentileSpread = (yHigh - yLow) / 255;
|
|
152
|
+
const normalizedAverage = yAvg / 255;
|
|
153
|
+
const contrast = percentileSpread < 0.35 ? clamp((0.35 - percentileSpread) * 0.4, "contrast") : 0;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
adjust: {
|
|
157
|
+
exposure: round(suggestedExposure(normalizedAverage, yLow, yHigh)),
|
|
158
|
+
contrast: round(contrast),
|
|
159
|
+
blacks: round(clamp(shadowClipRisk * 0.08, "blacks")),
|
|
160
|
+
whites: round(clamp(-highlightClipRisk * 0.08, "whites")),
|
|
161
|
+
temperature: round(suggestedTemperature(uAvg, vAvg)),
|
|
162
|
+
tint: round(suggestedTint(uAvg, vAvg)),
|
|
163
|
+
},
|
|
164
|
+
measured: {
|
|
165
|
+
frames: Number(stats.frames ?? 1),
|
|
166
|
+
yMin: round(yMin),
|
|
167
|
+
yLow: round(yLow),
|
|
168
|
+
yAvg: round(yAvg),
|
|
169
|
+
yHigh: round(yHigh),
|
|
170
|
+
yMax: round(yMax),
|
|
171
|
+
uAvg: round(uAvg),
|
|
172
|
+
vAvg: round(vAvg),
|
|
173
|
+
satAvg: round(Number(stats.satAvg ?? 0)),
|
|
174
|
+
shadowClipRisk: round(shadowClipRisk),
|
|
175
|
+
highlightClipRisk: round(highlightClipRisk),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function summarizeMediaTreatmentAnalysis(probe, frames) {
|
|
181
|
+
const result = statsToAdjust(summarizeFrames(frames));
|
|
182
|
+
const hdr = ["smpte2084", "arib-std-b67"].includes(probe.transfer);
|
|
183
|
+
const diagnosis = [];
|
|
184
|
+
if (result.measured.shadowClipRisk > 0) {
|
|
185
|
+
diagnosis.push("sampled frames contain deep or clipped shadows");
|
|
186
|
+
}
|
|
187
|
+
if (result.measured.highlightClipRisk > 0) {
|
|
188
|
+
diagnosis.push("sampled frames contain bright or clipped highlights");
|
|
189
|
+
}
|
|
190
|
+
if (diagnosis.length === 0) diagnosis.push("no obvious technical imbalance in sampled frames");
|
|
191
|
+
|
|
192
|
+
const warnings = [];
|
|
193
|
+
if (hdr) warnings.push("HDR transfer detected; the realtime treatment path is SDR/Rec.709.");
|
|
194
|
+
if (probe.colorSpace === "unknown" || probe.transfer === "unknown") {
|
|
195
|
+
warnings.push(
|
|
196
|
+
"Source color metadata is incomplete; camera LOG cannot be identified reliably from container metadata alone.",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...result,
|
|
202
|
+
source: {
|
|
203
|
+
colorSpace: probe.colorSpace,
|
|
204
|
+
transfer: probe.transfer,
|
|
205
|
+
primaries: probe.primaries,
|
|
206
|
+
pixelFormat: probe.pixelFormat,
|
|
207
|
+
hdr,
|
|
208
|
+
log: "unknown",
|
|
209
|
+
},
|
|
210
|
+
diagnosis,
|
|
211
|
+
warnings,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function gradeSampleTimes(duration) {
|
|
216
|
+
return Array.from({ length: SAMPLE_FRAMES }, (_, i) => (duration * (i + 0.5)) / SAMPLE_FRAMES);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readSignalStats(ffmpegPath, mediaPath, seekSeconds, frames) {
|
|
220
|
+
const timeoutMs = Number(process.env.HYPERFRAMES_ANALYZE_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
|
|
221
|
+
const keyframeSeek =
|
|
222
|
+
seekSeconds === null ? [] : ["-noaccurate_seek", "-ss", seekSeconds.toFixed(3)];
|
|
223
|
+
try {
|
|
224
|
+
return execFileSync(
|
|
225
|
+
ffmpegPath,
|
|
226
|
+
[
|
|
227
|
+
"-hide_banner",
|
|
228
|
+
"-nostdin",
|
|
229
|
+
"-v",
|
|
230
|
+
"error",
|
|
231
|
+
...keyframeSeek,
|
|
232
|
+
"-i",
|
|
233
|
+
mediaPath,
|
|
234
|
+
"-vf",
|
|
235
|
+
`trim=end_frame=${frames},format=yuv444p,signalstats,metadata=print:file=-`,
|
|
236
|
+
"-frames:v",
|
|
237
|
+
String(frames),
|
|
238
|
+
"-f",
|
|
239
|
+
"null",
|
|
240
|
+
"-",
|
|
241
|
+
],
|
|
242
|
+
{ encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] },
|
|
243
|
+
);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error.code !== "ETIMEDOUT") throw error;
|
|
246
|
+
const target =
|
|
247
|
+
seekSeconds === null
|
|
248
|
+
? `the first ${frames} frames`
|
|
249
|
+
: `the frame at ${seekSeconds.toFixed(1)}s`;
|
|
250
|
+
throw new Error(
|
|
251
|
+
`FFmpeg took longer than ${timeoutMs / 1000}s to measure ${target}; set HYPERFRAMES_ANALYZE_TIMEOUT_MS to allow more`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function analyzeMediaGrade(
|
|
257
|
+
mediaPath,
|
|
258
|
+
{ ffmpegPath = "ffmpeg", ffprobePath = "ffprobe" } = {},
|
|
259
|
+
) {
|
|
260
|
+
try {
|
|
261
|
+
const probe = probeMedia(mediaPath, ffprobePath);
|
|
262
|
+
const isImage = IMAGE_EXT.has(extname(mediaPath).toLowerCase());
|
|
263
|
+
const raw =
|
|
264
|
+
!isImage && probe.duration
|
|
265
|
+
? gradeSampleTimes(probe.duration)
|
|
266
|
+
.map((time) => readSignalStats(ffmpegPath, mediaPath, time, 1))
|
|
267
|
+
.join("\n")
|
|
268
|
+
: readSignalStats(ffmpegPath, mediaPath, null, SAMPLE_FRAMES);
|
|
269
|
+
return summarizeMediaTreatmentAnalysis(probe, parseMediaTreatmentSignalStats(raw));
|
|
270
|
+
} catch (error) {
|
|
271
|
+
throw new Error(`grade analysis failed for ${mediaPath}: ${error.message}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function formatMeasuredNote(mediaPath, measured) {
|
|
276
|
+
return `media-use: measured ${basename(mediaPath)}: frames=${measured.frames}, YMIN=${measured.yMin}, YLOW=${measured.yLow}, YAVG=${measured.yAvg}, YHIGH=${measured.yHigh}, YMAX=${measured.yMax}, UAVG=${measured.uAvg}, VAVG=${measured.vAvg}; adjust is a starting suggestion`;
|
|
277
|
+
}
|