hyperframes 0.8.58 → 0.8.60
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 +4529 -3220
- package/dist/docs/data-attributes.md +2 -1
- package/dist/fontLocalizeCli.js +96 -16
- package/dist/hyperframe-runtime.js +25 -25
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +25 -25
- package/dist/hyperframes-player.global.js +3 -3
- package/dist/registry/localSemantic.js +548 -0
- package/dist/skills/hyperframes-cli/SKILL.md +2 -2
- package/dist/skills/media-use/SKILL.md +96 -0
- package/dist/skills/media-use/audio/assets/sfx/CREDITS.md +35 -0
- package/dist/skills/media-use/audio/assets/sfx/chime.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/click-soft.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/click.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/error.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-1.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-2.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/glitch-3.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/impact-bass-1.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/impact-bass-2.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/key-press.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/manifest.json +97 -0
- package/dist/skills/media-use/audio/assets/sfx/notification.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/ping.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/pop.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/riser.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/sparkle.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/typing.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh-short.mp3 +0 -0
- package/dist/skills/media-use/audio/assets/sfx/whoosh.mp3 +0 -0
- package/dist/skills/media-use/audio/references/bgm.md +74 -0
- package/dist/skills/media-use/audio/references/captions/authoring.md +163 -0
- package/dist/skills/media-use/audio/references/captions/motion.md +87 -0
- package/dist/skills/media-use/audio/references/captions/transcript-handling.md +97 -0
- package/dist/skills/media-use/audio/references/remove-background.md +143 -0
- package/dist/skills/media-use/audio/references/requirements.md +29 -0
- package/dist/skills/media-use/audio/references/sfx.md +42 -0
- package/dist/skills/media-use/audio/references/transcribe.md +52 -0
- package/dist/skills/media-use/audio/references/tts-to-captions.md +26 -0
- package/dist/skills/media-use/audio/references/tts.md +165 -0
- package/dist/skills/media-use/audio/scripts/audio.mjs +295 -0
- package/dist/skills/media-use/audio/scripts/audio.test.mjs +49 -0
- package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +121 -0
- package/dist/skills/media-use/audio/scripts/lib/audio-meta.mjs +39 -0
- package/dist/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +136 -0
- package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +257 -0
- package/dist/skills/media-use/audio/scripts/lib/bgm.test.mjs +30 -0
- package/dist/skills/media-use/audio/scripts/lib/concurrency.mjs +14 -0
- package/dist/skills/media-use/audio/scripts/lib/concurrency.test.mjs +41 -0
- package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +156 -0
- package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +103 -0
- package/dist/skills/media-use/audio/scripts/lib/python.mjs +63 -0
- package/dist/skills/media-use/audio/scripts/lib/python.test.mjs +68 -0
- package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +143 -0
- package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +69 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.mjs +379 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +158 -0
- package/dist/skills/media-use/audio/scripts/lib/tts.test.mjs +157 -0
- package/dist/skills/media-use/audio/scripts/lyria-recipe.py +136 -0
- package/dist/skills/media-use/audio/scripts/wait-bgm.mjs +171 -0
- package/dist/skills/media-use/audio/scripts/wait-bgm.test.mjs +65 -0
- package/dist/skills/media-use/luts/README.md +28 -0
- package/dist/skills/media-use/luts/index.json +57 -0
- package/dist/skills/media-use/references/audio.md +19 -0
- package/dist/skills/media-use/references/grading.md +160 -0
- package/dist/skills/media-use/references/media-treatment-recipes.md +929 -0
- package/dist/skills/media-use/references/media-treatments.md +202 -0
- package/dist/skills/media-use/references/memory.md +36 -0
- package/dist/skills/media-use/references/meta.md +61 -0
- package/dist/skills/media-use/references/operations.md +325 -0
- package/dist/skills/media-use/references/resolve.md +150 -0
- package/dist/skills/media-use/references/setup-providers.md +80 -0
- package/dist/skills/media-use/references/telemetry-dashboard.md +49 -0
- package/dist/skills/media-use/registry/localSemantic.js +548 -0
- package/dist/skills/media-use/scripts/audio-duck.mjs +113 -0
- package/dist/skills/media-use/scripts/compatibility.test.mjs +27 -0
- package/dist/skills/media-use/scripts/dither.mjs +328 -0
- package/dist/skills/media-use/scripts/dither.test.mjs +176 -0
- package/dist/skills/media-use/scripts/eval.mjs +369 -0
- package/dist/skills/media-use/scripts/lib/adopt.mjs +128 -0
- package/dist/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
- package/dist/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
- package/dist/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
- package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
- package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
- package/dist/skills/media-use/scripts/lib/cache.mjs +150 -0
- package/dist/skills/media-use/scripts/lib/candidates.mjs +91 -0
- package/dist/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
- package/dist/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
- package/dist/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
- package/dist/skills/media-use/scripts/lib/coverage.test.mjs +132 -0
- package/dist/skills/media-use/scripts/lib/cube-build.mjs +181 -0
- package/dist/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
- package/dist/skills/media-use/scripts/lib/cube-validate.mjs +198 -0
- package/dist/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
- package/dist/skills/media-use/scripts/lib/cutlist.mjs +184 -0
- package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +162 -0
- package/dist/skills/media-use/scripts/lib/duck.mjs +95 -0
- package/dist/skills/media-use/scripts/lib/duck.test.mjs +153 -0
- package/dist/skills/media-use/scripts/lib/error-diffusion.mjs +230 -0
- package/dist/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
- package/dist/skills/media-use/scripts/lib/freeze.mjs +62 -0
- package/dist/skills/media-use/scripts/lib/freeze.test.mjs +46 -0
- package/dist/skills/media-use/scripts/lib/grade-analyzer.mjs +265 -0
- package/dist/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
- package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +191 -0
- package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +301 -0
- package/dist/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
- package/dist/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
- package/dist/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
- package/dist/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
- package/dist/skills/media-use/scripts/lib/image-provider.mjs +44 -0
- package/dist/skills/media-use/scripts/lib/index-gen.mjs +63 -0
- package/dist/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
- package/dist/skills/media-use/scripts/lib/local-models.mjs +318 -0
- package/dist/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
- package/dist/skills/media-use/scripts/lib/local-run.mjs +78 -0
- package/dist/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
- package/dist/skills/media-use/scripts/lib/logo-provider.mjs +223 -0
- package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +154 -0
- package/dist/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
- package/dist/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
- package/dist/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
- package/dist/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
- package/dist/skills/media-use/scripts/lib/manifest.mjs +229 -0
- package/dist/skills/media-use/scripts/lib/manifest.test.mjs +388 -0
- package/dist/skills/media-use/scripts/lib/match.mjs +47 -0
- package/dist/skills/media-use/scripts/lib/media-fetch.mjs +71 -0
- package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +169 -0
- package/dist/skills/media-use/scripts/lib/media-search.d.mts +9 -0
- package/dist/skills/media-use/scripts/lib/media-search.mjs +107 -0
- package/dist/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
- package/dist/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
- package/dist/skills/media-use/scripts/lib/misses.mjs +49 -0
- package/dist/skills/media-use/scripts/lib/misses.test.mjs +78 -0
- package/dist/skills/media-use/scripts/lib/npx-sync.mjs +31 -0
- package/dist/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
- package/dist/skills/media-use/scripts/lib/parakeet-words.mjs +27 -0
- package/dist/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
- package/dist/skills/media-use/scripts/lib/prefs-store.mjs +180 -0
- package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +189 -0
- package/dist/skills/media-use/scripts/lib/probe.mjs +39 -0
- package/dist/skills/media-use/scripts/lib/probe.test.mjs +31 -0
- package/dist/skills/media-use/scripts/lib/providers.mjs +5 -0
- package/dist/skills/media-use/scripts/lib/recipe-store.mjs +367 -0
- package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +367 -0
- package/dist/skills/media-use/scripts/lib/registry.mjs +218 -0
- package/dist/skills/media-use/scripts/lib/registry.test.mjs +262 -0
- package/dist/skills/media-use/scripts/lib/search.mjs +24 -0
- package/dist/skills/media-use/scripts/lib/search.test.mjs +32 -0
- package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -0
- package/dist/skills/media-use/scripts/lib/specs.mjs +132 -0
- package/dist/skills/media-use/scripts/lib/specs.test.mjs +157 -0
- package/dist/skills/media-use/scripts/lib/stats.mjs +117 -0
- package/dist/skills/media-use/scripts/lib/stats.test.mjs +165 -0
- package/dist/skills/media-use/scripts/lib/telemetry.mjs +193 -0
- package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +373 -0
- package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +92 -0
- package/dist/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
- package/dist/skills/media-use/scripts/lib/usage.mjs +57 -0
- package/dist/skills/media-use/scripts/lib/usage.test.mjs +54 -0
- package/dist/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
- package/dist/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
- package/dist/skills/media-use/scripts/lib/words.mjs +18 -0
- package/dist/skills/media-use/scripts/prefs.mjs +70 -0
- package/dist/skills/media-use/scripts/recipe.mjs +103 -0
- package/dist/skills/media-use/scripts/resolve.mjs +1287 -0
- package/dist/skills/media-use/scripts/transcribe.mjs +180 -0
- package/dist/skills/media-use/scripts/transcript-cut.mjs +239 -0
- package/dist/skills/registry/localSemantic.js +548 -0
- package/dist/studio/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
- package/dist/studio/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
- package/dist/studio/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
- package/dist/studio/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
- package/dist/studio/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
- package/dist/studio/index.d.ts +549 -13
- package/dist/studio/index.html +2 -2
- package/dist/studio/index.js +23133 -21941
- package/dist/studio/index.js.map +1 -1
- package/dist/studio/styles.css +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { wordListsFromMediaMeta } from "./words.mjs";
|
|
2
|
+
|
|
3
|
+
// audio_meta.json word times are relative to each line's own file.
|
|
4
|
+
export function speechSpans(meta, { mergeGap = 0.6, offsets, sequential = false, gap = 0 } = {}) {
|
|
5
|
+
const merge = Number(mergeGap);
|
|
6
|
+
const lists = wordListsFromMediaMeta(meta);
|
|
7
|
+
const voices = Array.isArray(meta?.voices) ? meta.voices : [];
|
|
8
|
+
if (lists.length > 1 && !offsets && !sequential) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"audio_meta has multiple voice lines with file-relative times; pass --sequential or --offsets so spans land at composition time",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
const intervals = [];
|
|
14
|
+
let cursor = 0;
|
|
15
|
+
for (let i = 0; i < lists.length; i++) {
|
|
16
|
+
const voice = voices[i];
|
|
17
|
+
let offset = 0;
|
|
18
|
+
if (offsets) {
|
|
19
|
+
const id = voice?.id ?? String(i);
|
|
20
|
+
if (!(id in offsets)) throw new Error(`--offsets is missing voice "${id}"`);
|
|
21
|
+
offset = Number(offsets[id]) || 0;
|
|
22
|
+
} else if (sequential) {
|
|
23
|
+
offset = cursor;
|
|
24
|
+
const lineDuration = Number(voice?.duration_s) || Math.max(...lists[i].map((w) => w.end), 0);
|
|
25
|
+
cursor += lineDuration + (Number(gap) || 0);
|
|
26
|
+
}
|
|
27
|
+
for (const word of lists[i]) {
|
|
28
|
+
if (word.end > word.start)
|
|
29
|
+
intervals.push({ start: word.start + offset, end: word.end + offset });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return mergeIntervals(intervals, Number.isFinite(merge) && merge >= 0 ? merge : 0.6);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function duckKeyframes(
|
|
36
|
+
spans,
|
|
37
|
+
{ duck = 0.25, attack = 0.15, release = 0.4, baseVolume = 1 } = {},
|
|
38
|
+
) {
|
|
39
|
+
const base = finiteOr(baseVolume, 1);
|
|
40
|
+
const ducked = round3(base * finiteOr(duck, 0.25));
|
|
41
|
+
const keyframes = [];
|
|
42
|
+
for (const span of spans) {
|
|
43
|
+
keyframes.push({
|
|
44
|
+
time: round3(Math.max(0, finiteOr(span.start, 0))),
|
|
45
|
+
volume: ducked,
|
|
46
|
+
duration: round3(finiteOr(attack, 0.15)),
|
|
47
|
+
});
|
|
48
|
+
keyframes.push({
|
|
49
|
+
time: round3(Math.max(0, finiteOr(span.end, 0))),
|
|
50
|
+
volume: round3(base),
|
|
51
|
+
duration: round3(finiteOr(release, 0.4)),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return keyframes.sort((a, b) => a.time - b.time);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Volume lane for `data-automation`: composition-time keyframes as clip-local ramps. */
|
|
58
|
+
export function duckLane(keyframes, { clipStart = 0, baseVolume = 1 } = {}) {
|
|
59
|
+
const start = finiteOr(clipStart, 0);
|
|
60
|
+
const points = [{ t: 0, v: round3(finiteOr(baseVolume, 1)) }];
|
|
61
|
+
const push = (t, v) => {
|
|
62
|
+
if (t > points.at(-1).t) points.push({ t: round3(t), v });
|
|
63
|
+
};
|
|
64
|
+
for (const kf of keyframes) {
|
|
65
|
+
const t = Math.max(0, kf.time - start);
|
|
66
|
+
push(t, points.at(-1).v);
|
|
67
|
+
push(t + kf.duration, kf.volume);
|
|
68
|
+
}
|
|
69
|
+
return { version: 1, lanes: [{ target: "volume", points }] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mergeIntervals(intervals, mergeGap) {
|
|
73
|
+
const sorted = intervals
|
|
74
|
+
.map((range) => ({ start: round3(range.start), end: round3(range.end) }))
|
|
75
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
76
|
+
const merged = [];
|
|
77
|
+
for (const range of sorted) {
|
|
78
|
+
const prev = merged.at(-1);
|
|
79
|
+
if (prev && (range.start <= prev.end || range.start - prev.end < mergeGap)) {
|
|
80
|
+
prev.end = Math.max(prev.end, range.end);
|
|
81
|
+
} else {
|
|
82
|
+
merged.push({ ...range });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return merged;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function finiteOr(value, fallback) {
|
|
89
|
+
const n = Number(value);
|
|
90
|
+
return Number.isFinite(n) ? n : fallback;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function round3(n) {
|
|
94
|
+
return Math.round(Number(n) * 1000) / 1000;
|
|
95
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { duckKeyframes, duckLane, speechSpans } from "./duck.mjs";
|
|
9
|
+
|
|
10
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
// Keep the skill-owned wrapper path under test until the compatibility layer
|
|
12
|
+
// is removed in a later release.
|
|
13
|
+
const SCRIPT = join(
|
|
14
|
+
HERE,
|
|
15
|
+
"..",
|
|
16
|
+
"..",
|
|
17
|
+
"..",
|
|
18
|
+
"..",
|
|
19
|
+
"..",
|
|
20
|
+
"skills",
|
|
21
|
+
"media-use",
|
|
22
|
+
"scripts",
|
|
23
|
+
"audio-duck.mjs",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
test("speechSpans bridges gaps smaller than mergeGap", () => {
|
|
27
|
+
const meta = {
|
|
28
|
+
words: [word("w0", "one", 0, 0.5), word("w1", "two", 0.8, 1), word("w2", "three", 2, 2.2)],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(speechSpans(meta, { mergeGap: 0.4 }), [
|
|
32
|
+
{ start: 0, end: 1 },
|
|
33
|
+
{ start: 2, end: 2.2 },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("speechSpans refuses multi-line meta without placement (file-relative times)", () => {
|
|
38
|
+
const meta = {
|
|
39
|
+
voices: [
|
|
40
|
+
{ id: "a", words: [word("w0", "one", 0, 1)] },
|
|
41
|
+
{ id: "b", words: [word("w1", "two", 0, 1)] },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
assert.throws(() => speechSpans(meta, { mergeGap: 0.2 }), /--sequential or --offsets/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("speechSpans sequential stacks lines by duration plus gap", () => {
|
|
48
|
+
const meta = {
|
|
49
|
+
voices: [
|
|
50
|
+
{ id: "a", duration_s: 2, words: [word("w0", "one", 0.1, 1.9)] },
|
|
51
|
+
{ id: "b", duration_s: 1, words: [word("w1", "two", 0.1, 0.9)] },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
assert.deepEqual(speechSpans(meta, { mergeGap: 0.2, sequential: true, gap: 0.5 }), [
|
|
55
|
+
{ start: 0.1, end: 1.9 },
|
|
56
|
+
{ start: 2.6, end: 3.4 },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("speechSpans explicit offsets place each line at composition time", () => {
|
|
61
|
+
const meta = {
|
|
62
|
+
voices: [
|
|
63
|
+
{ id: "a", words: [word("w0", "one", 0, 1)] },
|
|
64
|
+
{ id: "b", words: [word("w1", "two", 0, 1)] },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
assert.deepEqual(speechSpans(meta, { mergeGap: 0.2, offsets: { a: 0, b: 4 } }), [
|
|
68
|
+
{ start: 0, end: 1 },
|
|
69
|
+
{ start: 4, end: 5 },
|
|
70
|
+
]);
|
|
71
|
+
assert.throws(() => speechSpans(meta, { offsets: { a: 0 } }), /missing voice "b"/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("speechSpans returns empty spans for empty input", () => {
|
|
75
|
+
assert.deepEqual(speechSpans({ voices: [] }, { mergeGap: 0.6 }), []);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("duckKeyframes shapes attack and release from base volume", () => {
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
duckKeyframes([{ start: 3, end: 5 }], {
|
|
81
|
+
duck: 0.25,
|
|
82
|
+
attack: 0.15,
|
|
83
|
+
release: 0.4,
|
|
84
|
+
baseVolume: 0.6,
|
|
85
|
+
}),
|
|
86
|
+
[
|
|
87
|
+
{ time: 3, volume: 0.15, duration: 0.15 },
|
|
88
|
+
{ time: 5, volume: 0.6, duration: 0.4 },
|
|
89
|
+
],
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("duckLane turns composition-time keyframes into clip-local ramps", () => {
|
|
94
|
+
const keyframes = [
|
|
95
|
+
{ time: 3, volume: 0.15, duration: 0.15 },
|
|
96
|
+
{ time: 5, volume: 0.6, duration: 0.4 },
|
|
97
|
+
];
|
|
98
|
+
assert.deepEqual(duckLane(keyframes, { clipStart: 1, baseVolume: 0.6 }), {
|
|
99
|
+
version: 1,
|
|
100
|
+
lanes: [
|
|
101
|
+
{
|
|
102
|
+
target: "volume",
|
|
103
|
+
points: [
|
|
104
|
+
{ t: 0, v: 0.6 },
|
|
105
|
+
{ t: 2, v: 0.6 },
|
|
106
|
+
{ t: 2.15, v: 0.15 },
|
|
107
|
+
{ t: 4, v: 0.15 },
|
|
108
|
+
{ t: 4.4, v: 0.6 },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("--json spans match --merge-gap semantics exactly", () => {
|
|
116
|
+
const dir = mkdtempSync(join(tmpdir(), "media-use-duck-"));
|
|
117
|
+
try {
|
|
118
|
+
const metaPath = join(dir, "audio_meta.json");
|
|
119
|
+
writeFileSync(
|
|
120
|
+
metaPath,
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
voices: [
|
|
123
|
+
{
|
|
124
|
+
id: "narration",
|
|
125
|
+
words: [
|
|
126
|
+
word("w0", "one", 0, 0.4),
|
|
127
|
+
word("w1", "two", 0.9, 1.2),
|
|
128
|
+
word("w2", "three", 1.8, 2.1),
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const out = execFileSync(
|
|
136
|
+
process.execPath,
|
|
137
|
+
[SCRIPT, "--meta", metaPath, "--target", "#bgm", "--merge-gap", "0.6", "--json"],
|
|
138
|
+
{ encoding: "utf8" },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const parsed = JSON.parse(out);
|
|
142
|
+
assert.deepEqual(parsed.spans, [
|
|
143
|
+
{ start: 0, end: 1.2 },
|
|
144
|
+
{ start: 1.8, end: 2.1 },
|
|
145
|
+
]);
|
|
146
|
+
} finally {
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function word(id, text, start, end) {
|
|
152
|
+
return { id, text, start, end };
|
|
153
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
export const ERROR_DIFFUSION_ALGORITHMS = {
|
|
2
|
+
"floyd-steinberg": {
|
|
3
|
+
kernel: [
|
|
4
|
+
[1, 0, 7],
|
|
5
|
+
[-1, 1, 3],
|
|
6
|
+
[0, 1, 5],
|
|
7
|
+
[1, 1, 1],
|
|
8
|
+
],
|
|
9
|
+
divisor: 16,
|
|
10
|
+
},
|
|
11
|
+
atkinson: {
|
|
12
|
+
kernel: [
|
|
13
|
+
[1, 0, 1],
|
|
14
|
+
[2, 0, 1],
|
|
15
|
+
[-1, 1, 1],
|
|
16
|
+
[0, 1, 1],
|
|
17
|
+
[1, 1, 1],
|
|
18
|
+
[0, 2, 1],
|
|
19
|
+
],
|
|
20
|
+
divisor: 8,
|
|
21
|
+
},
|
|
22
|
+
"jarvis-judice-ninke": {
|
|
23
|
+
kernel: [
|
|
24
|
+
[1, 0, 7],
|
|
25
|
+
[2, 0, 5],
|
|
26
|
+
[-2, 1, 3],
|
|
27
|
+
[-1, 1, 5],
|
|
28
|
+
[0, 1, 7],
|
|
29
|
+
[1, 1, 5],
|
|
30
|
+
[2, 1, 3],
|
|
31
|
+
[-2, 2, 1],
|
|
32
|
+
[-1, 2, 3],
|
|
33
|
+
[0, 2, 5],
|
|
34
|
+
[1, 2, 3],
|
|
35
|
+
[2, 2, 1],
|
|
36
|
+
],
|
|
37
|
+
divisor: 48,
|
|
38
|
+
},
|
|
39
|
+
stucki: {
|
|
40
|
+
kernel: [
|
|
41
|
+
[1, 0, 8],
|
|
42
|
+
[2, 0, 4],
|
|
43
|
+
[-2, 1, 2],
|
|
44
|
+
[-1, 1, 4],
|
|
45
|
+
[0, 1, 8],
|
|
46
|
+
[1, 1, 4],
|
|
47
|
+
[2, 1, 2],
|
|
48
|
+
[-2, 2, 1],
|
|
49
|
+
[-1, 2, 2],
|
|
50
|
+
[0, 2, 4],
|
|
51
|
+
[1, 2, 2],
|
|
52
|
+
[2, 2, 1],
|
|
53
|
+
],
|
|
54
|
+
divisor: 42,
|
|
55
|
+
},
|
|
56
|
+
burkes: {
|
|
57
|
+
kernel: [
|
|
58
|
+
[1, 0, 8],
|
|
59
|
+
[2, 0, 4],
|
|
60
|
+
[-2, 1, 2],
|
|
61
|
+
[-1, 1, 4],
|
|
62
|
+
[0, 1, 8],
|
|
63
|
+
[1, 1, 4],
|
|
64
|
+
[2, 1, 2],
|
|
65
|
+
],
|
|
66
|
+
divisor: 32,
|
|
67
|
+
},
|
|
68
|
+
sierra: {
|
|
69
|
+
kernel: [
|
|
70
|
+
[1, 0, 5],
|
|
71
|
+
[2, 0, 3],
|
|
72
|
+
[-2, 1, 2],
|
|
73
|
+
[-1, 1, 4],
|
|
74
|
+
[0, 1, 5],
|
|
75
|
+
[1, 1, 4],
|
|
76
|
+
[2, 1, 2],
|
|
77
|
+
[-1, 2, 2],
|
|
78
|
+
[0, 2, 3],
|
|
79
|
+
[1, 2, 2],
|
|
80
|
+
],
|
|
81
|
+
divisor: 32,
|
|
82
|
+
},
|
|
83
|
+
"sierra-lite": {
|
|
84
|
+
kernel: [
|
|
85
|
+
[1, 0, 2],
|
|
86
|
+
[-1, 1, 1],
|
|
87
|
+
[0, 1, 1],
|
|
88
|
+
],
|
|
89
|
+
divisor: 4,
|
|
90
|
+
},
|
|
91
|
+
"two-row-sierra": {
|
|
92
|
+
kernel: [
|
|
93
|
+
[1, 0, 4],
|
|
94
|
+
[2, 0, 3],
|
|
95
|
+
[-2, 1, 1],
|
|
96
|
+
[-1, 1, 2],
|
|
97
|
+
[0, 1, 3],
|
|
98
|
+
[1, 1, 2],
|
|
99
|
+
[2, 1, 1],
|
|
100
|
+
],
|
|
101
|
+
divisor: 16,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const DEFAULTS = {
|
|
106
|
+
algorithm: "floyd-steinberg",
|
|
107
|
+
brightness: 1,
|
|
108
|
+
contrast: 1.2,
|
|
109
|
+
detail: 1,
|
|
110
|
+
palette: ["#000000", "#ffffff"],
|
|
111
|
+
pointSize: 3,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function errorDiffusionBufferLength(width, height, pointSize) {
|
|
115
|
+
return Math.ceil(width / pointSize) * Math.ceil(height / pointSize) * 3;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function applyErrorDiffusionRgba(data, width, height, options = {}, errorBuffer) {
|
|
119
|
+
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
|
|
120
|
+
throw new Error("width and height must be positive integers");
|
|
121
|
+
}
|
|
122
|
+
if (!data || data.length !== width * height * 4) {
|
|
123
|
+
throw new Error(`RGBA data must contain ${width * height * 4} bytes`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const algorithm = options.algorithm ?? DEFAULTS.algorithm;
|
|
127
|
+
const diffusion = ERROR_DIFFUSION_ALGORITHMS[algorithm];
|
|
128
|
+
if (!diffusion) throw new Error(`unknown error-diffusion algorithm: ${algorithm}`);
|
|
129
|
+
|
|
130
|
+
const pointSize = integerInRange(options.pointSize ?? DEFAULTS.pointSize, 1, 20, "pointSize");
|
|
131
|
+
const brightness = numberInRange(options.brightness ?? DEFAULTS.brightness, 0.5, 2, "brightness");
|
|
132
|
+
const contrast = numberInRange(options.contrast ?? DEFAULTS.contrast, 0.5, 2, "contrast");
|
|
133
|
+
const detail = numberInRange(options.detail ?? DEFAULTS.detail, 0.1, 1, "detail");
|
|
134
|
+
const palette = parsePalette(options.palette ?? DEFAULTS.palette);
|
|
135
|
+
const blockColumns = Math.ceil(width / pointSize);
|
|
136
|
+
const blockRows = Math.ceil(height / pointSize);
|
|
137
|
+
const errorLength = errorDiffusionBufferLength(width, height, pointSize);
|
|
138
|
+
const errors = errorBuffer ?? new Float32Array(errorLength);
|
|
139
|
+
if (!(errors instanceof Float32Array) || errors.length !== errorLength) {
|
|
140
|
+
throw new Error(`errorBuffer must be a Float32Array of length ${errorLength}`);
|
|
141
|
+
}
|
|
142
|
+
errors.fill(0);
|
|
143
|
+
|
|
144
|
+
const centerOffset = Math.floor(pointSize / 2);
|
|
145
|
+
for (let blockRow = 0; blockRow < blockRows; blockRow++) {
|
|
146
|
+
const blockY = blockRow * pointSize;
|
|
147
|
+
for (let blockColumn = 0; blockColumn < blockColumns; blockColumn++) {
|
|
148
|
+
const blockX = blockColumn * pointSize;
|
|
149
|
+
const centerX = Math.min(blockX + centerOffset, width - 1);
|
|
150
|
+
const centerY = Math.min(blockY + centerOffset, height - 1);
|
|
151
|
+
const rgbaIndex = (centerY * width + centerX) * 4;
|
|
152
|
+
const errorIndex = (blockRow * blockColumns + blockColumn) * 3;
|
|
153
|
+
const red = correctedChannel(data[rgbaIndex], errors[errorIndex], brightness, contrast);
|
|
154
|
+
const green = correctedChannel(
|
|
155
|
+
data[rgbaIndex + 1],
|
|
156
|
+
errors[errorIndex + 1],
|
|
157
|
+
brightness,
|
|
158
|
+
contrast,
|
|
159
|
+
);
|
|
160
|
+
const blue = correctedChannel(
|
|
161
|
+
data[rgbaIndex + 2],
|
|
162
|
+
errors[errorIndex + 2],
|
|
163
|
+
brightness,
|
|
164
|
+
contrast,
|
|
165
|
+
);
|
|
166
|
+
const luminance = 0.299 * red + 0.587 * green + 0.114 * blue;
|
|
167
|
+
const output = palette[Math.min(palette.length - 1, Math.floor(luminance * palette.length))];
|
|
168
|
+
|
|
169
|
+
for (let y = blockY; y < Math.min(blockY + pointSize, height); y++) {
|
|
170
|
+
for (let x = blockX; x < Math.min(blockX + pointSize, width); x++) {
|
|
171
|
+
const outputIndex = (y * width + x) * 4;
|
|
172
|
+
data[outputIndex] = Math.round(output[0] * 255);
|
|
173
|
+
data[outputIndex + 1] = Math.round(output[1] * 255);
|
|
174
|
+
data[outputIndex + 2] = Math.round(output[2] * 255);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const [dx, dy, weight] of diffusion.kernel) {
|
|
179
|
+
const targetColumn = blockColumn + dx;
|
|
180
|
+
const targetRow = blockRow + dy;
|
|
181
|
+
if (
|
|
182
|
+
targetColumn < 0 ||
|
|
183
|
+
targetColumn >= blockColumns ||
|
|
184
|
+
targetRow < 0 ||
|
|
185
|
+
targetRow >= blockRows
|
|
186
|
+
) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const target = (targetRow * blockColumns + targetColumn) * 3;
|
|
190
|
+
const scale = (weight / diffusion.divisor) * detail;
|
|
191
|
+
errors[target] += (red - output[0]) * scale;
|
|
192
|
+
errors[target + 1] += (green - output[1]) * scale;
|
|
193
|
+
errors[target + 2] += (blue - output[2]) * scale;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return data;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function correctedChannel(byte, error, brightness, contrast) {
|
|
201
|
+
return Math.min(1, Math.max(0, ((byte / 255 - 0.5) * contrast + 0.5) * brightness + error));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parsePalette(colors) {
|
|
205
|
+
if (!Array.isArray(colors) || colors.length < 2 || colors.length > 6) {
|
|
206
|
+
throw new Error("palette must contain 2 to 6 colors");
|
|
207
|
+
}
|
|
208
|
+
return colors.map((color) => {
|
|
209
|
+
const match = /^#([0-9a-f]{6})$/i.exec(color);
|
|
210
|
+
if (!match) throw new Error(`palette color must use #rrggbb: ${color}`);
|
|
211
|
+
const value = Number.parseInt(match[1], 16);
|
|
212
|
+
return [(value >> 16) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function numberInRange(value, min, max, name) {
|
|
217
|
+
const number = Number(value);
|
|
218
|
+
if (!Number.isFinite(number) || number < min || number > max) {
|
|
219
|
+
throw new Error(`${name} must be between ${min} and ${max}`);
|
|
220
|
+
}
|
|
221
|
+
return number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function integerInRange(value, min, max, name) {
|
|
225
|
+
const number = Number(value);
|
|
226
|
+
if (!Number.isInteger(number) || number < min || number > max) {
|
|
227
|
+
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
|
228
|
+
}
|
|
229
|
+
return number;
|
|
230
|
+
}
|
|
@@ -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,62 @@
|
|
|
1
|
+
import { fetchMedia, isPublicMediaUrl } from "./media-fetch.mjs";
|
|
2
|
+
import { writeFileSync, copyFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
// ponytail: bound the download so a hostile/runaway URL can't fill the disk.
|
|
6
|
+
// 256MB covers any real media asset; raise if 4K video sources ever exceed it.
|
|
7
|
+
const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export async function freezeUrl(url, destPath) {
|
|
10
|
+
const where = String(url).slice(0, 80);
|
|
11
|
+
const res = await fetchMedia(url);
|
|
12
|
+
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status} for ${where}`);
|
|
13
|
+
|
|
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}`);
|
|
32
|
+
|
|
33
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
34
|
+
writeFileSync(destPath, Buffer.concat(chunks, total));
|
|
35
|
+
return total;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function freezeLocalFile(srcPath, destPath) {
|
|
39
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
40
|
+
copyFileSync(srcPath, destPath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Ingest accepts a DIRECT public media URL only — not a platform page. yt-dlp is
|
|
44
|
+
// deliberately out (cloud IPs get blocked, and it's brittle); the supported case
|
|
45
|
+
// is "user points at their own file or a direct asset link". A direct URL is a
|
|
46
|
+
// non-platform host whose path ends in a known media extension.
|
|
47
|
+
const PLATFORM_HOSTS =
|
|
48
|
+
/(^|\.)(youtube\.com|youtu\.be|vimeo\.com|tiktok\.com|instagram\.com|twitter\.com|x\.com|facebook\.com|dailymotion\.com)$/i;
|
|
49
|
+
const MEDIA_EXT = /\.(mp3|wav|m4a|aac|ogg|flac|mp4|mov|webm|mkv|png|jpe?g|webp|gif|svg|avif)$/i;
|
|
50
|
+
|
|
51
|
+
export function isDirectMediaUrl(u) {
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(u);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
59
|
+
if (PLATFORM_HOSTS.test(url.hostname)) return false;
|
|
60
|
+
if (!isPublicMediaUrl(url)) return false;
|
|
61
|
+
return MEDIA_EXT.test(url.pathname);
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { isDirectMediaUrl } from "./freeze.mjs";
|
|
4
|
+
|
|
5
|
+
test("accepts direct public media URLs", () => {
|
|
6
|
+
assert.equal(isDirectMediaUrl("https://cdn.example.com/clip.mp4"), true);
|
|
7
|
+
assert.equal(isDirectMediaUrl("https://example.com/a/b/track.mp3"), true);
|
|
8
|
+
assert.equal(isDirectMediaUrl("http://example.com/logo.svg"), true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("rejects platform pages (no yt-dlp)", () => {
|
|
12
|
+
assert.equal(isDirectMediaUrl("https://www.youtube.com/watch?v=abc"), false);
|
|
13
|
+
assert.equal(isDirectMediaUrl("https://youtu.be/abc"), false);
|
|
14
|
+
assert.equal(isDirectMediaUrl("https://vimeo.com/12345"), false);
|
|
15
|
+
assert.equal(isDirectMediaUrl("https://x.com/u/status/1"), false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("rejects non-direct / non-media URLs", () => {
|
|
19
|
+
assert.equal(isDirectMediaUrl("https://example.com/page"), false, "no media extension");
|
|
20
|
+
assert.equal(isDirectMediaUrl("ftp://example.com/a.mp4"), false, "non-http(s)");
|
|
21
|
+
assert.equal(isDirectMediaUrl("not a url"), false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rejects local / private hosts (SSRF guard, m11)", () => {
|
|
25
|
+
for (const u of [
|
|
26
|
+
"http://localhost/a.mp4",
|
|
27
|
+
"http://127.0.0.1/a.mp4",
|
|
28
|
+
"http://127.1.2.3/a.mp4",
|
|
29
|
+
"http://0.0.0.0/a.mp4",
|
|
30
|
+
"http://10.0.0.5/a.mp4",
|
|
31
|
+
"http://192.168.1.1/a.mp4",
|
|
32
|
+
"http://172.16.0.1/a.mp4",
|
|
33
|
+
"http://172.31.255.255/a.mp4",
|
|
34
|
+
"http://169.254.169.254/a.mp4", // cloud metadata endpoint
|
|
35
|
+
"http://printer.local/a.mp4",
|
|
36
|
+
"http://svc.internal/a.mp4",
|
|
37
|
+
"http://[::1]/a.mp4",
|
|
38
|
+
"http://[fe80::1]/a.mp4",
|
|
39
|
+
"http://[fd00::1]/a.mp4",
|
|
40
|
+
]) {
|
|
41
|
+
assert.equal(isDirectMediaUrl(u), false, `should block ${u}`);
|
|
42
|
+
}
|
|
43
|
+
// A public host that merely starts with similar digits is still allowed.
|
|
44
|
+
assert.equal(isDirectMediaUrl("https://172.40.0.1/a.mp4"), true, "172.40 is public");
|
|
45
|
+
assert.equal(isDirectMediaUrl("https://11.example.com/a.mp4"), true);
|
|
46
|
+
});
|