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.
Files changed (178) hide show
  1. package/dist/{add-7GLP4BPM.js → add-KDCZCEMQ.js} +11 -11
  2. package/dist/{animatedGifPrep-NEBEGVSO.js → animatedGifPrep-MVP5LCEM.js} +5 -5
  3. package/dist/api.js +11 -11
  4. package/dist/{asset-RORTT5HS.js → asset-6VH3OCEC.js} +2 -2
  5. package/dist/{assetCataloger-RLJ6MDGF.js → assetCataloger-RN5A2SVA.js} +5 -5
  6. package/dist/{audio-OKDGU7TY.js → audio-636CQSXK.js} +11 -11
  7. package/dist/{auth-QWYNCU5U.js → auth-AHLKM4M2.js} +1 -1
  8. package/dist/{autoUpdate-EDJJPWAU.js → autoUpdate-5UA6TJW7.js} +2 -2
  9. package/dist/{backgroundChecks-KQR6FGVU.js → backgroundChecks-DP242536.js} +3 -3
  10. package/dist/backgroundChecksWorker.js +3 -3
  11. package/dist/{batchRender-YKTTAIHH.js → batchRender-KWBVAUE3.js} +3 -3
  12. package/dist/{beats-I6AGLTLW.js → beats-HKY5L463.js} +4 -4
  13. package/dist/{benchmark-NBSV7PNB.js → benchmark-4YQDVIO5.js} +8 -8
  14. package/dist/{browser-FPEL5FGW.js → browser-7ZPWXEYW.js} +3 -3
  15. package/dist/{bundleWithLocalizedFonts-TYQXMNSU.js → bundleWithLocalizedFonts-42V34HJF.js} +2 -2
  16. package/dist/{capture-FXGOI23U.js → capture-572X5NDD.js} +8 -8
  17. package/dist/{capture-5BRJJMZB.js → capture-VXMUZSBI.js} +2 -2
  18. package/dist/{catalog-SG3ZIS45.js → catalog-PUUTHPP7.js} +11 -11
  19. package/dist/{catch-up-ETXUT6Q5.js → catch-up-K6CV44WQ.js} +4 -4
  20. package/dist/{check-KXNNIEUR.js → check-MBDLSWCY.js} +32 -32
  21. package/dist/{checkBrowser-546UMI3N.js → checkBrowser-33UWWDZG.js} +29 -29
  22. package/dist/{chunk-EELTMBCC.js → chunk-2E7NDKTN.js} +1 -1
  23. package/dist/{chunk-DCG22HKT.js → chunk-2UD6O6QG.js} +6 -6
  24. package/dist/{chunk-5SQHHBAR.js → chunk-44GV6ZMC.js} +1 -1
  25. package/dist/{chunk-FQJDV2RI.js → chunk-4B6PUT4R.js} +1 -1
  26. package/dist/{chunk-UCNQE52B.js → chunk-4RXJG3SY.js} +197 -26
  27. package/dist/chunk-6M3IPJS6.js +3803 -0
  28. package/dist/{chunk-TJSTGSAW.js → chunk-7FMUTGOH.js} +2 -77
  29. package/dist/{chunk-T4HL3SZH.js → chunk-AGHHZIZN.js} +1 -1
  30. package/dist/{chunk-CNP6AMFM.js → chunk-AUMJ3QSO.js} +2 -2
  31. package/dist/{chunk-A6K7M46D.js → chunk-AWSVUIWG.js} +1 -1
  32. package/dist/{chunk-5ETJPA5Z.js → chunk-BAW7UNLK.js} +9 -9
  33. package/dist/{chunk-XK5SZNYE.js → chunk-CFBGKAKQ.js} +9 -9
  34. package/dist/{chunk-VCXVZRR2.js → chunk-EHDKCMKX.js} +1 -1
  35. package/dist/{chunk-AP5AVVAB.js → chunk-EJYBYR4C.js} +1 -1
  36. package/dist/{chunk-SJPNVLYS.js → chunk-ERJHLFPD.js} +2 -2
  37. package/dist/{chunk-P24X6CGY.js → chunk-FAORS3UR.js} +28 -13
  38. package/dist/{chunk-EEP5Y5IE.js → chunk-FE2XTCTM.js} +18 -1
  39. package/dist/{chunk-FSX7GTBB.js → chunk-FSP2JNAP.js} +1 -1
  40. package/dist/{chunk-ZFJ5RI7A.js → chunk-G6GKIAEM.js} +3 -3
  41. package/dist/{chunk-TVDXMJ43.js → chunk-GBWSBTEE.js} +1 -1
  42. package/dist/{chunk-K5PU5ISL.js → chunk-GG3V5BHG.js} +1 -1
  43. package/dist/{chunk-KA4LSAD5.js → chunk-GU5FLCZV.js} +1 -1
  44. package/dist/{chunk-EFP25BUY.js → chunk-JAERN4RH.js} +1 -1
  45. package/dist/{chunk-YT6TFGIS.js → chunk-KUFEPCR3.js} +1 -1
  46. package/dist/{chunk-S6U7YLPJ.js → chunk-KUW4LWY6.js} +1 -1
  47. package/dist/{chunk-TEFHMDVR.js → chunk-LMMFO5UV.js} +2 -2
  48. package/dist/{chunk-PVTHDNDZ.js → chunk-LWB3KZ5E.js} +1 -1
  49. package/dist/{chunk-QD5VJOQK.js → chunk-MSFON5DJ.js} +3 -3
  50. package/dist/{chunk-XJQ6NN2L.js → chunk-MXMIVNUE.js} +19 -23
  51. package/dist/{chunk-CESKA6VO.js → chunk-NSJLCWLG.js} +1 -1
  52. package/dist/{chunk-MCPDZWZY.js → chunk-NYCJCQNY.js} +4 -4
  53. package/dist/{chunk-RUYYAL3T.js → chunk-OGVQJHNM.js} +2 -2
  54. package/dist/{chunk-J5Z56PU3.js → chunk-PCX3YF2U.js} +1 -1
  55. package/dist/{chunk-U7P2XTRY.js → chunk-PECICVTJ.js} +2 -2
  56. package/dist/{chunk-GVX2443P.js → chunk-PH3RS5X4.js} +2 -2
  57. package/dist/{chunk-YZRS3COO.js → chunk-PP64Q43K.js} +1 -1
  58. package/dist/{chunk-Q5K73VW3.js → chunk-PXA4S6OE.js} +2 -2
  59. package/dist/{chunk-5FWVFCQM.js → chunk-QO3JEPEW.js} +2 -2
  60. package/dist/{chunk-O3J7BHBU.js → chunk-QZUVPWC4.js} +1 -1
  61. package/dist/{chunk-2F4HJFGW.js → chunk-T56MM7RC.js} +2 -2
  62. package/dist/{chunk-RZKQ3G6B.js → chunk-T5LGOTQR.js} +1 -1
  63. package/dist/{chunk-WAVFNZB7.js → chunk-UDDJQEGS.js} +1 -1
  64. package/dist/{chunk-BNEKKUPG.js → chunk-UQPH3Y45.js} +2 -2
  65. package/dist/{chunk-XZNHVWDD.js → chunk-UUP4KOMD.js} +4 -4
  66. package/dist/{chunk-UISU4SYG.js → chunk-V2T7FFPB.js} +1 -1
  67. package/dist/{chunk-7FHFPHEM.js → chunk-VKFYI7AQ.js} +182 -77
  68. package/dist/{chunk-JPN7SVWC.js → chunk-WLNNWLIE.js} +7 -7
  69. package/dist/{chunk-7635SDI5.js → chunk-WZQSL7UX.js} +2 -2
  70. package/dist/{chunk-YLAREDAU.js → chunk-X3U6KEID.js} +1 -1
  71. package/dist/{chunk-M4N6IHCQ.js → chunk-XRQBHOBT.js} +7 -1
  72. package/dist/{chunk-NKG3LTPI.js → chunk-YFGJVADL.js} +1 -1
  73. package/dist/{chunk-QLHXC4E6.js → chunk-ZINH7YA2.js} +1 -1
  74. package/dist/{clean-ARH23EGW.js → clean-5AXXHD45.js} +12 -12
  75. package/dist/cli.js +53 -53
  76. package/dist/{cloud-GBY7MRZM.js → cloud-DQCCYKOB.js} +4 -4
  77. package/dist/{cloudrun-HBVA5UZB.js → cloudrun-CDFL5JBL.js} +9 -11
  78. package/dist/{compare-Z4YNESYN.js → compare-6RB5X3SO.js} +24 -24
  79. package/dist/{compiler-6WJ5SVTD.js → compiler-GU5GPO5A.js} +2 -2
  80. package/dist/{component-AR4MS4R7.js → component-7RRNNC5F.js} +3 -3
  81. package/dist/{compositions-ERZVZBYW.js → compositions-CANET3EJ.js} +9 -9
  82. package/dist/{delete-NWSW3BET.js → delete-KEZJPDWJ.js} +2 -2
  83. package/dist/{deterministicFonts-KXVO7QEL.js → deterministicFonts-RWGQPEZU.js} +2 -2
  84. package/dist/{dist-VKLSNKRZ.js → dist-IDZRZHGE.js} +2 -2
  85. package/dist/{doctor-P4QZQRCB.js → doctor-5RAEGA6G.js} +7 -7
  86. package/dist/{events-F2UVJQIC.js → events-4ZJSJ76H.js} +2 -2
  87. package/dist/{events-SV46AWMI.js → events-SHOWEHI5.js} +3 -3
  88. package/dist/{feedback-XS4IK6SW.js → feedback-4IIGUUYZ.js} +6 -6
  89. package/dist/{figma-DELISEUP.js → figma-BFDECPE5.js} +3 -3
  90. package/dist/fontLocalizeCli.js +9 -9
  91. package/dist/{get-DNGXCEFB.js → get-BO32YZUQ.js} +2 -2
  92. package/dist/{gpuPolicy-JG5PUOJP.js → gpuPolicy-QWJYZLVJ.js} +5 -5
  93. package/dist/{grade-compare-F46JWE4O.js → grade-compare-SW4SAG5R.js} +24 -24
  94. package/dist/{help-XVEBMX3U.js → help-7AMSMPUF.js} +1 -1
  95. package/dist/{history-NS4PKG4S.js → history-VXAQFJJV.js} +6 -6
  96. package/dist/hyperframe-runtime.js +58 -57
  97. package/dist/hyperframe.manifest.json +1 -1
  98. package/dist/hyperframe.runtime.iife.js +58 -57
  99. package/dist/hyperframes-player.global.js +1 -1
  100. package/dist/{info-DJ6VNESS.js → info-KN4TX6T5.js} +7 -7
  101. package/dist/{init-TSRTMVRN.js → init-7A2T7LDW.js} +20 -20
  102. package/dist/{inspect-VTZXXDJ4.js → inspect-CCCV6JFV.js} +25 -25
  103. package/dist/{installer-3ERHU4CD.js → installer-ZCCZUOPN.js} +4 -4
  104. package/dist/{keyframes-6DN7BOYJ.js → keyframes-K6XNZGK3.js} +6 -6
  105. package/dist/{lambda-RCOIEGL6.js → lambda-B2K237DX.js} +10 -12
  106. package/dist/{layout-FFGRLVK4.js → layout-HAPR5OV4.js} +25 -25
  107. package/dist/{lint-MBYIDFKS.js → lint-3COFAXAJ.js} +6 -6
  108. package/dist/{list-KEZDFW5R.js → list-QCNHO7DR.js} +2 -2
  109. package/dist/{login-7ZV374QL.js → login-BAFYZS6W.js} +4 -4
  110. package/dist/{media-treatment-LGWWRXAX.js → media-treatment-C53ZBW2B.js} +8 -8
  111. package/dist/{models-TC4K6UQP.js → models-INX67ETR.js} +8 -8
  112. package/dist/{motionShot-4E3RTV5O.js → motionShot-UOS26VLB.js} +12 -12
  113. package/dist/{normalize-audio-XCGB2IQP.js → normalize-audio-I7HGJGA5.js} +4 -4
  114. package/dist/{open-JNNJSNTH.js → open-RFXGV4RY.js} +4 -4
  115. package/dist/{parakeetRunner-S3DG4BJB.js → parakeetRunner-PCCBODBS.js} +8 -8
  116. package/dist/{pipeline-3SIYCBLU.js → pipeline-GDC7SL5N.js} +5 -5
  117. package/dist/{play-LBPOOD3V.js → play-DRCWK35L.js} +17 -17
  118. package/dist/{present-PWEN5SDL.js → present-HUJV553D.js} +13 -13
  119. package/dist/{preview-IS32RLKM.js → preview-CZENYIGN.js} +15 -15
  120. package/dist/{producer-4UUUEPPS.js → producer-23NJEJRO.js} +1 -1
  121. package/dist/{projectConfig-K347VIO3.js → projectConfig-R3FCIARW.js} +6 -6
  122. package/dist/{publication-4CQS532O.js → publication-GY3OJ6VH.js} +2 -2
  123. package/dist/{publish-OJYF37FY.js → publish-XBW4NVFY.js} +7 -7
  124. package/dist/{remove-background-HO6YL2HU.js → remove-background-Q7CQ3PJF.js} +6 -6
  125. package/dist/{render-L76SEAMC.js → render-24JWWXMA.js} +3 -3
  126. package/dist/{render-Q6QAXJCN.js → render-DOHHCT2G.js} +8 -8
  127. package/dist/{render-QBQYF2NC.js → render-WWAXTKYR.js} +22 -22
  128. package/dist/{render-batch-JLANN5Y2.js → render-batch-EK4ZBQHH.js} +3 -3
  129. package/dist/renderSetupWorker.js +9 -1
  130. package/dist/{resolver-EDUM4DHG.js → resolver-5Y6VNMTC.js} +4 -4
  131. package/dist/{sherpa-QEM5XYHD.js → sherpa-VMADZBF7.js} +7 -7
  132. package/dist/skills/media-use/SKILL.md +3 -3
  133. package/dist/skills/media-use/audio/references/requirements.md +7 -7
  134. package/dist/skills/media-use/audio/references/sfx.md +19 -13
  135. package/dist/skills/media-use/audio/scripts/audio.mjs +9 -9
  136. package/dist/skills/media-use/audio/scripts/audio.test.mjs +17 -10
  137. package/dist/skills/media-use/audio/scripts/lib/media-record.mjs +7 -3
  138. package/dist/skills/media-use/audio/scripts/lib/media-record.test.mjs +22 -3
  139. package/dist/skills/media-use/audio/scripts/lib/openverse.mjs +134 -0
  140. package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +120 -89
  141. package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +222 -12
  142. package/dist/skills/media-use/references/audio.md +1 -1
  143. package/dist/skills/media-use/references/resolve.md +1 -1
  144. package/dist/skills/media-use/references/setup-providers.md +13 -12
  145. package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +4 -7
  146. package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +5 -5
  147. package/dist/skills/media-use/scripts/lib/manifest.d.mts +1 -0
  148. package/dist/skills/media-use/scripts/lib/manifest.mjs +9 -6
  149. package/dist/skills/media-use/scripts/lib/openverse.mjs +134 -0
  150. package/dist/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
  151. package/dist/skills/media-use/scripts/lib/registry.mjs +4 -2
  152. package/dist/skills/media-use/scripts/lib/registry.test.mjs +13 -13
  153. package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -13
  154. package/dist/skills/media-use/scripts/resolve.mjs +45 -55
  155. package/dist/{skills-FSZK6FIG.js → skills-ONFYYL6R.js} +5 -5
  156. package/dist/{skillsUpdateCheck-3354HXDJ.js → skillsUpdateCheck-JTHZOHRT.js} +3 -3
  157. package/dist/{snapshot-5VVMFEMM.js → snapshot-SUA62VQ2.js} +27 -27
  158. package/dist/{src-RYILCNU7.js → src-J6KABXGZ.js} +8 -8
  159. package/dist/{src-ZGMLMQ3B.js → src-VFTQPSQD.js} +4 -4
  160. package/dist/{staticProjectServer-PIVSGEAO.js → staticProjectServer-XQPUABYX.js} +13 -13
  161. package/dist/studio/assets/{hyperframes-player-B04Yx96S.js → hyperframes-player-Dsmhm7Q8.js} +1 -1
  162. package/dist/studio/assets/{index-D2e0J6kh.js → index-CV00F8qG.js} +4 -4
  163. package/dist/studio/assets/{index-D9CDX_Rr.js → index-DT1wigvW.js} +1 -1
  164. package/dist/studio/index.html +1 -1
  165. package/dist/{studio-api-PHADR6VB.js → studio-api-MBDDFEA2.js} +7 -7
  166. package/dist/{studioServer-5AVDKN5V.js → studioServer-7HWDCLKT.js} +37 -37
  167. package/dist/{telemetry-JCTHMPSB.js → telemetry-763NW3QF.js} +3 -3
  168. package/dist/{timeline-QU2F6R2M.js → timeline-ZLFPNI27.js} +15 -15
  169. package/dist/{tokens-YYK4G36C.js → tokens-RXJLVZKD.js} +2 -2
  170. package/dist/{transcribe-7ORH7R6O.js → transcribe-IM5UNBSH.js} +6 -6
  171. package/dist/{transcribe-PRG4FLLA.js → transcribe-UDISJXCZ.js} +12 -12
  172. package/dist/{updateCheck-NV7QNANK.js → updateCheck-V2RXHFQL.js} +2 -2
  173. package/dist/{upgrade-UZXN36J2.js → upgrade-MJI3EW3W.js} +2 -2
  174. package/dist/{urlDownloader-AW57H722.js → urlDownloader-BOLOMGC7.js} +4 -4
  175. package/dist/{validate-QD53ABUM.js → validate-IM5NPJHI.js} +19 -19
  176. package/dist/{video-VLVPYQQR.js → video-HUNOZDLT.js} +5 -5
  177. package/package.json +1 -1
  178. 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
- // Offline (no HeyGen) SFX resolution: the bundled library may ship manifest.json
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.equal(anomalies.length, 0);
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, 1);
67
- assert.match(anomalies[0], /missing from the offline library/);
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 offline = (libDir, projDir) =>
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 offline(libDir, projDir);
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 offline(libDir, projDir);
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/SFX retrieval. Local/provider-specific generators are explicit alternatives where installed; run `npx hyperframes media-use resolve --doctor` before assuming retrieval or TTS will work.
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 | Bundled 19-file library + HeyGen catalog |
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/sfx/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:
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/sfx | heygen catalog free-usage path; a host app's own music and sound tools come first |
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/sfx/image/icon catalog search, TTS (voice), and avatar
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 | Install |
77
- | ------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
78
- | `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
79
- | `heygen` | catalog (bgm/sfx/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) |
80
- | `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
81
- | `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
82
- | `parakeet-mlx` | local transcription (default ASR, best) on Apple Silicon | `uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx` |
83
- | `ltx-2-mlx` | local video gen | `git clone https://github.com/dgrauet/ltx-2-mlx && cd ltx-2-mlx && uv sync --all-extras` |
84
- | `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) |
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
- const TOOL_WORDS = { bgm: "music", sfx: "sound-effect" };
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
- const tool = TOOL_WORDS[type];
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 ${tool} tool if it has one, or ${outdated ? "update" : "install"} the CLI.`,
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 or sound-effect miss names the heygen CLI and the host app's own tool", () => {
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 sfx = heygenMiss("sfx", { code: "outdated" });
309
- assert.equal(sfx.code, "heygen_cli_outdated");
310
- assert.match(sfx.error, /host app's own sound-effect tool/);
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", () => {
@@ -11,6 +11,7 @@ export function allocateId(
11
11
  type: string,
12
12
  ext: string,
13
13
  ): Omit<Reservation, "fullPath">;
14
+ export function withMediaLock<T>(projectDir: string, fn: () => T): T;
14
15
  export function withReservedFileSync<T>(
15
16
  projectDir: string,
16
17
  type: string,
@@ -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
- mkdirSync(mediaDir(projectDir), { recursive: true });
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(mediaDir(projectDir), { recursive: true });
214
- const typeDir = typeDirPath(projectDir, type);
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
+ }