hyperframes 0.8.122 → 0.8.124

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 (166) hide show
  1. package/dist/{add-UDNFUHR7.js → add-KDN5JOAP.js} +14 -14
  2. package/dist/{animatedGifPrep-2EXOFDLI.js → animatedGifPrep-H6ZIMS3L.js} +5 -5
  3. package/dist/{asset-YFEFDXZV.js → asset-EJFBLFOA.js} +2 -2
  4. package/dist/{assetCataloger-NDJSFPJN.js → assetCataloger-3Z4RUM5K.js} +2 -2
  5. package/dist/{auth-22YSZLEK.js → auth-QUOT4AVB.js} +2 -2
  6. package/dist/{autoUpdate-4OUJ7O3D.js → autoUpdate-LM6YIFM4.js} +2 -2
  7. package/dist/{backgroundChecks-FP5AP654.js → backgroundChecks-PZEX77KC.js} +3 -3
  8. package/dist/backgroundChecksWorker.js +3 -3
  9. package/dist/{batchRender-ZXQVHWED.js → batchRender-SYREZLNM.js} +3 -3
  10. package/dist/{beats-AML5JHGX.js → beats-GAROPWZX.js} +11 -11
  11. package/dist/{benchmark-6LELU6ZT.js → benchmark-R27INLK2.js} +11 -11
  12. package/dist/{browser-5NUYM6P2.js → browser-UAMARUY3.js} +8 -8
  13. package/dist/{bundleWithLocalizedFonts-TSGE3UAO.js → bundleWithLocalizedFonts-SJFCS4WH.js} +2 -2
  14. package/dist/{capture-HFYBSASN.js → capture-42GHDJSH.js} +13 -18
  15. package/dist/{capture-33BJRABG.js → capture-NT33PXQT.js} +2 -2
  16. package/dist/{catalog-KZ2PUXHS.js → catalog-DDLMJXPU.js} +14 -14
  17. package/dist/{check-RJQIUSVM.js → check-IQJEGVTI.js} +30 -30
  18. package/dist/{checkBrowser-QQZCRUSY.js → checkBrowser-QDTOZNUM.js} +33 -33
  19. package/dist/{chunk-2CYCP3AY.js → chunk-2C5KGTYX.js} +1 -1
  20. package/dist/{chunk-Q4GCD5WE.js → chunk-2X6IXODS.js} +5 -5
  21. package/dist/{chunk-A6DWJRHP.js → chunk-4HC572SI.js} +2 -2
  22. package/dist/{chunk-ABMHBIWX.js → chunk-5JLKZMCL.js} +1 -1
  23. package/dist/{chunk-BGYDTAYR.js → chunk-5UQEHMDX.js} +4 -4
  24. package/dist/{chunk-FD5UEKRP.js → chunk-5WAPJM7Q.js} +1 -1
  25. package/dist/{chunk-OZ4V6VQ2.js → chunk-72BGWI35.js} +3 -3
  26. package/dist/{chunk-2BMZEUGD.js → chunk-73KQTPK5.js} +1 -1
  27. package/dist/{chunk-7T6RZSKP.js → chunk-AIAHR2KP.js} +7 -7
  28. package/dist/{chunk-SSOJUJFQ.js → chunk-AOJIC4Z6.js} +1 -1
  29. package/dist/{chunk-TV2LRJVK.js → chunk-BDERXFDQ.js} +2 -2
  30. package/dist/{chunk-DQKPTQEA.js → chunk-BTJLBKJM.js} +5 -5
  31. package/dist/{chunk-NKS75GFI.js → chunk-CMH5CSTH.js} +2 -2
  32. package/dist/{chunk-OQ6HLIYU.js → chunk-CQMEZZGB.js} +9 -9
  33. package/dist/{chunk-NUWXK2NW.js → chunk-CSACVU42.js} +1 -1
  34. package/dist/{chunk-EB5XYJBZ.js → chunk-E5BAQVYP.js} +1 -1
  35. package/dist/chunk-F6QGSVUR.js +3571 -0
  36. package/dist/{chunk-J22NAGNW.js → chunk-FFRD7C5Y.js} +91 -27
  37. package/dist/chunk-GULOKZLI.js +155 -0
  38. package/dist/{chunk-ZIPAUKTK.js → chunk-HHUXXGXS.js} +1 -1
  39. package/dist/{chunk-74G66BSC.js → chunk-HQWCFX5E.js} +1 -1
  40. package/dist/{chunk-OHJGKETC.js → chunk-JTOXXJ2V.js} +1 -1
  41. package/dist/{chunk-LQBR4IC7.js → chunk-KAG5RIH3.js} +1 -1
  42. package/dist/{chunk-C477PVA2.js → chunk-LHUITV7D.js} +1 -1
  43. package/dist/{chunk-VP4IJ3NC.js → chunk-LVXNXV7G.js} +1 -1
  44. package/dist/{chunk-ZTWFHWRR.js → chunk-M73S37KU.js} +1 -1
  45. package/dist/{chunk-NA2DKY3M.js → chunk-N2NKZH5T.js} +9 -9
  46. package/dist/{dotEnv-AI4SYVTX.js → chunk-NGKM64BP.js} +2 -2
  47. package/dist/{chunk-UQR6DWPM.js → chunk-O2UUDQIA.js} +3 -3
  48. package/dist/{chunk-QR77QVIT.js → chunk-ONB7D3WM.js} +2 -2
  49. package/dist/{chunk-5KTDZVF3.js → chunk-ORICANJF.js} +1 -1
  50. package/dist/{chunk-3VG4A22C.js → chunk-PLQ4HJOO.js} +1 -1
  51. package/dist/{chunk-JD3C44FP.js → chunk-PSUC7UH5.js} +1 -1
  52. package/dist/{chunk-Y6WM6GY3.js → chunk-RG4XNPAS.js} +1 -1
  53. package/dist/{chunk-KCJGTEJD.js → chunk-RGAOEDD7.js} +1 -1
  54. package/dist/{chunk-26LWC4RP.js → chunk-RPVBIUYA.js} +2 -2
  55. package/dist/{chunk-DNIJ7MH6.js → chunk-SR2PZ7YB.js} +2 -2
  56. package/dist/{chunk-EBODC6OT.js → chunk-TAVCXJDZ.js} +3 -3
  57. package/dist/{chunk-UUI2OMI7.js → chunk-UB7TKYHI.js} +1 -1
  58. package/dist/{chunk-KJHOYQ2T.js → chunk-UZKSKWYC.js} +2 -2
  59. package/dist/{chunk-X6SS5FWY.js → chunk-VFCR64GO.js} +7 -7
  60. package/dist/{chunk-XIZHC6UO.js → chunk-W777U57N.js} +1 -1
  61. package/dist/{chunk-JQPICVXM.js → chunk-W7MBO3OA.js} +2 -2
  62. package/dist/{chunk-MOPVB4VL.js → chunk-WCO25Y7W.js} +2 -2
  63. package/dist/{chunk-VFZICECI.js → chunk-ZXKQI3CL.js} +1 -1
  64. package/dist/{clean-QXMDWVYB.js → clean-JUJTGQLY.js} +14 -14
  65. package/dist/cli.js +53 -52
  66. package/dist/{cloud-WTPVHGBP.js → cloud-OTGRJ55X.js} +4 -4
  67. package/dist/{cloudrun-JFN7W6XI.js → cloudrun-MR2BDNIE.js} +3 -3
  68. package/dist/{compare-CCDZEHTS.js → compare-PI2BE6XG.js} +24 -24
  69. package/dist/{compiler-DNZFJIKO.js → compiler-534QUPGQ.js} +2 -2
  70. package/dist/{component-OSVQHEQJ.js → component-23F5FX7F.js} +3 -3
  71. package/dist/{compositions-CCMN5HAA.js → compositions-QBMBMSWK.js} +10 -10
  72. package/dist/{delete-ZE7Z7SDY.js → delete-OWPF7ECQ.js} +2 -2
  73. package/dist/{dist-Z7QP5AQY.js → dist-UBW5W3J6.js} +2 -2
  74. package/dist/{doctor-KKRGCPV3.js → doctor-ET66NXTR.js} +9 -9
  75. package/dist/dotEnv-AECLXQSO.js +14 -0
  76. package/dist/{events-WJKCFVF7.js → events-IF4ANAI4.js} +7 -7
  77. package/dist/{events-YN5EY7O7.js → events-YB7V7HKA.js} +8 -8
  78. package/dist/{feedback-DHLYWTNU.js → feedback-BXKESP4T.js} +9 -9
  79. package/dist/{figma-KCTT7ELR.js → figma-HNCCW27E.js} +3 -3
  80. package/dist/fontLocalizeCli.js +7 -7
  81. package/dist/{get-XCGOGN53.js → get-BFS6LW4G.js} +2 -2
  82. package/dist/{gpuPolicy-J5CPE3TK.js → gpuPolicy-NTPPP3CY.js} +5 -5
  83. package/dist/{grade-compare-FBX5S77X.js → grade-compare-ZODD5CA2.js} +24 -24
  84. package/dist/{help-6TQVEU2Q.js → help-PDKEUTLM.js} +3 -2
  85. package/dist/{history-PPDOAO2Z.js → history-DPCZXCFM.js} +14 -14
  86. package/dist/hyperframe-runtime.js +50 -50
  87. package/dist/hyperframe.manifest.json +1 -1
  88. package/dist/hyperframe.runtime.iife.js +50 -50
  89. package/dist/hyperframes-player.global.js +1 -1
  90. package/dist/{info-42EBGTUI.js → info-BZBRLNDJ.js} +10 -10
  91. package/dist/{init-SERRMFRH.js → init-24RQT53L.js} +21 -21
  92. package/dist/{inspect-6TEDC7QZ.js → inspect-UUJSVALX.js} +25 -25
  93. package/dist/{installer-C47R4ZSU.js → installer-3MYH2BBT.js} +4 -4
  94. package/dist/{keyframes-BNLMFEWU.js → keyframes-ZBT47ZXP.js} +11 -11
  95. package/dist/{lambda-DGWKFO3L.js → lambda-254KZLDB.js} +4 -4
  96. package/dist/{layout-PMYXB4ZJ.js → layout-D7VXT7ZZ.js} +25 -25
  97. package/dist/{lint-DDNL3QQ5.js → lint-CIMEKZSB.js} +11 -11
  98. package/dist/{list-VCELX3BO.js → list-WBRIEQZM.js} +2 -2
  99. package/dist/{login-WC4YD67C.js → login-PBKOV6TK.js} +4 -4
  100. package/dist/{media-treatment-4QGIOHQR.js → media-treatment-XIBOCHGF.js} +11 -11
  101. package/dist/{models-UTVBFNKD.js → models-7UT4RZUL.js} +6 -6
  102. package/dist/{motionShot-3WDJAW4C.js → motionShot-WB7E5FU7.js} +11 -11
  103. package/dist/{normalize-audio-ZXESA45N.js → normalize-audio-PMU2FXZA.js} +10 -10
  104. package/dist/open-AVSLHVOE.js +115 -0
  105. package/dist/{pipeline-DRXU55OB.js → pipeline-5E76S72B.js} +5 -5
  106. package/dist/{play-AUEOCKY3.js → play-SVTXUB2Z.js} +19 -19
  107. package/dist/{preflight-OJJZVHWE.js → preflight-LGJKUHUZ.js} +6 -6
  108. package/dist/{present-7JZQMSSU.js → present-NE6EYZY2.js} +15 -15
  109. package/dist/{preview-3AMXHHVS.js → preview-4EGR22E3.js} +27 -22
  110. package/dist/{producer-QI55RCFB.js → producer-2ZWSHMK3.js} +1 -1
  111. package/dist/{projectConfig-EJNHN452.js → projectConfig-VC5MAYSS.js} +6 -6
  112. package/dist/{publication-SH4OD64P.js → publication-YXOCJQ7B.js} +2 -2
  113. package/dist/{publish-2B5LKGKK.js → publish-GTXVX6O4.js} +7 -7
  114. package/dist/{remove-background-EJ4SPMBK.js → remove-background-VOBXCXXJ.js} +6 -6
  115. package/dist/{render-JRYEUEAR.js → render-4PBKSNC3.js} +41 -23
  116. package/dist/{render-Y4OQV7BU.js → render-BNUTBDFX.js} +3 -3
  117. package/dist/{render-GWIDYUYW.js → render-M446K7E4.js} +11 -11
  118. package/dist/{render-batch-3F43JEX2.js → render-batch-WJKUYKUZ.js} +3 -3
  119. package/dist/{renderCancellation-WUM56H5O.js → renderCancellation-QUEUCG6R.js} +5 -5
  120. package/dist/{resolver-WHBQ7QFS.js → resolver-3QPZA2X6.js} +4 -4
  121. package/dist/{sherpa-5OYOKOOP.js → sherpa-LQHE6MT7.js} +6 -6
  122. package/dist/skills/hyperframes/SKILL.md +9 -0
  123. package/dist/skills/hyperframes/references/brief-contract.md +1 -1
  124. package/dist/skills/hyperframes/references/review-loop.md +1 -1
  125. package/dist/skills/hyperframes-cli/SKILL.md +2 -1
  126. package/dist/skills/media-use/audio/references/tts.md +24 -7
  127. package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +3 -2
  128. package/dist/skills/media-use/audio/scripts/heygen-tts.test.mjs +38 -0
  129. package/dist/skills/media-use/audio/scripts/heygen-voice.mjs +98 -0
  130. package/dist/skills/media-use/audio/scripts/heygen-voice.test.mjs +233 -0
  131. package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +19 -5
  132. package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +17 -0
  133. package/dist/skills/media-use/audio/scripts/lib/tts.mjs +2 -2
  134. package/dist/{skills-JKBN3CQS.js → skills-C6C23Z7M.js} +10 -10
  135. package/dist/{skillsUpdateCheck-KUYRATKA.js → skillsUpdateCheck-MR5TXEAX.js} +3 -3
  136. package/dist/{snapshot-M7FF5DBZ.js → snapshot-F5ZSC5FM.js} +25 -25
  137. package/dist/{src-7NUMTCMB.js → src-HP5DTGMY.js} +6 -6
  138. package/dist/{src-S7FSBDSO.js → src-WEETQ4CZ.js} +4 -4
  139. package/dist/{staticProjectServer-AQFAOUJX.js → staticProjectServer-REKZ3KBB.js} +13 -13
  140. package/dist/{status-55DDWDMV.js → status-DKHWOUB4.js} +5 -5
  141. package/dist/studio/assets/{hyperframes-player-DMzBHhbH.js → hyperframes-player-ColOZ_78.js} +1 -1
  142. package/dist/studio/assets/{index-B-YDp8hN.js → index-BuSTrK-Z.js} +146 -146
  143. package/dist/studio/assets/{index-CBRX2eZX.js → index-CVuUfURE.js} +1 -1
  144. package/dist/studio/assets/{index-t1LH6H5H.css → index-D11atkBt.css} +1 -1
  145. package/dist/studio/assets/{index-lyjhSEzi.js → index-jQEqFKMk.js} +1 -1
  146. package/dist/studio/index.html +2 -2
  147. package/dist/studio/index.js +1250 -1043
  148. package/dist/studio/index.js.map +1 -1
  149. package/dist/studio/styles.css +1 -1
  150. package/dist/{studio-api-7RBB3E3E.js → studio-api-SY5SFHZD.js} +7 -7
  151. package/dist/{studioServer-5FPPFWL6.js → studioServer-2ZWULGC5.js} +79 -37
  152. package/dist/{system-BQ72Y6YD.js → system-L7VMXFIG.js} +5 -5
  153. package/dist/{telemetry-OIX76XCZ.js → telemetry-XYNKPYSU.js} +8 -8
  154. package/dist/{timeline-JWDLDSVO.js → timeline-MYGD5OEC.js} +14 -14
  155. package/dist/{tokens-HZKAUSHK.js → tokens-6DZ73AXR.js} +2 -2
  156. package/dist/{transcribe-G2HSKAJN.js → transcribe-3BUOOE6P.js} +11 -11
  157. package/dist/{transcribe-AQSGSWXV.js → transcribe-F4DDIZJO.js} +6 -6
  158. package/dist/{tts-P3LIPIL5.js → tts-CGUXR7R4.js} +1 -1
  159. package/dist/{updateCheck-Y6ICSKXS.js → updateCheck-VH46SJ2X.js} +2 -2
  160. package/dist/{upgrade-XU35HZ7O.js → upgrade-J4MZ4MLN.js} +2 -2
  161. package/dist/{urlDownloader-V7FRXT7U.js → urlDownloader-PCRAON2D.js} +4 -4
  162. package/dist/{validate-J2D2ZRPB.js → validate-BOYSXZFW.js} +15 -15
  163. package/dist/{video-MZPB3UAC.js → video-GL7ZDEFL.js} +5 -5
  164. package/package.json +1 -1
  165. package/dist/chunk-C6MUZ4T2.js +0 -3541
  166. package/dist/{synthesize-K62TIZWI.js → synthesize-BDXNRYZN.js} +4 -4
@@ -48,7 +48,7 @@ Default to `collaborative` only when a legacy project lacks enough state to deri
48
48
 
49
49
  Autonomous mode never silently drops a required capability. If the selected workflow has no local, cached, or offline provider for it, surface the blocker instead of omitting the capability. A credential problem does not relax the quality gates.
50
50
 
51
- Rendering remains user-gated in both modes. After checks pass, collaborative runs ask “render now, or what changes?” Autonomous runs ask “preview first, or render?” Render only after the answer.
51
+ Rendering remains user-gated in both modes. After checks pass, both open the final Studio preview and ask “render now, or what changes?”; in autonomous runs it is the one kept question. Render only after the answer.
52
52
 
53
53
  ### Checkpoint feedback
54
54
 
@@ -32,6 +32,6 @@ Mark each frame `animated` as it lands. The build gate carries the loop's condit
32
32
 
33
33
  ## § 4 — The final look
34
34
 
35
- After the workflow's checks pass, use the **final composition preview**. In collaborative mode open the Studio preview, hand the timeline URL and ask one thing: render now, or what changes? In autonomous mode this is the one question the mode keeps: ask “preview first, or render?” Open the final preview on yes; render on an explicit render answer. Render only on approval.
35
+ After the workflow's checks pass, use the **final composition preview**. In both modes open the Studio preview — on the user's own machine it opens in their browser by itself; on a remote machine or sandbox, say where it runs, since localhost may not reach them — hand the timeline URL and ask one thing: render now, or what changes? In autonomous mode this is the one question the mode keeps, and the preview still opens first: a user who asked only for a video has no other way to learn they can watch and edit it before the render. Render only on approval. Leave the preview running after the render, since the user may keep editing there, and say how to stop it: `npx hyperframes preview --stop`.
36
36
 
37
37
  **After approval, offer the recipe — once.** An approved run is a proven bundle. At delivery, offer to freeze it: `media-use` → `scripts/recipe.mjs freeze --name <name>` (the workflow comes from BRIEF.md; pass `--workflow` only in a project without one) keeps the design spec, the storyboard skeleton (structure kept, content blanked), the brief skeleton, and the confirmed brief values, and the next run of this type starts from it (the intent layer checks for a matching recipe before its first question). When the freeze lands, teach the recall in the confirmation — "Saved as **<name>** (v<N>). Next time say _make another <name>_, or just _like last time_." — the name is something the system reminds the user of, never something they must remember. In autonomous mode don't ask — name the freeze command in the delivery note instead.
@@ -25,7 +25,8 @@ Run commands as `npx hyperframes ...` unless project instructions provide a wrap
25
25
  6. **Inspect sub-compositions:** when `index.html` mounts `data-composition-src`, capture midpoint snapshots and inspect each mounted scene.
26
26
  7. **Open the final Studio preview:** run `npx hyperframes preview --background`, verify the URL returns HTTP 200, hand the timeline project URL to the user, and ask whether to revise or render. Keep it alive until review ends.
27
27
  8. **Render only after approval:** use `--quality draft` while iterating, `--quality looks` for the first real encode (the CLI default), and `--quality delivery` for final delivery.
28
- 9. **Verify the output:** confirm the file exists and is non-empty. Read the render summary's second line (`beginframe` vs `screenshot`, GPU, stage timings). `screenshot` + `software gpu` on Linux is the slow path. `ffprobe -v error -show_format -show_streams` and compare duration (and fps if the brief set it) to the root `data-duration`.
28
+ 9. **Hand the project to the desktop app (on offer):** when the render's Framey line ends in `hyperframes open …`, offer `npx hyperframes open [dir]`. It opens the project in the HyperFrames desktop app and adds it to Home; under Claude Code or Codex its chat picks up this conversation. Without the app it exits 1 and prints the download link. `--json` for agents.
29
+ 10. **Verify the output:** confirm the file exists and is non-empty. Read the render summary's second line (`beginframe` vs `screenshot`, GPU, stage timings). `screenshot` + `software gpu` on Linux is the slow path. `ffprobe -v error -show_format -show_streams` and compare duration (and fps if the brief set it) to the root `data-duration`.
29
30
 
30
31
  <!-- history (trial): remove this block together with the command -->
31
32
 
@@ -40,11 +40,11 @@ Gemini is an explicit alternative to the automatic provider order below. A
40
40
  request to use Gemini already chooses the provider; do not redirect that user
41
41
  to HeyGen sign-in. Read the Gemini section for its credential requirement.
42
42
 
43
- | Order | Provider | Env trigger | Voice IDs | Word timestamps | Audio format |
44
- | ----- | ----------------- | ------------------------------------------- | ------------------------------------------- | ----------------------------------------- | -------------------- |
45
- | 1 | HeyGen (Starfish) | `$HEYGEN_API_KEY` / `~/.heygen/credentials` | UUIDs from `GET /v3/voices?engine=starfish` | **Yes** (`word_timestamps[]` in response) | mp3 → wav via ffmpeg |
46
- | 2 | ElevenLabs | `$ELEVENLABS_API_KEY` | UUIDs from elevenlabs.io dashboard | No | mp3 → wav via ffmpeg |
47
- | 3 | Kokoro-82M | always (local fallback) | `am_michael`, `af_heart`, … (54 voices) | No | wav direct |
43
+ | Order | Provider | Env trigger | Voice IDs | Word timestamps | Audio format |
44
+ | ----- | ----------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------- |
45
+ | 1 | HeyGen (Starfish) | `$HEYGEN_ACCESS_TOKEN` / `$HEYGEN_API_KEY` / `~/.heygen/credentials` | UUIDs from `GET /v3/voices?engine=starfish`, or your cloned voice ids (`heygen-voice.mjs`) | **Yes** (`word_timestamps[]` in response) | mp3 → wav via ffmpeg |
46
+ | 2 | ElevenLabs | `$ELEVENLABS_API_KEY` | UUIDs from elevenlabs.io dashboard | No | mp3 → wav via ffmpeg |
47
+ | 3 | Kokoro-82M | always (local fallback) | `am_michael`, `af_heart`, … (54 voices) | No | wav direct |
48
48
 
49
49
  ```bash
50
50
  # Local Kokoro CLI
@@ -59,7 +59,8 @@ the skill's bundled script, which calls the HeyGen v3 REST API directly and need
59
59
  no CLI provider plumbing:
60
60
 
61
61
  The script resolves a HeyGen credential the same way the CLI does — first source
62
- wins: `$HEYGEN_API_KEY` → `$HYPERFRAMES_API_KEY` → a project `.env` (auto-loaded,
62
+ wins: a host-injected OAuth `$HEYGEN_ACCESS_TOKEN` → `$HEYGEN_API_KEY` →
63
+ `$HYPERFRAMES_API_KEY` → a project `.env` (auto-loaded,
63
64
  walks up ≤5 dirs) → `~/.heygen/credentials` (shared with heygen-cli;
64
65
  `$HEYGEN_CONFIG_DIR` overrides the dir). An OAuth login is sent as
65
66
  `Authorization: Bearer`; an API key as `X-Api-Key`; both include
@@ -80,11 +81,27 @@ node skills/media-use/audio/scripts/heygen-tts.mjs ./script.txt -o narration.wav
80
81
  node skills/media-use/audio/scripts/heygen-tts.mjs --list # public starfish voices
81
82
  ```
82
83
 
83
- - **Voice:** `--voice <id>` must be a **starfish** voice_id (`--list`, or `GET /v3/voices?engine=starfish`). v2-catalog ids are rejected with HTTP 400. Omit `--voice` (English) and it defaults to **Marcia** (`05f19352e8f74b0392a8f411eba40de1`, a fixed default so the choice is deterministic). Non-English with no `--voice` falls back to the first matching catalog voice.
84
+ - **Voice:** `--voice <id>` must be a **starfish** voice_id (`--list`, or `GET /v3/voices?engine=starfish`) or one of your cloned voice ids (below). v2-catalog ids are rejected with HTTP 400. Omit `--voice` (English) and it defaults to **Marcia** (`05f19352e8f74b0392a8f411eba40de1`, a fixed default so the choice is deterministic). Non-English with no `--voice` falls back to the first matching catalog voice.
84
85
  - **Output:** `.wav` → transcoded to 44.1k mono via ffmpeg; `.mp3` → raw bytes (no ffmpeg needed).
85
86
  - **Words:** `--words <path>` writes the flat `[{id,text,start,end}]` shape below, drop-in for the captions pipeline. HeyGen's `<start>`/`<end>` boundary sentinels are filtered out and ids are re-contiguous.
86
87
  - **Non-English:** `--lang <code>` (anything but `en`) is sent as the request `language`.
87
88
 
89
+ ## Cloned voices — `scripts/heygen-voice.mjs`
90
+
91
+ Clone a voice from an mp3 or wav recording, then pass the printed id to
92
+ `heygen-tts.mjs --voice`. Same credential resolution as above. Any API refusal
93
+ (clone limit reached, plan upgrade required, voice not found) prints HeyGen's own
94
+ message to stderr and exits 1.
95
+
96
+ ```bash
97
+ node skills/media-use/audio/scripts/heygen-voice.mjs clone take.mp3 --name "My voice" # → {"voice_id":"..."}
98
+ node skills/media-use/audio/scripts/heygen-voice.mjs list --prefix "My" # → [{"voice_id","name","created_at"}]
99
+ node skills/media-use/audio/scripts/heygen-voice.mjs delete <voice_id>
100
+ ```
101
+
102
+ `clone` waits up to 120 s for the clone to finish. `list` reads
103
+ `GET /v3/voices?type=private`.
104
+
88
105
  ## When to use which provider
89
106
 
90
107
  | Goal | Use |
@@ -94,7 +94,7 @@ const voiceId = await resolveVoiceId({ provider: "heygen", userVoice, lang });
94
94
  if (!userVoice) console.error(`· using voice ${voiceId}`);
95
95
 
96
96
  // ---------- synthesize (shared engine code) ----------
97
- const { ok, words } = await synthesizeOne({
97
+ const { ok, words, error } = await synthesizeOne({
98
98
  provider: "heygen",
99
99
  text,
100
100
  voiceId,
@@ -103,7 +103,8 @@ const { ok, words } = await synthesizeOne({
103
103
  wavAbs: output,
104
104
  hyperframesDir: process.cwd(),
105
105
  });
106
- if (!ok) die("synthesis failed (HeyGen request/transcode error)");
106
+ // HeyGen's own refusal, verbatim: a caller shows it or acts on it (a deleted voice, a limit).
107
+ if (!ok) die(error ?? "synthesis failed (HeyGen request/transcode error)");
107
108
 
108
109
  let wordCount = 0;
109
110
  if (wordsPath) {
@@ -0,0 +1,38 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { mkdtempSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const script = fileURLToPath(new URL("./heygen-tts.mjs", import.meta.url));
10
+ // Every fetch answers as HeyGen does for a voice that no longer exists.
11
+ const refusing = `data:text/javascript,globalThis.fetch = async () => new Response(${JSON.stringify(
12
+ JSON.stringify({ error: { code: "voice_not_found", message: "Voice not found: vc_gone" } }),
13
+ )}, { status: 404, headers: { "content-type": "application/json" } });`;
14
+
15
+ test("a refused speech prints HeyGen's own message, so a caller can act on it", () => {
16
+ const dir = mkdtempSync(join(tmpdir(), "heygen-tts-"));
17
+ const run = spawnSync(
18
+ process.execPath,
19
+ ["--import", refusing, script, "Hello", "--voice", "vc_gone", "-o", join(dir, "out.mp3")],
20
+ {
21
+ cwd: dir,
22
+ encoding: "utf8",
23
+ env: {
24
+ ...process.env,
25
+ HEYGEN_API_KEY: "",
26
+ HYPERFRAMES_API_KEY: "",
27
+ HEYGEN_ACCESS_TOKEN: "t",
28
+ },
29
+ },
30
+ );
31
+ assert.equal(run.status, 1);
32
+ assert.match(run.stderr, /✗ heygen-tts: Voice not found: vc_gone\n/);
33
+ assert.doesNotMatch(
34
+ run.stderr,
35
+ /HTTP 404|"error"/,
36
+ "HeyGen's message alone, not the raw response",
37
+ );
38
+ });
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // HeyGen voice clones: `clone <audio> --name <n>`, `delete <voice_id>`, `list --prefix <p>` (references/tts.md).
3
+ // The clone id works as `heygen-tts.mjs --voice <id>`. A refusal prints HeyGen's own message and exits 1.
4
+ // Auth: same resolver as heygen-tts.mjs (lib/heygen.mjs).
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { extname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { parseArgs } from "node:util";
10
+ import { heygenAuthHeaders, heygenJSON, heygenMessage, loadEnvFromDir } from "./lib/heygen.mjs";
11
+
12
+ const MEDIA_TYPES = { ".mp3": "audio/mpeg", ".wav": "audio/wav" };
13
+ const POLL_MS = 2_000;
14
+ const DEADLINE_MS = 120_000;
15
+
16
+ async function clone(file, name, headers, { sleep, now }) {
17
+ const mediaType = MEDIA_TYPES[extname(file).toLowerCase()];
18
+ if (!mediaType) throw new Error(`unsupported audio file ${file}: use .mp3 or .wav`);
19
+ const data = readFileSync(file).toString("base64");
20
+ const created = await heygenJSON("/voices/clone", {
21
+ method: "POST",
22
+ headers,
23
+ body: { voice_name: name, audio: { type: "base64", media_type: mediaType, data } },
24
+ });
25
+ const id = created.data.voice_clone_id;
26
+ const deadline = now() + DEADLINE_MS;
27
+ for (;;) {
28
+ const { data: voice } = await heygenJSON(`/voices/${id}`, { headers });
29
+ if (voice.status === "complete") return { voice_id: voice.voice_id ?? id };
30
+ if (voice.status === "failed") throw new Error(voice.failure_message || "voice clone failed");
31
+ if (now() >= deadline) throw new Error(`voice clone ${id} not ready after 120 s`);
32
+ await sleep(POLL_MS);
33
+ }
34
+ }
35
+
36
+ async function list(prefix, headers) {
37
+ const mine = [];
38
+ const seen = new Set();
39
+ let token;
40
+ do {
41
+ if (token && seen.has(token)) throw new Error(`HeyGen returned page token ${token} twice`);
42
+ if (token) seen.add(token);
43
+ const query = new URLSearchParams({ type: "private", limit: "100" });
44
+ if (token) query.set("token", token);
45
+ const page = await heygenJSON(`/voices?${query}`, { headers });
46
+ mine.push(...page.data.filter((v) => (v.name ?? "").startsWith(prefix)));
47
+ token = page.has_more ? page.next_token : null;
48
+ } while (token);
49
+ // ponytail: the list response has no created_at, so one GET per match; bounded by the clone limit.
50
+ return Promise.all(
51
+ mine.map(async ({ voice_id, name }) => {
52
+ const { data } = await heygenJSON(`/voices/${voice_id}`, { headers });
53
+ return { voice_id, name, created_at: data.created_at ?? null };
54
+ }),
55
+ );
56
+ }
57
+
58
+ export async function main(
59
+ argv,
60
+ {
61
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
62
+ now = Date.now,
63
+ out = (s) => process.stdout.write(`${s}\n`),
64
+ err = (s) => process.stderr.write(`${s}\n`),
65
+ } = {},
66
+ ) {
67
+ try {
68
+ const { positionals, values } = parseArgs({
69
+ args: argv,
70
+ allowPositionals: true,
71
+ options: { name: { type: "string" }, prefix: { type: "string", default: "" } },
72
+ });
73
+ const [command, arg] = positionals;
74
+ if (command === "clone") {
75
+ if (!arg || !values.name) throw new Error("usage: clone <audio-file> --name <name>");
76
+ out(JSON.stringify(await clone(arg, values.name, heygenAuthHeaders(), { sleep, now })));
77
+ } else if (command === "delete") {
78
+ if (!arg) throw new Error("usage: delete <voice_id>");
79
+ await heygenJSON(`/voices/${encodeURIComponent(arg)}`, {
80
+ method: "DELETE",
81
+ headers: heygenAuthHeaders(),
82
+ });
83
+ } else if (command === "list") {
84
+ out(JSON.stringify(await list(values.prefix, heygenAuthHeaders())));
85
+ } else {
86
+ throw new Error("usage: heygen-voice.mjs clone|delete|list (see the header of this file)");
87
+ }
88
+ return 0;
89
+ } catch (e) {
90
+ err(heygenMessage(e));
91
+ return 1;
92
+ }
93
+ }
94
+
95
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
96
+ loadEnvFromDir(process.cwd());
97
+ process.exitCode = await main(process.argv.slice(2));
98
+ }
@@ -0,0 +1,233 @@
1
+ import { afterEach, beforeEach, test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { main } from "./heygen-voice.mjs";
7
+
8
+ const realFetch = globalThis.fetch;
9
+ const savedEnv = {
10
+ HEYGEN_ACCESS_TOKEN: process.env.HEYGEN_ACCESS_TOKEN,
11
+ HEYGEN_API_KEY: process.env.HEYGEN_API_KEY,
12
+ };
13
+ let dir;
14
+ let calls;
15
+
16
+ beforeEach(() => {
17
+ dir = mkdtempSync(join(tmpdir(), "heygen-voice-"));
18
+ delete process.env.HEYGEN_ACCESS_TOKEN;
19
+ process.env.HEYGEN_API_KEY = "hg_test";
20
+ calls = [];
21
+ });
22
+
23
+ afterEach(() => {
24
+ globalThis.fetch = realFetch;
25
+ for (const [k, v] of Object.entries(savedEnv)) {
26
+ if (v === undefined) delete process.env[k];
27
+ else process.env[k] = v;
28
+ }
29
+ rmSync(dir, { recursive: true, force: true });
30
+ });
31
+
32
+ // routes: "METHOD /path" → array of [status, body] answered in order (last one repeats).
33
+ function mockFetch(routes) {
34
+ globalThis.fetch = async (url, opts) => {
35
+ const key = `${opts.method} ${String(url).replace("https://api.heygen.com/v3", "")}`;
36
+ calls.push({ key, opts });
37
+ const queue = routes[key];
38
+ if (!queue) throw new Error(`unexpected request ${key}`);
39
+ const [status, body] = queue.length > 1 ? queue.shift() : queue[0];
40
+ return new Response(body === undefined ? null : JSON.stringify(body), { status });
41
+ };
42
+ }
43
+
44
+ async function run(argv, { sleep = async () => {}, now = () => 0 } = {}) {
45
+ const out = [];
46
+ const err = [];
47
+ const code = await main(argv, {
48
+ sleep,
49
+ now,
50
+ out: (s) => out.push(s),
51
+ err: (s) => err.push(s),
52
+ });
53
+ return { code, out: out.join("\n"), err: err.join("\n") };
54
+ }
55
+
56
+ function audioFile(name = "sample.mp3") {
57
+ const path = join(dir, name);
58
+ writeFileSync(path, Buffer.from("fake-audio"));
59
+ return path;
60
+ }
61
+
62
+ test("clone uploads base64 audio, polls until complete, prints the voice id", async () => {
63
+ mockFetch({
64
+ "POST /voices/clone": [[200, { data: { voice_clone_id: "vc1" } }]],
65
+ "GET /voices/vc1": [
66
+ [200, { data: { status: "processing", voice_id: "vc1" } }],
67
+ [200, { data: { status: "complete", voice_id: "vc1", name: "Me" } }],
68
+ ],
69
+ });
70
+ const sleeps = [];
71
+ const r = await run(["clone", audioFile(), "--name", "Me"], {
72
+ sleep: async (ms) => sleeps.push(ms),
73
+ });
74
+ assert.deepEqual(r, { code: 0, out: '{"voice_id":"vc1"}', err: "" });
75
+ assert.deepEqual(sleeps, [2000]);
76
+ const post = calls[0];
77
+ assert.equal(post.opts.headers["X-Api-Key"], "hg_test");
78
+ assert.deepEqual(JSON.parse(post.opts.body), {
79
+ voice_name: "Me",
80
+ audio: {
81
+ type: "base64",
82
+ media_type: "audio/mpeg",
83
+ data: Buffer.from("fake-audio").toString("base64"),
84
+ },
85
+ });
86
+ });
87
+
88
+ test("clone sends audio/wav for a .wav file", async () => {
89
+ mockFetch({
90
+ "POST /voices/clone": [[200, { data: { voice_clone_id: "vc1" } }]],
91
+ "GET /voices/vc1": [[200, { data: { status: "complete", voice_id: "vc1" } }]],
92
+ });
93
+ const r = await run(["clone", audioFile("take.WAV"), "--name", "Me"]);
94
+ assert.equal(r.code, 0);
95
+ assert.equal(JSON.parse(calls[0].opts.body).audio.media_type, "audio/wav");
96
+ });
97
+
98
+ test("clone prints HeyGen's refusal verbatim and exits 1", async () => {
99
+ const message =
100
+ "Voice clone limit reached (10). Delete unused clones or contact support to increase your limit.";
101
+ mockFetch({
102
+ "POST /voices/clone": [[400, { error: { code: "resource_limit_reached", message } }]],
103
+ });
104
+ const r = await run(["clone", audioFile(), "--name", "Me"]);
105
+ assert.deepEqual(r, { code: 1, out: "", err: message });
106
+ });
107
+
108
+ test("clone surfaces a failed clone's failure_message", async () => {
109
+ mockFetch({
110
+ "POST /voices/clone": [[200, { data: { voice_clone_id: "vc1" } }]],
111
+ "GET /voices/vc1": [[200, { data: { status: "failed", failure_message: "Audio too noisy" } }]],
112
+ });
113
+ const r = await run(["clone", audioFile(), "--name", "Me"]);
114
+ assert.deepEqual(r, { code: 1, out: "", err: "Audio too noisy" });
115
+ });
116
+
117
+ test("clone gives up after the 120 s deadline", async () => {
118
+ mockFetch({
119
+ "POST /voices/clone": [[200, { data: { voice_clone_id: "vc1" } }]],
120
+ "GET /voices/vc1": [[200, { data: { status: "processing" } }]],
121
+ });
122
+ let clock = 0;
123
+ const r = await run(["clone", audioFile(), "--name", "Me"], {
124
+ now: () => clock,
125
+ sleep: async (ms) => {
126
+ clock += ms;
127
+ },
128
+ });
129
+ assert.deepEqual(r, { code: 1, out: "", err: "voice clone vc1 not ready after 120 s" });
130
+ assert.equal(calls.filter((c) => c.key === "GET /voices/vc1").length, 61);
131
+ });
132
+
133
+ test("clone rejects an audio type other than mp3 or wav without calling HeyGen", async () => {
134
+ mockFetch({});
135
+ const r = await run(["clone", audioFile("take.ogg"), "--name", "Me"]);
136
+ assert.equal(r.code, 1);
137
+ assert.match(r.err, /use \.mp3 or \.wav/);
138
+ assert.equal(calls.length, 0);
139
+ });
140
+
141
+ test("delete calls DELETE /voices/<id> and exits 0", async () => {
142
+ mockFetch({ "DELETE /voices/vc1": [[200, { data: {} }]] });
143
+ const r = await run(["delete", "vc1"]);
144
+ assert.deepEqual(r, { code: 0, out: "", err: "" });
145
+ });
146
+
147
+ test("delete exits 0 on a 204 with no body, and escapes the id in the path", async () => {
148
+ mockFetch({ "DELETE /voices/a%2Fb": [[204]] });
149
+ const r = await run(["delete", "a/b"]);
150
+ assert.deepEqual(r, { code: 0, out: "", err: "" });
151
+ });
152
+
153
+ test("clone falls back to the clone id when the finished voice has no voice_id", async () => {
154
+ mockFetch({
155
+ "POST /voices/clone": [[200, { data: { voice_clone_id: "vc9" } }]],
156
+ "GET /voices/vc9": [[200, { data: { status: "complete" } }]],
157
+ });
158
+ const r = await run(["clone", audioFile(), "--name", "Me"]);
159
+ assert.deepEqual(r, { code: 0, out: '{"voice_id":"vc9"}', err: "" });
160
+ });
161
+
162
+ test("list skips a nameless voice and stops on a page token HeyGen repeats", async () => {
163
+ mockFetch({
164
+ "GET /voices?type=private&limit=100": [
165
+ [200, { data: [{ voice_id: "n", name: null }], has_more: true, next_token: "t2" }],
166
+ ],
167
+ "GET /voices?type=private&limit=100&token=t2": [
168
+ [200, { data: [], has_more: true, next_token: "t2" }],
169
+ ],
170
+ });
171
+ // A fake answers at once, so a list that never stops would starve any timeout: cap the calls instead.
172
+ const answer = globalThis.fetch;
173
+ globalThis.fetch = (...args) =>
174
+ calls.length >= 10 ? Promise.reject(new Error("paged forever")) : answer(...args);
175
+ const r = await run(["list", "--prefix", "desk-"]);
176
+ assert.deepEqual(r, { code: 1, out: "", err: "HeyGen returned page token t2 twice" });
177
+ });
178
+
179
+ test("delete prints HeyGen's 404 message verbatim and exits 1", async () => {
180
+ mockFetch({
181
+ "DELETE /voices/gone": [
182
+ [404, { error: { code: "voice_not_found", message: "Voice not found: gone" } }],
183
+ ],
184
+ });
185
+ const r = await run(["delete", "gone"]);
186
+ assert.deepEqual(r, { code: 1, out: "", err: "Voice not found: gone" });
187
+ });
188
+
189
+ test("list pages through private voices and returns prefix matches with created_at", async () => {
190
+ mockFetch({
191
+ "GET /voices?type=private&limit=100": [
192
+ [
193
+ 200,
194
+ {
195
+ data: [
196
+ { voice_id: "a", name: "desk-1" },
197
+ { voice_id: "b", name: "other" },
198
+ ],
199
+ has_more: true,
200
+ next_token: "t2",
201
+ },
202
+ ],
203
+ ],
204
+ "GET /voices?type=private&limit=100&token=t2": [
205
+ [200, { data: [{ voice_id: "c", name: "desk-2" }], has_more: false }],
206
+ ],
207
+ "GET /voices/a": [[200, { data: { voice_id: "a", created_at: 1700000000 } }]],
208
+ "GET /voices/c": [[200, { data: { voice_id: "c", created_at: 1700000100 } }]],
209
+ });
210
+ const r = await run(["list", "--prefix", "desk-"]);
211
+ assert.equal(r.code, 0);
212
+ assert.deepEqual(JSON.parse(r.out), [
213
+ { voice_id: "a", name: "desk-1", created_at: 1700000000 },
214
+ { voice_id: "c", name: "desk-2", created_at: 1700000100 },
215
+ ]);
216
+ });
217
+
218
+ test("list prints HeyGen's refusal verbatim and exits 1", async () => {
219
+ const message = "Invalid or expired API key. Verify your x-api-key header.";
220
+ mockFetch({
221
+ "GET /voices?type=private&limit=100": [[401, { error: { code: "unauthorized", message } }]],
222
+ });
223
+ const r = await run(["list", "--prefix", "desk-"]);
224
+ assert.deepEqual(r, { code: 1, out: "", err: message });
225
+ });
226
+
227
+ test("a host-injected HEYGEN_ACCESS_TOKEN is sent as Bearer", async () => {
228
+ process.env.HEYGEN_ACCESS_TOKEN = "at_host";
229
+ mockFetch({ "DELETE /voices/vc1": [[200, { data: {} }]] });
230
+ await run(["delete", "vc1"]);
231
+ assert.equal(calls[0].opts.headers.Authorization, "Bearer at_host");
232
+ assert.equal(calls[0].opts.headers["X-Api-Key"], undefined);
233
+ });
@@ -1,7 +1,8 @@
1
1
  import { fetchMedia } from "../../../scripts/lib/media-fetch.mjs";
2
2
  // heygen.mjs — vendored HeyGen REST helpers (auth + transport) for the audio
3
3
  // pipeline. The credential resolver matches the hyperframes CLI auth: first
4
- // usable source wins — $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
4
+ // usable source wins — a host-injected OAuth $HEYGEN_ACCESS_TOKEN (Bearer) →
5
+ // $HEYGEN_API_KEY / $HYPERFRAMES_API_KEY → a nearby .env → ~/.heygen/
5
6
  // credentials (oauth → Bearer, else api_key → X-Api-Key; $HEYGEN_CONFIG_DIR
6
7
  // overrides the dir). Vendored so the skill ships standalone. Pure node.
7
8
 
@@ -67,6 +68,8 @@ export function heygenCredential() {
67
68
  // (a folder, a locked ~/.heygen), so heygenAuthHeaders can say to fix that path: logging in again would fail there too.
68
69
  // Read without checking first, so the file cannot change between a check and the read.
69
70
  function resolveCredential() {
71
+ const accessToken = process.env.HEYGEN_ACCESS_TOKEN;
72
+ if (accessToken) return { headers: { Authorization: `Bearer ${accessToken}` } };
70
73
  const envKey = process.env.HEYGEN_API_KEY || process.env.HYPERFRAMES_API_KEY;
71
74
  if (envKey) return { headers: { "X-Api-Key": envKey } };
72
75
 
@@ -144,11 +147,22 @@ export async function heygenJSON(path, { method = "GET", headers = {}, body } =
144
147
  const res = await fetch(`${HEYGEN_BASE}${path}`, opts);
145
148
  if (!res.ok) {
146
149
  const detail = await res.text().catch(() => "");
147
- throw new Error(
148
- `HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`,
149
- );
150
+ const message = `HeyGen ${method} ${path} → HTTP ${res.status}${detail ? `\n${detail.slice(0, 300)}` : ""}`;
151
+ throw Object.assign(new Error(message), { status: res.status, body: detail });
152
+ }
153
+ // A DELETE may answer 204 with no body.
154
+ const text = await res.text();
155
+ return text ? JSON.parse(text) : {};
156
+ }
157
+
158
+ // HeyGen's own words for a failed call: its {"error":{"message"}}, else the raw body, else the error's message.
159
+ export function heygenMessage(e) {
160
+ if (!e?.body) return e?.message ? String(e.message) : String(e);
161
+ try {
162
+ return JSON.parse(e.body).error?.message ?? e.body;
163
+ } catch {
164
+ return e.body;
150
165
  }
151
- return res.json();
152
166
  }
153
167
 
154
168
  // Download a (presigned) URL to destPath; returns byte length.
@@ -11,15 +11,19 @@ import {
11
11
  } from "./heygen.mjs";
12
12
 
13
13
  function withCleanHeygenEnv(fn) {
14
+ const previousAccessToken = process.env.HEYGEN_ACCESS_TOKEN;
14
15
  const previousApiKey = process.env.HEYGEN_API_KEY;
15
16
  const previousHyperframesApiKey = process.env.HYPERFRAMES_API_KEY;
16
17
  const previousConfigDir = process.env.HEYGEN_CONFIG_DIR;
17
18
  try {
19
+ delete process.env.HEYGEN_ACCESS_TOKEN;
18
20
  delete process.env.HEYGEN_API_KEY;
19
21
  delete process.env.HYPERFRAMES_API_KEY;
20
22
  delete process.env.HEYGEN_CONFIG_DIR;
21
23
  return fn();
22
24
  } finally {
25
+ if (previousAccessToken === undefined) delete process.env.HEYGEN_ACCESS_TOKEN;
26
+ else process.env.HEYGEN_ACCESS_TOKEN = previousAccessToken;
23
27
  if (previousApiKey === undefined) delete process.env.HEYGEN_API_KEY;
24
28
  else process.env.HEYGEN_API_KEY = previousApiKey;
25
29
  if (previousHyperframesApiKey === undefined) delete process.env.HYPERFRAMES_API_KEY;
@@ -67,6 +71,19 @@ test("heygenAuthHeaders tags OAuth requests as CLI traffic and with the media-us
67
71
  });
68
72
  });
69
73
 
74
+ test("a host-injected HEYGEN_ACCESS_TOKEN wins over an API key and is treated as OAuth", () => {
75
+ withCleanHeygenEnv(() => {
76
+ process.env.HEYGEN_ACCESS_TOKEN = "at_host";
77
+ process.env.HEYGEN_API_KEY = "hg_test";
78
+ assert.deepEqual(heygenAuthHeaders(), {
79
+ Authorization: "Bearer at_host",
80
+ "X-HeyGen-Source": "cli",
81
+ "X-HeyGen-Client-Source": "media-use",
82
+ });
83
+ assert.equal(heygenAuthMethod(), "oauth");
84
+ });
85
+ });
86
+
70
87
  test("heygenAuthMethod returns api_key for an env API key, without tagging headers", () => {
71
88
  withCleanHeygenEnv(() => {
72
89
  process.env.HEYGEN_API_KEY = "hg_test";
@@ -18,7 +18,7 @@ import { spawn, spawnSync } from "node:child_process";
18
18
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
20
  import { dirname, join } from "node:path";
21
- import { heygenAuthHeaders, heygenCredential, heygenJSON } from "./heygen.mjs";
21
+ import { heygenAuthHeaders, heygenCredential, heygenJSON, heygenMessage } from "./heygen.mjs";
22
22
  import { pythonInvocation } from "./python.mjs";
23
23
  import { synthesizeGemini } from "./gemini-tts.mjs";
24
24
  import { geminiConfigured } from "./gemini-auth.mjs";
@@ -350,7 +350,7 @@ export async function synthesizeHeygen({ text, voiceId, lang, speed, wavAbs }, d
350
350
  : [];
351
351
  return { ok: true, words };
352
352
  } catch (e) {
353
- return { ok: false, words: null, error: e?.message ? String(e.message) : String(e) };
353
+ return { ok: false, words: null, error: heygenMessage(e) };
354
354
  }
355
355
  }
356
356
 
@@ -9,15 +9,15 @@ import {
9
9
  } from "./chunk-WMQNIQDJ.js";
10
10
  import {
11
11
  trackSkillsInstallSkipped
12
- } from "./chunk-OZ4V6VQ2.js";
13
- import "./chunk-UQR6DWPM.js";
12
+ } from "./chunk-72BGWI35.js";
13
+ import "./chunk-O2UUDQIA.js";
14
14
  import {
15
15
  diag
16
16
  } from "./chunk-VWG7OQKU.js";
17
17
  import {
18
18
  c
19
19
  } from "./chunk-ZTZA4UWZ.js";
20
- import "./chunk-EB5XYJBZ.js";
20
+ import "./chunk-E5BAQVYP.js";
21
21
  import "./chunk-AMKC3AXA.js";
22
22
  import {
23
23
  defineCommand
@@ -27,12 +27,12 @@ import {
27
27
  CliResultSignal,
28
28
  setCommandExitCode
29
29
  } from "./chunk-PBN6GBJE.js";
30
- import "./chunk-NKS75GFI.js";
31
- import "./chunk-VP4IJ3NC.js";
30
+ import "./chunk-CMH5CSTH.js";
31
+ import "./chunk-LVXNXV7G.js";
32
32
  import "./chunk-F7MXHGU3.js";
33
33
  import "./chunk-DPVXBVLL.js";
34
- import "./chunk-LQBR4IC7.js";
35
- import "./chunk-C6MUZ4T2.js";
34
+ import "./chunk-KAG5RIH3.js";
35
+ import "./chunk-F6QGSVUR.js";
36
36
  import "./chunk-VC76R6W2.js";
37
37
  import "./chunk-OFQWTKPW.js";
38
38
  import "./chunk-TWWSYAKJ.js";
@@ -51,15 +51,15 @@ import {
51
51
  isCoreSkill,
52
52
  presentSkills,
53
53
  pruneOrphanedLockEntries
54
- } from "./chunk-KCJGTEJD.js";
54
+ } from "./chunk-RGAOEDD7.js";
55
55
  import {
56
56
  withMeta
57
- } from "./chunk-74G66BSC.js";
57
+ } from "./chunk-HQWCFX5E.js";
58
58
  import "./chunk-K4WIOZFP.js";
59
59
  import "./chunk-XUXGIA7H.js";
60
60
  import "./chunk-WERLJGIC.js";
61
61
  import "./chunk-ISN5GRYI.js";
62
- import "./chunk-DNIJ7MH6.js";
62
+ import "./chunk-SR2PZ7YB.js";
63
63
  import "./chunk-KNU5COPB.js";
64
64
  import {
65
65
  buildNpxCommand