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,285 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
analyzeMediaGrade,
|
|
9
|
+
formatMeasuredNote,
|
|
10
|
+
parseMediaTreatmentSignalStats,
|
|
11
|
+
statsToAdjust,
|
|
12
|
+
summarizeMediaTreatmentAnalysis,
|
|
13
|
+
} from "./grade-analyzer.mjs";
|
|
14
|
+
|
|
15
|
+
const SIGNALSTATS = `frame:0 pts:0 pts_time:0
|
|
16
|
+
lavfi.signalstats.YMIN=0
|
|
17
|
+
lavfi.signalstats.YLOW=8
|
|
18
|
+
lavfi.signalstats.YAVG=100
|
|
19
|
+
lavfi.signalstats.YHIGH=240
|
|
20
|
+
lavfi.signalstats.YMAX=255
|
|
21
|
+
lavfi.signalstats.UAVG=120
|
|
22
|
+
lavfi.signalstats.VAVG=140
|
|
23
|
+
lavfi.signalstats.SATAVG=40
|
|
24
|
+
frame:1 pts:2000 pts_time:2
|
|
25
|
+
lavfi.signalstats.YMIN=10
|
|
26
|
+
lavfi.signalstats.YLOW=20
|
|
27
|
+
lavfi.signalstats.YAVG=130
|
|
28
|
+
lavfi.signalstats.YHIGH=220
|
|
29
|
+
lavfi.signalstats.YMAX=245
|
|
30
|
+
lavfi.signalstats.UAVG=130
|
|
31
|
+
lavfi.signalstats.VAVG=125
|
|
32
|
+
lavfi.signalstats.SATAVG=60`;
|
|
33
|
+
|
|
34
|
+
// The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
|
|
35
|
+
// design — skills tests are meant to be node-builtin-only). Tests that shell to
|
|
36
|
+
// ffmpeg skip there and run wherever ffmpeg is present (locally, dev).
|
|
37
|
+
const FFMPEG_SKIP =
|
|
38
|
+
spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0
|
|
39
|
+
? false
|
|
40
|
+
: "ffmpeg not on PATH";
|
|
41
|
+
|
|
42
|
+
const ADJUST_LIMITS = {
|
|
43
|
+
exposure: { min: -2, max: 2 },
|
|
44
|
+
contrast: { min: -1, max: 1 },
|
|
45
|
+
highlights: { min: -1, max: 1 },
|
|
46
|
+
shadows: { min: -1, max: 1 },
|
|
47
|
+
whites: { min: -1, max: 1 },
|
|
48
|
+
blacks: { min: -1, max: 1 },
|
|
49
|
+
temperature: { min: -1, max: 1 },
|
|
50
|
+
tint: { min: -1, max: 1 },
|
|
51
|
+
vibrance: { min: -1, max: 1 },
|
|
52
|
+
saturation: { min: -1, max: 1 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function makeFrame(dir, name, color) {
|
|
56
|
+
const out = join(dir, name);
|
|
57
|
+
execFileSync(
|
|
58
|
+
"ffmpeg",
|
|
59
|
+
[
|
|
60
|
+
"-hide_banner",
|
|
61
|
+
"-loglevel",
|
|
62
|
+
"error",
|
|
63
|
+
"-f",
|
|
64
|
+
"lavfi",
|
|
65
|
+
"-i",
|
|
66
|
+
`color=c=${color}:s=64x64`,
|
|
67
|
+
"-frames:v",
|
|
68
|
+
"1",
|
|
69
|
+
"-y",
|
|
70
|
+
out,
|
|
71
|
+
],
|
|
72
|
+
{ stdio: "pipe" },
|
|
73
|
+
);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertWithinLimits(adjust) {
|
|
78
|
+
for (const [key, value] of Object.entries(adjust)) {
|
|
79
|
+
const limit = ADJUST_LIMITS[key];
|
|
80
|
+
assert.ok(limit, `unexpected adjust key ${key}`);
|
|
81
|
+
assert.ok(value >= limit.min && value <= limit.max, `${key} out of range: ${value}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test("under-exposed synthetic frame suggests positive exposure", { skip: FFMPEG_SKIP }, () => {
|
|
86
|
+
const dir = mkdtempSync(join(tmpdir(), "mu-grade-under-"));
|
|
87
|
+
try {
|
|
88
|
+
const file = makeFrame(dir, "under.png", "0x202020");
|
|
89
|
+
const { adjust, measured } = analyzeMediaGrade(file);
|
|
90
|
+
assert.ok(measured.frames >= 1);
|
|
91
|
+
assert.ok(adjust.exposure > 0, `expected positive exposure, got ${adjust.exposure}`);
|
|
92
|
+
assertWithinLimits(adjust);
|
|
93
|
+
} finally {
|
|
94
|
+
rmSync(dir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test(
|
|
99
|
+
"over-exposed synthetic frame pulls exposure down without inventing clipping",
|
|
100
|
+
{ skip: FFMPEG_SKIP },
|
|
101
|
+
() => {
|
|
102
|
+
const dir = mkdtempSync(join(tmpdir(), "mu-grade-over-"));
|
|
103
|
+
try {
|
|
104
|
+
const file = makeFrame(dir, "over.png", "white");
|
|
105
|
+
const { adjust } = analyzeMediaGrade(file);
|
|
106
|
+
assert.ok(adjust.exposure < 0, `expected negative exposure, got ${adjust.exposure}`);
|
|
107
|
+
assert.ok(adjust.whites <= 0, `expected non-positive whites, got ${adjust.whites}`);
|
|
108
|
+
assertWithinLimits(adjust);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(dir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
test(
|
|
116
|
+
"warm-cast synthetic frame suggests negative temperature correction",
|
|
117
|
+
{ skip: FFMPEG_SKIP },
|
|
118
|
+
() => {
|
|
119
|
+
const dir = mkdtempSync(join(tmpdir(), "mu-grade-warm-"));
|
|
120
|
+
try {
|
|
121
|
+
const file = makeFrame(dir, "warm.png", "orange");
|
|
122
|
+
const { adjust } = analyzeMediaGrade(file);
|
|
123
|
+
assert.ok(adjust.temperature < 0, `expected cooling correction, got ${adjust.temperature}`);
|
|
124
|
+
assertWithinLimits(adjust);
|
|
125
|
+
} finally {
|
|
126
|
+
rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
test("low-spread stats suggest positive contrast", () => {
|
|
132
|
+
const { adjust } = statsToAdjust({
|
|
133
|
+
frames: 1,
|
|
134
|
+
yMin: 104,
|
|
135
|
+
yMax: 116,
|
|
136
|
+
yAvg: 110,
|
|
137
|
+
uAvg: 128,
|
|
138
|
+
vAvg: 128,
|
|
139
|
+
});
|
|
140
|
+
assert.ok(adjust.contrast > 0, `expected positive contrast, got ${adjust.contrast}`);
|
|
141
|
+
assertWithinLimits(adjust);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("malformed media fails cleanly", () => {
|
|
145
|
+
assert.throws(
|
|
146
|
+
() => analyzeMediaGrade(join(tmpdir(), "does-not-exist.png")),
|
|
147
|
+
/grade analysis failed/,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test(
|
|
152
|
+
"media path with shell metacharacters is passed as argv, not a shell string",
|
|
153
|
+
{
|
|
154
|
+
skip: FFMPEG_SKIP,
|
|
155
|
+
},
|
|
156
|
+
() => {
|
|
157
|
+
const dir = mkdtempSync(join(tmpdir(), "mu-grade-shell-"));
|
|
158
|
+
const sentinel = join(process.cwd(), "mu-grade-shell-sentinel.png");
|
|
159
|
+
try {
|
|
160
|
+
if (existsSync(sentinel)) unlinkSync(sentinel);
|
|
161
|
+
const file = makeFrame(dir, "frame; touch mu-grade-shell-sentinel.png", "orange");
|
|
162
|
+
const result = analyzeMediaGrade(file);
|
|
163
|
+
assert.ok(result.measured.frames >= 1);
|
|
164
|
+
assert.equal(existsSync(sentinel), false);
|
|
165
|
+
} finally {
|
|
166
|
+
if (existsSync(sentinel)) unlinkSync(sentinel);
|
|
167
|
+
rmSync(dir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
test("measured note is a stderr-safe single-line summary", () => {
|
|
173
|
+
const note = formatMeasuredNote("/tmp/frame.png", {
|
|
174
|
+
frames: 1,
|
|
175
|
+
yMin: 10,
|
|
176
|
+
yMax: 240,
|
|
177
|
+
yAvg: 80,
|
|
178
|
+
uAvg: 120,
|
|
179
|
+
vAvg: 140,
|
|
180
|
+
});
|
|
181
|
+
assert.match(note, /^media-use: measured /);
|
|
182
|
+
assert.match(note, /YMIN=10/);
|
|
183
|
+
assert.match(note, /YMAX=240/);
|
|
184
|
+
assert.match(note, /YAVG=80/);
|
|
185
|
+
assert.equal(note.includes("\n"), false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("parses deterministic FFmpeg signalstats frames", () => {
|
|
189
|
+
const frames = parseMediaTreatmentSignalStats(SIGNALSTATS);
|
|
190
|
+
assert.equal(frames.length, 2);
|
|
191
|
+
assert.deepEqual(
|
|
192
|
+
{
|
|
193
|
+
ptsTime: frames[0]?.ptsTime,
|
|
194
|
+
YLOW: frames[0]?.YLOW,
|
|
195
|
+
YAVG: frames[0]?.YAVG,
|
|
196
|
+
YHIGH: frames[0]?.YHIGH,
|
|
197
|
+
SATAVG: frames[0]?.SATAVG,
|
|
198
|
+
},
|
|
199
|
+
{ ptsTime: 0, YLOW: 8, YAVG: 100, YHIGH: 240, SATAVG: 40 },
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("keeps adjust output while adding HDR metadata and warnings", () => {
|
|
204
|
+
const result = summarizeMediaTreatmentAnalysis(
|
|
205
|
+
{
|
|
206
|
+
duration: 4,
|
|
207
|
+
colorSpace: "bt2020nc",
|
|
208
|
+
transfer: "smpte2084",
|
|
209
|
+
primaries: "bt2020",
|
|
210
|
+
pixelFormat: "yuv420p10le",
|
|
211
|
+
},
|
|
212
|
+
parseMediaTreatmentSignalStats(SIGNALSTATS),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
assert.deepEqual(
|
|
216
|
+
{
|
|
217
|
+
blacks: result.adjust.blacks,
|
|
218
|
+
whites: result.adjust.whites,
|
|
219
|
+
frames: result.measured.frames,
|
|
220
|
+
yLow: result.measured.yLow,
|
|
221
|
+
yHigh: result.measured.yHigh,
|
|
222
|
+
satAvg: result.measured.satAvg,
|
|
223
|
+
shadowClipRisk: result.measured.shadowClipRisk,
|
|
224
|
+
highlightClipRisk: result.measured.highlightClipRisk,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
blacks: 0.04,
|
|
228
|
+
whites: -0.04,
|
|
229
|
+
frames: 2,
|
|
230
|
+
yLow: 14,
|
|
231
|
+
yHigh: 230,
|
|
232
|
+
satAvg: 50,
|
|
233
|
+
shadowClipRisk: 0.5,
|
|
234
|
+
highlightClipRisk: 0.5,
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
assert.equal(result.source.hdr, true);
|
|
238
|
+
assert.equal(result.source.log, "unknown");
|
|
239
|
+
assert.match(result.warnings[0] ?? "", /HDR/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("does not force middle-gray exposure onto healthy intentional contrast", () => {
|
|
243
|
+
const result = summarizeMediaTreatmentAnalysis(
|
|
244
|
+
{
|
|
245
|
+
duration: 4,
|
|
246
|
+
colorSpace: "bt709",
|
|
247
|
+
transfer: "bt709",
|
|
248
|
+
primaries: "bt709",
|
|
249
|
+
pixelFormat: "yuv420p",
|
|
250
|
+
},
|
|
251
|
+
[
|
|
252
|
+
{
|
|
253
|
+
YMIN: 20,
|
|
254
|
+
YLOW: 42,
|
|
255
|
+
YAVG: 122,
|
|
256
|
+
YHIGH: 190,
|
|
257
|
+
YMAX: 220,
|
|
258
|
+
UAVG: 128,
|
|
259
|
+
VAVG: 128,
|
|
260
|
+
SATAVG: 40,
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
YMIN: 18,
|
|
264
|
+
YLOW: 38,
|
|
265
|
+
YAVG: 118,
|
|
266
|
+
YHIGH: 196,
|
|
267
|
+
YMAX: 225,
|
|
268
|
+
UAVG: 128,
|
|
269
|
+
VAVG: 128,
|
|
270
|
+
SATAVG: 42,
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
assert.deepEqual(
|
|
276
|
+
{
|
|
277
|
+
exposure: result.adjust.exposure,
|
|
278
|
+
blacks: result.adjust.blacks,
|
|
279
|
+
whites: result.adjust.whites,
|
|
280
|
+
temperature: result.adjust.temperature,
|
|
281
|
+
tint: result.adjust.tint,
|
|
282
|
+
},
|
|
283
|
+
{ exposure: 0, blacks: 0, whites: 0, temperature: 0, tint: 0 },
|
|
284
|
+
);
|
|
285
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { track } from "./telemetry.mjs";
|
|
3
|
+
|
|
4
|
+
// v0.3.0 is the first CLI that can use an OAuth session; v0.1.x/0.2.x reject it
|
|
5
|
+
// ("heygen-cli can't use OAuth yet"), and OAuth is what the free-usage path
|
|
6
|
+
// needs — so anything below this can't authenticate for free usage at all.
|
|
7
|
+
export const HEYGEN_MIN_VERSION = "0.3.0";
|
|
8
|
+
// Free-usage path is OAuth (`--oauth` → subscription/free credits); `--api-key`
|
|
9
|
+
// bills API credits, so the onboarding steers to OAuth. Keep pipe-to-shell
|
|
10
|
+
// installer text out of the runtime module; the docs are the safer source of
|
|
11
|
+
// truth for platform-specific setup.
|
|
12
|
+
export const HEYGEN_INSTALL_COMMAND =
|
|
13
|
+
"Install the CLI from https://developers.heygen.com/cli, then run: heygen auth login --oauth";
|
|
14
|
+
export const HEYGEN_AUTH_COMMAND = "heygen auth login --oauth";
|
|
15
|
+
export const HEYGEN_UPDATE_COMMAND = "heygen update";
|
|
16
|
+
export const HEYGEN_CLIENT_SOURCE_ARGV = ["--headers", "X-HeyGen-Client-Source: media-use"];
|
|
17
|
+
|
|
18
|
+
export const HEYGEN_NOT_FOUND_MESSAGE = `media-use: heygen CLI not found — it's the free path for bgm/image/voice/avatar-video. ${HEYGEN_INSTALL_COMMAND}`;
|
|
19
|
+
export const HEYGEN_NOT_AUTHENTICATED_MESSAGE = `media-use: heygen CLI not authenticated (free usage) — run: ${HEYGEN_AUTH_COMMAND}`;
|
|
20
|
+
export const HEYGEN_OUTDATED_MESSAGE = `media-use: heygen CLI is outdated — run: ${HEYGEN_UPDATE_COMMAND} (need >= v${HEYGEN_MIN_VERSION})`;
|
|
21
|
+
|
|
22
|
+
const ACTIONABLE_MESSAGES = new Set([
|
|
23
|
+
HEYGEN_NOT_FOUND_MESSAGE,
|
|
24
|
+
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
|
|
25
|
+
HEYGEN_OUTDATED_MESSAGE,
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function classifyHeygenError(err) {
|
|
29
|
+
return classifyHeygenErrorResult(err).message;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function classifyHeygenErrorCode(err) {
|
|
33
|
+
return classifyHeygenErrorResult(err).code;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function classifyHeygenErrorResult(err) {
|
|
37
|
+
const detail = heygenErrorDetail(err);
|
|
38
|
+
const text = [err?.stderr, err?.stdout, err?.message, detail]
|
|
39
|
+
.map((value) => textOf(value))
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.join("\n");
|
|
42
|
+
const lower = text.toLowerCase();
|
|
43
|
+
|
|
44
|
+
// Only ENOENT (spawn of a missing binary) or a shell's "command not found"
|
|
45
|
+
// mean the CLI itself is absent. A bare "not found" would misfire on the CLI's
|
|
46
|
+
// own resource errors (e.g. a stale voiceId → "voice not found"), whose message
|
|
47
|
+
// embeds the `heygen ...` command line — sending users to reinstall a CLI they
|
|
48
|
+
// just ran successfully. Keep this narrow.
|
|
49
|
+
if (err?.code === "ENOENT" || lower.includes("command not found")) {
|
|
50
|
+
return { code: "not_found", message: HEYGEN_NOT_FOUND_MESSAGE };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
lower.includes("unauthorized") ||
|
|
55
|
+
lower.includes("unauthenticated") ||
|
|
56
|
+
// \b401\b, not a bare "401" substring — otherwise request IDs (req-401abc),
|
|
57
|
+
// URLs, and retry-after headers would misclassify as an auth failure.
|
|
58
|
+
/\b401\b/.test(lower) ||
|
|
59
|
+
lower.includes("not logged in") ||
|
|
60
|
+
lower.includes("no api key") ||
|
|
61
|
+
lower.includes("missing api key") ||
|
|
62
|
+
lower.includes("invalid api key") ||
|
|
63
|
+
lower.includes("login required") ||
|
|
64
|
+
lower.includes("auth required") ||
|
|
65
|
+
lower.includes("authentication required")
|
|
66
|
+
) {
|
|
67
|
+
return { code: "not_authenticated", message: HEYGEN_NOT_AUTHENTICATED_MESSAGE };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const version = firstSemver(text);
|
|
71
|
+
// A CLI older than the --headers flag rejects it before printing any version.
|
|
72
|
+
if (
|
|
73
|
+
(version && versionLessThan(version, HEYGEN_MIN_VERSION)) ||
|
|
74
|
+
lower.includes("unknown flag: --headers")
|
|
75
|
+
) {
|
|
76
|
+
return { code: "outdated", message: HEYGEN_OUTDATED_MESSAGE };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
lower.includes("rate limit") ||
|
|
81
|
+
lower.includes("quota") ||
|
|
82
|
+
lower.includes("insufficient credit") ||
|
|
83
|
+
lower.includes("too many requests") ||
|
|
84
|
+
lower.includes("throttled") ||
|
|
85
|
+
/\b429\b/.test(lower)
|
|
86
|
+
) {
|
|
87
|
+
return { code: "rate_limited", message: detail };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { code: "other", message: detail };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// reportHeygenFailure's callers (voice-provider.mjs, heygen-search.mjs) are
|
|
94
|
+
// synchronous and several layers below the CLI's process.exit() calls, so
|
|
95
|
+
// they can't await this tracking call themselves. Stash each attempt's
|
|
96
|
+
// promise here so a caller closer to exit (resolve.mjs) can join it first —
|
|
97
|
+
// same "awaited so a short-lived run flushes it" discipline telemetry.mjs's
|
|
98
|
+
// track() already documents, just reachable from a sync call site.
|
|
99
|
+
const pendingFailureTracking = new Set();
|
|
100
|
+
// resolve.mjs is a single-shot CLI (one resolve per process), so one shared
|
|
101
|
+
// consume-once slot is sufficient. If resolve becomes an in-process/concurrent
|
|
102
|
+
// API, move this state into a per-resolve context before reusing that path.
|
|
103
|
+
let pendingRemediation = null;
|
|
104
|
+
|
|
105
|
+
/** A music resolve miss after the heygen CLI was missing or too old: what is missing, the host app's own
|
|
106
|
+
* tool, the fix. Other types keep the generic miss. */
|
|
107
|
+
export function heygenMiss(type, { code }) {
|
|
108
|
+
if (type !== "bgm") return null;
|
|
109
|
+
const outdated = code === "outdated";
|
|
110
|
+
const state = outdated ? `older than v${HEYGEN_MIN_VERSION}` : "not installed";
|
|
111
|
+
return {
|
|
112
|
+
code: outdated ? "heygen_cli_outdated" : "heygen_cli_missing",
|
|
113
|
+
fix: outdated ? HEYGEN_UPDATE_COMMAND : HEYGEN_INSTALL_COMMAND,
|
|
114
|
+
error: `${type} needs the heygen CLI, which is ${state}: use your host app's own music tool if it has one, or ${outdated ? "update" : "install"} the CLI.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function consumeHeygenRemediation() {
|
|
119
|
+
const remediation = pendingRemediation;
|
|
120
|
+
pendingRemediation = null;
|
|
121
|
+
return remediation;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function reportHeygenFailure(err, context, trackEvent = track) {
|
|
125
|
+
const { code, message } = classifyHeygenErrorResult(err);
|
|
126
|
+
if (code === "not_found" || code === "outdated") {
|
|
127
|
+
pendingRemediation = { code, message };
|
|
128
|
+
}
|
|
129
|
+
if (ACTIONABLE_MESSAGES.has(message)) {
|
|
130
|
+
console.error(message);
|
|
131
|
+
} else {
|
|
132
|
+
console.error(`media-use: \`${context}\` failed: ${message}`);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const tracked = Promise.resolve(
|
|
136
|
+
trackEvent("media_use_provider_error", { provider: "heygen", reason: code }),
|
|
137
|
+
).catch(() => {});
|
|
138
|
+
pendingFailureTracking.add(tracked);
|
|
139
|
+
void tracked.finally(() => pendingFailureTracking.delete(tracked));
|
|
140
|
+
return tracked;
|
|
141
|
+
} catch {
|
|
142
|
+
// Telemetry must never affect the provider failure path.
|
|
143
|
+
return Promise.resolve();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Awaits every provider-error track fired since the last flush, so a caller
|
|
148
|
+
// about to process.exit() doesn't orphan one mid-request (both are separate,
|
|
149
|
+
// non-keepalive HTTP connections with no ordering guarantee otherwise).
|
|
150
|
+
// Never rejects: each tracked promise already swallows its own failure.
|
|
151
|
+
export async function flushHeygenFailureTracking() {
|
|
152
|
+
if (pendingFailureTracking.size === 0) return;
|
|
153
|
+
await Promise.all(pendingFailureTracking);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Shared discovery/generation helper for the CLI-shelling providers (voice,
|
|
157
|
+
// avatar-video): run a heygen JSON subcommand, report+classify on failure,
|
|
158
|
+
// and hand the caller the classified reason via onError (used by callers that
|
|
159
|
+
// need to distinguish e.g. not_authenticated from other failures).
|
|
160
|
+
export function runHeygenJson(bin, argv, label, onError) {
|
|
161
|
+
let out;
|
|
162
|
+
try {
|
|
163
|
+
out = execFileSync(bin, argv, {
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
timeout: 120000,
|
|
166
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
167
|
+
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
reportHeygenFailure(err, `${bin} ${label}`);
|
|
170
|
+
onError?.(classifyHeygenErrorCode(err));
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(out);
|
|
175
|
+
} catch {
|
|
176
|
+
console.error(`media-use: \`${bin} ${label}\` returned non-JSON output`);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function firstSemver(text) {
|
|
182
|
+
const match = String(text || "").match(/\bv?(\d+)\.(\d+)\.(\d+)\b/);
|
|
183
|
+
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function versionLessThan(version, minimum) {
|
|
187
|
+
const left = versionParts(version);
|
|
188
|
+
const right = versionParts(minimum);
|
|
189
|
+
if (!left || !right) return false;
|
|
190
|
+
for (let i = 0; i < 3; i++) {
|
|
191
|
+
if (left[i] < right[i]) return true;
|
|
192
|
+
if (left[i] > right[i]) return false;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function heygenErrorDetail(err) {
|
|
198
|
+
return textOf(err?.stderr) || textOf(err?.stdout) || err?.message || String(err);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function textOf(value) {
|
|
202
|
+
return value == null ? "" : String(value).trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function versionParts(version) {
|
|
206
|
+
const match = String(version || "").match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
|
207
|
+
return match ? match.slice(1).map((part) => Number.parseInt(part, 10)) : null;
|
|
208
|
+
}
|