dsh-hyperframes 0.10.2 → 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 +7 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- 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,173 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
utimesSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { projectPrefsPath, recordPreference, userPrefsPath } from "./prefs-store.mjs";
|
|
18
|
+
|
|
19
|
+
async function inSandbox(task) {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), "mu-prefs-concurrent-"));
|
|
21
|
+
const previousHome = process.env.HYPERFRAMES_MEDIA_HOME;
|
|
22
|
+
process.env.HYPERFRAMES_MEDIA_HOME = join(root, "home");
|
|
23
|
+
const project = join(root, "project");
|
|
24
|
+
mkdirSync(project);
|
|
25
|
+
try {
|
|
26
|
+
await task({ root, project });
|
|
27
|
+
} finally {
|
|
28
|
+
if (previousHome === undefined) delete process.env.HYPERFRAMES_MEDIA_HOME;
|
|
29
|
+
else process.env.HYPERFRAMES_MEDIA_HOME = previousHome;
|
|
30
|
+
rmSync(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function worker(root, source, args = []) {
|
|
35
|
+
const path = join(mkdtempSync(join(root, "worker-")), "worker.cjs");
|
|
36
|
+
writeFileSync(path, source);
|
|
37
|
+
const child = fork(path, args, { silent: true, execArgv: [] });
|
|
38
|
+
let stderr = "";
|
|
39
|
+
child.stderr.setEncoding("utf8").on("data", (data) => (stderr += data));
|
|
40
|
+
const ready = new Promise((resolve, reject) => {
|
|
41
|
+
child.once("message", resolve);
|
|
42
|
+
child.once("error", reject);
|
|
43
|
+
child.once("exit", () => reject(new Error(`worker exited before ready: ${stderr}`)));
|
|
44
|
+
});
|
|
45
|
+
const closed = new Promise((resolve, reject) => {
|
|
46
|
+
child.once("error", reject);
|
|
47
|
+
child.once("close", (code) => resolve({ code, stderr }));
|
|
48
|
+
});
|
|
49
|
+
return { child, ready, closed };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const tier of ["project", "user"]) {
|
|
53
|
+
test(`record re-reads the ${tier} tier after another process releases its lock`, async () => {
|
|
54
|
+
await inSandbox(async ({ root, project }) => {
|
|
55
|
+
const path = tier === "project" ? projectPrefsPath(project) : userPrefsPath();
|
|
56
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
57
|
+
const held = worker(
|
|
58
|
+
root,
|
|
59
|
+
`const fs = require("node:fs");
|
|
60
|
+
const path = process.argv[2];
|
|
61
|
+
fs.writeFileSync(path + ".lock", "held", { flag: "wx" });
|
|
62
|
+
process.send("locked");
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
fs.writeFileSync(path, JSON.stringify({version: 1,
|
|
65
|
+
preferences: {aspect: {value: "1080x1920", confirmed_in: ["other"]}},
|
|
66
|
+
sightings: {aspect: {"1080x1920": ["other"]}}}));
|
|
67
|
+
fs.rmSync(path + ".lock");
|
|
68
|
+
process.disconnect();
|
|
69
|
+
}, 300);`,
|
|
70
|
+
[path],
|
|
71
|
+
);
|
|
72
|
+
try {
|
|
73
|
+
await held.ready;
|
|
74
|
+
recordPreference({ projectDir: project, key: "destination", value: "youtube-shorts" });
|
|
75
|
+
const result = await held.closed;
|
|
76
|
+
assert.equal(result.code, 0, result.stderr);
|
|
77
|
+
const file = JSON.parse(readFileSync(path, "utf8"));
|
|
78
|
+
assert.equal(file.preferences.aspect?.value, "1080x1920");
|
|
79
|
+
if (tier === "project") {
|
|
80
|
+
assert.equal(file.preferences.destination?.value, "youtube-shorts");
|
|
81
|
+
} else {
|
|
82
|
+
assert.deepEqual(file.sightings.destination?.["youtube-shorts"], ["project"]);
|
|
83
|
+
assert.deepEqual(file.sightings.aspect?.["1080x1920"], ["other"]);
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
held.child.kill();
|
|
87
|
+
await held.closed;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("parallel records preserve every project key and promote every shared user sighting", async () => {
|
|
94
|
+
await inSandbox(async ({ root, project }) => {
|
|
95
|
+
const secondProject = join(root, "second-project");
|
|
96
|
+
mkdirSync(secondProject);
|
|
97
|
+
const modulePath = fileURLToPath(new URL("./prefs-store.mjs", import.meta.url));
|
|
98
|
+
const choices = {
|
|
99
|
+
destination: "youtube-shorts",
|
|
100
|
+
aspect: "1080x1920",
|
|
101
|
+
language: "en",
|
|
102
|
+
flow: "automation",
|
|
103
|
+
};
|
|
104
|
+
const children = [project, secondProject].flatMap((projectDir) =>
|
|
105
|
+
Object.entries(choices).map(([key, value]) =>
|
|
106
|
+
worker(
|
|
107
|
+
root,
|
|
108
|
+
`const {pathToFileURL} = require("node:url");
|
|
109
|
+
import(pathToFileURL(process.argv[2])).then(({recordPreference}) => {
|
|
110
|
+
process.send("ready");
|
|
111
|
+
process.once("message", () => {
|
|
112
|
+
try {recordPreference(JSON.parse(process.argv[3]));}
|
|
113
|
+
catch (error) {console.error(error.message); process.exitCode = 1;}
|
|
114
|
+
process.disconnect();
|
|
115
|
+
});
|
|
116
|
+
});`,
|
|
117
|
+
[modulePath, JSON.stringify({ projectDir, key, value })],
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
try {
|
|
122
|
+
await Promise.all(children.map((child) => child.ready));
|
|
123
|
+
for (const child of children) child.child.send("record");
|
|
124
|
+
const results = await Promise.all(children.map((child) => child.closed));
|
|
125
|
+
for (const result of results) assert.equal(result.code, 0, result.stderr);
|
|
126
|
+
for (const projectDir of [project, secondProject]) {
|
|
127
|
+
const file = JSON.parse(readFileSync(projectPrefsPath(projectDir), "utf8"));
|
|
128
|
+
assert.deepEqual(Object.keys(file.preferences).sort(), Object.keys(choices).sort());
|
|
129
|
+
for (const [key, value] of Object.entries(choices))
|
|
130
|
+
assert.equal(file.preferences[key].value, value);
|
|
131
|
+
assert.deepEqual(readdirSync(dirname(projectPrefsPath(projectDir))), ["preferences.json"]);
|
|
132
|
+
}
|
|
133
|
+
const user = JSON.parse(readFileSync(userPrefsPath(), "utf8"));
|
|
134
|
+
for (const [key, value] of Object.entries(choices)) {
|
|
135
|
+
assert.equal(user.preferences[key].value, value);
|
|
136
|
+
assert.deepEqual(user.sightings[key][value].sort(), ["project", "second-project"]);
|
|
137
|
+
}
|
|
138
|
+
assert.deepEqual(readdirSync(dirname(userPrefsPath())), ["preferences.json"]);
|
|
139
|
+
} finally {
|
|
140
|
+
for (const child of children) child.child.kill();
|
|
141
|
+
await Promise.all(children.map((child) => child.closed));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
for (const tier of ["project", "user"]) {
|
|
147
|
+
test(`an abandoned ${tier} lock never permits an unlocked preference write`, async () => {
|
|
148
|
+
await inSandbox(async ({ project }) => {
|
|
149
|
+
const path = tier === "project" ? projectPrefsPath(project) : userPrefsPath();
|
|
150
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
151
|
+
const original = JSON.stringify({ version: 1, preferences: {}, sightings: {} });
|
|
152
|
+
writeFileSync(path, original);
|
|
153
|
+
const lock = `${path}.lock`;
|
|
154
|
+
writeFileSync(lock, "abandoned");
|
|
155
|
+
const past = new Date(Date.now() - 60_000);
|
|
156
|
+
utimesSync(lock, past, past);
|
|
157
|
+
const record = () => recordPreference({ projectDir: project, key: "language", value: "en" });
|
|
158
|
+
if (tier === "project") {
|
|
159
|
+
assert.throws(record, (error) => error.code === "HF_SETTINGS_LOCKED");
|
|
160
|
+
assert.equal(existsSync(userPrefsPath()), false);
|
|
161
|
+
} else {
|
|
162
|
+
assert.equal(record().promoted, false);
|
|
163
|
+
assert.equal(
|
|
164
|
+
JSON.parse(readFileSync(projectPrefsPath(project), "utf8")).preferences.language.value,
|
|
165
|
+
"en",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
assert.equal(readFileSync(path, "utf8"), original);
|
|
169
|
+
assert.equal(readFileSync(lock, "utf8"), "abandoned");
|
|
170
|
+
assert.equal(existsSync(`${path}.${process.pid}.tmp`), false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
mergedPreferences,
|
|
10
|
+
preferenceKeyFor,
|
|
11
|
+
projectPrefsPath,
|
|
12
|
+
recordPreference,
|
|
13
|
+
userPrefsPath,
|
|
14
|
+
} from "./prefs-store.mjs";
|
|
15
|
+
|
|
16
|
+
function sandbox() {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), "mu-prefs-"));
|
|
18
|
+
const home = join(root, "home");
|
|
19
|
+
const projectA = join(root, "proj-a");
|
|
20
|
+
const projectB = join(root, "proj-b");
|
|
21
|
+
mkdirSync(home, { recursive: true });
|
|
22
|
+
mkdirSync(projectA, { recursive: true });
|
|
23
|
+
mkdirSync(projectB, { recursive: true });
|
|
24
|
+
process.env.HYPERFRAMES_MEDIA_HOME = home;
|
|
25
|
+
return { root, home, projectA, projectB };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function restoreEnv(saved) {
|
|
29
|
+
for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k];
|
|
30
|
+
Object.assign(process.env, saved);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withSandbox(fn) {
|
|
34
|
+
const savedEnv = { ...process.env };
|
|
35
|
+
const box = sandbox();
|
|
36
|
+
try {
|
|
37
|
+
fn(box);
|
|
38
|
+
} finally {
|
|
39
|
+
restoreEnv(savedEnv);
|
|
40
|
+
rmSync(box.root, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const code of ["EPERM", "EBUSY"]) {
|
|
45
|
+
test(`temporary-file cleanup ${code} does not fail a successful preference record`, (t) => {
|
|
46
|
+
const remove = fs.rmSync;
|
|
47
|
+
t.mock.method(fs, "rmSync", (path, options) => {
|
|
48
|
+
if (path.endsWith(".tmp")) throw Object.assign(new Error("cleanup failed"), { code });
|
|
49
|
+
return remove(path, options);
|
|
50
|
+
});
|
|
51
|
+
syncBuiltinESMExports();
|
|
52
|
+
t.after(() => {
|
|
53
|
+
t.mock.restoreAll();
|
|
54
|
+
syncBuiltinESMExports();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
withSandbox(({ projectA, projectB }) => {
|
|
58
|
+
recordPreference({ projectDir: projectA, key: "language", value: "en" });
|
|
59
|
+
const second = recordPreference({ projectDir: projectB, key: "language", value: "en" });
|
|
60
|
+
assert.equal(second.promoted, true);
|
|
61
|
+
for (const path of [
|
|
62
|
+
projectPrefsPath(projectA),
|
|
63
|
+
projectPrefsPath(projectB),
|
|
64
|
+
userPrefsPath(),
|
|
65
|
+
]) {
|
|
66
|
+
assert.equal(JSON.parse(readFileSync(path, "utf8")).preferences.language.value, "en");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test(`temporary-file cleanup ${code} preserves the original preference write error`, (t) => {
|
|
72
|
+
const remove = fs.rmSync;
|
|
73
|
+
const write = fs.writeFileSync;
|
|
74
|
+
const writeError = Object.assign(new Error("disk full"), { code: "ENOSPC" });
|
|
75
|
+
t.mock.method(fs, "rmSync", (path, options) => {
|
|
76
|
+
if (path.endsWith(".tmp")) throw Object.assign(new Error("cleanup failed"), { code });
|
|
77
|
+
return remove(path, options);
|
|
78
|
+
});
|
|
79
|
+
t.mock.method(fs, "writeFileSync", (path, ...args) => {
|
|
80
|
+
if (path.endsWith(".tmp")) throw writeError;
|
|
81
|
+
return write(path, ...args);
|
|
82
|
+
});
|
|
83
|
+
syncBuiltinESMExports();
|
|
84
|
+
t.after(() => {
|
|
85
|
+
t.mock.restoreAll();
|
|
86
|
+
syncBuiltinESMExports();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
withSandbox(({ projectA }) => {
|
|
90
|
+
assert.throws(
|
|
91
|
+
() => recordPreference({ projectDir: projectA, key: "language", value: "en" }),
|
|
92
|
+
(error) => error === writeError,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
test("record writes the project tier and get merges it as source=project", () => {
|
|
99
|
+
withSandbox(({ projectA }) => {
|
|
100
|
+
const result = recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
|
|
101
|
+
assert.equal(result.promoted, false);
|
|
102
|
+
assert.deepEqual(result.confirmed_in, ["proj-a"]);
|
|
103
|
+
|
|
104
|
+
const merged = mergedPreferences(projectA);
|
|
105
|
+
assert.equal(merged.destination.value, "x-feed");
|
|
106
|
+
assert.equal(merged.destination.source, "project");
|
|
107
|
+
|
|
108
|
+
const onDisk = JSON.parse(readFileSync(projectPrefsPath(projectA), "utf8"));
|
|
109
|
+
assert.equal(onDisk.preferences.destination.value, "x-feed");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("one project never promotes: a fresh project sees nothing user-tier", () => {
|
|
114
|
+
withSandbox(({ projectA, projectB }) => {
|
|
115
|
+
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
|
|
116
|
+
// proj-b has no project entry, and the value was only confirmed once.
|
|
117
|
+
assert.equal(mergedPreferences(projectB).destination, undefined);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("the same value confirmed in two projects promotes to the user tier", () => {
|
|
122
|
+
withSandbox(({ projectA, projectB }) => {
|
|
123
|
+
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
|
|
124
|
+
const second = recordPreference({ projectDir: projectB, key: "destination", value: "x-feed" });
|
|
125
|
+
assert.equal(second.promoted, true);
|
|
126
|
+
|
|
127
|
+
const userFile = JSON.parse(readFileSync(userPrefsPath(), "utf8"));
|
|
128
|
+
assert.deepEqual(userFile.preferences.destination.confirmed_in.sort(), ["proj-a", "proj-b"]);
|
|
129
|
+
|
|
130
|
+
// A third project with no local entry now inherits the promoted default.
|
|
131
|
+
const projectC = join(projectA, "..", "proj-c");
|
|
132
|
+
mkdirSync(projectC, { recursive: true });
|
|
133
|
+
const merged = mergedPreferences(projectC);
|
|
134
|
+
assert.equal(merged.destination.value, "x-feed");
|
|
135
|
+
assert.equal(merged.destination.source, "user");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("record is idempotent per project — confirmed_in stays deduped", () => {
|
|
140
|
+
withSandbox(({ projectA }) => {
|
|
141
|
+
recordPreference({ projectDir: projectA, key: "language", value: "zh" });
|
|
142
|
+
const again = recordPreference({ projectDir: projectA, key: "language", value: "zh" });
|
|
143
|
+
assert.deepEqual(again.confirmed_in, ["proj-a"]);
|
|
144
|
+
assert.equal(again.promoted, false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a changed value restarts provenance instead of inheriting confirmations", () => {
|
|
149
|
+
withSandbox(({ projectA, projectB }) => {
|
|
150
|
+
recordPreference({ projectDir: projectA, key: "destination", value: "x-feed" });
|
|
151
|
+
recordPreference({ projectDir: projectB, key: "destination", value: "x-feed" });
|
|
152
|
+
const switched = recordPreference({
|
|
153
|
+
projectDir: projectA,
|
|
154
|
+
key: "destination",
|
|
155
|
+
value: "youtube",
|
|
156
|
+
});
|
|
157
|
+
assert.deepEqual(switched.confirmed_in, ["proj-a"]);
|
|
158
|
+
assert.equal(switched.promoted, false);
|
|
159
|
+
// The previously promoted value keeps serving other projects until youtube
|
|
160
|
+
// earns its own two confirmations.
|
|
161
|
+
const userFile = JSON.parse(readFileSync(userPrefsPath(), "utf8"));
|
|
162
|
+
assert.equal(userFile.preferences.destination.value, "x-feed");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("project tier overrides a promoted user-tier value", () => {
|
|
167
|
+
withSandbox(({ projectA, projectB }) => {
|
|
168
|
+
recordPreference({ projectDir: projectA, key: "storyboard", value: "yes" });
|
|
169
|
+
recordPreference({ projectDir: projectB, key: "storyboard", value: "yes" });
|
|
170
|
+
recordPreference({ projectDir: projectA, key: "storyboard", value: "no" });
|
|
171
|
+
const merged = mergedPreferences(projectA);
|
|
172
|
+
assert.equal(merged.storyboard.value, "no");
|
|
173
|
+
assert.equal(merged.storyboard.source, "project");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("the retired mode key is rejected", () => {
|
|
178
|
+
withSandbox(({ projectA }) => {
|
|
179
|
+
assert.throws(
|
|
180
|
+
() => recordPreference({ projectDir: projectA, key: "mode", value: "collaborative" }),
|
|
181
|
+
/unknown preference key/,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("the run-shape keys record and promote like any field", () => {
|
|
187
|
+
withSandbox(({ projectA, projectB }) => {
|
|
188
|
+
recordPreference({ projectDir: projectA, key: "flow", value: "automation" });
|
|
189
|
+
const promoted = recordPreference({ projectDir: projectB, key: "flow", value: "automation" });
|
|
190
|
+
assert.equal(promoted.promoted, true);
|
|
191
|
+
recordPreference({ projectDir: projectA, key: "storyboard", value: "yes" });
|
|
192
|
+
const merged = mergedPreferences(projectA);
|
|
193
|
+
assert.equal(merged.flow.value, "automation");
|
|
194
|
+
assert.equal(merged.storyboard.value, "yes");
|
|
195
|
+
assert.equal(merged.storyboard.source, "project");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("style_preset is keyed per workflow", () => {
|
|
200
|
+
withSandbox(({ projectA }) => {
|
|
201
|
+
assert.equal(
|
|
202
|
+
preferenceKeyFor("style_preset", "faceless-explainer"),
|
|
203
|
+
"style_preset.faceless-explainer",
|
|
204
|
+
);
|
|
205
|
+
assert.equal(preferenceKeyFor("destination", "faceless-explainer"), "destination");
|
|
206
|
+
recordPreference({
|
|
207
|
+
projectDir: projectA,
|
|
208
|
+
key: "style_preset",
|
|
209
|
+
value: "pin-and-paper",
|
|
210
|
+
workflow: "faceless-explainer",
|
|
211
|
+
});
|
|
212
|
+
const merged = mergedPreferences(projectA);
|
|
213
|
+
assert.equal(merged["style_preset.faceless-explainer"].value, "pin-and-paper");
|
|
214
|
+
assert.equal(merged["style_preset"], undefined);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("style_preset without a workflow is rejected, never stored bare", () => {
|
|
219
|
+
withSandbox(({ projectA }) => {
|
|
220
|
+
assert.throws(
|
|
221
|
+
() => recordPreference({ projectDir: projectA, key: "style_preset", value: "pin-and-paper" }),
|
|
222
|
+
/pass --workflow/,
|
|
223
|
+
);
|
|
224
|
+
assert.deepEqual(mergedPreferences(projectA), {});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("unknown keys and empty values are rejected", () => {
|
|
229
|
+
withSandbox(({ projectA }) => {
|
|
230
|
+
assert.throws(() => recordPreference({ projectDir: projectA, key: "vibe", value: "x" }));
|
|
231
|
+
assert.throws(() => recordPreference({ projectDir: projectA, key: "voice", value: " " }));
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("corrupt files are treated as empty instead of crashing", () => {
|
|
236
|
+
withSandbox(({ projectA, home }) => {
|
|
237
|
+
mkdirSync(join(projectA, ".media"), { recursive: true });
|
|
238
|
+
writeFileSync(projectPrefsPath(projectA), "not json");
|
|
239
|
+
mkdirSync(join(home, ".media"), { recursive: true });
|
|
240
|
+
writeFileSync(userPrefsPath(), '{"preferences": 42}');
|
|
241
|
+
assert.deepEqual(mergedPreferences(projectA), {});
|
|
242
|
+
const result = recordPreference({ projectDir: projectA, key: "language", value: "zh" });
|
|
243
|
+
assert.equal(result.value, "zh");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { ffprobeBinary } from "./ff-binaries.mjs";
|
|
4
|
+
|
|
5
|
+
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]);
|
|
6
|
+
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
|
|
7
|
+
|
|
8
|
+
export function probe(filePath) {
|
|
9
|
+
const ext = extname(filePath).toLowerCase();
|
|
10
|
+
if (ext === ".svg") return { width: null, height: null, duration: null, codec: "svg" };
|
|
11
|
+
|
|
12
|
+
const ffprobe = ffprobeBinary();
|
|
13
|
+
try {
|
|
14
|
+
// execFileSync (no shell) so a hostile filename like `"; rm -rf ~; ".png`
|
|
15
|
+
// can't break out of the quoting — filePath is passed as a literal argv entry.
|
|
16
|
+
const raw = execFileSync(
|
|
17
|
+
ffprobe,
|
|
18
|
+
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "--", filePath],
|
|
19
|
+
{ encoding: "utf8", timeout: 5000 },
|
|
20
|
+
);
|
|
21
|
+
const info = JSON.parse(raw);
|
|
22
|
+
const streams = info.streams ?? [];
|
|
23
|
+
const wantedType = AUDIO_EXT.has(ext) ? "audio" : "video";
|
|
24
|
+
const stream =
|
|
25
|
+
streams.find((s) => s.codec_type === wantedType && s.disposition?.attached_pic !== 1) ??
|
|
26
|
+
streams[0];
|
|
27
|
+
const format = info.format;
|
|
28
|
+
|
|
29
|
+
const isImage = IMAGE_EXT.has(ext);
|
|
30
|
+
const duration = isImage
|
|
31
|
+
? null
|
|
32
|
+
: parseFloat(format?.duration) || parseFloat(stream?.duration) || null;
|
|
33
|
+
const width = parseInt(stream?.width, 10) || null;
|
|
34
|
+
const height = parseInt(stream?.height, 10) || null;
|
|
35
|
+
const codec = stream?.codec_name || null;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
duration: duration != null ? Math.round(duration * 10) / 10 : null,
|
|
39
|
+
width,
|
|
40
|
+
height,
|
|
41
|
+
codec,
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
return { duration: null, width: null, height: null, codec: null };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { chmodSync, mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { probe } from "./probe.mjs";
|
|
7
|
+
import childProcess from "node:child_process";
|
|
8
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
9
|
+
|
|
10
|
+
function mockProbe(t, streams, format = { duration: "2" }) {
|
|
11
|
+
t.mock.method(childProcess, "execFileSync", () => JSON.stringify({ streams, format }));
|
|
12
|
+
syncBuiltinESMExports();
|
|
13
|
+
t.after(() => {
|
|
14
|
+
t.mock.restoreAll();
|
|
15
|
+
syncBuiltinESMExports();
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const AUDIO_STREAM = { codec_type: "audio", codec_name: "aac", duration: "3" };
|
|
20
|
+
const VIDEO_STREAM = {
|
|
21
|
+
codec_type: "video",
|
|
22
|
+
codec_name: "h264",
|
|
23
|
+
width: 64,
|
|
24
|
+
height: 48,
|
|
25
|
+
duration: "4",
|
|
26
|
+
};
|
|
27
|
+
const COVER_STREAM = {
|
|
28
|
+
codec_type: "video",
|
|
29
|
+
codec_name: "mjpeg",
|
|
30
|
+
width: 100,
|
|
31
|
+
height: 100,
|
|
32
|
+
disposition: { attached_pic: 1 },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
for (const [name, streams] of [
|
|
36
|
+
["audio before video", [AUDIO_STREAM, VIDEO_STREAM]],
|
|
37
|
+
["video before audio", [VIDEO_STREAM, AUDIO_STREAM]],
|
|
38
|
+
["cover art before video", [COVER_STREAM, AUDIO_STREAM, VIDEO_STREAM]],
|
|
39
|
+
]) {
|
|
40
|
+
test(`probe selects the video stream with ${name}`, (t) => {
|
|
41
|
+
mockProbe(t, streams);
|
|
42
|
+
assert.deepEqual(probe("clip.mp4"), {
|
|
43
|
+
duration: 2,
|
|
44
|
+
width: 64,
|
|
45
|
+
height: 48,
|
|
46
|
+
codec: "h264",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("probe selects audio metadata for an audio file with cover art", (t) => {
|
|
52
|
+
mockProbe(t, [COVER_STREAM, AUDIO_STREAM]);
|
|
53
|
+
assert.deepEqual(probe("song.m4a"), { duration: 2, width: null, height: null, codec: "aac" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("probe uses the selected video's duration when the container has none", (t) => {
|
|
57
|
+
mockProbe(t, [AUDIO_STREAM, VIDEO_STREAM], {});
|
|
58
|
+
assert.equal(probe("clip.mp4").duration, 4);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("probe selects image dimensions without reporting an image duration", (t) => {
|
|
62
|
+
mockProbe(t, [AUDIO_STREAM, VIDEO_STREAM]);
|
|
63
|
+
assert.deepEqual(probe("frame.png"), {
|
|
64
|
+
duration: null,
|
|
65
|
+
width: 64,
|
|
66
|
+
height: 48,
|
|
67
|
+
codec: "h264",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("probe retains audio-only metadata and the empty-stream fallback", (t) => {
|
|
72
|
+
mockProbe(t, [AUDIO_STREAM]);
|
|
73
|
+
assert.deepEqual(probe("clip.mp4"), { duration: 2, width: null, height: null, codec: "aac" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("probe retains the container duration when no streams are reported", (t) => {
|
|
77
|
+
mockProbe(t, []);
|
|
78
|
+
assert.deepEqual(probe("clip.mp4"), { duration: 2, width: null, height: null, codec: null });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Regression for the shell-injection fix: probe() must pass the path as a literal
|
|
82
|
+
// argv entry, never through a shell. A filename containing shell metacharacters
|
|
83
|
+
// must NOT execute. Under the old execSync(`ffprobe ... "${path}"`) the embedded
|
|
84
|
+
// `touch` ran and created the marker; under execFileSync it cannot, regardless of
|
|
85
|
+
// whether ffprobe is installed (the injected command never reaches a shell).
|
|
86
|
+
test("probe does not execute shell metacharacters in a filename", () => {
|
|
87
|
+
const dir = mkdtempSync(join(tmpdir(), "probe-inject-"));
|
|
88
|
+
const marker = join(dir, "INJECTED");
|
|
89
|
+
// Slash-free basename (a real on-disk filename) that breaks out of the old
|
|
90
|
+
// double-quoted interpolation and would `touch INJECTED` in the cwd.
|
|
91
|
+
const evil = join(dir, `clip"; touch INJECTED; echo ".mp4`);
|
|
92
|
+
const prevCwd = process.cwd();
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(evil, "not real media");
|
|
95
|
+
process.chdir(dir); // so a leaked `touch INJECTED` would land next to `marker`
|
|
96
|
+
const meta = probe(evil);
|
|
97
|
+
assert.equal(existsSync(marker), false, "injected `touch` must not have run");
|
|
98
|
+
// Bogus/unreadable media still returns the null-shaped result, never throws.
|
|
99
|
+
assert.deepEqual(Object.keys(meta).sort(), ["codec", "duration", "height", "width"]);
|
|
100
|
+
} finally {
|
|
101
|
+
process.chdir(prevCwd);
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test(
|
|
107
|
+
"probe runs the ffprobe HYPERFRAMES_FFPROBE_PATH names",
|
|
108
|
+
{ skip: process.platform === "win32" },
|
|
109
|
+
() => {
|
|
110
|
+
const dir = mkdtempSync(join(tmpdir(), "probe-ffprobe-path-"));
|
|
111
|
+
const fake = join(dir, "fake-ffprobe");
|
|
112
|
+
writeFileSync(
|
|
113
|
+
fake,
|
|
114
|
+
`#!/bin/sh\n[ "$1" = -version ] && echo 'ffprobe version fake' && exit 0\necho '{"streams":[{"width":7,"height":3,"codec_name":"fake"}]}'\n`,
|
|
115
|
+
);
|
|
116
|
+
chmodSync(fake, 0o755);
|
|
117
|
+
const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
118
|
+
process.env.HYPERFRAMES_FFPROBE_PATH = fake;
|
|
119
|
+
try {
|
|
120
|
+
assert.deepEqual(probe(join(dir, "clip.png")), {
|
|
121
|
+
duration: null,
|
|
122
|
+
width: 7,
|
|
123
|
+
height: 3,
|
|
124
|
+
codec: "fake",
|
|
125
|
+
});
|
|
126
|
+
} finally {
|
|
127
|
+
if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
128
|
+
else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
|
|
129
|
+
rmSync(dir, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
test("probe refuses an HYPERFRAMES_FFPROBE_PATH that cannot run instead of reporting no metadata", () => {
|
|
135
|
+
const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
136
|
+
process.env.HYPERFRAMES_FFPROBE_PATH = join(tmpdir(), "no-ffprobe-here", "ffprobe");
|
|
137
|
+
try {
|
|
138
|
+
assert.throws(
|
|
139
|
+
() => probe("clip.wav"),
|
|
140
|
+
/HYPERFRAMES_FFPROBE_PATH names ".*no-ffprobe-here.*fix it or unset it/,
|
|
141
|
+
);
|
|
142
|
+
} finally {
|
|
143
|
+
if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
144
|
+
else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("probe refuses an HYPERFRAMES_FFPROBE_PATH that names a folder", () => {
|
|
149
|
+
const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
150
|
+
process.env.HYPERFRAMES_FFPROBE_PATH = tmpdir();
|
|
151
|
+
try {
|
|
152
|
+
assert.throws(() => probe("clip.wav"), /HYPERFRAMES_FFPROBE_PATH names .*fix it or unset it/);
|
|
153
|
+
} finally {
|
|
154
|
+
if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
155
|
+
else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
function withFfprobePath(value, run) {
|
|
160
|
+
const configured = process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
161
|
+
process.env.HYPERFRAMES_FFPROBE_PATH = value;
|
|
162
|
+
try {
|
|
163
|
+
return run();
|
|
164
|
+
} finally {
|
|
165
|
+
if (configured === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
|
166
|
+
else process.env.HYPERFRAMES_FFPROBE_PATH = configured;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
test(
|
|
171
|
+
"probe refuses a configured ffprobe that cannot start, exits with an error, or is another tool",
|
|
172
|
+
{
|
|
173
|
+
skip: process.platform === "win32",
|
|
174
|
+
},
|
|
175
|
+
() => {
|
|
176
|
+
const dir = mkdtempSync(join(tmpdir(), "probe-broken-ffprobe-"));
|
|
177
|
+
try {
|
|
178
|
+
const noInterpreter = join(dir, "no-interpreter");
|
|
179
|
+
writeFileSync(noInterpreter, "#!/nonexistent/interpreter\n");
|
|
180
|
+
const failing = join(dir, "failing");
|
|
181
|
+
writeFileSync(failing, "#!/bin/sh\nexit 3\n");
|
|
182
|
+
const otherTool = join(dir, "other-tool");
|
|
183
|
+
writeFileSync(otherTool, "#!/bin/sh\necho 'ffmpeg version 9.9'\n");
|
|
184
|
+
for (const broken of [noInterpreter, failing, otherTool]) {
|
|
185
|
+
chmodSync(broken, 0o755);
|
|
186
|
+
withFfprobePath(broken, () =>
|
|
187
|
+
assert.throws(
|
|
188
|
+
() => probe("clip.wav"),
|
|
189
|
+
/HYPERFRAMES_FFPROBE_PATH names .*fix it or unset it/,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
rmSync(dir, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
test(
|
|
200
|
+
"probe runs a relative HYPERFRAMES_FFPROBE_PATH from the working folder, not from PATH",
|
|
201
|
+
{
|
|
202
|
+
skip: process.platform === "win32",
|
|
203
|
+
},
|
|
204
|
+
() => {
|
|
205
|
+
const dir = mkdtempSync(join(tmpdir(), "probe-relative-ffprobe-"));
|
|
206
|
+
const prevCwd = process.cwd();
|
|
207
|
+
try {
|
|
208
|
+
writeFileSync(
|
|
209
|
+
join(dir, "local-ffprobe"),
|
|
210
|
+
`#!/bin/sh\n[ "$1" = -version ] && echo 'ffprobe version fake' && exit 0\necho '{"streams":[{"width":5,"height":4,"codec_name":"local"}]}'\n`,
|
|
211
|
+
);
|
|
212
|
+
chmodSync(join(dir, "local-ffprobe"), 0o755);
|
|
213
|
+
process.chdir(dir);
|
|
214
|
+
const meta = withFfprobePath("local-ffprobe", () => probe("clip.png"));
|
|
215
|
+
assert.deepEqual(meta, { duration: null, width: 5, height: 4, codec: "local" });
|
|
216
|
+
} finally {
|
|
217
|
+
process.chdir(prevCwd);
|
|
218
|
+
rmSync(dir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Back-compat surface for the v1 provider API. The ordered, capability-based
|
|
2
|
+
// registry now lives in registry.mjs; this re-exports the v1 helpers so existing
|
|
3
|
+
// callers keep working. New code should import from registry.mjs directly
|
|
4
|
+
// (getProviders / runCapability).
|
|
5
|
+
export { getProvider, listTypes } from "./registry.mjs";
|