hyperframes 0.8.168 → 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 (179) hide show
  1. package/dist/{add-VCX3UE2B.js → add-KDCZCEMQ.js} +11 -11
  2. package/dist/{animatedGifPrep-TWD5BNVK.js → animatedGifPrep-MVP5LCEM.js} +5 -5
  3. package/dist/api.js +11 -11
  4. package/dist/{asset-BBJ3GAL7.js → asset-6VH3OCEC.js} +2 -2
  5. package/dist/{assetCataloger-S47GINRQ.js → assetCataloger-RN5A2SVA.js} +5 -5
  6. package/dist/{audio-MEDCANA3.js → audio-636CQSXK.js} +11 -11
  7. package/dist/{auth-LZYMCNTB.js → auth-AHLKM4M2.js} +1 -1
  8. package/dist/{autoUpdate-AXTW2NKX.js → autoUpdate-5UA6TJW7.js} +2 -2
  9. package/dist/{backgroundChecks-3YDYYI3D.js → backgroundChecks-DP242536.js} +3 -3
  10. package/dist/backgroundChecksWorker.js +3 -3
  11. package/dist/{batchRender-UDE7TOKY.js → batchRender-KWBVAUE3.js} +3 -3
  12. package/dist/{beats-HHXMPMGT.js → beats-HKY5L463.js} +4 -4
  13. package/dist/{benchmark-2BY7PBAP.js → benchmark-4YQDVIO5.js} +8 -8
  14. package/dist/{browser-CXB4KTJP.js → browser-7ZPWXEYW.js} +3 -3
  15. package/dist/{bundleWithLocalizedFonts-M7CRHOS7.js → bundleWithLocalizedFonts-42V34HJF.js} +2 -2
  16. package/dist/{capture-WHG37ZLF.js → capture-572X5NDD.js} +9 -9
  17. package/dist/{capture-PRTE4Z7E.js → capture-VXMUZSBI.js} +2 -2
  18. package/dist/{catalog-WRM2373Z.js → catalog-PUUTHPP7.js} +11 -11
  19. package/dist/{catch-up-WHLD6E5F.js → catch-up-K6CV44WQ.js} +4 -4
  20. package/dist/{check-R5E7WLKF.js → check-MBDLSWCY.js} +35 -34
  21. package/dist/{checkBrowser-Y4CZVGIV.js → checkBrowser-33UWWDZG.js} +30 -30
  22. package/dist/{chunk-ID7B7NGN.js → chunk-2E7NDKTN.js} +1 -1
  23. package/dist/{chunk-EW2QBTYO.js → chunk-2UD6O6QG.js} +10 -7
  24. package/dist/{chunk-QKJ4KGNR.js → chunk-44GV6ZMC.js} +1 -1
  25. package/dist/{chunk-YTZA25TZ.js → chunk-4B6PUT4R.js} +1 -1
  26. package/dist/{chunk-N7QCMEQG.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-AQZZZTFL.js → chunk-AGHHZIZN.js} +1 -1
  30. package/dist/{chunk-FA3TWKZO.js → chunk-AUMJ3QSO.js} +2 -2
  31. package/dist/{chunk-ATOXD5VV.js → chunk-AWSVUIWG.js} +1 -1
  32. package/dist/{chunk-MD4S2RXW.js → chunk-BAW7UNLK.js} +9 -9
  33. package/dist/{chunk-RG66EU4L.js → chunk-CFBGKAKQ.js} +9 -9
  34. package/dist/{chunk-ATCB4PMO.js → chunk-EHDKCMKX.js} +1 -1
  35. package/dist/{chunk-LGCHE2F4.js → chunk-EJYBYR4C.js} +1 -1
  36. package/dist/{chunk-LUSWL6OJ.js → chunk-ERJHLFPD.js} +2 -2
  37. package/dist/{chunk-KDC5D5VM.js → chunk-FAORS3UR.js} +28 -13
  38. package/dist/{chunk-EEP5Y5IE.js → chunk-FE2XTCTM.js} +18 -1
  39. package/dist/{chunk-YULVV525.js → chunk-FSP2JNAP.js} +1 -1
  40. package/dist/{chunk-NT3WPYAU.js → chunk-G6GKIAEM.js} +3 -3
  41. package/dist/{chunk-4CIMRQQY.js → chunk-GBWSBTEE.js} +1 -1
  42. package/dist/{chunk-O7BABHM4.js → chunk-GG3V5BHG.js} +1 -1
  43. package/dist/{chunk-E7CIMKJS.js → chunk-GU5FLCZV.js} +1 -1
  44. package/dist/{chunk-XR3XDG7V.js → chunk-JAERN4RH.js} +1 -1
  45. package/dist/{chunk-2GSGFDEP.js → chunk-KUFEPCR3.js} +7 -7
  46. package/dist/{chunk-LZGRJDNJ.js → chunk-KUW4LWY6.js} +1 -1
  47. package/dist/{chunk-BTCUP5ZY.js → chunk-LMMFO5UV.js} +2 -2
  48. package/dist/{chunk-HL6GWREP.js → chunk-LWB3KZ5E.js} +1 -1
  49. package/dist/{chunk-EKEK3GOB.js → chunk-MSFON5DJ.js} +3 -3
  50. package/dist/{chunk-CDDIEZ4D.js → chunk-MXMIVNUE.js} +19 -23
  51. package/dist/{chunk-4WPRJ7ID.js → chunk-NSJLCWLG.js} +1 -1
  52. package/dist/{chunk-MOJKJ4K4.js → chunk-NYCJCQNY.js} +4 -4
  53. package/dist/{chunk-PYSGYMM4.js → chunk-OGVQJHNM.js} +2 -2
  54. package/dist/{chunk-C3WPYYGK.js → chunk-PCX3YF2U.js} +1 -1
  55. package/dist/{chunk-5VGEWXJL.js → chunk-PECICVTJ.js} +2 -2
  56. package/dist/{chunk-TW7H5CJO.js → chunk-PH3RS5X4.js} +2 -2
  57. package/dist/{chunk-XOODFU75.js → chunk-PP64Q43K.js} +1 -1
  58. package/dist/{chunk-DO3UPPO4.js → chunk-PXA4S6OE.js} +2 -2
  59. package/dist/{chunk-54MI3SGP.js → chunk-QO3JEPEW.js} +2 -2
  60. package/dist/{chunk-SYKPCN32.js → chunk-QZUVPWC4.js} +3 -1
  61. package/dist/{chunk-EXMTTXKQ.js → chunk-T56MM7RC.js} +2 -2
  62. package/dist/{chunk-BOT7PGM5.js → chunk-T5LGOTQR.js} +1 -1
  63. package/dist/{chunk-UWQL2MP4.js → chunk-UDDJQEGS.js} +25 -6
  64. package/dist/{chunk-BC2XZL6O.js → chunk-UQPH3Y45.js} +2 -2
  65. package/dist/{chunk-4PHDDZVL.js → chunk-UUP4KOMD.js} +4 -4
  66. package/dist/{chunk-BV2NH7ZA.js → chunk-V2T7FFPB.js} +1 -1
  67. package/dist/{chunk-CAHSX6IW.js → chunk-VKFYI7AQ.js} +182 -77
  68. package/dist/{chunk-2F7S7OK2.js → chunk-WICIV6R3.js} +23 -1
  69. package/dist/{chunk-4LXK7TOM.js → chunk-WLNNWLIE.js} +7 -7
  70. package/dist/{chunk-OQGORCT3.js → chunk-WZQSL7UX.js} +2 -2
  71. package/dist/{chunk-NYIKKCMC.js → chunk-X3U6KEID.js} +1 -1
  72. package/dist/{chunk-M4N6IHCQ.js → chunk-XRQBHOBT.js} +7 -1
  73. package/dist/{chunk-BWFUUGIZ.js → chunk-YFGJVADL.js} +10 -5
  74. package/dist/{chunk-VTBME36F.js → chunk-ZINH7YA2.js} +1 -1
  75. package/dist/{clean-UPXYD6VX.js → clean-5AXXHD45.js} +12 -12
  76. package/dist/cli.js +53 -53
  77. package/dist/{cloud-74GU7KDD.js → cloud-DQCCYKOB.js} +4 -4
  78. package/dist/{cloudrun-KHMVQL7C.js → cloudrun-CDFL5JBL.js} +9 -11
  79. package/dist/{compare-BG77ZXYF.js → compare-6RB5X3SO.js} +25 -25
  80. package/dist/{compiler-OSQ4L7FP.js → compiler-GU5GPO5A.js} +2 -2
  81. package/dist/{component-34PVECJG.js → component-7RRNNC5F.js} +3 -3
  82. package/dist/{compositions-KCVIKKSR.js → compositions-CANET3EJ.js} +9 -9
  83. package/dist/{delete-NU2ENZYQ.js → delete-KEZJPDWJ.js} +2 -2
  84. package/dist/{deterministicFonts-KXVO7QEL.js → deterministicFonts-RWGQPEZU.js} +2 -2
  85. package/dist/{dist-3VKMLNXT.js → dist-IDZRZHGE.js} +2 -2
  86. package/dist/{doctor-GVELHIHI.js → doctor-5RAEGA6G.js} +7 -7
  87. package/dist/{events-E6CVR42M.js → events-4ZJSJ76H.js} +2 -2
  88. package/dist/{events-UP3IQXPM.js → events-SHOWEHI5.js} +3 -3
  89. package/dist/{feedback-IRYQ4YKX.js → feedback-4IIGUUYZ.js} +6 -6
  90. package/dist/{figma-BLQBHAPG.js → figma-BFDECPE5.js} +3 -3
  91. package/dist/fontLocalizeCli.js +9 -9
  92. package/dist/{get-HESG62RX.js → get-BO32YZUQ.js} +2 -2
  93. package/dist/{gpuPolicy-CHXUX2ZT.js → gpuPolicy-QWJYZLVJ.js} +5 -5
  94. package/dist/{grade-compare-ZDJMJUYY.js → grade-compare-SW4SAG5R.js} +25 -25
  95. package/dist/{help-2BQBFF4Z.js → help-7AMSMPUF.js} +1 -1
  96. package/dist/{history-4IZQ6NRV.js → history-VXAQFJJV.js} +6 -6
  97. package/dist/hyperframe-runtime.js +63 -62
  98. package/dist/hyperframe.manifest.json +1 -1
  99. package/dist/hyperframe.runtime.iife.js +63 -62
  100. package/dist/hyperframes-player.global.js +1 -1
  101. package/dist/{info-JNGAI3JU.js → info-KN4TX6T5.js} +7 -7
  102. package/dist/{init-ZQO6G5C7.js → init-7A2T7LDW.js} +20 -20
  103. package/dist/{inspect-DWMKJEVE.js → inspect-CCCV6JFV.js} +26 -26
  104. package/dist/{installer-UKZT4SKK.js → installer-ZCCZUOPN.js} +4 -4
  105. package/dist/{keyframes-FC273E5N.js → keyframes-K6XNZGK3.js} +6 -6
  106. package/dist/{lambda-GSZCV77P.js → lambda-B2K237DX.js} +10 -12
  107. package/dist/{layout-ASSCBJCO.js → layout-HAPR5OV4.js} +26 -26
  108. package/dist/{lint-PSCEVVNL.js → lint-3COFAXAJ.js} +6 -6
  109. package/dist/{list-KO4N2VJL.js → list-QCNHO7DR.js} +2 -2
  110. package/dist/{login-ZJEBRFV5.js → login-BAFYZS6W.js} +4 -4
  111. package/dist/{media-treatment-HGHLLZR3.js → media-treatment-C53ZBW2B.js} +8 -8
  112. package/dist/{models-NTAVDCAV.js → models-INX67ETR.js} +8 -8
  113. package/dist/{motionShot-SJENYTRO.js → motionShot-UOS26VLB.js} +13 -13
  114. package/dist/{normalize-audio-PQIC7IPK.js → normalize-audio-I7HGJGA5.js} +4 -4
  115. package/dist/{open-EI7UEAZZ.js → open-RFXGV4RY.js} +4 -4
  116. package/dist/{parakeetRunner-7SDEI3IZ.js → parakeetRunner-PCCBODBS.js} +8 -8
  117. package/dist/{pipeline-EXHHCUE7.js → pipeline-GDC7SL5N.js} +5 -5
  118. package/dist/{play-Y4EMM2OJ.js → play-DRCWK35L.js} +17 -17
  119. package/dist/{present-LFMGQGX6.js → present-HUJV553D.js} +13 -13
  120. package/dist/{preview-EYQG7L6X.js → preview-CZENYIGN.js} +15 -15
  121. package/dist/{producer-FKOEAHJW.js → producer-23NJEJRO.js} +1 -1
  122. package/dist/{projectConfig-SXW3QMZE.js → projectConfig-R3FCIARW.js} +6 -6
  123. package/dist/{publication-ZC4LDEXQ.js → publication-GY3OJ6VH.js} +2 -2
  124. package/dist/{publish-4ZCLHA5W.js → publish-XBW4NVFY.js} +7 -7
  125. package/dist/{remove-background-IGDRS2QV.js → remove-background-Q7CQ3PJF.js} +6 -6
  126. package/dist/{render-ZN777RDD.js → render-24JWWXMA.js} +3 -3
  127. package/dist/{render-TA3DG3LZ.js → render-DOHHCT2G.js} +8 -8
  128. package/dist/{render-QJCBJQOM.js → render-WWAXTKYR.js} +23 -23
  129. package/dist/{render-batch-2JQNFAL4.js → render-batch-EK4ZBQHH.js} +3 -3
  130. package/dist/renderSetupWorker.js +9 -1
  131. package/dist/{resolver-JZF5G6YC.js → resolver-5Y6VNMTC.js} +4 -4
  132. package/dist/{sherpa-MDVSUF6Y.js → sherpa-VMADZBF7.js} +7 -7
  133. package/dist/skills/media-use/SKILL.md +3 -3
  134. package/dist/skills/media-use/audio/references/requirements.md +7 -7
  135. package/dist/skills/media-use/audio/references/sfx.md +19 -13
  136. package/dist/skills/media-use/audio/scripts/audio.mjs +9 -9
  137. package/dist/skills/media-use/audio/scripts/audio.test.mjs +17 -10
  138. package/dist/skills/media-use/audio/scripts/lib/media-record.mjs +7 -3
  139. package/dist/skills/media-use/audio/scripts/lib/media-record.test.mjs +22 -3
  140. package/dist/skills/media-use/audio/scripts/lib/openverse.mjs +134 -0
  141. package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +120 -89
  142. package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +222 -12
  143. package/dist/skills/media-use/references/audio.md +1 -1
  144. package/dist/skills/media-use/references/resolve.md +1 -1
  145. package/dist/skills/media-use/references/setup-providers.md +13 -12
  146. package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +4 -7
  147. package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +5 -5
  148. package/dist/skills/media-use/scripts/lib/manifest.d.mts +1 -0
  149. package/dist/skills/media-use/scripts/lib/manifest.mjs +9 -6
  150. package/dist/skills/media-use/scripts/lib/openverse.mjs +134 -0
  151. package/dist/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
  152. package/dist/skills/media-use/scripts/lib/registry.mjs +4 -2
  153. package/dist/skills/media-use/scripts/lib/registry.test.mjs +13 -13
  154. package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -13
  155. package/dist/skills/media-use/scripts/resolve.mjs +45 -55
  156. package/dist/{skills-JVNMX37H.js → skills-ONFYYL6R.js} +5 -5
  157. package/dist/{skillsUpdateCheck-KNTQFGOI.js → skillsUpdateCheck-JTHZOHRT.js} +3 -3
  158. package/dist/{snapshot-RDQTFIPX.js → snapshot-SUA62VQ2.js} +28 -28
  159. package/dist/{src-ZPEZRIUC.js → src-J6KABXGZ.js} +8 -8
  160. package/dist/{src-5XO7KBBL.js → src-VFTQPSQD.js} +4 -4
  161. package/dist/{staticProjectServer-RLERPIY6.js → staticProjectServer-XQPUABYX.js} +13 -13
  162. package/dist/studio/assets/{hyperframes-player-mYPt9XVX.js → hyperframes-player-Dsmhm7Q8.js} +1 -1
  163. package/dist/studio/assets/{index-B-v5xQjD.js → index-CV00F8qG.js} +4 -4
  164. package/dist/studio/assets/{index-DtHmIKM0.js → index-DT1wigvW.js} +1 -1
  165. package/dist/studio/index.html +1 -1
  166. package/dist/{studio-api-4INCUOCU.js → studio-api-MBDDFEA2.js} +7 -7
  167. package/dist/{studioServer-DMDE7IZU.js → studioServer-7HWDCLKT.js} +38 -38
  168. package/dist/{telemetry-4Y5DYI6H.js → telemetry-763NW3QF.js} +3 -3
  169. package/dist/{timeline-XJRKELVE.js → timeline-ZLFPNI27.js} +15 -15
  170. package/dist/{tokens-G4VKTABI.js → tokens-RXJLVZKD.js} +2 -2
  171. package/dist/{transcribe-JRSQRPVH.js → transcribe-IM5UNBSH.js} +6 -6
  172. package/dist/{transcribe-Y46B2ZVD.js → transcribe-UDISJXCZ.js} +12 -12
  173. package/dist/{updateCheck-NGYDWGQL.js → updateCheck-V2RXHFQL.js} +2 -2
  174. package/dist/{upgrade-M2LQP5S7.js → upgrade-MJI3EW3W.js} +2 -2
  175. package/dist/{urlDownloader-UFDUP3VF.js → urlDownloader-BOLOMGC7.js} +4 -4
  176. package/dist/{validate-B3AUFCC5.js → validate-IM5NPJHI.js} +20 -20
  177. package/dist/{video-WLCNVSVU.js → video-HUNOZDLT.js} +5 -5
  178. package/package.json +1 -1
  179. package/dist/chunk-QE7XLLGT.js +0 -3803
@@ -6,12 +6,12 @@
6
6
  //
7
7
  // node <MEDIA_DIR>/scripts/audio.mjs --request ./audio_request.json --hyperframes . --out ./audio_meta.json
8
8
  //
9
- // The three capabilities degrade on ONE switch — whether HeyGen is configured
10
- // (credential present, NOT the CLI). This mirrors the table in ../SKILL.md:
9
+ // TTS and BGM degrade on ONE switch — whether HeyGen is configured (credential
10
+ // present, NOT the CLI). SFX needs no credential. This mirrors ../SKILL.md:
11
11
  //
12
12
  // TTS : HeyGen REST → ElevenLabs → Kokoro (CLI)
13
13
  // BGM : HeyGen retrieve → (no credential) Lyria/MusicGen generate
14
- // SFX : HeyGen retrieve → (no credential) bundled 19-file library
14
+ // SFX : Openverse (CC0 / CC BY) → (offline, rate limited, no match) bundled library
15
15
  //
16
16
  // ── audio_request.json (input) ────────────────────────────────────────────────
17
17
  // {
@@ -31,7 +31,7 @@
31
31
  // bgm: { path, volume, mode, query?, duration_s? } | null,
32
32
  // bgm_pending, bgm_provider, bgm_pid, bgm_log, bgm_mode, bgm_target_duration_s, …,
33
33
  // voices: [ { id, path, duration_s, words: [{id,text,start,end}] } ],
34
- // sfx: [ { id, name, file, source, offset_s, duration_s, volume } ],
34
+ // sfx: [ { id, name, file, source, offset_s, duration_s, volume, license?, credit? } ],
35
35
  // total_duration_s }
36
36
  //
37
37
  // --only tts,bgm,sfx runs a subset and MERGES into an existing --out (so a
@@ -52,7 +52,7 @@ import {
52
52
  withWordIds,
53
53
  } from "./lib/tts.mjs";
54
54
  import { generateBgmDetached, inferBgmPrompt, retrieveBgm } from "./lib/bgm.mjs";
55
- import { resolveSfx } from "./lib/sfx.mjs";
55
+ import { BUNDLED_SFX_DIR, resolveSfx } from "./lib/sfx.mjs";
56
56
  import { mapWithConcurrency } from "./lib/concurrency.mjs";
57
57
  import { openAudioMeta } from "./lib/audio-meta.mjs";
58
58
  import { recordInManifest, voicePaths, writtenAssets } from "./lib/media-record.mjs";
@@ -83,7 +83,7 @@ const ttsConcurrency = Math.max(1, Number(process.env.HYPERFRAMES_TTS_CONCURRENC
83
83
  const hyperframesDir = resolve(flag("hyperframes", "."));
84
84
  const requestPath = resolve(flag("request", join(hyperframesDir, "audio_request.json")));
85
85
  const outPath = resolve(flag("out", join(hyperframesDir, "audio_meta.json")));
86
- const sfxLibDir = resolve(flag("sfx-lib", join(HERE, "..", "assets", "sfx")));
86
+ const sfxLibDir = resolve(flag("sfx-lib", BUNDLED_SFX_DIR));
87
87
  const lyriaRecipe = resolve(flag("lyria-recipe", join(HERE, "lyria-recipe.py")));
88
88
  const onlyArg = flag("only", "tts,bgm,sfx");
89
89
  const only = new Set(
@@ -275,12 +275,12 @@ if (only.has("sfx")) {
275
275
  .map((name) => ({ id: String(l.id), name: String(name).trim() }))
276
276
  .filter((c) => c.name),
277
277
  );
278
- const headers = heygenOK && cues.length ? heygenAuthHeaders() : null;
279
- const res = await resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir });
278
+ const res = await resolveSfx({ cues, hyperframesDir, sfxLibDir });
280
279
  sfx = res.sfx;
281
280
  anomalies.push(...res.anomalies);
281
+ const openverse = sfx.filter((s) => s.source === "openverse").length;
282
282
  console.error(
283
- `· sfx: ${sfx.length} cue(s) resolved (${heygenOK ? "heygen retrieval" : "bundled library"})`,
283
+ `· sfx: ${sfx.length} cue(s) resolved (${openverse} Openverse, ${sfx.length - openverse} bundled library)`,
284
284
  );
285
285
  }
286
286
 
@@ -10,7 +10,11 @@ import { resolveSfx } from "./lib/sfx.mjs";
10
10
 
11
11
  // Proves the relocated engine (skills/media-use/audio/) still resolves its
12
12
  // bundled SFX library from the moved location — the path most likely to break
13
- // on a subtree move. Offline (heygenOK:false), no network.
13
+ // on a subtree move. Openverse is unreachable unless a test serves it.
14
+ const offline = async () => {
15
+ throw new TypeError("fetch failed");
16
+ };
17
+ globalThis.fetch = offline;
14
18
 
15
19
  const HERE = dirname(fileURLToPath(import.meta.url));
16
20
  const sfxLibDir = join(HERE, "..", "assets", "sfx"); // same offset the engine uses
@@ -21,7 +25,6 @@ test("bundled SFX library resolves from the relocated path", async () => {
21
25
  try {
22
26
  const { sfx, anomalies } = await resolveSfx({
23
27
  cues: [{ id: "1", name: "whoosh" }],
24
- heygenOK: false,
25
28
  hyperframesDir: dir,
26
29
  sfxLibDir,
27
30
  });
@@ -39,7 +42,6 @@ test("an unknown cue is reported, not fatal", async () => {
39
42
  try {
40
43
  const { sfx, anomalies } = await resolveSfx({
41
44
  cues: [{ id: "1", name: "definitely-not-a-real-sfx" }],
42
- heygenOK: false,
43
45
  hyperframesDir: dir,
44
46
  sfxLibDir,
45
47
  });
@@ -59,7 +61,6 @@ test("a person's own file under a bundled name survives, and the cue gets the ne
59
61
  const resolve = () =>
60
62
  resolveSfx({
61
63
  cues: [{ id: "1", name: "whoosh" }],
62
- heygenOK: false,
63
64
  hyperframesDir: dir,
64
65
  sfxLibDir,
65
66
  });
@@ -89,18 +90,24 @@ test("two effects never share a file when one's name is taken by the person, run
89
90
  try {
90
91
  mkdirSync(join(dir, "assets", "sfx"), { recursive: true });
91
92
  writeFileSync(join(dir, "assets", "sfx", "glitch.mp3"), "the person's own glitch");
92
- const realFetch = globalThis.fetch;
93
93
  globalThis.fetch = async (url) => {
94
- const query = new URL(url).searchParams.get("query");
94
+ const query = new URL(url).searchParams.get("q");
95
95
  if (query)
96
- return Response.json({ data: [{ audio_url: `https://sound.test/${query}`, score: 0.6 }] });
96
+ return Response.json({
97
+ results: [
98
+ {
99
+ license: "cc0",
100
+ filetype: "mp3",
101
+ duration: 500,
102
+ url: `https://sound.test/${encodeURIComponent(query)}`,
103
+ },
104
+ ],
105
+ });
97
106
  return new Response(`bytes of ${new URL(url).pathname}`);
98
107
  };
99
108
  const run = async (names) => {
100
109
  const { sfx } = await resolveSfx({
101
110
  cues: names.map((name, index) => ({ id: String(index), name })),
102
- heygenOK: true,
103
- headers: {},
104
111
  hyperframesDir: dir,
105
112
  sfxLibDir,
106
113
  });
@@ -119,7 +126,7 @@ test("two effects never share a file when one's name is taken by the person, run
119
126
  "assets/sfx/glitch-2.mp3",
120
127
  ]);
121
128
  } finally {
122
- globalThis.fetch = realFetch;
129
+ globalThis.fetch = offline;
123
130
  }
124
131
  } finally {
125
132
  rmSync(dir, { recursive: true, force: true });
@@ -43,7 +43,12 @@ export function voicePaths(hyperframesDir, lines, anomalies) {
43
43
  return paths;
44
44
  }
45
45
 
46
- const SFX_SOURCES = { heygen: "search", local: "bundled" };
46
+ // A cue's source as the manifest records it, and who provided it. A source missing here records as the person's
47
+ // file, so every rerun would write a numbered copy beside it.
48
+ const SFX_SOURCES = {
49
+ openverse: { source: "search", provider: "openverse.audio" },
50
+ local: { source: "bundled", provider: "bundled.sfx" },
51
+ };
47
52
 
48
53
  // The files one engine run wrote, each with how it was made, for the project's media manifest.
49
54
  export function writtenAssets({ only, lines, voices, ttsProvider, bgm, bgmFields, sfx }) {
@@ -73,8 +78,7 @@ export function writtenAssets({ only, lines, voices, ttsProvider, bgm, bgmFields
73
78
  }
74
79
  if (only.has("sfx")) {
75
80
  for (const cue of new Map(sfx.map((entry) => [entry.file, entry])).values()) {
76
- const source = SFX_SOURCES[cue.source];
77
- const provider = source === "search" ? "heygen" : "bundled.sfx";
81
+ const { source, provider } = SFX_SOURCES[cue.source];
78
82
  assets.push({
79
83
  path: cue.file,
80
84
  type: "sfx",
@@ -1,6 +1,6 @@
1
1
  import { strict as assert } from "node:assert";
2
2
  import { test } from "node:test";
3
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { findByPrompt, readManifest } from "../../../scripts/lib/manifest.mjs";
@@ -97,7 +97,7 @@ test("music and sound effects are marked by where they came from", () => {
97
97
  sfx: [
98
98
  { file: "assets/sfx/whoosh.mp3", name: "whoosh", source: "local" },
99
99
  { file: "assets/sfx/whoosh.mp3", name: "whoosh", source: "local" },
100
- { file: "assets/sfx/glass.mp3", name: "glass", source: "heygen" },
100
+ { file: "assets/sfx/glass.mp3", name: "glass", source: "openverse" },
101
101
  ],
102
102
  });
103
103
 
@@ -106,11 +106,30 @@ test("music and sound effects are marked by where they came from", () => {
106
106
  [
107
107
  ["assets/bgm/track.mp3", "search", undefined],
108
108
  ["assets/sfx/whoosh.mp3", "bundled", "bundled.sfx"],
109
- ["assets/sfx/glass.mp3", "search", "heygen"],
109
+ ["assets/sfx/glass.mp3", "search", "openverse.audio"],
110
110
  ],
111
111
  );
112
112
  });
113
113
 
114
+ test("a rerun of an Openverse sound effect writes over its own file, not a numbered copy", (t) => {
115
+ const dir = project(t);
116
+ mkdirSync(join(dir, "assets/sfx"), { recursive: true });
117
+ const anomalies = [];
118
+ for (let run = 0; run < 3; run++) {
119
+ const file = agentWritePath(dir, "assets/sfx/door-knock.mp3", { anomalies });
120
+ writeFileSync(join(dir, file), "knock");
121
+ const sfx = [{ file, name: "door knock", source: "openverse", duration_s: 1 }];
122
+ recordInManifest(
123
+ dir,
124
+ writtenAssets({ only: new Set(["sfx"]), lines: [], voices: [], sfx, ...noBgm }),
125
+ );
126
+ }
127
+
128
+ assert.deepEqual(readdirSync(join(dir, "assets/sfx")), ["door-knock.mp3"]);
129
+ assert.deepEqual(anomalies, []);
130
+ assert.equal(readManifest(dir).length, 1);
131
+ });
132
+
114
133
  test("music made locally is marked generated once it is ready, not while pending", () => {
115
134
  const written = (bgm_pending) =>
116
135
  writtenAssets({
@@ -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
+ }
@@ -1,23 +1,26 @@
1
- // sfx.mjs — sound effects for the media audio engine. Provider-gated (NOT a
2
- // per-cue merge): the decision is made once, by whether HeyGen is configured —
3
- // mirroring how TTS and BGM degrade.
4
- //
5
- // HeyGen credential present → retrieve EVERY cue from HeyGen's audio library
6
- // (/v3/audio/sounds, type=sound_effects, min_score=0.4). The bundled
7
- // library is NOT consulted.
8
- // HeyGen credential absent → resolve cues against the bundled 21-file
9
- // library (assets/sfx/manifest.json), copying matched files into the
10
- // project. Offline, deterministic, free.
1
+ // sfx.mjs — sound effects for the media audio engine. Each cue is searched on
2
+ // Openverse (CC0 / CC BY only, see openverse.mjs). A cue Openverse cannot answer,
3
+ // and every cue after Openverse is unreachable or rate limited, resolves against
4
+ // the bundled 21-file library (assets/sfx/manifest.json) instead.
11
5
  //
12
6
  // A cue that matches nothing is skipped (recorded as an anomaly); SFX never
13
7
  // blocks a render. Every cue sits at volume ~0.35, under voice + BGM.
14
8
 
15
9
  import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
16
- import { join } from "node:path";
17
- import { downloadTo, searchSounds } from "./heygen.mjs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { downloadTo } from "./heygen.mjs";
13
+ import { OpenverseUnavailableError, searchOpenverseSfx, writeSfxCredit } from "./openverse.mjs";
18
14
  import { latestRecordFor } from "../../../scripts/lib/manifest.mjs";
19
15
  import { agentWritePath } from "./media-record.mjs";
20
16
 
17
+ export const BUNDLED_SFX_DIR = join(
18
+ dirname(fileURLToPath(import.meta.url)),
19
+ "..",
20
+ "..",
21
+ "assets",
22
+ "sfx",
23
+ );
21
24
  const SFX_VOLUME = 0.35;
22
25
  const slug = (s) =>
23
26
  s
@@ -27,14 +30,42 @@ const slug = (s) =>
27
30
  .slice(0, 40) || "x";
28
31
  const r3 = (x) => Number(x.toFixed(3));
29
32
 
33
+ // Lookups by manifest key, file basename, and slug of either, so a cue can name
34
+ // "whoosh", "whoosh.mp3", or "ui click" (→ slug match). { error } when unusable.
35
+ function loadBundledLibrary(sfxLibDir) {
36
+ const manifestPath = join(sfxLibDir, "manifest.json");
37
+ if (!existsSync(manifestPath)) return { error: `no bundled SFX library at ${sfxLibDir}` };
38
+ let manifest;
39
+ try {
40
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
41
+ } catch (e) {
42
+ return { error: `bundled SFX manifest parse failed (${e.message})` };
43
+ }
44
+ const byKey = new Map();
45
+ for (const [key, entry] of Object.entries(manifest)) {
46
+ if (!entry?.file || !isFinite(entry.duration)) continue;
47
+ const rec = { key, file: entry.file, duration: entry.duration };
48
+ byKey.set(key, rec);
49
+ byKey.set(entry.file, rec);
50
+ byKey.set(slug(key), rec);
51
+ byKey.set(slug(entry.file.replace(/\.\w+$/, "")), rec);
52
+ }
53
+ return { byKey };
54
+ }
55
+
30
56
  // cues: [{ id, name }] (id = the line/frame/scene the cue fires in). Returns
31
- // { sfx: [{ id, name, file, source, offset_s, duration_s, volume }], anomalies }.
32
- export async function resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxLibDir }) {
57
+ // { sfx: [{ id, name, file, source, offset_s, duration_s, volume, license?, credit? }], anomalies }.
58
+ export async function resolveSfx({ cues, hyperframesDir, sfxLibDir = BUNDLED_SFX_DIR }) {
33
59
  const sfx = [];
34
60
  const anomalies = [];
35
61
  const destDir = join(hyperframesDir, "assets", "sfx");
36
62
  // Each effect's file this run: one effect named twice gets one file, and two effects never share one.
37
63
  const fileFor = new Map();
64
+ const taken = () => new Set(fileFor.values());
65
+ // One Openverse sound per effect name this run, so a repeated cue costs no second request.
66
+ const openverseFor = new Map();
67
+ let openverseDown = false;
68
+ let library;
38
69
 
39
70
  // Dedupe identical (id,name) cues — the same effect named twice in one line
40
71
  // downloads/copies once.
@@ -46,112 +77,82 @@ export async function resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxL
46
77
  return true;
47
78
  });
48
79
 
49
- if (heygenOK) {
50
- for (const { id, name } of uniq) {
80
+ async function fromOpenverse({ id, name }) {
81
+ const key = `openverse:${slug(name)}`;
82
+ let entry = openverseFor.get(key);
83
+ if (!entry) {
84
+ const sound = await searchOpenverseSfx(name);
85
+ if (!sound) return null;
86
+ const file = agentWritePath(hyperframesDir, `assets/sfx/${slug(name)}${sound.ext}`, {
87
+ anomalies,
88
+ taken: taken(),
89
+ });
51
90
  try {
52
- // SFX hits score low (~0.5–0.67), below the API's default 0.7 which
53
- // silently drops most named cues — floor to 0.4. (BGM/music score high
54
- // and keep the default.)
55
- const results = await searchSounds(name, "sound_effects", headers, {
56
- limit: 3,
57
- minScore: 0.4,
58
- });
59
- if (!results.length) {
60
- anomalies.push(`sfx "${name}" (id ${id}): no HeyGen match — skipped`);
61
- continue;
62
- }
63
- const top = results[0];
64
- const file =
65
- fileFor.get(slug(name)) ??
66
- agentWritePath(hyperframesDir, `assets/sfx/${slug(name)}.mp3`, {
67
- anomalies,
68
- taken: new Set(fileFor.values()),
69
- });
70
- await downloadTo(top.audio_url, join(hyperframesDir, file));
71
- fileFor.set(slug(name), file);
72
- sfx.push({
73
- id,
74
- name,
75
- file,
76
- source: "heygen",
77
- offset_s: 0,
78
- duration_s: typeof top.duration === "number" ? r3(top.duration) : 1.0,
79
- volume: SFX_VOLUME,
80
- });
91
+ await downloadTo(sound.url, join(hyperframesDir, file));
81
92
  } catch (e) {
82
- anomalies.push(`sfx "${name}" (id ${id}): retrieval failed — ${e.message}`);
93
+ anomalies.push(`sfx "${name}" (id ${id}): Openverse download failed — ${e.message}`);
94
+ return null;
83
95
  }
96
+ fileFor.set(key, file);
97
+ writeSfxCredit(join(destDir, "CREDITS.md"), file, sound);
98
+ entry = {
99
+ file,
100
+ source: "openverse",
101
+ duration_s: r3(sound.durationS),
102
+ license: sound.license,
103
+ credit: sound.credit,
104
+ };
105
+ openverseFor.set(key, entry);
84
106
  }
85
- return { sfx, anomalies };
107
+ return { id, name, ...entry, offset_s: 0, volume: SFX_VOLUME };
86
108
  }
87
109
 
88
- // ── offline: bundled library ──
89
- const manifestPath = join(sfxLibDir, "manifest.json");
90
- if (!existsSync(manifestPath)) {
91
- if (uniq.length)
92
- anomalies.push(`no HeyGen credential and no SFX library at ${sfxLibDir} — all cues dropped`);
93
- return { sfx, anomalies };
94
- }
95
- let manifest;
96
- try {
97
- manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
98
- } catch (e) {
99
- anomalies.push(`SFX manifest parse failed (${e.message}) — all cues dropped`);
100
- return { sfx, anomalies };
101
- }
102
- // Build lookups: by manifest key, by file basename, and by slug of either, so
103
- // a cue can name "whoosh", "whoosh.mp3", or "ui click" (→ slug match).
104
- const byKey = new Map();
105
- for (const [key, entry] of Object.entries(manifest)) {
106
- if (!entry?.file || !isFinite(entry.duration)) continue;
107
- const rec = { key, file: entry.file, duration: entry.duration };
108
- byKey.set(key, rec);
109
- byKey.set(entry.file, rec);
110
- byKey.set(slug(key), rec);
111
- byKey.set(slug(entry.file.replace(/\.\w+$/, "")), rec);
112
- }
113
- mkdirSync(destDir, { recursive: true });
114
- for (const { id, name } of uniq) {
110
+ function fromBundled({ id, name }) {
111
+ library ??= loadBundledLibrary(sfxLibDir);
112
+ if (library.error) {
113
+ anomalies.push(`sfx "${name}" (id ${id}): ${library.error} — skipped`);
114
+ return null;
115
+ }
116
+ const { byKey } = library;
115
117
  const hit = byKey.get(name) ?? byKey.get(slug(name));
116
118
  if (!hit) {
117
119
  const known = [...new Set([...byKey.values()].map((v) => v.key))].slice(0, 8).join(", ");
118
120
  anomalies.push(
119
121
  `sfx "${name}" (id ${id}): not in bundled library — skipped (have: ${known}…)`,
120
122
  );
121
- continue;
123
+ return null;
122
124
  }
125
+ mkdirSync(destDir, { recursive: true });
123
126
  const src = join(sfxLibDir, hit.file);
124
- const library = existsSync(src) ? readFileSync(src) : null;
127
+ const bytes = existsSync(src) ? readFileSync(src) : null;
125
128
  const isLibraryCopy = (rel) =>
126
- library &&
129
+ bytes &&
127
130
  existsSync(join(hyperframesDir, rel)) &&
128
- readFileSync(join(hyperframesDir, rel)).equals(library);
131
+ readFileSync(join(hyperframesDir, rel)).equals(bytes);
129
132
  const destRel =
130
133
  fileFor.get(hit.file) ??
131
134
  agentWritePath(hyperframesDir, `assets/sfx/${hit.file}`, {
132
135
  anomalies,
133
- taken: new Set(fileFor.values()),
136
+ taken: taken(),
134
137
  // An unrecorded copy of the library file is one an engine run made before the manifest had it.
135
138
  reusable: (rel) => !latestRecordFor(hyperframesDir, rel) && isLibraryCopy(rel),
136
139
  });
137
140
  const dest = join(hyperframesDir, destRel);
138
- // The bundled library may be incomplete: some installs of the skill ship
139
- // manifest.json without the actual mp3s. Pushing an sfx entry that points at
140
- // a file we never copied produces a dangling reference that silently drops
141
- // downstream ("not on disk"). Surface it as a loud anomaly and skip the cue
142
- // instead, so the audio_meta never references a missing file.
143
- if (library) {
141
+ // Some installs of the skill ship manifest.json without the mp3s. An entry
142
+ // pointing at a file we never copied would silently drop downstream, so
143
+ // skip the cue with a loud anomaly instead.
144
+ if (bytes) {
144
145
  if (!isLibraryCopy(destRel)) copyFileSync(src, dest);
145
146
  } else if (!existsSync(dest)) {
146
147
  anomalies.push(
147
148
  `sfx "${name}" (id ${id}): bundled file ${hit.file} missing from the offline ` +
148
149
  `library (${sfxLibDir}) — skipped. Reinstall the media-use skill to ` +
149
- `restore assets/sfx/*.mp3, or configure a HeyGen credential for retrieval.`,
150
+ `restore assets/sfx/*.mp3.`,
150
151
  );
151
- continue;
152
+ return null;
152
153
  } // else the engine's earlier copy at dest stands in for the file this install lacks
153
154
  fileFor.set(hit.file, destRel);
154
- sfx.push({
155
+ return {
155
156
  id,
156
157
  name,
157
158
  file: destRel,
@@ -159,7 +160,37 @@ export async function resolveSfx({ cues, heygenOK, headers, hyperframesDir, sfxL
159
160
  offset_s: 0,
160
161
  duration_s: r3(hit.duration),
161
162
  volume: SFX_VOLUME,
162
- });
163
+ };
164
+ }
165
+
166
+ for (const cue of uniq) {
167
+ let entry = null;
168
+ if (!openverseDown) {
169
+ try {
170
+ entry = await fromOpenverse(cue);
171
+ } catch (e) {
172
+ if (!(e instanceof OpenverseUnavailableError)) throw e;
173
+ openverseDown = true;
174
+ anomalies.push(`${e.message}: the remaining SFX cues use the bundled library`);
175
+ }
176
+ }
177
+ entry ??= fromBundled(cue);
178
+ if (entry) sfx.push(entry);
163
179
  }
164
180
  return { sfx, anomalies };
165
181
  }
182
+
183
+ /**
184
+ * One sound effect for `query`, saved under the project's assets/sfx/ (the entry point for hosts).
185
+ * { sound: { file, source, duration_s, license?, credit? } | null, anomalies }.
186
+ */
187
+ export async function findSoundEffect(query, { hyperframesDir, sfxLibDir = BUNDLED_SFX_DIR }) {
188
+ const { sfx, anomalies } = await resolveSfx({
189
+ cues: [{ id: "1", name: query }],
190
+ hyperframesDir,
191
+ sfxLibDir,
192
+ });
193
+ if (!sfx.length) return { sound: null, anomalies };
194
+ const { file, source, duration_s, license, credit } = sfx[0];
195
+ return { sound: { file, source, duration_s, ...(license && { license, credit }) }, anomalies };
196
+ }