voxflow 1.14.0 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -2
- package/bin/voxflow.js +27 -0
- package/dist/index.js +1 -1
- package/dist/remotion-bundle/02a2fb2eb80bc7bf.woff2 +0 -0
- package/dist/remotion-bundle/052ca5351e5e06ba.woff2 +0 -0
- package/dist/remotion-bundle/05853dd28f4019cb.woff2 +0 -0
- package/dist/remotion-bundle/072ead3737f7c0d0.woff2 +0 -0
- package/dist/remotion-bundle/07d4248613c86a2e.woff2 +0 -0
- package/dist/remotion-bundle/0884a5c2d1d2d99b.woff2 +0 -0
- package/dist/remotion-bundle/0b0e185b2752095e.woff2 +0 -0
- package/dist/remotion-bundle/0e66c11bde067d91.woff2 +0 -0
- package/dist/remotion-bundle/0f7794cfba2c5d21.woff2 +0 -0
- package/dist/remotion-bundle/0fdbae5a4365783a.woff2 +0 -0
- package/dist/remotion-bundle/112.bundle.js +11 -0
- package/dist/remotion-bundle/112.bundle.js.map +1 -0
- package/dist/remotion-bundle/113.bundle.js +11 -0
- package/dist/remotion-bundle/113.bundle.js.map +1 -0
- package/dist/remotion-bundle/119cae0c4c16f7ed.woff2 +0 -0
- package/dist/remotion-bundle/14725f649fd1e78c.woff2 +0 -0
- package/dist/remotion-bundle/14abe9e3f95f7888.woff2 +0 -0
- package/dist/remotion-bundle/163.bundle.js +14678 -0
- package/dist/remotion-bundle/163.bundle.js.map +1 -0
- package/dist/remotion-bundle/1808c54072bf6d14.woff2 +0 -0
- package/dist/remotion-bundle/18948bec3e3012fe.woff2 +0 -0
- package/dist/remotion-bundle/1a661c60d0fc84fc.woff2 +0 -0
- package/dist/remotion-bundle/1af94941e1bc7e1e.woff2 +0 -0
- package/dist/remotion-bundle/1bee0219595f606c.woff2 +0 -0
- package/dist/remotion-bundle/1bfd5da7ce9d4ec4.woff2 +0 -0
- package/dist/remotion-bundle/1c158d56f1884f3f.woff2 +0 -0
- package/dist/remotion-bundle/1cf5e88e667610eb.woff2 +0 -0
- package/dist/remotion-bundle/1d431bd10f53c481.woff2 +0 -0
- package/dist/remotion-bundle/1d701a81a7670db2.woff2 +0 -0
- package/dist/remotion-bundle/1da0fecad4240f16.woff2 +0 -0
- package/dist/remotion-bundle/1ed14d3d0c5c63fe.woff2 +0 -0
- package/dist/remotion-bundle/1edfecf40e586f53.woff2 +0 -0
- package/dist/remotion-bundle/1f479711bc34b054.woff +0 -0
- package/dist/remotion-bundle/1f86e54a0ff5fcd1.woff2 +0 -0
- package/dist/remotion-bundle/2043ea87d9aabd11.woff2 +0 -0
- package/dist/remotion-bundle/20563c39ee8a0e40.woff2 +0 -0
- package/dist/remotion-bundle/20c231590fd12c44.woff2 +0 -0
- package/dist/remotion-bundle/20ce61713f754c07.woff2 +0 -0
- package/dist/remotion-bundle/21eb9306fce24bb1.woff2 +0 -0
- package/dist/remotion-bundle/244bf71c0cc851af.woff2 +0 -0
- package/dist/remotion-bundle/274d4cfc02bffbcb.woff2 +0 -0
- package/dist/remotion-bundle/275.bundle.js +21 -0
- package/dist/remotion-bundle/275.bundle.js.map +1 -0
- package/dist/remotion-bundle/2958f540b39513dc.woff2 +0 -0
- package/dist/remotion-bundle/2a168b98fd97722e.woff2 +0 -0
- package/dist/remotion-bundle/2d1f6373937ab55f.woff2 +0 -0
- package/dist/remotion-bundle/2d213ae47ff6daa9.woff2 +0 -0
- package/dist/remotion-bundle/2e4b1f04fcd05047.woff2 +0 -0
- package/dist/remotion-bundle/304170d98f4c4563.woff2 +0 -0
- package/dist/remotion-bundle/30d02e136e7a5642.woff2 +0 -0
- package/dist/remotion-bundle/3135562b52a714cd.woff2 +0 -0
- package/dist/remotion-bundle/313713af2c8144e9.woff2 +0 -0
- package/dist/remotion-bundle/325fa4108d2285b9.woff2 +0 -0
- package/dist/remotion-bundle/338e927ed3345e0c.woff2 +0 -0
- package/dist/remotion-bundle/35fc6b190365bc17.woff2 +0 -0
- package/dist/remotion-bundle/37a51f1122d4efc5.woff2 +0 -0
- package/dist/remotion-bundle/39a4d63e02736f5e.woff2 +0 -0
- package/dist/remotion-bundle/3a00e0d62dfc4171.woff2 +0 -0
- package/dist/remotion-bundle/3a6955e6561affe1.woff2 +0 -0
- package/dist/remotion-bundle/3c573945aef49b89.woff2 +0 -0
- package/dist/remotion-bundle/3cdbfbfa23b516a5.woff2 +0 -0
- package/dist/remotion-bundle/3e42f85a9e64ca8a.woff2 +0 -0
- package/dist/remotion-bundle/3e83eaf1ec859415.woff2 +0 -0
- package/dist/remotion-bundle/3f3c8c90de1250ee.woff2 +0 -0
- package/dist/remotion-bundle/434.bundle.js +205 -0
- package/dist/remotion-bundle/434.bundle.js.map +1 -0
- package/dist/remotion-bundle/44ffc6ca4d781692.woff2 +0 -0
- package/dist/remotion-bundle/4670d9c4580b09eb.woff2 +0 -0
- package/dist/remotion-bundle/479756881b302824.woff2 +0 -0
- package/dist/remotion-bundle/481b82134bfa9c82.woff2 +0 -0
- package/dist/remotion-bundle/48d27029626f4328.woff2 +0 -0
- package/dist/remotion-bundle/49b7b2a30329c511.woff2 +0 -0
- package/dist/remotion-bundle/4c8b25a1a9337045.woff2 +0 -0
- package/dist/remotion-bundle/4cba14788ca9259b.woff2 +0 -0
- package/dist/remotion-bundle/4cd6c589c004a6a7.woff2 +0 -0
- package/dist/remotion-bundle/4cd8d79c1021608d.woff2 +0 -0
- package/dist/remotion-bundle/4d8fa99b3f00f9f0.woff2 +0 -0
- package/dist/remotion-bundle/4e7805a643f86d53.woff2 +0 -0
- package/dist/remotion-bundle/4ff91be454542e3f.woff2 +0 -0
- package/dist/remotion-bundle/504cbcba1f63591b.woff2 +0 -0
- package/dist/remotion-bundle/5202d792e5791d6c.woff2 +0 -0
- package/dist/remotion-bundle/534db5ad4770cc1d.woff2 +0 -0
- package/dist/remotion-bundle/53b9568eb85f866b.woff2 +0 -0
- package/dist/remotion-bundle/543ad386ca171de9.woff2 +0 -0
- package/dist/remotion-bundle/54798e55bbf7976e.woff2 +0 -0
- package/dist/remotion-bundle/580.bundle.js +11 -0
- package/dist/remotion-bundle/580.bundle.js.map +1 -0
- package/dist/remotion-bundle/58d174d1193af6d1.woff2 +0 -0
- package/dist/remotion-bundle/591d29ff3ff53c80.woff2 +0 -0
- package/dist/remotion-bundle/5c28c4f4824383c6.woff2 +0 -0
- package/dist/remotion-bundle/5da9740d2ce894c8.woff2 +0 -0
- package/dist/remotion-bundle/6197735364642360.woff2 +0 -0
- package/dist/remotion-bundle/6265a4335724080f.woff2 +0 -0
- package/dist/remotion-bundle/633f5e4f6394daa7.woff2 +0 -0
- package/dist/remotion-bundle/637d95ace6a69c49.woff2 +0 -0
- package/dist/remotion-bundle/648e04a04dacff8f.woff2 +0 -0
- package/dist/remotion-bundle/64a6e83045a008b2.woff2 +0 -0
- package/dist/remotion-bundle/651.bundle.js +11 -0
- package/dist/remotion-bundle/651.bundle.js.map +1 -0
- package/dist/remotion-bundle/65e2a988c070facc.woff2 +0 -0
- package/dist/remotion-bundle/66a2f6ce5cc69105.woff2 +0 -0
- package/dist/remotion-bundle/690.bundle.js +3479 -0
- package/dist/remotion-bundle/690.bundle.js.map +1 -0
- package/dist/remotion-bundle/690ff55252ca715d.woff2 +0 -0
- package/dist/remotion-bundle/6a01a1cff49314fc.woff2 +0 -0
- package/dist/remotion-bundle/6cbc32670982986c.woff2 +0 -0
- package/dist/remotion-bundle/6d3cc42ae547f454.woff2 +0 -0
- package/dist/remotion-bundle/6d8f4cfa1ddc0830.woff2 +0 -0
- package/dist/remotion-bundle/6e4d7c6ae65e2dc3.woff2 +0 -0
- package/dist/remotion-bundle/6e86418bbcefb2e8.woff2 +0 -0
- package/dist/remotion-bundle/6ee02884b29cf7fb.woff2 +0 -0
- package/dist/remotion-bundle/6f436a74c9e3252c.woff2 +0 -0
- package/dist/remotion-bundle/78c8022f1657618b.woff2 +0 -0
- package/dist/remotion-bundle/7c5444169792bca4.woff2 +0 -0
- package/dist/remotion-bundle/7c86bddd9d997212.woff2 +0 -0
- package/dist/remotion-bundle/7e1284684767f584.woff2 +0 -0
- package/dist/remotion-bundle/7e81c17522d182b2.woff2 +0 -0
- package/dist/remotion-bundle/7eb87be198f7858c.woff2 +0 -0
- package/dist/remotion-bundle/8060c928f948aab5.woff2 +0 -0
- package/dist/remotion-bundle/80bc9dfbea2b35ae.woff2 +0 -0
- package/dist/remotion-bundle/811b83f69963bb48.woff2 +0 -0
- package/dist/remotion-bundle/813.bundle.js +117511 -0
- package/dist/remotion-bundle/813.bundle.js.map +1 -0
- package/dist/remotion-bundle/84df492e349f82e9.woff2 +0 -0
- package/dist/remotion-bundle/8501bfd73eb36f2b.woff2 +0 -0
- package/dist/remotion-bundle/854236a8376093fe.woff2 +0 -0
- package/dist/remotion-bundle/8571d74529082753.woff2 +0 -0
- package/dist/remotion-bundle/860bf44f8e6f4b5d.woff2 +0 -0
- package/dist/remotion-bundle/879.bundle.js +64 -0
- package/dist/remotion-bundle/879.bundle.js.map +1 -0
- package/dist/remotion-bundle/887dd482f848d56f.woff2 +0 -0
- package/dist/remotion-bundle/89b2132e85fbbb5a.woff2 +0 -0
- package/dist/remotion-bundle/8ba60d6c306010c2.woff2 +0 -0
- package/dist/remotion-bundle/8c7c4dadea897806.woff2 +0 -0
- package/dist/remotion-bundle/8c943f9999706f61.woff2 +0 -0
- package/dist/remotion-bundle/8f2a718c90575cc9.woff2 +0 -0
- package/dist/remotion-bundle/906b6edb3e1772c9.woff2 +0 -0
- package/dist/remotion-bundle/930ff9daccdf14eb.woff2 +0 -0
- package/dist/remotion-bundle/934db2f1c403c4d0.woff2 +0 -0
- package/dist/remotion-bundle/938.bundle.js +451 -0
- package/dist/remotion-bundle/938.bundle.js.map +1 -0
- package/dist/remotion-bundle/967.bundle.js +4462 -0
- package/dist/remotion-bundle/967.bundle.js.map +1 -0
- package/dist/remotion-bundle/9684a1093d3c02ce.woff2 +0 -0
- package/dist/remotion-bundle/973dcd0faa6116cc.woff2 +0 -0
- package/dist/remotion-bundle/9745400694e76cd8.woff2 +0 -0
- package/dist/remotion-bundle/999ef957bed3bdca.woff2 +0 -0
- package/dist/remotion-bundle/99a3d67c8b0f43e3.woff2 +0 -0
- package/dist/remotion-bundle/a0586c3e03127283.woff2 +0 -0
- package/dist/remotion-bundle/a0eb654fdae46269.woff2 +0 -0
- package/dist/remotion-bundle/a20e35d3b08f7994.woff2 +0 -0
- package/dist/remotion-bundle/a2dcaced7c8c25ab.woff2 +0 -0
- package/dist/remotion-bundle/a79255a972a2681a.woff2 +0 -0
- package/dist/remotion-bundle/a804b352cb9fec1a.woff2 +0 -0
- package/dist/remotion-bundle/aae7117164e1eabc.woff2 +0 -0
- package/dist/remotion-bundle/affd121385d0442d.woff2 +0 -0
- package/dist/remotion-bundle/b19a6083987ee0d7.woff2 +0 -0
- package/dist/remotion-bundle/b1b2bd04d8637981.woff2 +0 -0
- package/dist/remotion-bundle/b2c07f341486be87.woff2 +0 -0
- package/dist/remotion-bundle/b33d8f82e575c4ce.woff2 +0 -0
- package/dist/remotion-bundle/b366c0bed35ef491.woff2 +0 -0
- package/dist/remotion-bundle/b41e857ec1b85642.woff2 +0 -0
- package/dist/remotion-bundle/b420bb34ccf23e7f.woff2 +0 -0
- package/dist/remotion-bundle/b4f7bf4efb0c0ccf.woff2 +0 -0
- package/dist/remotion-bundle/b60fe5eca03cff93.woff2 +0 -0
- package/dist/remotion-bundle/b6bd31a336e64bce.woff2 +0 -0
- package/dist/remotion-bundle/b6d2befba3dfefeb.woff2 +0 -0
- package/dist/remotion-bundle/b75f39ab06c43bf4.woff2 +0 -0
- package/dist/remotion-bundle/b77880e8c413d4fd.woff2 +0 -0
- package/dist/remotion-bundle/b7e38ec441e4a77a.woff2 +0 -0
- package/dist/remotion-bundle/b83baa383ff0bf2b.woff2 +0 -0
- package/dist/remotion-bundle/b9ad7b6c0a11450a.woff2 +0 -0
- package/dist/remotion-bundle/baf84486e8ae3aaf.woff2 +0 -0
- package/dist/remotion-bundle/bc047b1f6869cffa.woff2 +0 -0
- package/dist/remotion-bundle/bf4f3ac6e93f33aa.woff2 +0 -0
- package/dist/remotion-bundle/bf6835ffec5897a2.woff2 +0 -0
- package/dist/remotion-bundle/bf8885f581eb1724.woff2 +0 -0
- package/dist/remotion-bundle/bundle.js +83376 -0
- package/dist/remotion-bundle/bundle.js.map +1 -0
- package/dist/remotion-bundle/c03f046bccd789d0.woff2 +0 -0
- package/dist/remotion-bundle/c0bb1f8962b73bc3.woff2 +0 -0
- package/dist/remotion-bundle/c1003f9a7db6e1cf.woff2 +0 -0
- package/dist/remotion-bundle/c15d83fb1e199515.woff2 +0 -0
- package/dist/remotion-bundle/c28e7e5d310f73ef.woff2 +0 -0
- package/dist/remotion-bundle/c2b840274db78aea.woff2 +0 -0
- package/dist/remotion-bundle/c3000e3299d4e45f.woff2 +0 -0
- package/dist/remotion-bundle/c83ce886e5288510.woff2 +0 -0
- package/dist/remotion-bundle/c87a5a64d4ac0918.woff2 +0 -0
- package/dist/remotion-bundle/c8a7e0d049e965fa.woff2 +0 -0
- package/dist/remotion-bundle/c949a35d3a3b1faf.woff2 +0 -0
- package/dist/remotion-bundle/c9618c9b9ac2bc78.woff2 +0 -0
- package/dist/remotion-bundle/ca3add3b84152d5b.woff2 +0 -0
- package/dist/remotion-bundle/cad9dd036408d707.woff2 +0 -0
- package/dist/remotion-bundle/cbb24916619df439.woff2 +0 -0
- package/dist/remotion-bundle/cc054f0b5514e177.woff2 +0 -0
- package/dist/remotion-bundle/ccc248ed9312bc71.woff2 +0 -0
- package/dist/remotion-bundle/cd9d623aa07af925.woff2 +0 -0
- package/dist/remotion-bundle/ce2ba7a321bd1247.woff2 +0 -0
- package/dist/remotion-bundle/cf72455f79a29b14.woff2 +0 -0
- package/dist/remotion-bundle/d267cbfefab452ac.woff2 +0 -0
- package/dist/remotion-bundle/d435cff46a64955f.woff +0 -0
- package/dist/remotion-bundle/d494d07f67e363f6.woff2 +0 -0
- package/dist/remotion-bundle/d7aa0cc1fa47bf38.woff2 +0 -0
- package/dist/remotion-bundle/d7c5ca93d885160a.woff2 +0 -0
- package/dist/remotion-bundle/d855d3e252db74e2.woff2 +0 -0
- package/dist/remotion-bundle/d8f13d47f02f82c2.woff2 +0 -0
- package/dist/remotion-bundle/d9567cce2ee11019.woff2 +0 -0
- package/dist/remotion-bundle/db8d4456fc75dd86.woff +0 -0
- package/dist/remotion-bundle/dc274628378c47ee.woff2 +0 -0
- package/dist/remotion-bundle/dc3e06947bb69903.woff2 +0 -0
- package/dist/remotion-bundle/dd67040ac3b6d523.woff2 +0 -0
- package/dist/remotion-bundle/e0b04bd488f953f4.woff2 +0 -0
- package/dist/remotion-bundle/e2a572ff95089370.woff2 +0 -0
- package/dist/remotion-bundle/e2e18a86b1c2b0cc.woff2 +0 -0
- package/dist/remotion-bundle/e3a78ee2fc9c6931.woff2 +0 -0
- package/dist/remotion-bundle/e654c9d547605a9f.woff2 +0 -0
- package/dist/remotion-bundle/e67a3a64c129927c.woff2 +0 -0
- package/dist/remotion-bundle/e6be28b4203cd6ce.woff2 +0 -0
- package/dist/remotion-bundle/e841907ad9b0a191.woff +0 -0
- package/dist/remotion-bundle/e889d1541c69fffa.woff2 +0 -0
- package/dist/remotion-bundle/e88ef8c76373a9e2.woff2 +0 -0
- package/dist/remotion-bundle/e9c72f4bc37defef.woff2 +0 -0
- package/dist/remotion-bundle/e9e35f863403a255.woff2 +0 -0
- package/dist/remotion-bundle/eb23b37b009375da.woff2 +0 -0
- package/dist/remotion-bundle/ee1342b741625721.woff2 +0 -0
- package/dist/remotion-bundle/f07da88543a57ec9.woff2 +0 -0
- package/dist/remotion-bundle/f522982115306f8a.woff2 +0 -0
- package/dist/remotion-bundle/f8449bd864e6d8bc.woff2 +0 -0
- package/dist/remotion-bundle/f906dd5bd95ff9ab.woff2 +0 -0
- package/dist/remotion-bundle/f9e9e9413e3c38bb.woff2 +0 -0
- package/dist/remotion-bundle/fa5a5b16280994a8.woff2 +0 -0
- package/dist/remotion-bundle/favicon.ico +0 -0
- package/dist/remotion-bundle/fb19c0517725599b.woff2 +0 -0
- package/dist/remotion-bundle/fcaf24232f684b9b.woff2 +0 -0
- package/dist/remotion-bundle/fe09e084a3eea8cf.woff2 +0 -0
- package/dist/remotion-bundle/ff38d5317df7345a.woff2 +0 -0
- package/dist/remotion-bundle/ffe7ea1ea08f455a.woff2 +0 -0
- package/dist/remotion-bundle/index.html +49 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaomei/communication/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaomei/communication/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaomei/communication/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaomei/communication/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaoxin/career/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaoxin/career/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaoxin/career/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaoxin/career/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaoyue/parenting/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaoyue/parenting/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaoyue/parenting/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/female-kefu-xiaoyue/parenting/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/male-kefu-xiaoxu/time-trap/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/male-kefu-xiaoxu/time-trap/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/male-kefu-xiaoxu/time-trap/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/male-kefu-xiaoxu/time-trap/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/cognition/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/cognition/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/cognition/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/cognition/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/growth/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/growth/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/growth/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/growth/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/parenting/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/parenting/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/parenting/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/parenting/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/soothing/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/soothing/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/soothing/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-A6b7WpG3/soothing/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-R2s4N9qJ/cognition/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-R2s4N9qJ/cognition/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-R2s4N9qJ/cognition/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-female-R2s4N9qJ/cognition/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-Bk7vD3xP/decision/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-Bk7vD3xP/decision/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-Bk7vD3xP/decision/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-Bk7vD3xP/decision/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-W1tH9jVc/manager/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-W1tH9jVc/manager/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-W1tH9jVc/manager/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-W1tH9jVc/manager/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-W1tH9jVc/manager/4.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-s5NqE0rZ/founder/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-s5NqE0rZ/founder/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-s5NqE0rZ/founder/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide/v-male-s5NqE0rZ/founder/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/career-advice/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/career-advice/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/career-advice/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/career-advice/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/career-advice/4.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/founder-lesson/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/founder-lesson/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/founder-lesson/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/founder-lesson/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/founder-lesson/4.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/incident-review/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/incident-review/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/incident-review/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/incident-review/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/incident-review/4.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/learning-loop/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/learning-loop/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/learning-loop/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/learning-loop/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/learning-loop/4.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/meeting-closure/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/meeting-closure/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/meeting-closure/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/meeting-closure/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/product-update/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/product-update/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/product-update/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/product-update/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/research-reading/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/research-reading/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/research-reading/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/research-reading/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/sales-enablement/0.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/sales-enablement/1.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/sales-enablement/2.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/sales-enablement/3.mp3 +0 -0
- package/dist/remotion-bundle/public/paper-slide-experiments/sales-enablement/4.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/ai-life/card-0.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/ai-life/card-1.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/ai-life/card-2.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/ai-life/card-3.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/ai-life/card-4.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/ai-life/card-5.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/coffee-science/card-0.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/coffee-science/card-1.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/coffee-science/card-2.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/coffee-science/card-3.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/coffee-science/card-4.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/coffee-science/card-5.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/coffee-science/card-6.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/reading-secrets/card-0.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/reading-secrets/card-1.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/reading-secrets/card-2.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/reading-secrets/card-3.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/reading-secrets/card-4.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/reading-secrets/card-5.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/reading-secrets/card-6.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/remote-work/card-0.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/remote-work/card-1.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/remote-work/card-2.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/remote-work/card-3.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/remote-work/card-4.mp3 +0 -0
- package/dist/remotion-bundle/public/voiceover/remote-work/card-5.mp3 +0 -0
- package/dist/remotion-bundle/source-map-helper.wasm +0 -0
- package/lib/cli.js +270 -0
- package/lib/commands/_registry.js +48 -0
- package/lib/commands/add.js +242 -0
- package/lib/commands/asr/azure-transcribe.js +336 -0
- package/lib/commands/asr/cloud-transcribe.js +384 -0
- package/lib/commands/asr/helpers.js +76 -0
- package/lib/commands/asr/index.js +236 -0
- package/lib/commands/asr/local-transcribe.js +125 -0
- package/lib/commands/asr-jobs.js +257 -0
- package/lib/commands/asr.js +11 -0
- package/lib/commands/auth-cmds.js +358 -0
- package/lib/commands/dub.js +542 -0
- package/lib/commands/explain.js +512 -0
- package/lib/commands/feedback.js +152 -0
- package/lib/commands/image.js +207 -0
- package/lib/commands/mcp-key.js +166 -0
- package/lib/commands/narrate.js +639 -0
- package/lib/commands/picstory-templates.js +276 -0
- package/lib/commands/picstory.js +547 -0
- package/lib/commands/podcast/dialogue.js +109 -0
- package/lib/commands/podcast/generate.js +127 -0
- package/lib/commands/podcast/index.js +561 -0
- package/lib/commands/podcast/synthesize.js +188 -0
- package/lib/commands/podcast.js +11 -0
- package/lib/commands/present.js +519 -0
- package/lib/commands/publish.js +415 -0
- package/lib/commands/skills.js +473 -0
- package/lib/commands/slice-preview.js +266 -0
- package/lib/commands/slice-render.js +282 -0
- package/lib/commands/slice-stage.js +264 -0
- package/lib/commands/slice.js +343 -0
- package/lib/commands/slides/constants.js +108 -0
- package/lib/commands/slides/html-renderer.js +338 -0
- package/lib/commands/slides/index.js +345 -0
- package/lib/commands/slides.js +11 -0
- package/lib/commands/story.js +302 -0
- package/lib/commands/summarize.js +532 -0
- package/lib/commands/synthesize.js +261 -0
- package/lib/commands/translate.js +593 -0
- package/lib/commands/upgrade.js +249 -0
- package/lib/commands/video-translate.js +577 -0
- package/lib/commands/voices.js +292 -0
- package/lib/core/agent-env.js +104 -0
- package/lib/core/args.js +107 -0
- package/lib/core/asr-client.js +448 -0
- package/lib/core/asr-jobs-client.js +126 -0
- package/lib/core/asr-jobs-store.js +105 -0
- package/lib/core/asr-r2-upload.js +181 -0
- package/lib/core/asr-upload.js +132 -0
- package/lib/core/audio-extract.js +150 -0
- package/lib/core/audio.js +219 -0
- package/lib/core/auth.js +880 -0
- package/lib/core/config.js +197 -0
- package/lib/core/feedback.js +64 -0
- package/lib/core/ffmpeg.js +476 -0
- package/lib/core/http.js +188 -0
- package/lib/core/image-client.js +55 -0
- package/lib/core/intent-params.js +11 -0
- package/lib/core/llm-client.js +76 -0
- package/lib/core/logger.js +208 -0
- package/lib/core/mic-recorder.js +182 -0
- package/lib/core/pause-markers.js +94 -0
- package/lib/core/podcast-pacing.js +118 -0
- package/lib/core/spinner.js +33 -0
- package/lib/core/srt.js +394 -0
- package/lib/core/telemetry.js +100 -0
- package/lib/core/timeline.js +92 -0
- package/lib/core/tts-synthesizer.js +70 -0
- package/lib/core/update-check.js +185 -0
- package/lib/core/url-download.js +148 -0
- package/lib/core/whisper-local.js +279 -0
- package/lib/internal/deck-validator.js +488 -0
- package/lib/internal/slice-themes.json +370 -0
- package/lib/stage-core/cloud-render.js +170 -0
- package/lib/stage-core/deck-format.js +133 -0
- package/lib/stage-core/edit-prompt.js +104 -0
- package/lib/stage-core/event-bus.js +31 -0
- package/lib/stage-core/port.js +46 -0
- package/lib/stage-core/server.js +352 -0
- package/lib/stage-core/snapshot-store.js +198 -0
- package/lib/stage-core/watcher.js +106 -0
- package/lib/stage-ui/slice/template.js +1672 -0
- package/package.json +9 -4
- package/skills/.claude-plugin/marketplace.json +22 -0
- package/skills/.claude-plugin/plugin.json +25 -0
- package/skills/LICENSE +21 -0
- package/skills/README.md +120 -0
- package/skills/hub/SKILL.md +317 -0
- package/skills/podcast/SKILL.md +146 -0
- package/skills/slice/SKILL.md +205 -0
- package/skills/slice/agents/openai.yaml +4 -0
- package/skills/slice/references/deck-schema.md +183 -0
- package/skills/slice/references/example-decks.md +108 -0
- package/skills/slice/references/themes.md +172 -0
- package/skills/transcribe/SKILL.md +473 -0
- package/skills/video/SKILL.md +261 -0
- package/skills/voxflow-slice/SKILL.md +271 -0
- package/skills/voxflow-slice/examples/article.md +13 -0
- package/skills/voxflow-slice/examples/expected-deck.json +39 -0
- package/skills/voxflow-slice/examples/validate.mjs +46 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoxFlow CLI — FFmpeg utility layer
|
|
3
|
+
*
|
|
4
|
+
* Provides ffmpeg/ffprobe operations via child_process.execFile.
|
|
5
|
+
* Zero external dependencies — requires ffmpeg to be installed on PATH.
|
|
6
|
+
* Also provides a one-time dependency hint for users without FFmpeg.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { execFile } = require('child_process');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run a command via execFile and return { stdout, stderr }.
|
|
15
|
+
* Rejects on non-zero exit code or spawn error.
|
|
16
|
+
* @param {string} cmd
|
|
17
|
+
* @param {string[]} args
|
|
18
|
+
* @param {object} [opts]
|
|
19
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
20
|
+
*/
|
|
21
|
+
function runCommand(cmd, args, opts) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
execFile(cmd, args, { timeout: 600_000, ...opts }, (error, stdout, stderr) => {
|
|
24
|
+
if (error) {
|
|
25
|
+
error.stderr = stderr;
|
|
26
|
+
error.stdout = stdout;
|
|
27
|
+
reject(error);
|
|
28
|
+
} else {
|
|
29
|
+
resolve({ stdout, stderr });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if ffmpeg and ffprobe are available.
|
|
37
|
+
* @returns {Promise<{available: boolean, version?: string, ffprobeAvailable?: boolean}>}
|
|
38
|
+
*/
|
|
39
|
+
async function checkFfmpeg() {
|
|
40
|
+
try {
|
|
41
|
+
const { stdout } = await runCommand('ffmpeg', ['-version']);
|
|
42
|
+
const versionMatch = stdout.match(/ffmpeg version (\S+)/);
|
|
43
|
+
const version = versionMatch ? versionMatch[1] : 'unknown';
|
|
44
|
+
|
|
45
|
+
// Also check ffprobe
|
|
46
|
+
let ffprobeOk = false;
|
|
47
|
+
try {
|
|
48
|
+
await runCommand('ffprobe', ['-version']);
|
|
49
|
+
ffprobeOk = true;
|
|
50
|
+
} catch {
|
|
51
|
+
// ffprobe not available
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
available: true,
|
|
56
|
+
version,
|
|
57
|
+
ffprobeAvailable: ffprobeOk,
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return { available: false };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the duration of an audio or video file in milliseconds.
|
|
66
|
+
* Uses ffprobe to extract duration.
|
|
67
|
+
* @param {string} filePath - Path to audio/video file
|
|
68
|
+
* @returns {Promise<number>} Duration in milliseconds
|
|
69
|
+
*/
|
|
70
|
+
async function getAudioDuration(filePath) {
|
|
71
|
+
const resolved = path.resolve(filePath);
|
|
72
|
+
try {
|
|
73
|
+
const { stdout } = await runCommand('ffprobe', [
|
|
74
|
+
'-v', 'error',
|
|
75
|
+
'-show_entries', 'format=duration',
|
|
76
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
77
|
+
resolved,
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const seconds = parseFloat(stdout.trim());
|
|
81
|
+
if (isNaN(seconds)) {
|
|
82
|
+
throw new Error(`Could not parse duration from ffprobe output: "${stdout.trim()}"`);
|
|
83
|
+
}
|
|
84
|
+
return Math.round(seconds * 1000);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err.code === 'ENOENT') {
|
|
87
|
+
throw new Error('ffprobe not found. Please install ffmpeg: https://ffmpeg.org/download.html');
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`Failed to get duration of ${filePath}: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract audio track from a video file as WAV (24kHz, 16-bit, mono).
|
|
95
|
+
* @param {string} videoPath - Input video file
|
|
96
|
+
* @param {string} outputPath - Output WAV file path
|
|
97
|
+
* @returns {Promise<string>} Output file path
|
|
98
|
+
*/
|
|
99
|
+
async function extractAudio(videoPath, outputPath) {
|
|
100
|
+
const resolvedVideo = path.resolve(videoPath);
|
|
101
|
+
const resolvedOutput = path.resolve(outputPath);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await runCommand('ffmpeg', [
|
|
105
|
+
'-i', resolvedVideo,
|
|
106
|
+
'-vn', // no video
|
|
107
|
+
'-acodec', 'pcm_s16le', // 16-bit PCM
|
|
108
|
+
'-ar', '24000', // 24kHz sample rate
|
|
109
|
+
'-ac', '1', // mono
|
|
110
|
+
'-y', // overwrite
|
|
111
|
+
resolvedOutput,
|
|
112
|
+
]);
|
|
113
|
+
return resolvedOutput;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.code === 'ENOENT') {
|
|
116
|
+
throw new Error('ffmpeg not found. Please install ffmpeg: https://ffmpeg.org/download.html');
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`Failed to extract audio from ${videoPath}: ${err.stderr || err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Merge an audio track into a video file, replacing original audio.
|
|
124
|
+
* @param {string} videoPath - Input video file
|
|
125
|
+
* @param {string} audioPath - Input audio file (WAV/MP3)
|
|
126
|
+
* @param {string} outputPath - Output video file path
|
|
127
|
+
* @returns {Promise<string>} Output file path
|
|
128
|
+
*/
|
|
129
|
+
async function mergeAudioVideo(videoPath, audioPath, outputPath) {
|
|
130
|
+
const resolvedVideo = path.resolve(videoPath);
|
|
131
|
+
const resolvedAudio = path.resolve(audioPath);
|
|
132
|
+
const resolvedOutput = path.resolve(outputPath);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await runCommand('ffmpeg', [
|
|
136
|
+
'-i', resolvedVideo,
|
|
137
|
+
'-i', resolvedAudio,
|
|
138
|
+
'-c:v', 'copy', // copy video stream
|
|
139
|
+
'-map', '0:v:0', // video from first input
|
|
140
|
+
'-map', '1:a:0', // audio from second input
|
|
141
|
+
'-shortest', // match shortest stream
|
|
142
|
+
'-y', // overwrite
|
|
143
|
+
resolvedOutput,
|
|
144
|
+
]);
|
|
145
|
+
return resolvedOutput;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err.code === 'ENOENT') {
|
|
148
|
+
throw new Error('ffmpeg not found. Please install ffmpeg: https://ffmpeg.org/download.html');
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`Failed to merge audio/video: ${err.stderr || err.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Mix main audio with background music using volume ducking.
|
|
156
|
+
* BGM is reduced to the ducking level and mixed under the main audio.
|
|
157
|
+
* @param {string} mainAudioPath - Main audio (dubbing voice)
|
|
158
|
+
* @param {string} bgmPath - Background music file
|
|
159
|
+
* @param {string} outputPath - Output mixed audio path
|
|
160
|
+
* @param {object} [opts]
|
|
161
|
+
* @param {number} [opts.ducking=0.2] - BGM volume level (0-1, default 0.2 = 20%)
|
|
162
|
+
* @returns {Promise<string>} Output file path
|
|
163
|
+
*/
|
|
164
|
+
async function mixWithBgm(mainAudioPath, bgmPath, outputPath, opts = {}) {
|
|
165
|
+
const ducking = opts.ducking ?? 0.2;
|
|
166
|
+
const resolvedMain = path.resolve(mainAudioPath);
|
|
167
|
+
const resolvedBgm = path.resolve(bgmPath);
|
|
168
|
+
const resolvedOutput = path.resolve(outputPath);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await runCommand('ffmpeg', [
|
|
172
|
+
'-i', resolvedMain,
|
|
173
|
+
'-i', resolvedBgm,
|
|
174
|
+
'-filter_complex',
|
|
175
|
+
`[1:a]volume=${ducking}[bgm_low];` +
|
|
176
|
+
`[0:a][bgm_low]amix=inputs=2:duration=first:dropout_transition=2[out]`,
|
|
177
|
+
'-map', '[out]',
|
|
178
|
+
'-acodec', 'pcm_s16le',
|
|
179
|
+
'-ar', '24000',
|
|
180
|
+
'-ac', '1',
|
|
181
|
+
'-y',
|
|
182
|
+
resolvedOutput,
|
|
183
|
+
]);
|
|
184
|
+
return resolvedOutput;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err.code === 'ENOENT') {
|
|
187
|
+
throw new Error('ffmpeg not found. Please install ffmpeg: https://ffmpeg.org/download.html');
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`Failed to mix audio with BGM: ${err.stderr || err.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Dependency hint ────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Show a one-time friendly hint if FFmpeg is not installed.
|
|
197
|
+
* Does NOT block execution — just prints a warning once.
|
|
198
|
+
* The hint is suppressed after the first display (flag stored in config dir).
|
|
199
|
+
*
|
|
200
|
+
* @param {string} configDir - Path to the config directory (e.g. ~/.config/voxflow)
|
|
201
|
+
* @param {string} command - The command being run (e.g. 'dub', 'asr')
|
|
202
|
+
* @returns {Promise<{available: boolean, version?: string}>}
|
|
203
|
+
*/
|
|
204
|
+
async function warnIfMissingFfmpeg(configDir, command) {
|
|
205
|
+
const info = await checkFfmpeg();
|
|
206
|
+
if (info.available) return info;
|
|
207
|
+
|
|
208
|
+
// Check if we already showed the hint
|
|
209
|
+
const hintFlag = path.join(configDir, '.ffmpeg-hint-shown');
|
|
210
|
+
try {
|
|
211
|
+
if (fs.existsSync(hintFlag)) return info;
|
|
212
|
+
} catch {
|
|
213
|
+
// ignore fs errors, just show the hint
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Show the hint
|
|
217
|
+
const features = {
|
|
218
|
+
dub: 'video merging (--video), BGM mixing (--bgm), speed adjustment (--speed-auto)',
|
|
219
|
+
asr: 'audio format conversion, video audio extraction',
|
|
220
|
+
};
|
|
221
|
+
const featureDesc = features[command] || 'audio/video processing';
|
|
222
|
+
|
|
223
|
+
console.log(
|
|
224
|
+
'\n\x1b[33m' +
|
|
225
|
+
`[hint] ffmpeg not found — needed for ${featureDesc}.\n` +
|
|
226
|
+
' Install: brew install ffmpeg (macOS) / sudo apt install ffmpeg (Linux)\n' +
|
|
227
|
+
' Without ffmpeg, some features will be unavailable.\x1b[0m\n'
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Persist the flag so the hint only shows once
|
|
231
|
+
try {
|
|
232
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
233
|
+
fs.writeFileSync(hintFlag, new Date().toISOString(), 'utf8');
|
|
234
|
+
} catch {
|
|
235
|
+
// non-critical — next run will show hint again
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return info;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Video summarize helpers ────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Cut a segment from a video file.
|
|
245
|
+
* @param {string} input - Input video path
|
|
246
|
+
* @param {string} output - Output segment path
|
|
247
|
+
* @param {number} startSec - Start time in seconds
|
|
248
|
+
* @param {number} endSec - End time in seconds
|
|
249
|
+
*/
|
|
250
|
+
async function cutVideo(input, output, startSec, endSec) {
|
|
251
|
+
await runCommand('ffmpeg', [
|
|
252
|
+
'-i', path.resolve(input),
|
|
253
|
+
'-ss', String(startSec),
|
|
254
|
+
'-to', String(endSec),
|
|
255
|
+
'-c', 'copy',
|
|
256
|
+
'-y', path.resolve(output),
|
|
257
|
+
]);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Overlay text on video with a semi-transparent bottom bar.
|
|
262
|
+
* @param {string} input - Input video path
|
|
263
|
+
* @param {string} output - Output video path
|
|
264
|
+
* @param {string} text - Text to display
|
|
265
|
+
* @param {object} [opts]
|
|
266
|
+
* @param {string} [opts.fontfile] - Path to font file
|
|
267
|
+
* @param {number} [opts.fontSize=28] - Font size
|
|
268
|
+
*/
|
|
269
|
+
async function overlayText(input, output) {
|
|
270
|
+
// Copy through — text overlay requires drawtext (libfreetype).
|
|
271
|
+
// Future: install FFmpeg with --enable-libfreetype for text overlays.
|
|
272
|
+
fs.copyFileSync(path.resolve(input), path.resolve(output));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Generate a title card video (solid color background + centered text).
|
|
277
|
+
* @param {string} output - Output video path
|
|
278
|
+
* @param {string} text - Title text
|
|
279
|
+
* @param {object} [opts]
|
|
280
|
+
* @param {number} [opts.duration=3] - Duration in seconds
|
|
281
|
+
* @param {string} [opts.bgColor='0x0F172A'] - Background color
|
|
282
|
+
* @param {string} [opts.resolution='1920x1080'] - Video resolution
|
|
283
|
+
* @param {string} [opts.fontfile] - Path to font file
|
|
284
|
+
* @param {number} [opts.fontSize=52] - Font size
|
|
285
|
+
*/
|
|
286
|
+
/**
|
|
287
|
+
* Generate a title card from the first frame of a video (darkened + blurred).
|
|
288
|
+
* Falls back to solid color if no source video is provided.
|
|
289
|
+
* No drawtext/libfreetype needed — narration provides the context.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} output - Output video path
|
|
292
|
+
* @param {string} text - Title text (unused — reserved for future drawtext support)
|
|
293
|
+
* @param {object} [opts]
|
|
294
|
+
* @param {number} [opts.duration=3] - Duration in seconds
|
|
295
|
+
* @param {string} [opts.sourceVideo] - Source video to extract first frame from
|
|
296
|
+
* @param {string} [opts.bgColor='0x0F172A'] - Fallback background color
|
|
297
|
+
* @param {string} [opts.resolution='1920x1080'] - Video resolution
|
|
298
|
+
*/
|
|
299
|
+
async function generateTitleCard(output, text, opts = {}) {
|
|
300
|
+
const duration = opts.duration || 3;
|
|
301
|
+
const resolution = opts.resolution || '1920x1080';
|
|
302
|
+
|
|
303
|
+
if (opts.sourceVideo && fs.existsSync(path.resolve(opts.sourceVideo))) {
|
|
304
|
+
// Extract first frame, darken + slight blur → cinematic title card
|
|
305
|
+
const tmpFrame = output + '.frame.jpg';
|
|
306
|
+
try {
|
|
307
|
+
await runCommand('ffmpeg', [
|
|
308
|
+
'-i', path.resolve(opts.sourceVideo),
|
|
309
|
+
'-vframes', '1', '-q:v', '2',
|
|
310
|
+
'-y', tmpFrame,
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
// Loop the darkened frame as a video clip with silent audio
|
|
314
|
+
await runCommand('ffmpeg', [
|
|
315
|
+
'-loop', '1', '-i', tmpFrame,
|
|
316
|
+
'-f', 'lavfi', '-i', 'anullsrc=r=24000:cl=mono',
|
|
317
|
+
'-t', String(duration),
|
|
318
|
+
'-vf', 'eq=brightness=-0.4:saturation=0.6,boxblur=8:2',
|
|
319
|
+
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'fast',
|
|
320
|
+
'-c:a', 'aac', '-shortest',
|
|
321
|
+
'-y', path.resolve(output),
|
|
322
|
+
]);
|
|
323
|
+
return;
|
|
324
|
+
} catch {
|
|
325
|
+
// Fall through to solid color
|
|
326
|
+
} finally {
|
|
327
|
+
try { fs.unlinkSync(tmpFrame); } catch {}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Fallback: solid color card
|
|
332
|
+
const bgColor = opts.bgColor || '0x0F172A';
|
|
333
|
+
await runCommand('ffmpeg', [
|
|
334
|
+
'-f', 'lavfi', '-i', `color=c=${bgColor}:s=${resolution}:d=${duration}`,
|
|
335
|
+
'-f', 'lavfi', '-i', 'anullsrc=r=24000:cl=mono',
|
|
336
|
+
'-c:v', 'libx264', '-pix_fmt', 'yuv420p',
|
|
337
|
+
'-c:a', 'aac', '-shortest',
|
|
338
|
+
'-y', path.resolve(output),
|
|
339
|
+
]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Replace the audio track of a video with a new audio file.
|
|
344
|
+
* @param {string} videoInput - Input video path
|
|
345
|
+
* @param {string} audioInput - New audio file (WAV)
|
|
346
|
+
* @param {string} output - Output video path
|
|
347
|
+
*/
|
|
348
|
+
async function replaceAudio(videoInput, audioInput, output) {
|
|
349
|
+
await runCommand('ffmpeg', [
|
|
350
|
+
'-i', path.resolve(videoInput),
|
|
351
|
+
'-i', path.resolve(audioInput),
|
|
352
|
+
'-c:v', 'copy',
|
|
353
|
+
'-map', '0:v:0',
|
|
354
|
+
'-map', '1:a:0',
|
|
355
|
+
'-shortest',
|
|
356
|
+
'-y', path.resolve(output),
|
|
357
|
+
]);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Concatenate multiple video files using the concat demuxer.
|
|
362
|
+
* All inputs must have the same codec, resolution, and frame rate.
|
|
363
|
+
* @param {string[]} inputs - Array of input video file paths
|
|
364
|
+
* @param {string} output - Output concatenated video path
|
|
365
|
+
*/
|
|
366
|
+
async function concatVideos(inputs, output) {
|
|
367
|
+
const tmpDir = path.dirname(output);
|
|
368
|
+
const listFile = path.join(tmpDir, `concat-list-${Date.now()}.txt`);
|
|
369
|
+
const content = inputs.map(f => `file '${path.resolve(f)}'`).join('\n');
|
|
370
|
+
fs.writeFileSync(listFile, content, 'utf8');
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
await runCommand('ffmpeg', [
|
|
374
|
+
'-f', 'concat', '-safe', '0',
|
|
375
|
+
'-i', listFile,
|
|
376
|
+
'-c', 'copy',
|
|
377
|
+
'-y', path.resolve(output),
|
|
378
|
+
]);
|
|
379
|
+
} finally {
|
|
380
|
+
try { fs.unlinkSync(listFile); } catch {}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Re-encode a video to a standard format (H.264 + AAC) for concat compatibility.
|
|
386
|
+
* @param {string} input - Input video path
|
|
387
|
+
* @param {string} output - Output normalized video path
|
|
388
|
+
* @param {object} [opts]
|
|
389
|
+
* @param {string} [opts.resolution] - Target resolution (e.g. '1920x1080')
|
|
390
|
+
*/
|
|
391
|
+
async function normalizeVideo(input, output, opts = {}) {
|
|
392
|
+
const args = [
|
|
393
|
+
'-i', path.resolve(input),
|
|
394
|
+
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
|
|
395
|
+
'-c:a', 'aac', '-ar', '24000', '-ac', '1',
|
|
396
|
+
'-pix_fmt', 'yuv420p',
|
|
397
|
+
];
|
|
398
|
+
if (opts.resolution) {
|
|
399
|
+
args.push('-vf', `scale=${opts.resolution.replace('x', ':')}:force_original_aspect_ratio=decrease,pad=${opts.resolution.replace('x', ':')}:(ow-iw)/2:(oh-ih)/2`);
|
|
400
|
+
}
|
|
401
|
+
args.push('-y', path.resolve(output));
|
|
402
|
+
await runCommand('ffmpeg', args, { timeout: 600_000 });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Detect a suitable CJK font on the current system.
|
|
407
|
+
* @returns {string|null} Font file path or null
|
|
408
|
+
*/
|
|
409
|
+
function detectCjkFont() {
|
|
410
|
+
const candidates = [
|
|
411
|
+
// macOS
|
|
412
|
+
'/System/Library/Fonts/PingFang.ttc',
|
|
413
|
+
'/System/Library/Fonts/STHeiti Light.ttc',
|
|
414
|
+
'/System/Library/Fonts/Hiragino Sans GB.ttc',
|
|
415
|
+
// Linux
|
|
416
|
+
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
|
417
|
+
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
|
418
|
+
'/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc',
|
|
419
|
+
// Windows
|
|
420
|
+
'C:\\Windows\\Fonts\\msyh.ttc',
|
|
421
|
+
'C:\\Windows\\Fonts\\simhei.ttf',
|
|
422
|
+
];
|
|
423
|
+
for (const f of candidates) {
|
|
424
|
+
if (fs.existsSync(f)) return f;
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Convert an audio file to a different format via ffmpeg.
|
|
431
|
+
* Used when the pipeline produces WAV but the user requested .mp3 output.
|
|
432
|
+
* @param {string} inputPath - Input audio file (e.g. WAV)
|
|
433
|
+
* @param {string} outputPath - Output audio file (e.g. MP3)
|
|
434
|
+
* @returns {Promise<string>} Output file path
|
|
435
|
+
*/
|
|
436
|
+
async function convertAudioFormat(inputPath, outputPath) {
|
|
437
|
+
const resolvedInput = path.resolve(inputPath);
|
|
438
|
+
const resolvedOutput = path.resolve(outputPath);
|
|
439
|
+
|
|
440
|
+
if (resolvedInput === resolvedOutput) {
|
|
441
|
+
throw new Error('convertAudioFormat: input and output paths must differ');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
await runCommand('ffmpeg', [
|
|
446
|
+
'-i', resolvedInput,
|
|
447
|
+
'-y',
|
|
448
|
+
resolvedOutput,
|
|
449
|
+
]);
|
|
450
|
+
return resolvedOutput;
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if (err.code === 'ENOENT') {
|
|
453
|
+
throw new Error('ffmpeg not found. Please install ffmpeg: https://ffmpeg.org/download.html');
|
|
454
|
+
}
|
|
455
|
+
throw new Error(`Failed to convert audio format: ${err.stderr || err.message}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
module.exports = {
|
|
460
|
+
runCommand,
|
|
461
|
+
checkFfmpeg,
|
|
462
|
+
getAudioDuration,
|
|
463
|
+
extractAudio,
|
|
464
|
+
mergeAudioVideo,
|
|
465
|
+
mixWithBgm,
|
|
466
|
+
warnIfMissingFfmpeg,
|
|
467
|
+
convertAudioFormat,
|
|
468
|
+
// Video summarize helpers
|
|
469
|
+
cutVideo,
|
|
470
|
+
overlayText,
|
|
471
|
+
generateTitleCard,
|
|
472
|
+
replaceAudio,
|
|
473
|
+
concatVideos,
|
|
474
|
+
normalizeVideo,
|
|
475
|
+
detectCjkFont,
|
|
476
|
+
};
|
package/lib/core/http.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoxFlow CLI — HTTP request helper and API error types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const { logger } = require('./logger');
|
|
9
|
+
const pkg = require('../../package.json');
|
|
10
|
+
|
|
11
|
+
// ─── Error types ─────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
class ApiError extends Error {
|
|
14
|
+
constructor(message, code, status, retryAfterSec) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ApiError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.status = status;
|
|
19
|
+
if (Number.isFinite(retryAfterSec)) this.retryAfterSec = retryAfterSec;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a Retry-After value from a 429 response.
|
|
25
|
+
* Standard HTTP header (seconds OR HTTP-date) takes precedence; fall back to
|
|
26
|
+
* a `retryAfter` / `retry_after` field in the JSON body, which some VoxFlow
|
|
27
|
+
* routes echo for client convenience.
|
|
28
|
+
*
|
|
29
|
+
* @returns {number|null} seconds to wait, or null if absent / unparseable
|
|
30
|
+
*/
|
|
31
|
+
function parseRetryAfterSeconds(headers, data) {
|
|
32
|
+
const headerVal = headers && (headers['retry-after'] || headers['Retry-After']);
|
|
33
|
+
if (headerVal) {
|
|
34
|
+
const numeric = Number(headerVal);
|
|
35
|
+
if (Number.isFinite(numeric) && numeric > 0) return Math.ceil(numeric);
|
|
36
|
+
const dateMs = Date.parse(headerVal);
|
|
37
|
+
if (Number.isFinite(dateMs)) {
|
|
38
|
+
const sec = Math.ceil((dateMs - Date.now()) / 1000);
|
|
39
|
+
if (sec > 0) return sec;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const body = data?.retryAfter ?? data?.retry_after;
|
|
43
|
+
if (Number.isFinite(body) && body > 0) return Math.ceil(body);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function throwApiError(status, data, context, headers) {
|
|
48
|
+
if (status === 401) {
|
|
49
|
+
throw new ApiError(
|
|
50
|
+
`Token expired or invalid. Run: voxflow login`,
|
|
51
|
+
'token_expired',
|
|
52
|
+
401
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
// 402 = Payment Required (quota exhausted) → distinct from 429 (rate limit).
|
|
56
|
+
// Conflating them was misleading: a podcast user with full bonus quota who
|
|
57
|
+
// tripped the per-minute TTS limit (20/min) would see "Monthly quota exceeded"
|
|
58
|
+
// and assume their account was empty. Now we keep them split, and retry-after
|
|
59
|
+
// is surfaced when the server returns it.
|
|
60
|
+
if (status === 402 || (data && data.code === 'quota_exceeded')) {
|
|
61
|
+
const remaining = data?.quotaInfo?.remainingTotal;
|
|
62
|
+
const detail = Number.isFinite(remaining)
|
|
63
|
+
? ` Remaining: ${remaining.toLocaleString()}.`
|
|
64
|
+
: '';
|
|
65
|
+
throw new ApiError(
|
|
66
|
+
`Insufficient quota for this request.${detail} Top up at https://voxflow.studio/pricing or check: voxflow status`,
|
|
67
|
+
'insufficient_quota',
|
|
68
|
+
status
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (status === 429) {
|
|
72
|
+
const ra = parseRetryAfterSeconds(headers, data);
|
|
73
|
+
const wait = ra ? ` Retry after ${ra}s.` : ' Slow down and retry.';
|
|
74
|
+
throw new ApiError(
|
|
75
|
+
`Rate limit hit (too many requests in a short window).${wait} Quota is unaffected.`,
|
|
76
|
+
'rate_limited',
|
|
77
|
+
429,
|
|
78
|
+
ra ?? undefined
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (status >= 500) {
|
|
82
|
+
// Surface the backend's error code and message when available — the
|
|
83
|
+
// older generic "Server error (500). Please try again later." swallowed
|
|
84
|
+
// the actual cause (e.g. LLM provider failure on `slides`, issue #2916)
|
|
85
|
+
// and made it impossible to debug from the CLI alone.
|
|
86
|
+
const backendCode = data?.code;
|
|
87
|
+
const detail = data?.message || data?.error;
|
|
88
|
+
let suffix;
|
|
89
|
+
if (detail || backendCode) {
|
|
90
|
+
const codeLabel = backendCode ? `[${backendCode}] ` : '';
|
|
91
|
+
suffix = `: ${codeLabel}${detail || 'no message from server'}`;
|
|
92
|
+
} else {
|
|
93
|
+
suffix = '. Please try again later.';
|
|
94
|
+
}
|
|
95
|
+
throw new ApiError(
|
|
96
|
+
`Server error (${status})${suffix}`,
|
|
97
|
+
'server_error',
|
|
98
|
+
status
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const detail = data?.message || data?.code || JSON.stringify(data);
|
|
102
|
+
throw new ApiError(`${context} failed (${status}): ${detail}`, 'api_error', status);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function throwNetworkError(err, apiBase) {
|
|
106
|
+
const code = err.code || '';
|
|
107
|
+
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT') {
|
|
108
|
+
throw new ApiError(
|
|
109
|
+
`Cannot reach API server at ${apiBase}. Check your internet connection or try --api <url>`,
|
|
110
|
+
'network_error',
|
|
111
|
+
0
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── HTTP Helper ────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fire an HTTP request and parse a JSON response.
|
|
121
|
+
*
|
|
122
|
+
* `options.timeoutMs` (default 60s) is the inactivity timeout — long-running
|
|
123
|
+
* synth calls (TTS for a paragraph, LLM slide generation) are expected to
|
|
124
|
+
* exceed the default and pass an explicit larger value.
|
|
125
|
+
*/
|
|
126
|
+
function request(url, options, body) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const parsedUrl = new URL(url);
|
|
129
|
+
const mod = parsedUrl.protocol === 'https:' ? https : http;
|
|
130
|
+
|
|
131
|
+
const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 60_000;
|
|
132
|
+
|
|
133
|
+
// Inject CLI client source header into every request, plus a
|
|
134
|
+
// per-request UUID for tracing + deduct_quota idempotency (#3194).
|
|
135
|
+
// Caller can override X-Request-Id explicitly to chain a retry to
|
|
136
|
+
// the same logical request — without that, each call gets a fresh
|
|
137
|
+
// ID. crypto.randomUUID is built-in on Node 19+ (we require 20+).
|
|
138
|
+
if (!options.headers) {
|
|
139
|
+
options.headers = {};
|
|
140
|
+
}
|
|
141
|
+
options.headers['X-Client-Source'] = 'cli';
|
|
142
|
+
options.headers['X-VoxFlow-Client'] = `voxflow-cli/${pkg.version}`;
|
|
143
|
+
if (!options.headers['X-Request-Id']) {
|
|
144
|
+
options.headers['X-Request-Id'] = crypto.randomUUID();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const startMs = Date.now();
|
|
148
|
+
const method = (options.method || 'GET').toUpperCase();
|
|
149
|
+
// Trim path query down to something greppable without dumping auth tokens.
|
|
150
|
+
logger.debug({ method, host: parsedUrl.host, path: parsedUrl.pathname }, 'http request');
|
|
151
|
+
|
|
152
|
+
const req = mod.request(parsedUrl, options, (res) => {
|
|
153
|
+
const chunks = [];
|
|
154
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
155
|
+
res.on('end', () => {
|
|
156
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
157
|
+
const durationMs = Date.now() - startMs;
|
|
158
|
+
logger.debug({
|
|
159
|
+
method,
|
|
160
|
+
path: parsedUrl.pathname,
|
|
161
|
+
status: res.statusCode,
|
|
162
|
+
durationMs,
|
|
163
|
+
responseBytes: raw.length
|
|
164
|
+
}, 'http response');
|
|
165
|
+
try {
|
|
166
|
+
resolve({ status: res.statusCode, headers: res.headers, data: JSON.parse(raw) });
|
|
167
|
+
} catch {
|
|
168
|
+
reject(new Error(`Non-JSON response (${res.statusCode}): ${raw.slice(0, 200)}`));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
req.on('error', (err) => {
|
|
174
|
+
logger.debug({ method, path: parsedUrl.pathname, err }, 'http error');
|
|
175
|
+
reject(err);
|
|
176
|
+
});
|
|
177
|
+
req.setTimeout(timeoutMs, () => {
|
|
178
|
+
req.destroy();
|
|
179
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
180
|
+
reject(new Error(`Request timeout (${seconds}s)`));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (body) req.write(JSON.stringify(body));
|
|
184
|
+
req.end();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { request, ApiError, throwApiError, throwNetworkError };
|