hyperframes 0.8.169 → 0.8.170
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/{add-7GLP4BPM.js → add-KDCZCEMQ.js} +11 -11
- package/dist/{animatedGifPrep-NEBEGVSO.js → animatedGifPrep-MVP5LCEM.js} +5 -5
- package/dist/api.js +11 -11
- package/dist/{asset-RORTT5HS.js → asset-6VH3OCEC.js} +2 -2
- package/dist/{assetCataloger-RLJ6MDGF.js → assetCataloger-RN5A2SVA.js} +5 -5
- package/dist/{audio-OKDGU7TY.js → audio-636CQSXK.js} +11 -11
- package/dist/{auth-QWYNCU5U.js → auth-AHLKM4M2.js} +1 -1
- package/dist/{autoUpdate-EDJJPWAU.js → autoUpdate-5UA6TJW7.js} +2 -2
- package/dist/{backgroundChecks-KQR6FGVU.js → backgroundChecks-DP242536.js} +3 -3
- package/dist/backgroundChecksWorker.js +3 -3
- package/dist/{batchRender-YKTTAIHH.js → batchRender-KWBVAUE3.js} +3 -3
- package/dist/{beats-I6AGLTLW.js → beats-HKY5L463.js} +4 -4
- package/dist/{benchmark-NBSV7PNB.js → benchmark-4YQDVIO5.js} +8 -8
- package/dist/{browser-FPEL5FGW.js → browser-7ZPWXEYW.js} +3 -3
- package/dist/{bundleWithLocalizedFonts-TYQXMNSU.js → bundleWithLocalizedFonts-42V34HJF.js} +2 -2
- package/dist/{capture-FXGOI23U.js → capture-572X5NDD.js} +8 -8
- package/dist/{capture-5BRJJMZB.js → capture-VXMUZSBI.js} +2 -2
- package/dist/{catalog-SG3ZIS45.js → catalog-PUUTHPP7.js} +11 -11
- package/dist/{catch-up-ETXUT6Q5.js → catch-up-K6CV44WQ.js} +4 -4
- package/dist/{check-KXNNIEUR.js → check-MBDLSWCY.js} +32 -32
- package/dist/{checkBrowser-546UMI3N.js → checkBrowser-33UWWDZG.js} +29 -29
- package/dist/{chunk-EELTMBCC.js → chunk-2E7NDKTN.js} +1 -1
- package/dist/{chunk-DCG22HKT.js → chunk-2UD6O6QG.js} +6 -6
- package/dist/{chunk-5SQHHBAR.js → chunk-44GV6ZMC.js} +1 -1
- package/dist/{chunk-FQJDV2RI.js → chunk-4B6PUT4R.js} +1 -1
- package/dist/{chunk-UCNQE52B.js → chunk-4RXJG3SY.js} +197 -26
- package/dist/chunk-6M3IPJS6.js +3803 -0
- package/dist/{chunk-TJSTGSAW.js → chunk-7FMUTGOH.js} +2 -77
- package/dist/{chunk-T4HL3SZH.js → chunk-AGHHZIZN.js} +1 -1
- package/dist/{chunk-CNP6AMFM.js → chunk-AUMJ3QSO.js} +2 -2
- package/dist/{chunk-A6K7M46D.js → chunk-AWSVUIWG.js} +1 -1
- package/dist/{chunk-5ETJPA5Z.js → chunk-BAW7UNLK.js} +9 -9
- package/dist/{chunk-XK5SZNYE.js → chunk-CFBGKAKQ.js} +9 -9
- package/dist/{chunk-VCXVZRR2.js → chunk-EHDKCMKX.js} +1 -1
- package/dist/{chunk-AP5AVVAB.js → chunk-EJYBYR4C.js} +1 -1
- package/dist/{chunk-SJPNVLYS.js → chunk-ERJHLFPD.js} +2 -2
- package/dist/{chunk-P24X6CGY.js → chunk-FAORS3UR.js} +28 -13
- package/dist/{chunk-EEP5Y5IE.js → chunk-FE2XTCTM.js} +18 -1
- package/dist/{chunk-FSX7GTBB.js → chunk-FSP2JNAP.js} +1 -1
- package/dist/{chunk-ZFJ5RI7A.js → chunk-G6GKIAEM.js} +3 -3
- package/dist/{chunk-TVDXMJ43.js → chunk-GBWSBTEE.js} +1 -1
- package/dist/{chunk-K5PU5ISL.js → chunk-GG3V5BHG.js} +1 -1
- package/dist/{chunk-KA4LSAD5.js → chunk-GU5FLCZV.js} +1 -1
- package/dist/{chunk-EFP25BUY.js → chunk-JAERN4RH.js} +1 -1
- package/dist/{chunk-YT6TFGIS.js → chunk-KUFEPCR3.js} +1 -1
- package/dist/{chunk-S6U7YLPJ.js → chunk-KUW4LWY6.js} +1 -1
- package/dist/{chunk-TEFHMDVR.js → chunk-LMMFO5UV.js} +2 -2
- package/dist/{chunk-PVTHDNDZ.js → chunk-LWB3KZ5E.js} +1 -1
- package/dist/{chunk-QD5VJOQK.js → chunk-MSFON5DJ.js} +3 -3
- package/dist/{chunk-XJQ6NN2L.js → chunk-MXMIVNUE.js} +19 -23
- package/dist/{chunk-CESKA6VO.js → chunk-NSJLCWLG.js} +1 -1
- package/dist/{chunk-MCPDZWZY.js → chunk-NYCJCQNY.js} +4 -4
- package/dist/{chunk-RUYYAL3T.js → chunk-OGVQJHNM.js} +2 -2
- package/dist/{chunk-J5Z56PU3.js → chunk-PCX3YF2U.js} +1 -1
- package/dist/{chunk-U7P2XTRY.js → chunk-PECICVTJ.js} +2 -2
- package/dist/{chunk-GVX2443P.js → chunk-PH3RS5X4.js} +2 -2
- package/dist/{chunk-YZRS3COO.js → chunk-PP64Q43K.js} +1 -1
- package/dist/{chunk-Q5K73VW3.js → chunk-PXA4S6OE.js} +2 -2
- package/dist/{chunk-5FWVFCQM.js → chunk-QO3JEPEW.js} +2 -2
- package/dist/{chunk-O3J7BHBU.js → chunk-QZUVPWC4.js} +1 -1
- package/dist/{chunk-2F4HJFGW.js → chunk-T56MM7RC.js} +2 -2
- package/dist/{chunk-RZKQ3G6B.js → chunk-T5LGOTQR.js} +1 -1
- package/dist/{chunk-WAVFNZB7.js → chunk-UDDJQEGS.js} +1 -1
- package/dist/{chunk-BNEKKUPG.js → chunk-UQPH3Y45.js} +2 -2
- package/dist/{chunk-XZNHVWDD.js → chunk-UUP4KOMD.js} +4 -4
- package/dist/{chunk-UISU4SYG.js → chunk-V2T7FFPB.js} +1 -1
- package/dist/{chunk-7FHFPHEM.js → chunk-VKFYI7AQ.js} +182 -77
- package/dist/{chunk-JPN7SVWC.js → chunk-WLNNWLIE.js} +7 -7
- package/dist/{chunk-7635SDI5.js → chunk-WZQSL7UX.js} +2 -2
- package/dist/{chunk-YLAREDAU.js → chunk-X3U6KEID.js} +1 -1
- package/dist/{chunk-M4N6IHCQ.js → chunk-XRQBHOBT.js} +7 -1
- package/dist/{chunk-NKG3LTPI.js → chunk-YFGJVADL.js} +1 -1
- package/dist/{chunk-QLHXC4E6.js → chunk-ZINH7YA2.js} +1 -1
- package/dist/{clean-ARH23EGW.js → clean-5AXXHD45.js} +12 -12
- package/dist/cli.js +53 -53
- package/dist/{cloud-GBY7MRZM.js → cloud-DQCCYKOB.js} +4 -4
- package/dist/{cloudrun-HBVA5UZB.js → cloudrun-CDFL5JBL.js} +9 -11
- package/dist/{compare-Z4YNESYN.js → compare-6RB5X3SO.js} +24 -24
- package/dist/{compiler-6WJ5SVTD.js → compiler-GU5GPO5A.js} +2 -2
- package/dist/{component-AR4MS4R7.js → component-7RRNNC5F.js} +3 -3
- package/dist/{compositions-ERZVZBYW.js → compositions-CANET3EJ.js} +9 -9
- package/dist/{delete-NWSW3BET.js → delete-KEZJPDWJ.js} +2 -2
- package/dist/{deterministicFonts-KXVO7QEL.js → deterministicFonts-RWGQPEZU.js} +2 -2
- package/dist/{dist-VKLSNKRZ.js → dist-IDZRZHGE.js} +2 -2
- package/dist/{doctor-P4QZQRCB.js → doctor-5RAEGA6G.js} +7 -7
- package/dist/{events-F2UVJQIC.js → events-4ZJSJ76H.js} +2 -2
- package/dist/{events-SV46AWMI.js → events-SHOWEHI5.js} +3 -3
- package/dist/{feedback-XS4IK6SW.js → feedback-4IIGUUYZ.js} +6 -6
- package/dist/{figma-DELISEUP.js → figma-BFDECPE5.js} +3 -3
- package/dist/fontLocalizeCli.js +9 -9
- package/dist/{get-DNGXCEFB.js → get-BO32YZUQ.js} +2 -2
- package/dist/{gpuPolicy-JG5PUOJP.js → gpuPolicy-QWJYZLVJ.js} +5 -5
- package/dist/{grade-compare-F46JWE4O.js → grade-compare-SW4SAG5R.js} +24 -24
- package/dist/{help-XVEBMX3U.js → help-7AMSMPUF.js} +1 -1
- package/dist/{history-NS4PKG4S.js → history-VXAQFJJV.js} +6 -6
- package/dist/hyperframe-runtime.js +58 -57
- package/dist/hyperframe.manifest.json +1 -1
- package/dist/hyperframe.runtime.iife.js +58 -57
- package/dist/hyperframes-player.global.js +1 -1
- package/dist/{info-DJ6VNESS.js → info-KN4TX6T5.js} +7 -7
- package/dist/{init-TSRTMVRN.js → init-7A2T7LDW.js} +20 -20
- package/dist/{inspect-VTZXXDJ4.js → inspect-CCCV6JFV.js} +25 -25
- package/dist/{installer-3ERHU4CD.js → installer-ZCCZUOPN.js} +4 -4
- package/dist/{keyframes-6DN7BOYJ.js → keyframes-K6XNZGK3.js} +6 -6
- package/dist/{lambda-RCOIEGL6.js → lambda-B2K237DX.js} +10 -12
- package/dist/{layout-FFGRLVK4.js → layout-HAPR5OV4.js} +25 -25
- package/dist/{lint-MBYIDFKS.js → lint-3COFAXAJ.js} +6 -6
- package/dist/{list-KEZDFW5R.js → list-QCNHO7DR.js} +2 -2
- package/dist/{login-7ZV374QL.js → login-BAFYZS6W.js} +4 -4
- package/dist/{media-treatment-LGWWRXAX.js → media-treatment-C53ZBW2B.js} +8 -8
- package/dist/{models-TC4K6UQP.js → models-INX67ETR.js} +8 -8
- package/dist/{motionShot-4E3RTV5O.js → motionShot-UOS26VLB.js} +12 -12
- package/dist/{normalize-audio-XCGB2IQP.js → normalize-audio-I7HGJGA5.js} +4 -4
- package/dist/{open-JNNJSNTH.js → open-RFXGV4RY.js} +4 -4
- package/dist/{parakeetRunner-S3DG4BJB.js → parakeetRunner-PCCBODBS.js} +8 -8
- package/dist/{pipeline-3SIYCBLU.js → pipeline-GDC7SL5N.js} +5 -5
- package/dist/{play-LBPOOD3V.js → play-DRCWK35L.js} +17 -17
- package/dist/{present-PWEN5SDL.js → present-HUJV553D.js} +13 -13
- package/dist/{preview-IS32RLKM.js → preview-CZENYIGN.js} +15 -15
- package/dist/{producer-4UUUEPPS.js → producer-23NJEJRO.js} +1 -1
- package/dist/{projectConfig-K347VIO3.js → projectConfig-R3FCIARW.js} +6 -6
- package/dist/{publication-4CQS532O.js → publication-GY3OJ6VH.js} +2 -2
- package/dist/{publish-OJYF37FY.js → publish-XBW4NVFY.js} +7 -7
- package/dist/{remove-background-HO6YL2HU.js → remove-background-Q7CQ3PJF.js} +6 -6
- package/dist/{render-L76SEAMC.js → render-24JWWXMA.js} +3 -3
- package/dist/{render-Q6QAXJCN.js → render-DOHHCT2G.js} +8 -8
- package/dist/{render-QBQYF2NC.js → render-WWAXTKYR.js} +22 -22
- package/dist/{render-batch-JLANN5Y2.js → render-batch-EK4ZBQHH.js} +3 -3
- package/dist/renderSetupWorker.js +9 -1
- package/dist/{resolver-EDUM4DHG.js → resolver-5Y6VNMTC.js} +4 -4
- package/dist/{sherpa-QEM5XYHD.js → sherpa-VMADZBF7.js} +7 -7
- package/dist/skills/media-use/SKILL.md +3 -3
- package/dist/skills/media-use/audio/references/requirements.md +7 -7
- package/dist/skills/media-use/audio/references/sfx.md +19 -13
- package/dist/skills/media-use/audio/scripts/audio.mjs +9 -9
- package/dist/skills/media-use/audio/scripts/audio.test.mjs +17 -10
- package/dist/skills/media-use/audio/scripts/lib/media-record.mjs +7 -3
- package/dist/skills/media-use/audio/scripts/lib/media-record.test.mjs +22 -3
- package/dist/skills/media-use/audio/scripts/lib/openverse.mjs +134 -0
- package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +120 -89
- package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +222 -12
- package/dist/skills/media-use/references/audio.md +1 -1
- package/dist/skills/media-use/references/resolve.md +1 -1
- package/dist/skills/media-use/references/setup-providers.md +13 -12
- package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +4 -7
- package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +5 -5
- package/dist/skills/media-use/scripts/lib/manifest.d.mts +1 -0
- package/dist/skills/media-use/scripts/lib/manifest.mjs +9 -6
- package/dist/skills/media-use/scripts/lib/openverse.mjs +134 -0
- package/dist/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
- package/dist/skills/media-use/scripts/lib/registry.mjs +4 -2
- package/dist/skills/media-use/scripts/lib/registry.test.mjs +13 -13
- package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -13
- package/dist/skills/media-use/scripts/resolve.mjs +45 -55
- package/dist/{skills-FSZK6FIG.js → skills-ONFYYL6R.js} +5 -5
- package/dist/{skillsUpdateCheck-3354HXDJ.js → skillsUpdateCheck-JTHZOHRT.js} +3 -3
- package/dist/{snapshot-5VVMFEMM.js → snapshot-SUA62VQ2.js} +27 -27
- package/dist/{src-RYILCNU7.js → src-J6KABXGZ.js} +8 -8
- package/dist/{src-ZGMLMQ3B.js → src-VFTQPSQD.js} +4 -4
- package/dist/{staticProjectServer-PIVSGEAO.js → staticProjectServer-XQPUABYX.js} +13 -13
- package/dist/studio/assets/{hyperframes-player-B04Yx96S.js → hyperframes-player-Dsmhm7Q8.js} +1 -1
- package/dist/studio/assets/{index-D2e0J6kh.js → index-CV00F8qG.js} +4 -4
- package/dist/studio/assets/{index-D9CDX_Rr.js → index-DT1wigvW.js} +1 -1
- package/dist/studio/index.html +1 -1
- package/dist/{studio-api-PHADR6VB.js → studio-api-MBDDFEA2.js} +7 -7
- package/dist/{studioServer-5AVDKN5V.js → studioServer-7HWDCLKT.js} +37 -37
- package/dist/{telemetry-JCTHMPSB.js → telemetry-763NW3QF.js} +3 -3
- package/dist/{timeline-QU2F6R2M.js → timeline-ZLFPNI27.js} +15 -15
- package/dist/{tokens-YYK4G36C.js → tokens-RXJLVZKD.js} +2 -2
- package/dist/{transcribe-7ORH7R6O.js → transcribe-IM5UNBSH.js} +6 -6
- package/dist/{transcribe-PRG4FLLA.js → transcribe-UDISJXCZ.js} +12 -12
- package/dist/{updateCheck-NV7QNANK.js → updateCheck-V2RXHFQL.js} +2 -2
- package/dist/{upgrade-UZXN36J2.js → upgrade-MJI3EW3W.js} +2 -2
- package/dist/{urlDownloader-AW57H722.js → urlDownloader-BOLOMGC7.js} +4 -4
- package/dist/{validate-QD53ABUM.js → validate-IM5NPJHI.js} +19 -19
- package/dist/{video-VLVPYQQR.js → video-HUNOZDLT.js} +5 -5
- package/package.json +1 -1
- package/dist/chunk-BIV5HVTQ.js +0 -3803
|
@@ -4,9 +4,18 @@ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { appendRecord } from "../../../scripts/lib/manifest.mjs";
|
|
7
|
-
import { resolveSfx } from "./sfx.mjs";
|
|
7
|
+
import { BUNDLED_SFX_DIR, findSoundEffect, resolveSfx } from "./sfx.mjs";
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// No real network: each test serves Openverse's answers; by default it is unreachable.
|
|
10
|
+
const offline = async () => {
|
|
11
|
+
throw new TypeError("fetch failed");
|
|
12
|
+
};
|
|
13
|
+
let serve = offline;
|
|
14
|
+
const UNREACHABLE =
|
|
15
|
+
"Openverse unreachable (fetch failed): the remaining SFX cues use the bundled library";
|
|
16
|
+
globalThis.fetch = (url, init) => serve(String(url), init);
|
|
17
|
+
|
|
18
|
+
// Offline SFX resolution: the bundled library may ship manifest.json
|
|
10
19
|
// without the actual mp3s. The old code copied only when the source existed but
|
|
11
20
|
// pushed the sfx entry unconditionally — producing a dangling reference that
|
|
12
21
|
// silently dropped downstream ("not on disk"). These tests lock in the loud
|
|
@@ -37,7 +46,6 @@ test("offline: copies and references a present bundled file", async () => {
|
|
|
37
46
|
writeFileSync(join(libDir, "whoosh.mp3"), "ID3-fake-bytes");
|
|
38
47
|
const { sfx, anomalies } = await resolveSfx({
|
|
39
48
|
cues: [{ id: "s1", name: "whoosh" }],
|
|
40
|
-
heygenOK: false,
|
|
41
49
|
hyperframesDir: projDir,
|
|
42
50
|
sfxLibDir: libDir,
|
|
43
51
|
});
|
|
@@ -45,7 +53,7 @@ test("offline: copies and references a present bundled file", async () => {
|
|
|
45
53
|
assert.equal(sfx[0].file, "assets/sfx/whoosh.mp3");
|
|
46
54
|
assert.equal(sfx[0].source, "local");
|
|
47
55
|
assert.ok(existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "mp3 copied into project");
|
|
48
|
-
assert.
|
|
56
|
+
assert.deepEqual(anomalies, [UNREACHABLE]);
|
|
49
57
|
});
|
|
50
58
|
});
|
|
51
59
|
|
|
@@ -58,13 +66,12 @@ test("offline: a matched-but-missing bundled file yields an anomaly and NO dangl
|
|
|
58
66
|
);
|
|
59
67
|
const { sfx, anomalies } = await resolveSfx({
|
|
60
68
|
cues: [{ id: "s1", name: "whoosh" }],
|
|
61
|
-
heygenOK: false,
|
|
62
69
|
hyperframesDir: projDir,
|
|
63
70
|
sfxLibDir: libDir,
|
|
64
71
|
});
|
|
65
72
|
assert.equal(sfx.length, 0, "no dangling entry for a file that was never copied");
|
|
66
|
-
assert.equal(anomalies.length,
|
|
67
|
-
assert.match(anomalies[
|
|
73
|
+
assert.equal(anomalies.length, 2);
|
|
74
|
+
assert.match(anomalies[1], /missing from the offline library/);
|
|
68
75
|
assert.ok(!existsSync(join(projDir, "assets/sfx/whoosh.mp3")), "nothing copied");
|
|
69
76
|
});
|
|
70
77
|
});
|
|
@@ -78,10 +85,9 @@ const whooshLibrary = (libDir, withFile) => {
|
|
|
78
85
|
};
|
|
79
86
|
const record = (projDir, source) =>
|
|
80
87
|
appendRecord(projDir, { id: "sfx_001", type: "sfx", path: "assets/sfx/whoosh.mp3", source });
|
|
81
|
-
const
|
|
88
|
+
const bundledOnly = (libDir, projDir) =>
|
|
82
89
|
resolveSfx({
|
|
83
90
|
cues: [{ id: "s1", name: "whoosh" }],
|
|
84
|
-
heygenOK: false,
|
|
85
91
|
hyperframesDir: projDir,
|
|
86
92
|
sfxLibDir: libDir,
|
|
87
93
|
});
|
|
@@ -93,13 +99,13 @@ test("offline: the engine's earlier copy stands in for a library file this insta
|
|
|
93
99
|
writeFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "engine copy");
|
|
94
100
|
record(projDir, "bundled");
|
|
95
101
|
|
|
96
|
-
const { sfx, anomalies } = await
|
|
102
|
+
const { sfx, anomalies } = await bundledOnly(libDir, projDir);
|
|
97
103
|
|
|
98
104
|
assert.deepEqual(
|
|
99
105
|
sfx.map(({ file }) => file),
|
|
100
106
|
["assets/sfx/whoosh.mp3"],
|
|
101
107
|
);
|
|
102
|
-
assert.deepEqual(anomalies, []);
|
|
108
|
+
assert.deepEqual(anomalies, [UNREACHABLE]);
|
|
103
109
|
});
|
|
104
110
|
});
|
|
105
111
|
|
|
@@ -110,7 +116,7 @@ test("offline: a person's file with the library's bytes stays theirs once record
|
|
|
110
116
|
writeFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "ID3-library-bytes");
|
|
111
117
|
record(projDir, "existing");
|
|
112
118
|
|
|
113
|
-
const { sfx } = await
|
|
119
|
+
const { sfx } = await bundledOnly(libDir, projDir);
|
|
114
120
|
|
|
115
121
|
assert.deepEqual(
|
|
116
122
|
sfx.map(({ file }) => file),
|
|
@@ -119,3 +125,207 @@ test("offline: a person's file with the library's bytes stays theirs once record
|
|
|
119
125
|
assert.equal(readFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "utf8"), "ID3-library-bytes");
|
|
120
126
|
});
|
|
121
127
|
});
|
|
128
|
+
|
|
129
|
+
const libraryWith = (libDir, names) => {
|
|
130
|
+
const manifest = {};
|
|
131
|
+
for (const name of names) {
|
|
132
|
+
manifest[name] = { file: `${name}.mp3`, duration: 0.5 };
|
|
133
|
+
writeFileSync(join(libDir, `${name}.mp3`), `library ${name}`);
|
|
134
|
+
}
|
|
135
|
+
writeFileSync(join(libDir, "manifest.json"), JSON.stringify(manifest));
|
|
136
|
+
};
|
|
137
|
+
const sound = (overrides = {}) => ({
|
|
138
|
+
id: "ov-1",
|
|
139
|
+
title: "Swoosh",
|
|
140
|
+
creator: "Ana",
|
|
141
|
+
license: "by",
|
|
142
|
+
foreign_landing_url: "https://freesound.org/s/1",
|
|
143
|
+
url: "https://cdn.freesound.test/swoosh.mp3",
|
|
144
|
+
filetype: "mp3",
|
|
145
|
+
duration: 820,
|
|
146
|
+
attribution: '"Swoosh" by Ana is licensed under CC BY 4.0.',
|
|
147
|
+
...overrides,
|
|
148
|
+
});
|
|
149
|
+
// Openverse stand-in: `answer(query)` gives a search response; any other URL is the sound file.
|
|
150
|
+
function openverse(answer) {
|
|
151
|
+
const searches = [];
|
|
152
|
+
serve = async (url) => {
|
|
153
|
+
const query = new URL(url).searchParams.get("q");
|
|
154
|
+
if (!url.startsWith("https://api.openverse.org/")) return new Response(`bytes of ${url}`);
|
|
155
|
+
searches.push(query);
|
|
156
|
+
return answer(query);
|
|
157
|
+
};
|
|
158
|
+
return searches;
|
|
159
|
+
}
|
|
160
|
+
const run = (libDir, projDir, names) =>
|
|
161
|
+
resolveSfx({
|
|
162
|
+
cues: names.map((name, index) => ({ id: String(index + 1), name })),
|
|
163
|
+
hyperframesDir: projDir,
|
|
164
|
+
sfxLibDir: libDir,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("openverse: each cue gets the best licensed sound, with its licence and credit", async () => {
|
|
168
|
+
await withDirs(async ({ libDir, projDir }) => {
|
|
169
|
+
libraryWith(libDir, ["whoosh"]);
|
|
170
|
+
openverse(() => Response.json({ results: [sound()] }));
|
|
171
|
+
try {
|
|
172
|
+
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh"]);
|
|
173
|
+
assert.deepEqual(sfx, [
|
|
174
|
+
{
|
|
175
|
+
id: "1",
|
|
176
|
+
name: "whoosh",
|
|
177
|
+
file: "assets/sfx/whoosh.mp3",
|
|
178
|
+
source: "openverse",
|
|
179
|
+
duration_s: 0.82,
|
|
180
|
+
license: "by",
|
|
181
|
+
credit: '"Swoosh" by Ana is licensed under CC BY 4.0.',
|
|
182
|
+
offset_s: 0,
|
|
183
|
+
volume: 0.35,
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
assert.deepEqual(anomalies, []);
|
|
187
|
+
assert.equal(
|
|
188
|
+
readFileSync(join(projDir, "assets/sfx/whoosh.mp3"), "utf8"),
|
|
189
|
+
"bytes of https://cdn.freesound.test/swoosh.mp3",
|
|
190
|
+
);
|
|
191
|
+
assert.match(
|
|
192
|
+
readFileSync(join(projDir, "assets/sfx/CREDITS.md"), "utf8"),
|
|
193
|
+
/- `assets\/sfx\/whoosh\.mp3`: "Swoosh" by Ana is licensed under CC BY 4\.0\. Source: https:\/\/freesound\.org\/s\/1/,
|
|
194
|
+
);
|
|
195
|
+
} finally {
|
|
196
|
+
serve = offline;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("openverse: a rate limit sends this and every later cue to the bundled library, asking once", async () => {
|
|
202
|
+
await withDirs(async ({ libDir, projDir }) => {
|
|
203
|
+
libraryWith(libDir, ["whoosh", "pop"]);
|
|
204
|
+
const searches = openverse(() => new Response("slow down", { status: 429 }));
|
|
205
|
+
try {
|
|
206
|
+
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh", "pop"]);
|
|
207
|
+
assert.deepEqual(searches, ["whoosh"]);
|
|
208
|
+
assert.deepEqual(
|
|
209
|
+
sfx.map(({ file, source }) => [file, source]),
|
|
210
|
+
[
|
|
211
|
+
["assets/sfx/whoosh.mp3", "local"],
|
|
212
|
+
["assets/sfx/pop.mp3", "local"],
|
|
213
|
+
],
|
|
214
|
+
);
|
|
215
|
+
assert.equal(readFileSync(join(projDir, "assets/sfx/pop.mp3"), "utf8"), "library pop");
|
|
216
|
+
assert.deepEqual(anomalies, [
|
|
217
|
+
"Openverse rate limit reached (HTTP 429): the remaining SFX cues use the bundled library",
|
|
218
|
+
]);
|
|
219
|
+
} finally {
|
|
220
|
+
serve = offline;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("openverse: a cue with no licensed match falls back to the bundled library alone", async () => {
|
|
226
|
+
await withDirs(async ({ libDir, projDir }) => {
|
|
227
|
+
libraryWith(libDir, ["pop"]);
|
|
228
|
+
const searches = openverse((query) =>
|
|
229
|
+
Response.json({ results: query === "pop" ? [sound({ license: "by-nc" })] : [sound()] }),
|
|
230
|
+
);
|
|
231
|
+
try {
|
|
232
|
+
const { sfx } = await run(libDir, projDir, ["pop", "swoosh"]);
|
|
233
|
+
assert.deepEqual(searches, ["pop", "swoosh"]);
|
|
234
|
+
assert.deepEqual(
|
|
235
|
+
sfx.map(({ file, source }) => [file, source]),
|
|
236
|
+
[
|
|
237
|
+
["assets/sfx/pop.mp3", "local"],
|
|
238
|
+
["assets/sfx/swoosh.mp3", "openverse"],
|
|
239
|
+
],
|
|
240
|
+
);
|
|
241
|
+
} finally {
|
|
242
|
+
serve = offline;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("openverse: a failed download falls back to the bundled file with an anomaly", async () => {
|
|
248
|
+
await withDirs(async ({ libDir, projDir }) => {
|
|
249
|
+
libraryWith(libDir, ["whoosh"]);
|
|
250
|
+
serve = async (url) =>
|
|
251
|
+
url.startsWith("https://api.openverse.org/")
|
|
252
|
+
? Response.json({ results: [sound()] })
|
|
253
|
+
: new Response("gone", { status: 404 });
|
|
254
|
+
try {
|
|
255
|
+
const { sfx, anomalies } = await run(libDir, projDir, ["whoosh"]);
|
|
256
|
+
assert.deepEqual(
|
|
257
|
+
sfx.map(({ file, source }) => [file, source]),
|
|
258
|
+
[["assets/sfx/whoosh.mp3", "local"]],
|
|
259
|
+
);
|
|
260
|
+
assert.match(anomalies[0], /Openverse download failed — download HTTP 404/);
|
|
261
|
+
} finally {
|
|
262
|
+
serve = offline;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("openverse: one effect named in two lines is searched and downloaded once", async () => {
|
|
268
|
+
await withDirs(async ({ libDir, projDir }) => {
|
|
269
|
+
libraryWith(libDir, []);
|
|
270
|
+
const searches = openverse(() => Response.json({ results: [sound({ license: "cc0" })] }));
|
|
271
|
+
try {
|
|
272
|
+
const { sfx } = await run(libDir, projDir, ["whoosh", "whoosh"]);
|
|
273
|
+
assert.deepEqual(searches, ["whoosh"]);
|
|
274
|
+
assert.deepEqual(
|
|
275
|
+
sfx.map(({ id, file }) => [id, file]),
|
|
276
|
+
[
|
|
277
|
+
["1", "assets/sfx/whoosh.mp3"],
|
|
278
|
+
["2", "assets/sfx/whoosh.mp3"],
|
|
279
|
+
],
|
|
280
|
+
);
|
|
281
|
+
assert.equal(
|
|
282
|
+
existsSync(join(projDir, "assets/sfx/CREDITS.md")),
|
|
283
|
+
false,
|
|
284
|
+
"CC0 needs no credit",
|
|
285
|
+
);
|
|
286
|
+
} finally {
|
|
287
|
+
serve = offline;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("findSoundEffect: a host gets the saved file, licence, credit and duration for one query", async () => {
|
|
293
|
+
await withDirs(async ({ projDir }) => {
|
|
294
|
+
const searches = openverse(() => Response.json({ results: [sound()] }));
|
|
295
|
+
try {
|
|
296
|
+
assert.deepEqual(await findSoundEffect("whoosh", { hyperframesDir: projDir }), {
|
|
297
|
+
sound: {
|
|
298
|
+
file: "assets/sfx/whoosh.mp3",
|
|
299
|
+
source: "openverse",
|
|
300
|
+
duration_s: 0.82,
|
|
301
|
+
license: "by",
|
|
302
|
+
credit: '"Swoosh" by Ana is licensed under CC BY 4.0.',
|
|
303
|
+
},
|
|
304
|
+
anomalies: [],
|
|
305
|
+
});
|
|
306
|
+
assert.deepEqual(searches, ["whoosh"]);
|
|
307
|
+
} finally {
|
|
308
|
+
serve = offline;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("findSoundEffect: offline, the skill's own bundled library answers", async () => {
|
|
314
|
+
await withDirs(async ({ projDir }) => {
|
|
315
|
+
const { sound, anomalies } = await findSoundEffect("whoosh", { hyperframesDir: projDir });
|
|
316
|
+
assert.deepEqual(sound, {
|
|
317
|
+
file: "assets/sfx/whoosh.mp3",
|
|
318
|
+
source: "local",
|
|
319
|
+
duration_s: sound.duration_s,
|
|
320
|
+
});
|
|
321
|
+
assert.deepEqual(
|
|
322
|
+
readFileSync(join(projDir, sound.file)),
|
|
323
|
+
readFileSync(join(BUNDLED_SFX_DIR, "whoosh.mp3")),
|
|
324
|
+
);
|
|
325
|
+
assert.deepEqual(anomalies, [UNREACHABLE]);
|
|
326
|
+
assert.equal(
|
|
327
|
+
(await findSoundEffect("no such sound at all", { hyperframesDir: projDir })).sound,
|
|
328
|
+
null,
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -12,7 +12,7 @@ node <SKILL_DIR>/audio/scripts/audio.mjs --request ./audio_request.json --out ./
|
|
|
12
12
|
- **Request** `{ provider?, voice?, lang?, speed?, tts_model?, style?, lines: [{ id, text, style?, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`.
|
|
13
13
|
- **Gemini narration** is opt-in with `provider: "gemini"` and an API key or service-account credentials. `tts_model` defaults to `gemini-3.8-flash-tts`; 3.8 Flash-Lite, 3.1 Flash Preview, and 2.5 Pro/Flash Preview TTS are also supported. `style` directs delivery; a line's style overrides the request's style. CLI overrides: `--tts-model`, `--style`. See `audio/references/tts.md` for a complete request. The existing automatic provider order is unchanged.
|
|
14
14
|
- **Output** `audio_meta.json` (id-keyed): `voices[].{path,duration_s,words[]}` (word timestamps for captions), `sfx[]`, `bgm`, `total_duration_s`.
|
|
15
|
-
- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music
|
|
15
|
+
- **HeyGen free-usage path**: HeyGen CLI auth unlocks TTS plus music retrieval; SFX come from Openverse with no account (`audio/references/sfx.md`). Local/provider-specific generators are explicit alternatives where installed; run `npx hyperframes media-use resolve --doctor` before assuming retrieval or TTS will work.
|
|
16
16
|
- If BGM took the generate path (`bgm_pending: true`), run `audio/scripts/wait-bgm.mjs` before final render.
|
|
17
17
|
|
|
18
18
|
Single-shot helpers: `audio/scripts/heygen-tts.mjs` (one voice file). Transcription / background removal / captions use the `hyperframes` CLI (`transcribe`, `remove-background`), see the per-topic guides in `audio/references/` (`tts.md`, `bgm.md`, `sfx.md`, `transcribe.md`, `remove-background.md`, `captions/`).
|
|
@@ -11,7 +11,7 @@ Returns one line: `resolved <id> → <path> (<type>, <metadata>)`
|
|
|
11
11
|
| Type | What it finds | Provider / cascade |
|
|
12
12
|
| ------- | -------------------------------- | ------------------------------------------------------------ |
|
|
13
13
|
| `bgm` | Background music | HeyGen audio catalog (10k+ tracks) |
|
|
14
|
-
| `sfx` | Sound effects |
|
|
14
|
+
| `sfx` | Sound effects | Openverse (CC0 / CC BY), then the bundled 21-file library |
|
|
15
15
|
| `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) |
|
|
16
16
|
| `icon` | Icons, symbols | HeyGen asset search (type=icon) |
|
|
17
17
|
| `logo` | Official brand marks | theSVG → GitHub org avatar → domain favicon |
|
|
@@ -9,7 +9,7 @@ heygen update # free usage needs the OAuth-capable CLI (v0.3.0+)
|
|
|
9
9
|
heygen auth login --oauth # OAuth = free subscription credits; --api-key bills API credits
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
This unlocks the FREE path for bgm/
|
|
12
|
+
This unlocks the FREE path for bgm/image/icon catalog search, TTS (voice), and avatar videos. Sign in with `--oauth` — the free allowance rides on the OAuth session (an API key bills API credits instead). **media-use requires heygen >= v0.3.0 uniformly** (the OAuth free-usage path needs it), so `--doctor` nudges older CLIs to update even for API-key-only use. Before resolving anything, verify setup with:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
npx hyperframes media-use resolve --doctor
|
|
@@ -41,7 +41,8 @@ in `references/operations.md`).
|
|
|
41
41
|
|
|
42
42
|
| Type | Provider / path |
|
|
43
43
|
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
-
| bgm
|
|
44
|
+
| bgm | heygen catalog free-usage path; a host app's own music tools come first |
|
|
45
|
+
| sfx | Openverse (CC0 / CC BY, no account), bundled library offline; a host app's own sound tools come first |
|
|
45
46
|
| image | heygen search free-usage path; optional local mflux; codex `image_gen` upsell |
|
|
46
47
|
| voice | heygen tts free-usage path; optional local **Kokoro** (free, on-device) |
|
|
47
48
|
| icon | heygen asset search free-usage path |
|
|
@@ -67,21 +68,21 @@ HeyGen-only types, that means no fresh resolve.
|
|
|
67
68
|
## CLI tools used (what to run, and how to enable each)
|
|
68
69
|
|
|
69
70
|
`resolve` auto-cascades; each provider shells one CLI. HeyGen is the
|
|
70
|
-
free-usage path for bgm/
|
|
71
|
+
free-usage path for bgm/image/icon catalog search, TTS (voice), and avatar
|
|
71
72
|
video, so those capabilities need `heygen` installed and authenticated. Local
|
|
72
73
|
tools are OPT-IN alternatives where they exist; install one to unlock its free,
|
|
73
74
|
private, on-device path instead of or ahead of HeyGen for that type. Only
|
|
74
75
|
`ffmpeg`/`ffprobe` are strictly required for the tool to run at all.
|
|
75
76
|
|
|
76
|
-
| Tool | Serves
|
|
77
|
-
| ------------------ |
|
|
78
|
-
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm
|
|
79
|
-
| `heygen` | catalog (bgm/
|
|
80
|
-
| `mflux-generate` | local image gen (FLUX), best-for-RAM
|
|
81
|
-
| `codex` | image gen upsell (ChatGPT sub)
|
|
82
|
-
| `parakeet-mlx` | local transcription (default ASR, best) on Apple Silicon
|
|
83
|
-
| `ltx-2-mlx` | local video gen
|
|
84
|
-
| `npx hyperframes` | Kokoro TTS (voice), Parakeet or whisper.cpp (transcribe), remove-background
|
|
77
|
+
| Tool | Serves | Install |
|
|
78
|
+
| ------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
79
|
+
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
|
|
80
|
+
| `heygen` | catalog (bgm/image/icon) + TTS (voice) + avatar video — the free-usage path | install through [verified HeyGen release instructions](https://developers.heygen.com/cli), then `heygen auth login --oauth` (needs >= v0.3.0) |
|
|
81
|
+
| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
|
|
82
|
+
| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
|
|
83
|
+
| `parakeet-mlx` | local transcription (default ASR, best) on Apple Silicon | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
|
|
84
|
+
| `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
|
|
85
|
+
| `npx hyperframes` | Kokoro TTS (voice), Parakeet or whisper.cpp (transcribe), remove-background | via the hyperframes CLI; Parakeet on macOS, Windows and glibc 2.32+ Linux: `npx hyperframes models install parakeet`; whisper.cpp is built on first use (Homebrew on macOS, else git+cmake) |
|
|
85
86
|
|
|
86
87
|
The RAM-graded local-model shortlist + exact per-tier install/invoke lives in
|
|
87
88
|
the CLI's bundled media-use engine, not in the standalone skill. Without a tool on PATH, its provider
|
|
@@ -102,19 +102,16 @@ const pendingFailureTracking = new Set();
|
|
|
102
102
|
// API, move this state into a per-resolve context before reusing that path.
|
|
103
103
|
let pendingRemediation = null;
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
/** A music or sound-effect resolve miss after the heygen CLI was missing or too old: what is missing, the host
|
|
108
|
-
* app's own tool, the fix. Other types keep the generic miss. */
|
|
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. */
|
|
109
107
|
export function heygenMiss(type, { code }) {
|
|
110
|
-
|
|
111
|
-
if (!tool) return null;
|
|
108
|
+
if (type !== "bgm") return null;
|
|
112
109
|
const outdated = code === "outdated";
|
|
113
110
|
const state = outdated ? `older than v${HEYGEN_MIN_VERSION}` : "not installed";
|
|
114
111
|
return {
|
|
115
112
|
code: outdated ? "heygen_cli_outdated" : "heygen_cli_missing",
|
|
116
113
|
fix: outdated ? HEYGEN_UPDATE_COMMAND : HEYGEN_INSTALL_COMMAND,
|
|
117
|
-
error: `${type} needs the heygen CLI, which is ${state}: use your host app's own
|
|
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.`,
|
|
118
115
|
};
|
|
119
116
|
}
|
|
120
117
|
|
|
@@ -301,13 +301,13 @@ test("flushHeygenFailureTracking resolves immediately when nothing is pending",
|
|
|
301
301
|
await flushHeygenFailureTracking();
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
-
test("only a music
|
|
305
|
-
for (const type of ["image", "icon", "voice", "video"])
|
|
304
|
+
test("only a music miss names the heygen CLI and the host app's own tool", () => {
|
|
305
|
+
for (const type of ["sfx", "image", "icon", "voice", "video"])
|
|
306
306
|
assert.equal(heygenMiss(type, { code: "not_found" }), null, type);
|
|
307
307
|
assert.equal(heygenMiss("bgm", { code: "not_found" }).code, "heygen_cli_missing");
|
|
308
|
-
const
|
|
309
|
-
assert.equal(
|
|
310
|
-
assert.match(
|
|
308
|
+
const bgm = heygenMiss("bgm", { code: "outdated" });
|
|
309
|
+
assert.equal(bgm.code, "heygen_cli_outdated");
|
|
310
|
+
assert.match(bgm.error, /host app's own music tool/);
|
|
311
311
|
});
|
|
312
312
|
|
|
313
313
|
test("a CLI that rejects --headers without printing a version is outdated", () => {
|
|
@@ -107,8 +107,7 @@ export function recordInPlace(
|
|
|
107
107
|
description: description || basename(path),
|
|
108
108
|
...(duration != null && { duration: Math.round(duration * 10) / 10 }),
|
|
109
109
|
};
|
|
110
|
-
|
|
111
|
-
return withLock(mediaDir(projectDir), () => {
|
|
110
|
+
return withMediaLock(projectDir, () => {
|
|
112
111
|
const latest = latestRecordFor(projectDir, path);
|
|
113
112
|
const same = ["source", "description", "duration"].every(
|
|
114
113
|
(key) => latest?.[key] === fields[key],
|
|
@@ -203,6 +202,12 @@ function withLock(dir, fn) {
|
|
|
203
202
|
}
|
|
204
203
|
}
|
|
205
204
|
|
|
205
|
+
/** Runs `fn` holding the project's `.media/.lock`, so concurrent resolves take turns on what it guards. */
|
|
206
|
+
export function withMediaLock(projectDir, fn) {
|
|
207
|
+
mkdirSync(mediaDir(projectDir), { recursive: true });
|
|
208
|
+
return withLock(mediaDir(projectDir), fn);
|
|
209
|
+
}
|
|
210
|
+
|
|
206
211
|
// Atomically allocate the next free id for `type` AND reserve it on disk, so a
|
|
207
212
|
// slow download/copy between allocation and appendRecord can't let a concurrent
|
|
208
213
|
// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
|
|
@@ -210,10 +215,8 @@ function withLock(dir, fn) {
|
|
|
210
215
|
// O_EXCL-create an empty marker under core's atomic temp name, which Studio's
|
|
211
216
|
// project history skips. Returns { id, localPath, markerPath }.
|
|
212
217
|
export function allocateId(projectDir, type, ext) {
|
|
213
|
-
mkdirSync(
|
|
214
|
-
|
|
215
|
-
mkdirSync(typeDir, { recursive: true });
|
|
216
|
-
return withLock(mediaDir(projectDir), () => {
|
|
218
|
+
mkdirSync(typeDirPath(projectDir, type), { recursive: true });
|
|
219
|
+
return withMediaLock(projectDir, () => {
|
|
217
220
|
const id = nextFreeId(projectDir, type);
|
|
218
221
|
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
|
219
222
|
// Same shape as core atomicTempPath: an empty file at the final name would be committed.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Sound effects from Openverse (https://api.openverse.org): free, no key.
|
|
2
|
+
// Anonymous use is rate limited (20 requests a minute, 200 a day); callers fall
|
|
3
|
+
// back to the bundled library when it is unavailable.
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
const OPENVERSE_AUDIO_URL = "https://api.openverse.org/v1/audio/";
|
|
9
|
+
// The only licences media-use accepts: no NonCommercial, ShareAlike or NoDerivatives.
|
|
10
|
+
export const OPENVERSE_SFX_LICENSES = new Set(["cc0", "by"]);
|
|
11
|
+
const PLAYABLE_FILETYPES = new Set(["mp3", "wav", "ogg", "flac"]);
|
|
12
|
+
const MAX_SFX_MS = 15000;
|
|
13
|
+
const IDEAL_SFX_MS = 4000;
|
|
14
|
+
const BY_PENALTY = 2;
|
|
15
|
+
const SEARCH_TIMEOUT_MS = 10000;
|
|
16
|
+
|
|
17
|
+
// Openverse text lands in markdown and JSON: one line, no control or markdown-structure characters, bounded.
|
|
18
|
+
const oneLine = (text) =>
|
|
19
|
+
String(text ?? "")
|
|
20
|
+
.replace(/[\p{Cc}`[\]<>\\]+/gu, " ")
|
|
21
|
+
.trim()
|
|
22
|
+
.slice(0, 300);
|
|
23
|
+
// Openverse's audio providers (api.openverse.org/v1/audio/stats/); a new provider's link is dropped until listed.
|
|
24
|
+
const SOURCE_HOSTS = new Set(["freesound.org", "www.jamendo.com", "commons.wikimedia.org"]);
|
|
25
|
+
function httpsUrl(url, hosts) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(url);
|
|
28
|
+
return parsed.protocol === "https:" && (!hosts || hosts.has(parsed.hostname))
|
|
29
|
+
? oneLine(parsed.href)
|
|
30
|
+
: "";
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Openverse could not answer (offline, rate limited, server error): stop asking it this run. */
|
|
37
|
+
export class OpenverseUnavailableError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "OpenverseUnavailableError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function usable(result) {
|
|
45
|
+
return (
|
|
46
|
+
OPENVERSE_SFX_LICENSES.has(result?.license) &&
|
|
47
|
+
PLAYABLE_FILETYPES.has(result.filetype) &&
|
|
48
|
+
typeof result.url === "string" &&
|
|
49
|
+
result.url.startsWith("https://") &&
|
|
50
|
+
Number.isFinite(result.duration) &&
|
|
51
|
+
result.duration > 0 &&
|
|
52
|
+
result.duration <= MAX_SFX_MS
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Lower is better: relevance position, then seconds past a typical cue length, then CC0 over CC BY.
|
|
57
|
+
function penalty(result, position) {
|
|
58
|
+
const longBy = Math.max(0, result.duration - IDEAL_SFX_MS) / 1000;
|
|
59
|
+
return position + longBy + (result.license === "by" ? BY_PENALTY : 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Best usable sound in Openverse's relevance-ordered results, or null. */
|
|
63
|
+
export function pickOpenverseSfx(results) {
|
|
64
|
+
let best = null;
|
|
65
|
+
results.forEach((result, position) => {
|
|
66
|
+
if (!usable(result)) return;
|
|
67
|
+
const score = penalty(result, position);
|
|
68
|
+
if (!best || score < best.score) best = { result, score };
|
|
69
|
+
});
|
|
70
|
+
if (!best) return null;
|
|
71
|
+
const r = best.result;
|
|
72
|
+
return {
|
|
73
|
+
id: r.id,
|
|
74
|
+
url: r.url,
|
|
75
|
+
ext: `.${r.filetype}`,
|
|
76
|
+
title: oneLine(r.title),
|
|
77
|
+
creator: oneLine(r.creator),
|
|
78
|
+
license: r.license,
|
|
79
|
+
licenseUrl: httpsUrl(r.license_url),
|
|
80
|
+
sourceUrl: httpsUrl(r.foreign_landing_url, SOURCE_HOSTS),
|
|
81
|
+
credit: oneLine(r.attribution),
|
|
82
|
+
durationS: Math.round(r.duration) / 1000,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Best licensed sound effect for `query`, null on no match; throws OpenverseUnavailableError. */
|
|
87
|
+
export async function searchOpenverseSfx(query, { fetchImpl = fetch } = {}) {
|
|
88
|
+
const params = new URLSearchParams({
|
|
89
|
+
q: query,
|
|
90
|
+
page_size: "20",
|
|
91
|
+
license: [...OPENVERSE_SFX_LICENSES].join(","),
|
|
92
|
+
});
|
|
93
|
+
let response;
|
|
94
|
+
try {
|
|
95
|
+
response = await fetchImpl(`${OPENVERSE_AUDIO_URL}?${params}`, {
|
|
96
|
+
headers: { Accept: "application/json" },
|
|
97
|
+
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new OpenverseUnavailableError(`Openverse unreachable (${error.message})`);
|
|
101
|
+
}
|
|
102
|
+
if (response.status === 429)
|
|
103
|
+
throw new OpenverseUnavailableError("Openverse rate limit reached (HTTP 429)");
|
|
104
|
+
if (!response.ok) throw new OpenverseUnavailableError(`Openverse HTTP ${response.status}`);
|
|
105
|
+
let body;
|
|
106
|
+
try {
|
|
107
|
+
body = await response.json();
|
|
108
|
+
} catch {
|
|
109
|
+
throw new OpenverseUnavailableError("Openverse returned non-JSON");
|
|
110
|
+
}
|
|
111
|
+
return pickOpenverseSfx(Array.isArray(body?.results) ? body.results : []);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Record a CC BY sound's credit for `file` in the project's credits file (one line per file). */
|
|
115
|
+
export function writeSfxCredit(creditsPath, file, sound) {
|
|
116
|
+
if (sound.license !== "by") return;
|
|
117
|
+
// The sound may come from a saved record, not this run's pick: clean it again here.
|
|
118
|
+
const sourceUrl = httpsUrl(sound.sourceUrl, SOURCE_HOSTS);
|
|
119
|
+
const source = sourceUrl ? ` Source: ${sourceUrl}` : "";
|
|
120
|
+
const line = `- \`${file}\`: ${oneLine(sound.credit)}${source}`;
|
|
121
|
+
let prior = "";
|
|
122
|
+
try {
|
|
123
|
+
prior = readFileSync(creditsPath, "utf8");
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.code !== "ENOENT") throw error;
|
|
126
|
+
}
|
|
127
|
+
const kept = prior
|
|
128
|
+
.split("\n")
|
|
129
|
+
.filter((l) => l.startsWith("- ") && !l.startsWith(`- \`${file}\`:`));
|
|
130
|
+
const header =
|
|
131
|
+
"# Sound effect credits\n\nThese sounds are licensed CC BY: credit them wherever the video is published.\n\n";
|
|
132
|
+
mkdirSync(dirname(creditsPath), { recursive: true });
|
|
133
|
+
writeFileSync(creditsPath, `${header}${[...kept, line].join("\n")}\n`);
|
|
134
|
+
}
|