framefields 2.13.1 → 2.15.0

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 (339) hide show
  1. package/NOTICE.md +5 -1
  2. package/dist/audio/index.d.mts +5 -5
  3. package/dist/audio/index.mjs +10 -10
  4. package/dist/{audio-DIvi6hTD.mjs → audio-6TfPlfl4.mjs} +3 -3
  5. package/dist/{audio-DIvi6hTD.mjs.map → audio-6TfPlfl4.mjs.map} +1 -1
  6. package/dist/{audio-C4B9p_yq.d.mts → audio-7oqTUi4G.d.mts} +3 -3
  7. package/dist/{audio-C4B9p_yq.d.mts.map → audio-7oqTUi4G.d.mts.map} +1 -1
  8. package/dist/cli/check-worker.mjs +44 -44
  9. package/dist/cli.mjs +1 -1
  10. package/dist/cloud/index.d.mts.map +1 -1
  11. package/dist/cloud/index.mjs +1 -1
  12. package/dist/{cloud-Cj1TIme8.mjs → cloud-jnk1hPYf.mjs} +2 -2
  13. package/dist/{cloud-Cj1TIme8.mjs.map → cloud-jnk1hPYf.mjs.map} +1 -1
  14. package/dist/components/index.d.mts +13 -13
  15. package/dist/components/index.mjs +43 -43
  16. package/dist/{dist-Kbf-JTry.mjs → dist-BtI6ukB5.mjs} +2 -2
  17. package/dist/{dist-Kbf-JTry.mjs.map → dist-BtI6ukB5.mjs.map} +1 -1
  18. package/dist/{dist-CCrYOkcc.mjs → dist-COOpgG9G.mjs} +4 -3
  19. package/dist/{dist-CCrYOkcc.mjs.map → dist-COOpgG9G.mjs.map} +1 -1
  20. package/dist/{dist-DtPPPsZV.mjs → dist-XqlFM2a8.mjs} +14063 -5308
  21. package/dist/dist-XqlFM2a8.mjs.map +1 -0
  22. package/dist/{dist-CiTgmfUw.mjs → dist-f1zkl69f.mjs} +4 -4
  23. package/dist/dist-f1zkl69f.mjs.map +1 -0
  24. package/dist/{dist-B_rSWeOz.mjs → dist-umQOWz0a.mjs} +1 -1
  25. package/dist/effects/index.d.mts +5 -5
  26. package/dist/effects/index.mjs +35 -35
  27. package/dist/{effects-B-L3rLs6.mjs → effects-DlRMovU5.mjs} +2 -2
  28. package/dist/{effects-B-L3rLs6.mjs.map → effects-DlRMovU5.mjs.map} +1 -1
  29. package/dist/fonts/index.d.mts +1 -1
  30. package/dist/fonts/index.mjs +2 -2
  31. package/dist/{fonts-k5ll8NTE.mjs → fonts-BWYcXjyK.mjs} +3 -3
  32. package/dist/fonts-BWYcXjyK.mjs.map +1 -0
  33. package/dist/{gpu-D2HT3KcE.mjs → gpu-CH1PdM4T.mjs} +2 -2
  34. package/dist/{gpu-D2HT3KcE.mjs.map → gpu-CH1PdM4T.mjs.map} +1 -1
  35. package/dist/{index-rtCXNxGO.d.mts → index-BHTycml5.d.mts} +2 -2
  36. package/dist/{index-rtCXNxGO.d.mts.map → index-BHTycml5.d.mts.map} +1 -1
  37. package/dist/{index-BFTtXosj.d.mts → index-BVd6nXd5.d.mts} +2 -2
  38. package/dist/{index-BFTtXosj.d.mts.map → index-BVd6nXd5.d.mts.map} +1 -1
  39. package/dist/{index-ByhlTwxh.d.mts → index-BcxxxwhV.d.mts} +5 -5
  40. package/dist/{index-ByhlTwxh.d.mts.map → index-BcxxxwhV.d.mts.map} +1 -1
  41. package/dist/{index-CnZhBlob.d.mts → index-BnB_Ou2y.d.mts} +2 -2
  42. package/dist/index-BnB_Ou2y.d.mts.map +1 -0
  43. package/dist/{index-a6JrbAK0.d.mts → index-C-V6CAhP.d.mts} +32 -2
  44. package/dist/index-C-V6CAhP.d.mts.map +1 -0
  45. package/dist/{index-DBh3WphX.d.mts → index-CMhkkc56.d.mts} +44 -12
  46. package/dist/index-CMhkkc56.d.mts.map +1 -0
  47. package/dist/{index-BGlaws7-.d.mts → index-CYVpYGM-.d.mts} +3 -3
  48. package/dist/{index-BGlaws7-.d.mts.map → index-CYVpYGM-.d.mts.map} +1 -1
  49. package/dist/{index-DQ1U3HC3.d.mts → index-DhL7ckvs.d.mts} +7 -7
  50. package/dist/index-DhL7ckvs.d.mts.map +1 -0
  51. package/dist/{index-CtKUQ2VG.d.mts → index-DkAjKnhW.d.mts} +1487 -533
  52. package/dist/index-DkAjKnhW.d.mts.map +1 -0
  53. package/dist/{index-B08eSpaI.d.mts → index-RILu3ULT.d.mts} +2 -2
  54. package/dist/{index-B08eSpaI.d.mts.map → index-RILu3ULT.d.mts.map} +1 -1
  55. package/dist/{index-C5sgRLlG.d.mts → index-TCtdSXd7.d.mts} +3 -1
  56. package/dist/index-TCtdSXd7.d.mts.map +1 -0
  57. package/dist/{index-C2yaponf.d.mts → index-s2qQ8ifZ.d.mts} +911 -57
  58. package/dist/index-s2qQ8ifZ.d.mts.map +1 -0
  59. package/dist/index.d.mts +15 -15
  60. package/dist/index.mjs +52 -52
  61. package/dist/preview/index.d.mts +5 -5
  62. package/dist/preview/index.mjs +10 -10
  63. package/dist/preview-BeGF1Bb1.mjs +1002 -0
  64. package/dist/preview-BeGF1Bb1.mjs.map +1 -0
  65. package/dist/preview-engine/audio.mjs +1 -1
  66. package/dist/preview-engine/{chunk-64L4GUOF.mjs → chunk-4F3DG6YB.mjs} +2 -2
  67. package/dist/preview-engine/chunk-A2OTOW3B.mjs +4694 -0
  68. package/dist/preview-engine/chunk-BR56TQBP.mjs +5 -0
  69. package/dist/preview-engine/{chunk-ADLIF544.mjs → chunk-H2XKXXAU.mjs} +1 -1
  70. package/dist/preview-engine/chunk-NDUODAY2.mjs +3 -0
  71. package/dist/preview-engine/{chunk-FZ4RD7HV.mjs → chunk-QAQJSWUT.mjs} +1 -1
  72. package/dist/preview-engine/chunk-SWRGQTVC.mjs +138 -0
  73. package/dist/preview-engine/{chunk-52X7SURQ.mjs → chunk-TEA3GVBJ.mjs} +1 -1
  74. package/dist/preview-engine/chunk-VM2ZBPLM.mjs +644 -0
  75. package/dist/preview-engine/{chunk-UBY2UYEL.mjs → chunk-XSHNQJ7L.mjs} +1 -1
  76. package/dist/preview-engine/components.mjs +1 -1
  77. package/dist/preview-engine/effects.mjs +1 -1
  78. package/dist/preview-engine/fonts.mjs +1 -1
  79. package/dist/preview-engine/framefields.mjs +1 -1
  80. package/dist/preview-engine/player.mjs +2 -2
  81. package/dist/preview-engine/{renderers-DU7KPFVV.mjs → renderers-6C7NWWDX.mjs} +1 -1
  82. package/dist/preview-engine/renderers-HHVGNMSJ.mjs +206 -0
  83. package/dist/preview-engine/{src-YXNX4MLK.mjs → src-RCNEKIRP.mjs} +1 -1
  84. package/dist/preview-page/index.html +19 -0
  85. package/dist/preview-page/main.css +1 -0
  86. package/dist/preview-page/main.js +327 -0
  87. package/dist/{program-BfiB9ttP.mjs → program-CMigyfso.mjs} +919 -58
  88. package/dist/program-CMigyfso.mjs.map +1 -0
  89. package/dist/renderer/index.d.mts +4 -4
  90. package/dist/renderer/index.mjs +2 -2
  91. package/dist/{renderer-CoC64eam.mjs → renderer-0GMEL_Zi.mjs} +3 -3
  92. package/dist/{renderer-CoC64eam.mjs.map → renderer-0GMEL_Zi.mjs.map} +1 -1
  93. package/dist/{renderer-CSu_vYPn.mjs → renderer-3Wv2ZBJG.mjs} +3 -3
  94. package/dist/{renderer-CSu_vYPn.mjs.map → renderer-3Wv2ZBJG.mjs.map} +1 -1
  95. package/dist/{renderer-Dv_iRIyx.mjs → renderer-4nXkaAWu.mjs} +1280 -285
  96. package/dist/renderer-4nXkaAWu.mjs.map +1 -0
  97. package/dist/{renderer-TfeohPxt.mjs → renderer-6SnfLXeg.mjs} +3 -3
  98. package/dist/{renderer-TfeohPxt.mjs.map → renderer-6SnfLXeg.mjs.map} +1 -1
  99. package/dist/{renderer-D0xM67cK.mjs → renderer-8gHw3O0P.mjs} +2 -2
  100. package/dist/{renderer-D0xM67cK.mjs.map → renderer-8gHw3O0P.mjs.map} +1 -1
  101. package/dist/{renderer-C64_B8It.mjs → renderer-AOBglZHC.mjs} +2 -2
  102. package/dist/{renderer-B10HDC13.mjs → renderer-B0bSARK3.mjs} +3 -3
  103. package/dist/{renderer-B10HDC13.mjs.map → renderer-B0bSARK3.mjs.map} +1 -1
  104. package/dist/{renderer-DWYmJSrl.mjs → renderer-B2_tuRfl.mjs} +2 -2
  105. package/dist/{renderer-DWYmJSrl.mjs.map → renderer-B2_tuRfl.mjs.map} +1 -1
  106. package/dist/{renderer-mDBoz0zT.mjs → renderer-B82FhkEr.mjs} +3 -3
  107. package/dist/{renderer-mDBoz0zT.mjs.map → renderer-B82FhkEr.mjs.map} +1 -1
  108. package/dist/{renderer-DYeNNc_G.mjs → renderer-B9lzA0PY.mjs} +3 -3
  109. package/dist/{renderer-DYeNNc_G.mjs.map → renderer-B9lzA0PY.mjs.map} +1 -1
  110. package/dist/{renderer-BGchUBW_.mjs → renderer-BBSIWSry.mjs} +2 -2
  111. package/dist/{renderer-BGchUBW_.mjs.map → renderer-BBSIWSry.mjs.map} +1 -1
  112. package/dist/{renderer-GIlQ9lZq.mjs → renderer-BEKDMxbi.mjs} +2 -2
  113. package/dist/{renderer-GIlQ9lZq.mjs.map → renderer-BEKDMxbi.mjs.map} +1 -1
  114. package/dist/renderer-BERGAxqA.mjs +5 -0
  115. package/dist/{renderer-Bw0O--gW.mjs → renderer-BF960HY8.mjs} +3 -3
  116. package/dist/{renderer-Bw0O--gW.mjs.map → renderer-BF960HY8.mjs.map} +1 -1
  117. package/dist/{renderer-BhcTYhaP.mjs → renderer-BFwyVxCu.mjs} +3 -3
  118. package/dist/{renderer-BhcTYhaP.mjs.map → renderer-BFwyVxCu.mjs.map} +1 -1
  119. package/dist/{renderer-DYlx7VHo.mjs → renderer-BNiwcI3-.mjs} +2 -2
  120. package/dist/{renderer-DYlx7VHo.mjs.map → renderer-BNiwcI3-.mjs.map} +1 -1
  121. package/dist/{renderer-B3_GddgJ.mjs → renderer-BPbVb2Q4.mjs} +2 -2
  122. package/dist/{renderer-B3_GddgJ.mjs.map → renderer-BPbVb2Q4.mjs.map} +1 -1
  123. package/dist/renderer-BQ_YkBMt.mjs +6 -0
  124. package/dist/{renderer-Y1kjtOto.mjs → renderer-BUWxlA8T.mjs} +3 -3
  125. package/dist/{renderer-Y1kjtOto.mjs.map → renderer-BUWxlA8T.mjs.map} +1 -1
  126. package/dist/renderer-BW0Tjsv9.mjs +5 -0
  127. package/dist/{renderer-DtFZbE1j.mjs → renderer-BX8liu-e.mjs} +2 -2
  128. package/dist/{renderer-BOlHW3Mi.mjs → renderer-BXIwDAr_.mjs} +2 -2
  129. package/dist/{renderer-BOlHW3Mi.mjs.map → renderer-BXIwDAr_.mjs.map} +1 -1
  130. package/dist/{renderer-DzON3wDZ.mjs → renderer-B_Hy1W-g.mjs} +3 -3
  131. package/dist/{renderer-DzON3wDZ.mjs.map → renderer-B_Hy1W-g.mjs.map} +1 -1
  132. package/dist/{renderer-BM19iK8v.mjs → renderer-Bb6cvSgh.mjs} +2 -2
  133. package/dist/{renderer-BM19iK8v.mjs.map → renderer-Bb6cvSgh.mjs.map} +1 -1
  134. package/dist/{renderer-D0h1hTOo.mjs → renderer-BdppwWC9.mjs} +2 -2
  135. package/dist/{renderer-D0h1hTOo.mjs.map → renderer-BdppwWC9.mjs.map} +1 -1
  136. package/dist/{renderer-BTwuM9u-.mjs → renderer-Bhc24HVe.mjs} +3 -3
  137. package/dist/{renderer-BTwuM9u-.mjs.map → renderer-Bhc24HVe.mjs.map} +1 -1
  138. package/dist/{renderer-BZX932Lj.mjs → renderer-BiNA2wF0.mjs} +2 -2
  139. package/dist/{renderer-BZX932Lj.mjs.map → renderer-BiNA2wF0.mjs.map} +1 -1
  140. package/dist/{renderer-CkTehyRX.mjs → renderer-BmoBgTp5.mjs} +3 -3
  141. package/dist/{renderer-CkTehyRX.mjs.map → renderer-BmoBgTp5.mjs.map} +1 -1
  142. package/dist/{renderer-B3un4Pdi.mjs → renderer-BmqFmCWS.mjs} +3 -3
  143. package/dist/{renderer-B3un4Pdi.mjs.map → renderer-BmqFmCWS.mjs.map} +1 -1
  144. package/dist/{renderer-CHCRcCKw.mjs → renderer-BnVwAObV.mjs} +2 -2
  145. package/dist/{renderer-CHCRcCKw.mjs.map → renderer-BnVwAObV.mjs.map} +1 -1
  146. package/dist/{renderer-Cv9CKKWG.mjs → renderer-BxttZHme.mjs} +2 -2
  147. package/dist/{renderer-Cv9CKKWG.mjs.map → renderer-BxttZHme.mjs.map} +1 -1
  148. package/dist/renderer-BzyPdUD1.mjs +5 -0
  149. package/dist/renderer-C0hM-mtY.mjs +5 -0
  150. package/dist/renderer-C6cyUTvk.mjs +5 -0
  151. package/dist/{renderer-B6bK9rXm.mjs → renderer-C8E4XsSg.mjs} +2 -2
  152. package/dist/{renderer-B6bK9rXm.mjs.map → renderer-C8E4XsSg.mjs.map} +1 -1
  153. package/dist/renderer-C8zY3pzk.mjs +5 -0
  154. package/dist/{renderer-4nsuReW2.mjs → renderer-C9U-LY50.mjs} +3 -3
  155. package/dist/{renderer-4nsuReW2.mjs.map → renderer-C9U-LY50.mjs.map} +1 -1
  156. package/dist/{renderer-BvBqTAtk.mjs → renderer-CB2JVu5x.mjs} +3 -3
  157. package/dist/{renderer-BvBqTAtk.mjs.map → renderer-CB2JVu5x.mjs.map} +1 -1
  158. package/dist/{renderer-CQ4h8n7p.mjs → renderer-CC4YBJ8Z.mjs} +3 -3
  159. package/dist/{renderer-CQ4h8n7p.mjs.map → renderer-CC4YBJ8Z.mjs.map} +1 -1
  160. package/dist/renderer-CEkSOyKu.mjs +5 -0
  161. package/dist/{renderer-zLi2qFGT.mjs → renderer-CIycc5O-.mjs} +2 -2
  162. package/dist/{renderer-zLi2qFGT.mjs.map → renderer-CIycc5O-.mjs.map} +1 -1
  163. package/dist/renderer-CJYtfa4x.mjs +5 -0
  164. package/dist/{renderer-yNWYqvvq.mjs → renderer-CJraQlV-.mjs} +2 -2
  165. package/dist/{renderer-yNWYqvvq.mjs.map → renderer-CJraQlV-.mjs.map} +1 -1
  166. package/dist/renderer-CKMtHGZh.mjs +5 -0
  167. package/dist/renderer-CLzicoiy.mjs +5 -0
  168. package/dist/renderer-CM2A4BHd.mjs +5 -0
  169. package/dist/{renderer-BrEeUw7T.mjs → renderer-COWGWtob.mjs} +2 -2
  170. package/dist/{renderer-BrEeUw7T.mjs.map → renderer-COWGWtob.mjs.map} +1 -1
  171. package/dist/{renderer-uER8kn7f.mjs → renderer-COxG2XGf.mjs} +2 -2
  172. package/dist/{renderer-uER8kn7f.mjs.map → renderer-COxG2XGf.mjs.map} +1 -1
  173. package/dist/{renderer-C4hd6Cqi.mjs → renderer-CQDCIsQm.mjs} +2 -2
  174. package/dist/{renderer-C4hd6Cqi.mjs.map → renderer-CQDCIsQm.mjs.map} +1 -1
  175. package/dist/{renderer-Cfx_FCSf.mjs → renderer-CRUeag8i.mjs} +3 -3
  176. package/dist/{renderer-Cfx_FCSf.mjs.map → renderer-CRUeag8i.mjs.map} +1 -1
  177. package/dist/{renderer-CXrnzihz.mjs → renderer-CWVput06.mjs} +3 -3
  178. package/dist/{renderer-CXrnzihz.mjs.map → renderer-CWVput06.mjs.map} +1 -1
  179. package/dist/renderer-CZlHjcxr.mjs +5 -0
  180. package/dist/{renderer-BmyFfvVA.mjs → renderer-CbbnBLOB.mjs} +3 -3
  181. package/dist/{renderer-BmyFfvVA.mjs.map → renderer-CbbnBLOB.mjs.map} +1 -1
  182. package/dist/renderer-CbimvSU8.mjs +5 -0
  183. package/dist/renderer-Ccvc63yd.mjs +5 -0
  184. package/dist/renderer-Cdp-SGfq.mjs +5 -0
  185. package/dist/renderer-CeTcSs3e.mjs +5 -0
  186. package/dist/{renderer-_J9JcCMR.mjs → renderer-CfLZein2.mjs} +3 -3
  187. package/dist/{renderer-_J9JcCMR.mjs.map → renderer-CfLZein2.mjs.map} +1 -1
  188. package/dist/{renderer-D-mX1EVA.mjs → renderer-Ch-q1O-q.mjs} +3 -3
  189. package/dist/{renderer-D-mX1EVA.mjs.map → renderer-Ch-q1O-q.mjs.map} +1 -1
  190. package/dist/{renderer-uzU6dsdR.mjs → renderer-Ch5JvJWl.mjs} +2 -2
  191. package/dist/{renderer-uzU6dsdR.mjs.map → renderer-Ch5JvJWl.mjs.map} +1 -1
  192. package/dist/{renderer-BZMh2io2.mjs → renderer-ChtEzq-R.mjs} +2 -2
  193. package/dist/{renderer-BZMh2io2.mjs.map → renderer-ChtEzq-R.mjs.map} +1 -1
  194. package/dist/{renderer-B2IUPjhw.mjs → renderer-Cj9nTC5l.mjs} +3 -3
  195. package/dist/{renderer-B2IUPjhw.mjs.map → renderer-Cj9nTC5l.mjs.map} +1 -1
  196. package/dist/{renderer-CdXBRdFf.mjs → renderer-CqT8HsSS.mjs} +2 -2
  197. package/dist/{renderer-CdXBRdFf.mjs.map → renderer-CqT8HsSS.mjs.map} +1 -1
  198. package/dist/{renderer-CldjJmRR.mjs → renderer-CuQjMhdV.mjs} +2 -2
  199. package/dist/renderer-CvNuu52U.mjs +5 -0
  200. package/dist/renderer-CwciC0oI.mjs +5 -0
  201. package/dist/renderer-CysLUbIr.mjs +5 -0
  202. package/dist/{renderer-BsrKj6Td.mjs → renderer-Cz30EVws.mjs} +2 -2
  203. package/dist/{renderer-BsrKj6Td.mjs.map → renderer-Cz30EVws.mjs.map} +1 -1
  204. package/dist/renderer-D3A22Vxb.mjs +5 -0
  205. package/dist/renderer-D5SY9EmA.mjs +5 -0
  206. package/dist/renderer-D8h3ZvaE.mjs +5 -0
  207. package/dist/renderer-DE-B6N9n.mjs +5 -0
  208. package/dist/renderer-DETZTI7t.mjs +5 -0
  209. package/dist/{renderer-CC22Djgi.mjs → renderer-DHoX-5U_.mjs} +3 -3
  210. package/dist/{renderer-CC22Djgi.mjs.map → renderer-DHoX-5U_.mjs.map} +1 -1
  211. package/dist/{renderer-HKv5cBdp.mjs → renderer-DOkm0Ycd.mjs} +2 -2
  212. package/dist/{renderer-HKv5cBdp.mjs.map → renderer-DOkm0Ycd.mjs.map} +1 -1
  213. package/dist/{renderer-fG8Jy8Rx.d.mts → renderer-DU3f6ZrS.d.mts} +2 -2
  214. package/dist/{renderer-fG8Jy8Rx.d.mts.map → renderer-DU3f6ZrS.d.mts.map} +1 -1
  215. package/dist/{renderer-C98XwEIr.mjs → renderer-DUzv-shq.mjs} +2 -2
  216. package/dist/{renderer-C98XwEIr.mjs.map → renderer-DUzv-shq.mjs.map} +1 -1
  217. package/dist/renderer-DWnDj0IM.mjs +5 -0
  218. package/dist/renderer-DcGUIbQF.mjs +5 -0
  219. package/dist/renderer-Dfq6FoNO.mjs +5 -0
  220. package/dist/renderer-DhasXW6S.mjs +3 -0
  221. package/dist/{renderer-BEuXJY-C.mjs → renderer-DhnOWnEA.mjs} +3 -3
  222. package/dist/{renderer-BEuXJY-C.mjs.map → renderer-DhnOWnEA.mjs.map} +1 -1
  223. package/dist/{renderer-BIKkxw_Z.mjs → renderer-Dhy1XHzz.mjs} +3 -3
  224. package/dist/{renderer-BIKkxw_Z.mjs.map → renderer-Dhy1XHzz.mjs.map} +1 -1
  225. package/dist/renderer-DjCvpsV_.mjs +5 -0
  226. package/dist/{renderer-pn52dzZz.mjs → renderer-DjJshz9Y.mjs} +2 -2
  227. package/dist/{renderer-pn52dzZz.mjs.map → renderer-DjJshz9Y.mjs.map} +1 -1
  228. package/dist/{renderer-DhGZ41hQ.mjs → renderer-DmTUIdus.mjs} +3 -3
  229. package/dist/{renderer-DhGZ41hQ.mjs.map → renderer-DmTUIdus.mjs.map} +1 -1
  230. package/dist/{renderer-D_m6Qbsi.mjs → renderer-Dq7AioJa.mjs} +2 -2
  231. package/dist/{renderer-D_m6Qbsi.mjs.map → renderer-Dq7AioJa.mjs.map} +1 -1
  232. package/dist/renderer-DrAaM6JD.mjs +5 -0
  233. package/dist/{renderer-D-GWBoVT.mjs → renderer-Dt4sqNEu.mjs} +2 -2
  234. package/dist/{renderer-D-GWBoVT.mjs.map → renderer-Dt4sqNEu.mjs.map} +1 -1
  235. package/dist/{renderer-DCrKEVjb.mjs → renderer-Dtcs4idV.mjs} +3 -3
  236. package/dist/{renderer-DCrKEVjb.mjs.map → renderer-Dtcs4idV.mjs.map} +1 -1
  237. package/dist/renderer-Dxlv8LYm.mjs +5 -0
  238. package/dist/{renderer-CO9vE3sd.mjs → renderer-Gj8SrE2W.mjs} +2 -2
  239. package/dist/{renderer-CO9vE3sd.mjs.map → renderer-Gj8SrE2W.mjs.map} +1 -1
  240. package/dist/{renderer-DvHi_wXE.mjs → renderer-KfnXDJS0.mjs} +3 -3
  241. package/dist/{renderer-DvHi_wXE.mjs.map → renderer-KfnXDJS0.mjs.map} +1 -1
  242. package/dist/{renderer-7xQmXXMH.mjs → renderer-MXVlg4p5.mjs} +3 -3
  243. package/dist/{renderer-7xQmXXMH.mjs.map → renderer-MXVlg4p5.mjs.map} +1 -1
  244. package/dist/{renderer-YONlI6W-.mjs → renderer-N2b6QZ1E.mjs} +3 -3
  245. package/dist/{renderer-YONlI6W-.mjs.map → renderer-N2b6QZ1E.mjs.map} +1 -1
  246. package/dist/renderer-Pb43OO2t.mjs +5 -0
  247. package/dist/{renderer-DNynPM6X.mjs → renderer-Q7gDzm8c.mjs} +2 -2
  248. package/dist/{renderer-DNynPM6X.mjs.map → renderer-Q7gDzm8c.mjs.map} +1 -1
  249. package/dist/{renderer-BEhZiOT4.mjs → renderer-RCiANtRV.mjs} +2 -2
  250. package/dist/{renderer-BEhZiOT4.mjs.map → renderer-RCiANtRV.mjs.map} +1 -1
  251. package/dist/{renderer-Dm57W73p.mjs → renderer-Sig9nZcI.mjs} +2 -2
  252. package/dist/renderer-VQbm-cGr.mjs +5 -0
  253. package/dist/{renderer-DiZkZ0Pr.mjs → renderer-fabBgwQX.mjs} +3 -3
  254. package/dist/{renderer-DiZkZ0Pr.mjs.map → renderer-fabBgwQX.mjs.map} +1 -1
  255. package/dist/{renderer-Bw-_GZVw.mjs → renderer-g-0PCNoo.mjs} +3 -3
  256. package/dist/{renderer-Bw-_GZVw.mjs.map → renderer-g-0PCNoo.mjs.map} +1 -1
  257. package/dist/{renderer-QbSrnPZU.mjs → renderer-gmHKIbKI.mjs} +3 -3
  258. package/dist/{renderer-QbSrnPZU.mjs.map → renderer-gmHKIbKI.mjs.map} +1 -1
  259. package/dist/{renderer-CU6WT5jW.mjs → renderer-kVZwiRoK.mjs} +3 -3
  260. package/dist/{renderer-CU6WT5jW.mjs.map → renderer-kVZwiRoK.mjs.map} +1 -1
  261. package/dist/renderer-oPMypDM1.mjs +5 -0
  262. package/dist/{renderer-CRhEGqW4.mjs → renderer-p7tgxP-A.mjs} +3 -3
  263. package/dist/{renderer-CRhEGqW4.mjs.map → renderer-p7tgxP-A.mjs.map} +1 -1
  264. package/dist/{renderer-CO4NGvAh.mjs → renderer-tvYj306A.mjs} +81 -78
  265. package/dist/renderer-tvYj306A.mjs.map +1 -0
  266. package/dist/renderer-xvWGpVyp.mjs +5 -0
  267. package/dist/renderer-zafZbD6Q.mjs +5 -0
  268. package/dist/{renderers-CP8CFWDs-BufiCyn7.mjs → renderers-CP8CFWDs-DLjtjrwx.mjs} +2 -2
  269. package/dist/{renderers-CP8CFWDs-BufiCyn7.mjs.map → renderers-CP8CFWDs-DLjtjrwx.mjs.map} +1 -1
  270. package/dist/sam/index.d.mts +3 -3
  271. package/dist/sam/index.mjs +1 -1
  272. package/dist/signals/index.d.mts +5 -5
  273. package/dist/signals/index.mjs +4 -4
  274. package/dist/{src-BU6vm9sP-DdTHmCr4.mjs → src-B23p_eh7-Cj6-lvV6.mjs} +293 -6
  275. package/dist/src-B23p_eh7-Cj6-lvV6.mjs.map +1 -0
  276. package/dist/{src-DrP7fGJX.mjs → src-CoHAjviq.mjs} +1355 -178
  277. package/dist/src-CoHAjviq.mjs.map +1 -0
  278. package/dist/storage/index.mjs +1 -1
  279. package/package.json +63 -60
  280. package/dist/dist-CiTgmfUw.mjs.map +0 -1
  281. package/dist/dist-DtPPPsZV.mjs.map +0 -1
  282. package/dist/fonts-k5ll8NTE.mjs.map +0 -1
  283. package/dist/index-C2yaponf.d.mts.map +0 -1
  284. package/dist/index-C5sgRLlG.d.mts.map +0 -1
  285. package/dist/index-CnZhBlob.d.mts.map +0 -1
  286. package/dist/index-CtKUQ2VG.d.mts.map +0 -1
  287. package/dist/index-DBh3WphX.d.mts.map +0 -1
  288. package/dist/index-DQ1U3HC3.d.mts.map +0 -1
  289. package/dist/index-a6JrbAK0.d.mts.map +0 -1
  290. package/dist/preview-engine/chunk-5ZS46EFB.mjs +0 -5
  291. package/dist/preview-engine/chunk-N3XTDAL6.mjs +0 -2227
  292. package/dist/preview-engine/chunk-NCQHOGZK.mjs +0 -3
  293. package/dist/preview-engine/chunk-PH3UUX6A.mjs +0 -138
  294. package/dist/preview-engine/chunk-XCPRRMMW.mjs +0 -2462
  295. package/dist/preview-engine/renderers-ZMMQ7S2A.mjs +0 -228
  296. package/dist/preview-lHH8Tv4j.mjs +0 -2446
  297. package/dist/preview-lHH8Tv4j.mjs.map +0 -1
  298. package/dist/program-BfiB9ttP.mjs.map +0 -1
  299. package/dist/renderer-0rLmztAv.mjs +0 -5
  300. package/dist/renderer-B0pjOXFY.mjs +0 -5
  301. package/dist/renderer-B7J13reL.mjs +0 -5
  302. package/dist/renderer-BPGIKGgl.mjs +0 -5
  303. package/dist/renderer-Bv-Artj1.mjs +0 -5
  304. package/dist/renderer-BvLjbhcL.mjs +0 -5
  305. package/dist/renderer-By6awWaf.mjs +0 -5
  306. package/dist/renderer-C0rvsz7m.mjs +0 -5
  307. package/dist/renderer-CDgud_HB.mjs +0 -5
  308. package/dist/renderer-CDiRF3bG.mjs +0 -6
  309. package/dist/renderer-CIIEQd4B.mjs +0 -5
  310. package/dist/renderer-CJblwOA9.mjs +0 -5
  311. package/dist/renderer-CNMOXtXE.mjs +0 -5
  312. package/dist/renderer-CO4NGvAh.mjs.map +0 -1
  313. package/dist/renderer-COeSeNGg.mjs +0 -5
  314. package/dist/renderer-CUfElmtZ.mjs +0 -5
  315. package/dist/renderer-CYh2lFoJ.mjs +0 -5
  316. package/dist/renderer-CYtVZYMH.mjs +0 -5
  317. package/dist/renderer-CjxZlkjb.mjs +0 -5
  318. package/dist/renderer-Cpd6TvTC.mjs +0 -5
  319. package/dist/renderer-D3ziOPhX.mjs +0 -5
  320. package/dist/renderer-D5yp74NL.mjs +0 -3
  321. package/dist/renderer-DL-phtL5.mjs +0 -5
  322. package/dist/renderer-DSnmYnHg.mjs +0 -5
  323. package/dist/renderer-DUGfsT-E.mjs +0 -5
  324. package/dist/renderer-DW5af9x1.mjs +0 -5
  325. package/dist/renderer-DYMrol1t.mjs +0 -5
  326. package/dist/renderer-DekeUHaz.mjs +0 -5
  327. package/dist/renderer-Dt5uyJdZ.mjs +0 -5
  328. package/dist/renderer-Dv_iRIyx.mjs.map +0 -1
  329. package/dist/renderer-DwLQAnaK.mjs +0 -5
  330. package/dist/renderer-IXQP2CWM.mjs +0 -5
  331. package/dist/renderer-K-Pkw63O.mjs +0 -5
  332. package/dist/renderer-MPw2KH9P.mjs +0 -5
  333. package/dist/renderer-RPu8RMav.mjs +0 -5
  334. package/dist/renderer-dCqxd5N2.mjs +0 -5
  335. package/dist/renderer-lhjmE-Fp.mjs +0 -5
  336. package/dist/renderer-u9cmBuvM.mjs +0 -5
  337. package/dist/renderer-vH12jcpz.mjs +0 -5
  338. package/dist/src-BU6vm9sP-DdTHmCr4.mjs.map +0 -1
  339. package/dist/src-DrP7fGJX.mjs.map +0 -1
@@ -1,2446 +0,0 @@
1
- import { z as inputStore } from "./dist-DtPPPsZV.mjs";
2
- import { t as encodeStereoWav } from "./audio-DS9Jg2ls.mjs";
3
- import { s as HeadlessMediaRenderer } from "./renderer-CO4NGvAh.mjs";
4
- import { h as openAssetLocator } from "./dist-CiTgmfUw.mjs";
5
- import { createRequire, isBuiltin } from "node:module";
6
- import fs from "node:fs";
7
- import path from "node:path";
8
- import { createHash, randomUUID } from "node:crypto";
9
- import os from "node:os";
10
- import { fileURLToPath, pathToFileURL } from "node:url";
11
- import { spawn } from "node:child_process";
12
- import http from "node:http";
13
-
14
- //#region src/preview/bundle.ts
15
- /**
16
- * The preview page runs the project's composition code and renders it with
17
- * WebGPU. Two bundles make that work:
18
- *
19
- * - The engine (`buildEngine`): framefields and its browser player, built once
20
- * for the browser. Published packages ship it in `dist/preview-engine`;
21
- * running from source, it is built on first use.
22
- * - The project (`bundleProject`): only the user's own files, built on each
23
- * preview. Its `framefields` imports point at the engine's files, so the
24
- * composition and the player share one engine (signals, clock, caches).
25
- *
26
- * Node APIs that compositions use at build time are shimmed: `path`, `url`,
27
- * `os`, `import.meta.url` (each file keeps its own `file://` URL), and `fs`
28
- * reads, which fetch the file from the preview server. Absolute file paths
29
- * double as URLs there, so assets referenced by path load unchanged.
30
- * Node-only packages become stubs that throw when used.
31
- */
32
- const HERE = path.dirname(fileURLToPath(import.meta.url));
33
- const META_URL = [
34
- "import",
35
- "meta",
36
- "url"
37
- ].join(".");
38
- const require = createRequire(import.meta.url);
39
- /** URL prefix the server serves the engine's files under. */
40
- const ENGINE_URL = "/@framefields/engine";
41
- /** Public entry points a composition may import, and their sources. */
42
- const ENTRIES = {
43
- framefields: "index.ts",
44
- "framefields/effects": "effects/index.ts",
45
- "framefields/audio": "audio/index.ts",
46
- "framefields/signals": "signals/index.ts",
47
- "framefields/fonts": "fonts/index.ts",
48
- "framefields/components": "components/index.ts"
49
- };
50
- /** Node built-ins with a browser version (shim file names); others are stubbed. */
51
- const SHIMS = {
52
- path: "path",
53
- "path/posix": "path",
54
- url: "url",
55
- os: "os",
56
- fs: "fs",
57
- "fs/promises": "fs-promises",
58
- module: "module"
59
- };
60
- /** Packages that only work in Node; the browser never needs them to draw. */
61
- const NODE_ONLY = new Set([
62
- "sharp",
63
- "skia-canvas",
64
- "node-av",
65
- "onnxruntime-node",
66
- "@resvg/resvg-js",
67
- "@napi-rs/webcodecs",
68
- "@mediabunny/server",
69
- "@mediabunny/mp3-encoder",
70
- "webgpu",
71
- "pino",
72
- "pino-pretty",
73
- "dotenv",
74
- "esbuild",
75
- "@framefields/renderer",
76
- "framefields/renderer",
77
- "framefields/preview"
78
- ]);
79
- const STUB = `
80
- const stub = () => new Proxy(function () {}, {
81
- get: (_t, k) => (k === "__esModule" ? true : k === Symbol.toPrimitive ? () => "" : stub()),
82
- apply: () => stub(),
83
- construct: () => stub(),
84
- });
85
- module.exports = stub();
86
- `;
87
- /**
88
- * Builds framefields for the browser into `outDir`: one module per public entry,
89
- * the player and the shims, with shared code split into chunks (node renderers
90
- * load only when a composition uses them).
91
- */
92
- async function buildEngine(outDir) {
93
- const { build } = await import("esbuild");
94
- const src = path.resolve(HERE, "..");
95
- const shims = path.join(HERE, "shims");
96
- const entryPoints = { player: path.join(HERE, "player.ts") };
97
- for (const [spec, file] of Object.entries(ENTRIES)) entryPoints[engineName(spec)] = path.join(src, file);
98
- for (const name of new Set(Object.values(SHIMS))) entryPoints[`shims/${name}`] = path.join(shims, `${name}.ts`);
99
- await fs.promises.rm(outDir, {
100
- recursive: true,
101
- force: true
102
- });
103
- await build({
104
- ...BROWSER,
105
- entryPoints,
106
- outdir: outDir,
107
- outExtension: { ".js": ".mjs" },
108
- splitting: true,
109
- minify: true,
110
- conditions: [
111
- "development",
112
- "browser",
113
- "import"
114
- ],
115
- define: {
116
- "process.env.NODE_ENV": "\"production\"",
117
- global: "globalThis"
118
- },
119
- inject: [path.join(shims, "process.ts")],
120
- plugins: [{
121
- name: "framefields-engine",
122
- setup(b) {
123
- b.onResolve({ filter: /^framefields-preview:renderers$/ }, () => ({ path: builtinRenderersModule() }));
124
- resolveNode(b, (name) => ({ path: path.join(shims, `${name}.ts`) }));
125
- }
126
- }]
127
- });
128
- }
129
- /** Bundles the module that builds the composition, and what it imports. */
130
- async function bundleProject(options) {
131
- const { build } = await import("esbuild");
132
- const entry = path.resolve(options.entry);
133
- const fromEngine = (file) => ({
134
- path: `${ENGINE_URL}/${file}.mjs`,
135
- external: true
136
- });
137
- const settings = {
138
- modelsDir: options.modelsDir,
139
- ortBase: options.ortBase
140
- };
141
- const result = await build({
142
- ...BROWSER,
143
- entryPoints: ["framefields-preview:entry"],
144
- write: false,
145
- metafile: true,
146
- sourcemap: "inline",
147
- plugins: [{
148
- name: "framefields-project",
149
- setup(b) {
150
- b.onResolve({ filter: /^framefields-preview:entry$/ }, () => ({
151
- path: "entry",
152
- namespace: "framefields-preview"
153
- }));
154
- b.onLoad({
155
- filter: /.*/,
156
- namespace: "framefields-preview"
157
- }, () => ({
158
- contents: [
159
- `import { startAudioMixer, startPlayer } from ${JSON.stringify(`${ENGINE_URL}/player.mjs`)};`,
160
- `import * as mod from ${JSON.stringify(entry)};`,
161
- `if (typeof document === "undefined") startAudioMixer(mod, ${JSON.stringify(options.exportName)});`,
162
- `else startPlayer(mod, ${JSON.stringify(options.exportName)}, ${JSON.stringify(settings)});`
163
- ].join("\n"),
164
- resolveDir: path.dirname(entry),
165
- loader: "js"
166
- }));
167
- b.onResolve({ filter: /^\/@framefields\/engine\// }, (args) => ({
168
- path: args.path,
169
- external: true
170
- }));
171
- b.onResolve({ filter: /^framefields(\/|$)/ }, (args) => ENTRIES[args.path] ? fromEngine(engineName(args.path)) : void 0);
172
- resolveNode(b, (name) => fromEngine(`shims/${name}`));
173
- rewriteMetaUrl(b);
174
- }
175
- }]
176
- });
177
- const inputs = Object.keys(result.metafile?.inputs ?? {}).filter((p) => !p.includes(":") && !p.includes("node_modules")).map((p) => path.resolve(p));
178
- return {
179
- code: result.outputFiles[0].text,
180
- inputs
181
- };
182
- }
183
- let engineBuild;
184
- /**
185
- * The prebuilt engine shipped beside a compiled bundle, if there is one. The
186
- * bundle lands either at the package's dist root or beside dist/preview,
187
- * depending on how the bundler splits it, so both are checked.
188
- */
189
- function prebuiltEngineDir(here) {
190
- return [here, path.join(here, "..")].map((base) => path.join(base, "preview-engine")).find((dir) => fs.existsSync(path.join(dir, "player.mjs")));
191
- }
192
- /**
193
- * The engine to serve: prebuilt in the package's dist, or, running from
194
- * source, built once per process (so engine edits show without a rebuild).
195
- */
196
- function engineDir() {
197
- const prebuilt = prebuiltEngineDir(HERE);
198
- if (prebuilt) return Promise.resolve(prebuilt);
199
- engineBuild ??= (async () => {
200
- const dir = path.join(os.tmpdir(), `framefields-preview-engine-${process.pid}`);
201
- await buildEngine(dir);
202
- return dir;
203
- })();
204
- engineBuild.catch(() => {
205
- engineBuild = void 0;
206
- });
207
- return engineBuild;
208
- }
209
- /** Where vision models are cached, as `@framefields/vision` resolves it. */
210
- function visionModelsDir() {
211
- return process.env.FRAMEFIELDS_MODELS_DIR ? path.resolve(process.env.FRAMEFIELDS_MODELS_DIR) : path.join(os.homedir(), ".cache", "framefields", "models");
212
- }
213
- const ORT_WEB_VERSION = "1.30.0";
214
- /**
215
- * onnxruntime-web, for vision effects: the project's own install when it has
216
- * one (served from disk), otherwise a pinned CDN build. Loaded only when a
217
- * vision effect runs, so framefields doesn't ship its WebAssembly.
218
- */
219
- function onnxRuntimeWeb(fromDir) {
220
- for (const from of [fromDir, HERE]) try {
221
- const main = createRequire(path.join(from, "noop.js")).resolve("onnxruntime-web");
222
- const dir = path.dirname(main);
223
- return {
224
- base: `/@fs${dir}/`,
225
- dir
226
- };
227
- } catch {}
228
- return { base: `https://cdn.jsdelivr.net/npm/onnxruntime-web@${ORT_WEB_VERSION}/dist/` };
229
- }
230
- const BROWSER = {
231
- bundle: true,
232
- format: "esm",
233
- platform: "browser",
234
- target: "es2022",
235
- mainFields: [
236
- "browser",
237
- "module",
238
- "main"
239
- ],
240
- logLevel: "silent"
241
- };
242
- function engineName(spec) {
243
- return spec === "framefields" ? "framefields" : spec.slice(12);
244
- }
245
- /** Node built-ins to shims, the rest of Node to stubs. */
246
- function resolveNode(b, shim) {
247
- b.onResolve({ filter: /^(node:)?[a-z_/]+$/ }, (args) => {
248
- const name = args.path.replace(/^node:/, "");
249
- if (SHIMS[name]) return shim(SHIMS[name]);
250
- if (args.path.startsWith("node:") || isBuiltin(name)) return {
251
- path: name,
252
- namespace: "framefields-stub"
253
- };
254
- });
255
- b.onResolve({ filter: /.*/ }, (args) => {
256
- const pkg = packageName(args.path);
257
- if (pkg && (NODE_ONLY.has(pkg) || NODE_ONLY.has(args.path))) return {
258
- path: args.path,
259
- namespace: "framefields-stub"
260
- };
261
- });
262
- b.onLoad({
263
- filter: /.*/,
264
- namespace: "framefields-stub"
265
- }, () => ({
266
- contents: STUB,
267
- loader: "js"
268
- }));
269
- }
270
- /** Each project file keeps its own URL, as `fileURLToPath(import.meta.url)` expects. */
271
- function rewriteMetaUrl(b) {
272
- b.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
273
- if (args.path.includes(`${path.sep}node_modules${path.sep}`)) return void 0;
274
- const source = await fs.promises.readFile(args.path, "utf8");
275
- if (!source.includes(META_URL)) return void 0;
276
- return {
277
- contents: `const __framefieldsFileUrl = ${JSON.stringify(pathToFileURL(args.path).href)};\n${source.replaceAll(META_URL, "__framefieldsFileUrl")}`,
278
- loader: loaderFor(args.path)
279
- };
280
- });
281
- }
282
- /** The generated list of node renderers (needed only to build the engine). */
283
- function builtinRenderersModule() {
284
- const dist = require.resolve("@framefields/renderer");
285
- const file = path.join(path.dirname(dist), "..", "src", "generated", "node-renderers.ts");
286
- if (!fs.existsSync(file)) throw new Error(`[framefields] node renderer list not found at ${file}`);
287
- return file;
288
- }
289
- function packageName(spec) {
290
- if (spec.startsWith(".") || spec.startsWith("/")) return void 0;
291
- const parts = spec.split("/");
292
- return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
293
- }
294
- function loaderFor(file) {
295
- if (file.endsWith(".tsx")) return "tsx";
296
- if (file.endsWith(".jsx")) return "jsx";
297
- return /\.[cm]?ts$/.test(file) ? "ts" : "js";
298
- }
299
-
300
- //#endregion
301
- //#region src/preview/notes.ts
302
- /**
303
- * Notes the user pins on the preview: a moment, a spot (or an area) on the
304
- * frame, and what should change there. They are kept as files so whoever
305
- * edits the film (a person, or an agent) can read them: `notes.json`, plus the
306
- * frame with the spot marked as `note-<id>.jpg`. The folder ignores itself in
307
- * git. `notes.json` is read on every change, so it can be edited by hand
308
- * (marking a note done, say) while the preview runs.
309
- */
310
- var NoteStore = class {
311
- file;
312
- constructor(dir) {
313
- this.dir = dir;
314
- this.file = path.join(dir, "notes.json");
315
- }
316
- async list() {
317
- try {
318
- const notes = JSON.parse(await fs.promises.readFile(this.file, "utf8"));
319
- return Array.isArray(notes) ? notes : [];
320
- } catch {
321
- return [];
322
- }
323
- }
324
- async add(input) {
325
- const notes = await this.list();
326
- const id = notes.reduce((max, n) => Math.max(max, n.id), 0) + 1;
327
- const note = {
328
- id,
329
- frame: Math.max(0, Math.round(Number(input.frame) || 0)),
330
- time: round(Number(input.time) || 0, 3),
331
- x: round(clamp01(input.x), 4),
332
- y: round(clamp01(input.y), 4),
333
- ...input.w !== void 0 && input.h !== void 0 && {
334
- w: round(clamp01(input.w), 4),
335
- h: round(clamp01(input.h), 4)
336
- },
337
- text: String(input.text ?? "").trim(),
338
- done: false,
339
- created: (/* @__PURE__ */ new Date()).toISOString()
340
- };
341
- await this.ensureDir();
342
- const jpeg = /^data:image\/jpeg;base64,(.+)$/.exec(input.image ?? "");
343
- if (jpeg) {
344
- note.image = path.join(this.dir, `note-${id}.jpg`);
345
- await fs.promises.writeFile(note.image, Buffer.from(jpeg[1], "base64"));
346
- }
347
- await this.save([...notes, note]);
348
- return note;
349
- }
350
- async update(id, patch) {
351
- const notes = await this.list();
352
- const note = notes.find((n) => n.id === id);
353
- if (!note) return void 0;
354
- if (typeof patch.done === "boolean") note.done = patch.done;
355
- if (typeof patch.text === "string" && patch.text.trim()) note.text = patch.text.trim();
356
- await this.save(notes);
357
- return note;
358
- }
359
- async remove(id) {
360
- const notes = await this.list();
361
- const note = notes.find((n) => n.id === id);
362
- if (!note) return void 0;
363
- if (note.image) await fs.promises.rm(note.image, { force: true });
364
- await this.save(notes.filter((n) => n !== note));
365
- return note;
366
- }
367
- async save(notes) {
368
- await this.ensureDir();
369
- const tmp = `${this.file}.${process.pid}.tmp`;
370
- await fs.promises.writeFile(tmp, `${JSON.stringify(notes, null, " ")}\n`);
371
- await fs.promises.rename(tmp, this.file);
372
- }
373
- async ensureDir() {
374
- await fs.promises.mkdir(this.dir, { recursive: true });
375
- const ignore = path.join(path.dirname(this.dir), ".gitignore");
376
- if (!fs.existsSync(ignore)) await fs.promises.writeFile(ignore, "*\n");
377
- }
378
- };
379
- /** One line for a terminal (or an agent reading one). */
380
- function describeNote(note, fps) {
381
- const pct = (v) => `${Math.round(v * 100)}%`;
382
- const where = note.w !== void 0 && note.h !== void 0 ? `area ${pct(note.x)},${pct(note.y)} to ${pct(note.x + note.w)},${pct(note.y + note.h)}` : `spot ${pct(note.x)},${pct(note.y)}`;
383
- return `note ${note.id} at ${timecode(note.frame, fps)} (frame ${note.frame}), ${where}: ${note.text}`;
384
- }
385
- function timecode(frame, fps) {
386
- const s = frame / fps;
387
- const m = Math.floor(s / 60);
388
- return `${m}:${(s - m * 60).toFixed(2).padStart(5, "0")}`;
389
- }
390
- function clamp01(v) {
391
- const n = Number(v);
392
- return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
393
- }
394
- function round(v, digits) {
395
- return Number(v.toFixed(digits));
396
- }
397
-
398
- //#endregion
399
- //#region src/preview/page.ts
400
- /**
401
- * Single-file preview UI served by `startPreview`. No framework, no build step.
402
- * The page script avoids template literals so it can live inside this one.
403
- */
404
- const previewPage = `<!doctype html>
405
- <html lang="en">
406
- <head>
407
- <meta charset="utf-8">
408
- <meta name="viewport" content="width=device-width, initial-scale=1">
409
- <title>framefields preview</title>
410
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Cg fill=%22none%22 stroke=%22%233B82F6%22 stroke-linecap=%22round%22%3E%3Cpath d=%22M184 56H86q-30 0-30 30v98M328 56h98q30 0 30 30v98M184 456H86q-30 0-30-30v-98M328 456h98q30 0 30-30v-98%22 stroke-width=%2234%22/%3E%3Cpath d=%22M176 182v148m0-148c0 74 60 74 100 74%22 stroke-width=%2228%22/%3E%3C/g%3E%3Cg fill=%22%233B82F6%22%3E%3Ccircle cx=%22176%22 cy=%22172%22 r=%2232%22/%3E%3Ccircle cx=%22176%22 cy=%22340%22 r=%2232%22/%3E%3Cpath d=%22M284 200l78 56-78 56z%22 stroke=%22%233B82F6%22 stroke-width=%2234%22 stroke-linejoin=%22round%22/%3E%3C/g%3E%3C/svg%3E">
411
- <link rel="preconnect" href="https://fonts.googleapis.com">
412
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
413
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
414
- <style>
415
- :root {
416
- --bg: #0e0f11; --surface: #16171a; --surface-2: #1d1e22; --surface-3: #26282d;
417
- --line: #2a2c31; --line-2: #3a3c43;
418
- --ink: #ededf0; --ink-2: #a6a8b0; --ink-3: #74777f;
419
- --stage: #060607;
420
- --primary-bg: #ededf0; --primary-ink: #0e0f11;
421
- --brand: #3b82f6; --focus: #5eaefc; --ok: #4fbf7a; --bad: #f27d8d;
422
- --wave: #4b4e57;
423
- --radius: 10px;
424
- --font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
425
- color-scheme: dark;
426
- }
427
- @media (prefers-color-scheme: light) {
428
- :root {
429
- --bg: #f5f5f6; --surface: #ffffff; --surface-2: #f0f0f2; --surface-3: #e6e6e9;
430
- --line: #e2e2e6; --line-2: #cfcfd5;
431
- --ink: #15161a; --ink-2: #53555d; --ink-3: #85878f;
432
- --stage: #0b0b0c;
433
- --primary-bg: #15161a; --primary-ink: #ffffff;
434
- --brand: #2563eb; --focus: #1c74cc; --ok: #23874a; --bad: #cf4057;
435
- --wave: #c2c3ca;
436
- color-scheme: light;
437
- }
438
- }
439
- * { box-sizing: border-box; }
440
- html, body { height: 100%; margin: 0; overflow: hidden; background: var(--bg); color: var(--ink); }
441
- body {
442
- display: flex; flex-direction: column; height: 100dvh;
443
- font: 13.5px/1.45 var(--font); -webkit-font-smoothing: antialiased;
444
- font-feature-settings: "cv11", "ss03";
445
- }
446
- button, input { font: inherit; color: inherit; }
447
- button { cursor: pointer; }
448
- button:focus-visible, input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
449
- .tnum { font-variant-numeric: tabular-nums; }
450
- .grow { flex: 1; }
451
- .spinner {
452
- width: 12px; height: 12px; border-radius: 50%; flex: none;
453
- border: 1.5px solid var(--line-2); border-top-color: var(--ink); animation: spin .8s linear infinite;
454
- }
455
- @keyframes spin { to { transform: rotate(360deg); } }
456
- .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
457
-
458
- /* ── Top bar ── */
459
- .top { flex: none; display: flex; align-items: center; gap: 10px; padding: 0 20px; height: 50px; border-bottom: 1px solid var(--line); min-width: 0; }
460
- .logo { width: 22px; height: 22px; flex: none; color: var(--brand); }
461
- .sep { width: 1px; height: 18px; background: var(--line-2); flex: none; margin: 0 2px; }
462
- .top h1 { font-size: 14px; font-weight: 600; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
463
- .spec { color: var(--ink-3); font-size: 12px; white-space: nowrap; }
464
- @media (max-width: 640px) { .spec { display: none; } }
465
- .chip {
466
- display: inline-flex; align-items: center; gap: 8px; height: 26px; padding: 0 10px; white-space: nowrap;
467
- border: 1px solid var(--line); border-radius: 999px; color: var(--ink-2); font-size: 12px; background: var(--surface);
468
- }
469
- .chip[data-state="ready"] .dot { color: var(--ok); }
470
- .chip[data-state="none"] .dot, .chip[data-state="offline"] .dot { color: var(--ink-3); }
471
- .chip[data-state="error"] { color: var(--bad); }
472
-
473
- /* ── Player: one screen, no page scroll; the picture takes the space left ── */
474
- .player {
475
- flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px;
476
- width: 100%; max-width: 1600px; margin: 0 auto; padding: 16px 20px 20px;
477
- }
478
- @media (max-width: 640px) { .player { padding: 12px; } }
479
- .stage-wrap { flex: 1 1 0; min-height: 96px; container-type: size; display: grid; place-items: center; }
480
- .stage {
481
- position: relative; background: var(--stage); border-radius: var(--radius); overflow: hidden;
482
- box-shadow: 0 0 0 1px var(--line); user-select: none; -webkit-user-select: none;
483
- aspect-ratio: var(--ar, 16 / 9); width: min(100cqw, calc(100cqh * var(--ar-n, 1.7778)));
484
- }
485
- .stage canvas { display: block; width: 100%; height: 100%; }
486
- .stage-msg {
487
- position: absolute; inset: 0; display: none; place-items: center; padding: 24px; text-align: center;
488
- color: #fff; background: rgb(0 0 0 / .72); font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow: auto;
489
- }
490
- .stage-msg.on { display: grid; }
491
- /* Buffering: a centred spinning arc over the picture, like YouTube's. It
492
- fades in after a beat so brief waits don't flash it. */
493
- .buffering {
494
- position: absolute; left: 50%; top: 50%; width: clamp(36px, 9cqmin, 64px); aspect-ratio: 1;
495
- transform: translate(-50%, -50%); pointer-events: none; opacity: 0; visibility: hidden;
496
- transition: opacity .15s, visibility 0s .15s; filter: drop-shadow(0 0 3px rgb(0 0 0 / .45));
497
- }
498
- .buffering.on { opacity: 1; visibility: visible; transition: opacity .2s .25s, visibility 0s .25s; }
499
- .buffering svg { width: 100%; height: 100%; animation: yt-rotate 1.4s linear infinite; }
500
- .buffering circle {
501
- fill: none; stroke: #fff; stroke-width: 3.6; stroke-linecap: round;
502
- stroke-dasharray: 1 150; stroke-dashoffset: 0; animation: yt-dash 1.4s ease-in-out infinite;
503
- }
504
- @keyframes yt-rotate { to { transform: rotate(360deg); } }
505
- @keyframes yt-dash {
506
- 0% { stroke-dasharray: 1 150; stroke-dashoffset: 0; }
507
- 50% { stroke-dasharray: 90 150; stroke-dashoffset: -35; }
508
- 100% { stroke-dasharray: 90 150; stroke-dashoffset: -124; }
509
- }
510
- @media (prefers-reduced-motion: reduce) { .buffering circle, .play-btn .spin circle { animation: none; stroke-dasharray: 70 150; } }
511
-
512
- /* ── Transport ── */
513
- .transport { flex: none; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; row-gap: 6px; }
514
- .icon-btn {
515
- width: 32px; height: 32px; display: inline-grid; place-items: center; border-radius: 8px;
516
- border: 0; background: transparent; color: var(--ink-2); flex: none;
517
- }
518
- .icon-btn:hover:not(:disabled) { background: var(--surface-2); color: var(--ink); }
519
- .icon-btn:disabled { opacity: .35; cursor: default; }
520
- .icon-btn svg { width: 16px; height: 16px; }
521
- .play-btn {
522
- width: 36px; height: 36px; border-radius: 50%; border: 0; display: inline-grid; place-items: center; flex: none;
523
- background: var(--primary-bg); color: var(--primary-ink); margin: 0 2px;
524
- }
525
- .play-btn:hover:not(:disabled) { filter: brightness(.9); }
526
- .play-btn:disabled { opacity: .4; cursor: default; }
527
- .play-btn svg { width: 14px; height: 14px; }
528
- .play-btn .spin { display: none; width: 20px; height: 20px; animation: yt-rotate 1.4s linear infinite; }
529
- .play-btn .spin circle {
530
- fill: none; stroke: currentColor; stroke-width: 5; stroke-linecap: round;
531
- stroke-dasharray: 1 150; animation: yt-dash 1.4s ease-in-out infinite;
532
- }
533
- .play-btn.loading .spin { display: block; }
534
- .play-btn.loading svg:not(.spin) { display: none !important; }
535
- .timecode { display: flex; align-items: baseline; gap: 5px; margin-left: 8px; font-size: 13px; white-space: nowrap; }
536
- .timecode .dim { color: var(--ink-3); }
537
- .timecode .frame { color: var(--ink-3); font-size: 11.5px; margin-left: 6px; }
538
- .fps { margin-left: 10px; color: var(--ink-3); font-size: 11.5px; white-space: nowrap; }
539
- input[type="range"].slider { -webkit-appearance: none; appearance: none; width: 72px; height: 4px; border-radius: 2px; background: var(--line-2); margin: 0 8px 0 0; }
540
- input[type="range"].slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--ink); border: 0; }
541
- input[type="range"].slider::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--ink); border: 0; }
542
- input[type="range"].slider:disabled { opacity: .35; }
543
-
544
- /* ── Timeline ── */
545
- .timeline {
546
- flex: none; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
547
- padding: 0 14px; user-select: none; -webkit-user-select: none; touch-action: none; cursor: pointer;
548
- }
549
- .tl-inner { position: relative; }
550
- .ruler { position: relative; height: 22px; border-bottom: 1px solid var(--line); }
551
- .tick { position: absolute; bottom: 0; width: 1px; height: 4px; background: var(--line-2); }
552
- .tick.major { height: 8px; background: var(--ink-3); }
553
- .tick span { position: absolute; bottom: 9px; left: 4px; font-size: 10.5px; line-height: 1; color: var(--ink-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
554
- .wave-lane { position: relative; height: 48px; }
555
- @media (max-height: 600px) { .wave-lane { height: 30px; } }
556
- .wave-lane canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
557
- .wave-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--ink-3); font-size: 12px; }
558
- .wave-state.shimmer {
559
- background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--ink) 6%, transparent) 50%, transparent 100%);
560
- background-size: 200% 100%; animation: shimmer 1.4s linear infinite;
561
- }
562
- @keyframes shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }
563
- .playhead { position: absolute; top: 0; bottom: 0; width: 0; pointer-events: none; z-index: 3; }
564
- .playhead::before { content: ""; position: absolute; top: 0; bottom: 0; left: -0.5px; width: 1px; background: var(--ink); }
565
- .playhead::after { content: ""; position: absolute; top: 0; left: -5px; width: 10px; height: 11px; background: var(--ink); clip-path: polygon(0 0, 100% 0, 100% 55%, 50% 100%, 0 55%); }
566
- .hover-line { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--line-2); display: none; pointer-events: none; }
567
- .hover-time {
568
- position: absolute; top: -28px; transform: translateX(-50%); display: none; pointer-events: none;
569
- font-size: 11px; line-height: 1; background: var(--surface-3); color: var(--ink); padding: 5px 7px; border-radius: 5px;
570
- white-space: nowrap; z-index: 4; font-variant-numeric: tabular-nums;
571
- }
572
- /* ── Notes: pinned to a spot (or an area) and a moment ── */
573
- .stage.can-note { cursor: crosshair; }
574
- .overlay { position: absolute; inset: 0; pointer-events: none; }
575
- .pin {
576
- position: absolute; width: 22px; height: 22px; margin: -11px 0 0 -11px; padding: 0; border-radius: 50%;
577
- display: grid; place-items: center; pointer-events: auto; cursor: pointer;
578
- background: var(--brand); color: #fff; font-size: 11px; font-weight: 600; line-height: 1;
579
- border: 2px solid #fff; box-shadow: 0 1px 5px rgb(0 0 0 / .55); transition: transform .12s;
580
- }
581
- .pin:hover, .pin.active { transform: scale(1.18); }
582
- .pin.done { background: #6b6e76; }
583
- .pin.draft { animation: pin-pulse 1.5s ease-out infinite; }
584
- .pin svg { width: 11px; height: 11px; }
585
- @keyframes pin-pulse {
586
- from { box-shadow: 0 0 0 0 rgb(59 130 246 / .6), 0 1px 5px rgb(0 0 0 / .55); }
587
- to { box-shadow: 0 0 0 14px rgb(59 130 246 / 0), 0 1px 5px rgb(0 0 0 / .55); }
588
- }
589
- .area {
590
- position: absolute; border: 1.5px solid var(--brand); border-radius: 3px;
591
- background: rgb(59 130 246 / .12); box-shadow: 0 0 0 1px rgb(0 0 0 / .35);
592
- }
593
- .area.done { border-color: #9a9da5; background: rgb(255 255 255 / .05); }
594
- .marquee { position: absolute; display: none; border: 1.5px dashed #fff; background: rgb(59 130 246 / .16); border-radius: 3px; pointer-events: none; }
595
- .stage-hint, .toast {
596
- position: absolute; left: 50%; transform: translateX(-50%); pointer-events: none; white-space: nowrap;
597
- padding: 6px 11px; border-radius: 999px; font-size: 12px; color: #fff; background: rgb(0 0 0 / .62);
598
- backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); opacity: 0; transition: opacity .15s;
599
- }
600
- .stage-hint { top: 12px; }
601
- .stage.can-note.hint:hover .stage-hint { opacity: 1; }
602
- .toast { bottom: 14px; }
603
- .toast.on { opacity: 1; }
604
-
605
- .pop {
606
- position: fixed; z-index: 20; width: 280px; display: none; padding: 10px 10px 8px;
607
- background: var(--surface); border: 1px solid var(--line-2); border-radius: 12px;
608
- box-shadow: 0 12px 32px rgb(0 0 0 / .35);
609
- }
610
- .pop.on { display: block; }
611
- .pop-head { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--ink-3); margin-bottom: 6px; }
612
- .badge {
613
- min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; display: inline-grid; place-items: center; flex: none;
614
- background: var(--brand); color: #fff; font-size: 10.5px; font-weight: 600;
615
- }
616
- .badge.done { background: var(--ink-3); }
617
- .pop textarea {
618
- display: block; width: 100%; min-height: 54px; max-height: 180px; resize: none; padding: 2px 2px 0;
619
- border: 0; outline: none; background: transparent; color: var(--ink); font: inherit; line-height: 1.45;
620
- }
621
- .pop textarea::placeholder { color: var(--ink-3); }
622
- .pop-text { white-space: pre-wrap; overflow-wrap: anywhere; padding: 0 2px; max-height: 200px; overflow: auto; }
623
- .pop-foot { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 11.5px; color: var(--ink-3); }
624
- .pop-foot .err { color: var(--bad); }
625
- .btn {
626
- height: 26px; padding: 0 10px; border-radius: 7px; border: 0; font-size: 12px; font-weight: 500;
627
- display: inline-flex; align-items: center; gap: 6px; background: transparent; color: var(--ink-2); white-space: nowrap;
628
- }
629
- .btn:hover:not(:disabled) { background: var(--surface-2); color: var(--ink); }
630
- .btn.primary { background: var(--primary-bg); color: var(--primary-ink); }
631
- .btn.primary:hover:not(:disabled) { background: var(--primary-bg); filter: brightness(.9); }
632
- .btn.danger:hover:not(:disabled) { color: var(--bad); }
633
- .btn:disabled { opacity: .4; cursor: default; }
634
- .btn svg { width: 13px; height: 13px; }
635
- .x-btn { width: 22px; height: 22px; padding: 0; justify-content: center; margin-right: -4px; }
636
-
637
- .transport { position: relative; }
638
- .notes-btn { height: 32px; padding: 0 9px 0 8px; margin-right: 6px; font-size: 12.5px; }
639
- .notes-btn svg { width: 15px; height: 15px; }
640
- .notes-btn .badge { background: var(--surface-3); color: var(--ink-2); }
641
- .notes-btn .badge.open { background: var(--brand); color: #fff; }
642
- .notes-panel {
643
- position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 15; width: min(360px, calc(100vw - 24px));
644
- max-height: min(440px, 62vh); display: none; flex-direction: column; overflow: hidden;
645
- background: var(--surface); border: 1px solid var(--line-2); border-radius: 12px; box-shadow: 0 12px 32px rgb(0 0 0 / .35);
646
- }
647
- .notes-panel.on { display: flex; }
648
- .np-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--line); }
649
- .np-head h2 { margin: 0; font-size: 13px; font-weight: 600; }
650
- .np-list { overflow: auto; padding: 4px; }
651
- .np-empty { padding: 22px 18px; text-align: center; color: var(--ink-3); font-size: 12.5px; }
652
- .np-foot { flex: none; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--ink-3); font-size: 11.5px; overflow-wrap: anywhere; }
653
- .note-row {
654
- display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: start; padding: 8px 8px 8px 10px;
655
- border-radius: 8px; cursor: pointer;
656
- }
657
- .note-row:hover { background: var(--surface-2); }
658
- .note-row .badge { margin-top: 1px; }
659
- .note-row .when { font-size: 11.5px; color: var(--ink-3); }
660
- .note-row .text { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
661
- .note-row.done .text { color: var(--ink-3); text-decoration: line-through; }
662
- .check {
663
- width: 20px; height: 20px; border-radius: 50%; padding: 0; display: grid; place-items: center; margin-top: 1px;
664
- border: 1.5px solid var(--line-2); background: transparent; color: transparent;
665
- }
666
- .check:hover { border-color: var(--ok); color: var(--ok); }
667
- .check.on { background: var(--ok); border-color: var(--ok); color: #fff; }
668
- .check svg { width: 11px; height: 11px; }
669
-
670
- .note-marks { position: absolute; left: 0; right: 0; top: 22px; bottom: 0; pointer-events: none; z-index: 2; }
671
- .note-mark {
672
- position: absolute; top: 5px; width: 14px; height: 14px; margin-left: -7px; padding: 0; border: 0; border-radius: 50%;
673
- display: grid; place-items: center; pointer-events: auto; cursor: pointer;
674
- background: var(--brand); color: #fff; font-size: 8.5px; font-weight: 700; box-shadow: 0 0 0 2px var(--surface);
675
- }
676
- .note-mark::after { content: ""; position: absolute; top: 14px; left: 6.5px; width: 1px; height: 30px; background: var(--brand); opacity: .45; }
677
- .note-mark.done { background: var(--ink-3); }
678
- .note-mark.stacked::after { display: none; }
679
- .note-mark.done::after { background: var(--ink-3); }
680
- .note-mark:hover { transform: scale(1.2); }
681
- </style>
682
- </head>
683
- <body>
684
- <header class="top">
685
- <svg class="logo" viewBox="0 0 512 512" aria-label="framefields" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round"><path d="M184 56H86q-30 0-30 30v98M328 56h98q30 0 30 30v98M184 456H86q-30 0-30-30v-98M328 456h98q30 0 30-30v-98" stroke-width="34" stroke-linejoin="round"/><path d="M176 182v148m0-148c0 74 60 74 100 74" stroke-width="28"/></g><g fill="currentColor"><circle cx="176" cy="172" r="32"/><circle cx="176" cy="340" r="32"/><path d="M284 200l78 56-78 56z" stroke="currentColor" stroke-width="34" stroke-linejoin="round"/></g></svg>
686
- <span class="sep" aria-hidden="true"></span>
687
- <h1 id="title">framefields preview</h1>
688
- <span class="grow"></span>
689
- <span class="spec tnum" id="spec"></span>
690
- <span class="chip" id="chip" data-state="mixing" role="status" aria-live="polite"><span class="spinner"></span><span>Mixing audio</span></span>
691
- </header>
692
-
693
- <main class="player" aria-label="Player">
694
- <div class="stage-wrap">
695
- <div class="stage" id="stage">
696
- <canvas id="canvas" aria-label="Rendered frame"></canvas>
697
- <div class="stage-msg" id="stageMsg" role="alert"></div>
698
- <div class="overlay" id="pins"></div>
699
- <div class="marquee" id="marquee"></div>
700
- <div class="stage-hint" id="stageHint">Click to pin a note · drag to mark an area</div>
701
- <div class="toast" id="toast" role="status" aria-live="polite"></div>
702
- <div class="buffering" id="buffering" role="status" aria-label="Loading"><svg viewBox="0 0 50 50"><circle cx="25" cy="25" r="20"/></svg></div>
703
- </div>
704
- </div>
705
-
706
- <div class="transport">
707
- <button class="icon-btn" id="prev" title="Previous frame" aria-label="Previous frame">
708
- <svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 3h1.6v10H3zM13 3.6v8.8a.6.6 0 0 1-.92.5L5.8 8.5a.6.6 0 0 1 0-1l6.28-4.4a.6.6 0 0 1 .92.5z"/></svg>
709
- </button>
710
- <button class="play-btn" id="play" title="Play" aria-label="Play" disabled>
711
- <svg id="playIcon" viewBox="0 0 16 16" fill="currentColor"><path d="M4.5 2.9v10.2a.7.7 0 0 0 1.06.6l8.1-5.1a.7.7 0 0 0 0-1.2l-8.1-5.1a.7.7 0 0 0-1.06.6z"/></svg>
712
- <svg class="spin" viewBox="0 0 50 50" aria-hidden="true"><circle cx="25" cy="25" r="18"/></svg>
713
- <svg id="pauseIcon" viewBox="0 0 16 16" fill="currentColor" style="display:none"><rect x="3.5" y="2.5" width="3.2" height="11" rx=".8"/><rect x="9.3" y="2.5" width="3.2" height="11" rx=".8"/></svg>
714
- </button>
715
- <button class="icon-btn" id="next" title="Next frame" aria-label="Next frame">
716
- <svg viewBox="0 0 16 16" fill="currentColor"><path d="M11.4 3H13v10h-1.6zM3 3.6v8.8a.6.6 0 0 0 .92.5l6.28-4.4a.6.6 0 0 0 0-1L3.92 3.1A.6.6 0 0 0 3 3.6z"/></svg>
717
- </button>
718
- <div class="timecode tnum">
719
- <span id="tcNow">0:00.00</span><span class="dim">/</span><span class="dim" id="tcDur">0:00.00</span>
720
- <span class="frame" id="tcFrame">frame 0</span>
721
- </div>
722
- <span class="fps tnum" id="fps" role="status" hidden></span>
723
- <span class="grow"></span>
724
- <button class="btn notes-btn" id="notesBtn" title="Notes" aria-haspopup="dialog" aria-expanded="false" hidden>
725
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M3 2.75h10a1.25 1.25 0 0 1 1.25 1.25v6.5A1.25 1.25 0 0 1 13 11.75H7.5L4.5 14v-2.25H3A1.25 1.25 0 0 1 1.75 10.5V4A1.25 1.25 0 0 1 3 2.75z"/></svg>
726
- Notes <span class="badge" id="notesCount">0</span>
727
- </button>
728
- <div class="notes-panel" id="notesPanel" role="dialog" aria-label="Notes">
729
- <div class="np-head">
730
- <h2>Notes</h2><span class="grow"></span>
731
- <button class="btn" id="notesCopy" title="Copy the open notes, to paste into a chat">
732
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="5.25" y="5.25" width="8" height="8" rx="1.5"/><path d="M10.75 3.5V3.25a1.5 1.5 0 0 0-1.5-1.5h-5.5a1.5 1.5 0 0 0-1.5 1.5v5.5a1.5 1.5 0 0 0 1.5 1.5H4"/></svg>
733
- <span id="notesCopyLabel">Copy</span>
734
- </button>
735
- <button class="btn x-btn" id="notesClose" title="Close" aria-label="Close"><svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
736
- </div>
737
- <div class="np-list" id="notesList"></div>
738
- <div class="np-foot" id="notesFoot"></div>
739
- </div>
740
- <button class="icon-btn" id="mute" title="Mute" aria-label="Mute" disabled>
741
- <svg id="volIcon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6h2.2L8 3.2v9.6L4.7 10H2.5z" fill="currentColor" stroke="none"/><path d="M10.6 5.6a3.4 3.4 0 0 1 0 4.8M12.4 3.9a5.8 5.8 0 0 1 0 8.2"/></svg>
742
- <svg id="muteIcon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" style="display:none"><path d="M2.5 6h2.2L8 3.2v9.6L4.7 10H2.5z" fill="currentColor" stroke="none"/><path d="M10.5 6l3.5 4M14 6l-3.5 4"/></svg>
743
- </button>
744
- <input class="slider" type="range" id="volume" min="0" max="1" step="0.01" value="0.9" aria-label="Volume" disabled>
745
- </div>
746
-
747
- <div class="timeline" id="timeline" aria-label="Timeline">
748
- <div class="tl-inner" id="tlInner">
749
- <div class="ruler" id="ruler"></div>
750
- <div class="note-marks" id="noteMarks"></div>
751
- <div class="wave-lane">
752
- <canvas id="wave"></canvas>
753
- <div class="wave-state shimmer" id="waveState"><span class="spinner"></span>Mixing audio</div>
754
- </div>
755
- <div class="hover-line" id="hoverLine"></div>
756
- <div class="hover-time" id="hoverTime"></div>
757
- <div class="playhead" id="playhead"></div>
758
- </div>
759
- </div>
760
- </main>
761
-
762
- <div class="pop" id="pop" role="dialog" aria-label="Note"></div>
763
-
764
- <script>
765
- (function () {
766
- var $ = function (id) { return document.getElementById(id); };
767
- var stage = $("stage"), timeline = $("timeline"), tlInner = $("tlInner");
768
-
769
- var meta = null, session = null, frame = 0, shown = -1, playing = false, playToken = 0;
770
- var player = null;
771
-
772
- // ── Helpers ──
773
- function fmt(f) {
774
- var s = f / meta.fps, m = Math.floor(s / 60);
775
- return m + ":" + (s - m * 60).toFixed(2).padStart(5, "0");
776
- }
777
- function last() { return meta.frameCount - 1; }
778
- function clampFrame(f) { return Math.max(0, Math.min(last(), Math.round(f))); }
779
- function pct(f) { return meta.frameCount > 1 ? (f / last()) * 100 : 0; }
780
- function el(tag, cls, text) {
781
- var e = document.createElement(tag);
782
- if (cls) e.className = cls;
783
- if (text != null) e.textContent = text;
784
- return e;
785
- }
786
- function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
787
-
788
- // ── Reporting: errors and the engine's warnings go to the preview's
789
- // terminal too, so whoever runs it sees them without opening this tab.
790
- // Once each; frame numbers aside, playback repeats the same one per frame ──
791
- var reportedKeys = new Set();
792
- function errText(x) {
793
- if (x && x.message) return x.stack && x.stack.indexOf(x.message) >= 0 ? x.stack.split("\\n").slice(0, 4).join("\\n") : x.message;
794
- return typeof x === "string" ? x : String(x);
795
- }
796
- function report(level, parts) {
797
- var text = parts.map(errText).join(" ").trim(), key = level + text.replace(/\\d+/g, "#");
798
- if (!text || reportedKeys.has(key) || reportedKeys.size > 100) return;
799
- reportedKeys.add(key);
800
- fetch("/@framefields/log", {
801
- method: "POST",
802
- headers: { "content-type": "application/json" },
803
- body: JSON.stringify({ level: level, message: text.slice(0, 4000) }),
804
- keepalive: true
805
- }).catch(function () {});
806
- }
807
- ["error", "warn"].forEach(function (name) {
808
- var original = console[name].bind(console);
809
- console[name] = function () {
810
- original.apply(null, arguments);
811
- var args = Array.prototype.slice.call(arguments);
812
- // Errors from anywhere; warnings only from the engine (fonts, renderers).
813
- if (name === "error" || (typeof args[0] === "string" && args[0].indexOf("[framefields]") === 0)) {
814
- report(name === "error" ? "error" : "warning", args);
815
- }
816
- };
817
- });
818
-
819
- // ── Frames: drawn in this tab by the composition's own code ──
820
- function setTime(f) {
821
- frame = f;
822
- $("tcNow").textContent = fmt(f);
823
- $("tcFrame").textContent = "frame " + f;
824
- $("playhead").style.left = pct(f) + "%";
825
- if (!playing && notesOn) renderPins();
826
- }
827
- function show(f) {
828
- setTime(f);
829
- if (!player) return Promise.resolve();
830
- $("buffering").classList.add("on");
831
- // Calls made while a frame draws collapse to the latest.
832
- return player.render(f).then(function (drawn) {
833
- shown = drawn;
834
- if (!stalled) $("buffering").classList.remove("on");
835
- });
836
- }
837
- function seek(f) {
838
- stop();
839
- var done = show(clampFrame(f));
840
- keepPosition();
841
- scheduleWarm();
842
- return done;
843
- }
844
-
845
- // ── Position in the address: #t=12.5 opens there, and a reload (the next
846
- // preview taking over) comes back to the same moment ──
847
- function startFrame() {
848
- var m = /[#&]t=([\\d.]+)/.exec(location.hash), n = /[#&]frame=(\\d+)/.exec(location.hash);
849
- if (m) return clampFrame(Number(m[1]) * meta.fps);
850
- if (n) return clampFrame(Number(n[1]));
851
- return 0;
852
- }
853
- function keepPosition() {
854
- var at = frame > 0 ? "#t=" + Number((frame / meta.fps).toFixed(3)) : location.pathname;
855
- try { history.replaceState(null, "", at); } catch (e) {}
856
- }
857
-
858
- // ── Analysing ahead: while paused, frames after the playhead are drawn
859
- // off-screen so vision results are cached before they play ──
860
- var warmTimer = null, warmToken = 0, warmed = new Set();
861
- function scheduleWarm() {
862
- clearTimeout(warmTimer);
863
- warmToken++;
864
- if (!player || !player.hasVision || playing) return;
865
- warmTimer = setTimeout(warmAhead, 1000);
866
- }
867
- async function warmAhead() {
868
- var token = ++warmToken;
869
- var end = Math.min(last(), frame + Math.round(meta.fps * 20));
870
- for (var f = frame + 1; f <= end && token === warmToken && !playing; f++) {
871
- if (warmed.has(f)) continue;
872
- if (!(await player.warm(f))) { scheduleWarm(); return; }
873
- warmed.add(f);
874
- await sleep(0);
875
- }
876
- }
877
- function showError(err) {
878
- report("error", [err]);
879
- pageState = "error";
880
- $("buffering").classList.remove("on");
881
- $("stageMsg").textContent = String((err && err.message) || err);
882
- $("stageMsg").classList.add("on");
883
- }
884
-
885
- // ── Audio ──
886
- var audio = { mode: "browser", state: "mixing", error: "", buffer: null, peaks: null, ctx: null, gain: null, src: null, muted: false, volume: 0.9 };
887
- var AUDIO_LABEL = { mixing: "Mixing audio", loading: "Loading audio", ready: "Audio ready", none: "No audio", error: "Audio failed" };
888
- var offline = false;
889
-
890
- function renderChip() {
891
- var chip = $("chip"), state, label, busy;
892
- if (offline) { state = "offline"; label = "Preview stopped"; busy = false; }
893
- else { state = audio.state; label = AUDIO_LABEL[audio.state]; busy = state === "mixing" || state === "loading"; }
894
- chip.dataset.state = state;
895
- chip.title = offline ? "The preview command isn't running. This tab reconnects when it starts again." : audio.error || "";
896
- chip.replaceChildren(el("span", busy ? "spinner" : "dot"), el("span", null, label));
897
- }
898
- function setAudioStatus(state, error) {
899
- audio.state = state;
900
- audio.error = error || "";
901
- var busy = state === "mixing" || state === "loading", ws = $("waveState");
902
- ws.classList.toggle("shimmer", busy);
903
- ws.style.display = state === "ready" ? "none" : "flex";
904
- ws.replaceChildren();
905
- if (busy) ws.append(el("span", "spinner"));
906
- ws.append(document.createTextNode(state === "none" ? "No audio in this composition" : state === "error" ? "Audio could not be mixed" : AUDIO_LABEL[state]));
907
- var usable = state === "ready" && (!!audio.buffer || !!stream);
908
- $("mute").disabled = !usable;
909
- $("volume").disabled = !usable;
910
- renderChip();
911
- drawWave();
912
- }
913
- function ensureAudioContext() {
914
- if (audio.ctx) return;
915
- var Ctx = window.AudioContext || window.webkitAudioContext;
916
- audio.ctx = new Ctx();
917
- audio.gain = audio.ctx.createGain();
918
- audio.gain.connect(audio.ctx.destination);
919
- applyVolume();
920
- }
921
- // ── Soundtrack, streamed. The page mixes it with the export's mixer in
922
- // BLOCK_SEC blocks: the ones at the playhead first, as it plays, the rest
923
- // in the background (which draws the waveform and finds the film's loudest
924
- // sample for the master gain). Blocks play back to back on the
925
- // AudioContext clock. Sources stream straight from where they live, pinned
926
- // assets from their bucket. The mixing runs in a worker with its own GPU
927
- // device, so it never holds up drawing; if the worker can't, the page mixes,
928
- // and if that fails too, the server does. ──
929
- var BLOCK_SEC = 2, AHEAD_SEC = 4, KEEP_BLOCKS = 64, PEAK_BINS = 1200, SILENT = 0.001;
930
- var stream = null, playback = null;
931
- function filmSec() { return meta.frameCount / meta.fps; }
932
- // The project's bundle, loaded as a worker, runs the mixer instead of a player.
933
- function mixerWorker() {
934
- var worker;
935
- try { worker = new Worker("/@framefields/player.js", { type: "module" }); } catch (e) { return null; }
936
- var calls = new Map(), next = 0, failed = null;
937
- var fail = function (err) {
938
- failed = failed || err;
939
- calls.forEach(function (c) { c.reject(failed); });
940
- calls.clear();
941
- };
942
- var ready = new Promise(function (resolve, reject) {
943
- worker.onmessage = function (e) {
944
- var d = e.data;
945
- if ("ready" in d) { if (d.ready) resolve(); else { reject(new Error(d.error)); fail(new Error(d.error)); } return; }
946
- var call = calls.get(d.id);
947
- if (!call) return;
948
- calls.delete(d.id);
949
- if (d.error) call.reject(new Error(d.error)); else call.resolve(d.mix);
950
- };
951
- worker.onerror = function (e) {
952
- var err = new Error(e.message || "the mixing worker failed");
953
- reject(err);
954
- fail(err);
955
- };
956
- });
957
- return {
958
- mix: function (range, chain) {
959
- return ready.then(function () {
960
- if (failed) throw failed;
961
- return new Promise(function (resolve, reject) {
962
- var id = ++next;
963
- calls.set(id, { resolve: resolve, reject: reject });
964
- worker.postMessage({ id: id, range: range, chain: chain });
965
- });
966
- });
967
- },
968
- stop: function () { worker.terminate(); fail(new Error("stopped")); }
969
- };
970
- }
971
- function beginStream(p) {
972
- if (stream && stream.worker) stream.worker.stop();
973
- stopClock();
974
- audio.buffer = null;
975
- audio.peaks = new Array(PEAK_BINS).fill(0);
976
- stream = { player: p, worker: mixerWorker(), blocks: new Map(), order: [], pending: new Map(), queue: [], busy: false,
977
- seen: new Set(), peak: 0, gain: 1, fill: 0, filling: false, total: Math.max(1, Math.ceil(filmSec() / BLOCK_SEC)) };
978
- ensureAudioContext();
979
- setAudioStatus("mixing");
980
- var s = stream;
981
- streamBlock(s, Math.floor(frame / meta.fps / BLOCK_SEC), true).then(function () {
982
- if (stream === s && audio.state === "mixing") setAudioStatus("ready");
983
- }, function () {});
984
- }
985
- // Blocks mix on two chains, each continuing its own effect state from the
986
- // block before: "fill" runs through the film from its start, so its blocks
987
- // are exactly the export's; "play" starts at a seek with a lead-in and
988
- // carries on from there. An exact block is never replaced by a lead-in one.
989
- function streamBlock(s, i, urgent) {
990
- var have = s.blocks.get(i);
991
- if (have) return Promise.resolve(have);
992
- var waiting = s.pending.get(i);
993
- if (waiting) {
994
- if (urgent) promote(s, i);
995
- return waiting;
996
- }
997
- waiting = enqueue(s, i, i === s.fill ? "fill" : "play", urgent, true);
998
- pumpStream(s);
999
- return waiting;
1000
- }
1001
- function enqueue(s, i, chain, urgent, own) {
1002
- if (chain === "fill") s.filling = true;
1003
- var waiting = new Promise(function (resolve, reject) {
1004
- var job = { i: i, chain: chain, own: own, resolve: resolve, reject: reject };
1005
- if (urgent) s.queue.unshift(job); else s.queue.push(job);
1006
- });
1007
- if (own) s.pending.set(i, waiting);
1008
- else waiting.catch(function () {});
1009
- return waiting;
1010
- }
1011
- function promote(s, i) {
1012
- var at = s.queue.findIndex(function (j) { return j.i === i; });
1013
- if (at > 0) s.queue.unshift(s.queue.splice(at, 1)[0]);
1014
- }
1015
- function pumpStream(s) {
1016
- if (s.busy || stream !== s) return;
1017
- if (!s.queue.length) { fillStream(s); if (!s.queue.length) return; }
1018
- s.busy = true;
1019
- var job = s.queue.shift();
1020
- var range = { startSec: job.i * BLOCK_SEC, endSec: Math.min(filmSec(), (job.i + 1) * BLOCK_SEC) };
1021
- mixBlock(s, range, job.chain).then(function (mix) {
1022
- if (stream === s) keepBlock(s, job.i, { left: mix.left, right: mix.right, sampleRate: mix.sampleRate, exact: mix.exact }, mix.peak);
1023
- if (job.chain === "fill") { s.fill = job.i + 1; s.filling = false; }
1024
- job.resolve(s.blocks.get(job.i));
1025
- }, function (err) {
1026
- if (job.chain === "fill") s.filling = false;
1027
- job.reject(err);
1028
- streamFailed(s, err);
1029
- }).then(function () {
1030
- if (job.own) s.pending.delete(job.i);
1031
- s.busy = false;
1032
- pumpStream(s);
1033
- });
1034
- }
1035
- // In the worker; on the page itself once the worker has failed.
1036
- function mixBlock(s, range, chain) {
1037
- if (!s.worker) return s.player.mixAudio(range, chain);
1038
- return s.worker.mix(range, chain).catch(function (err) {
1039
- if (s.worker) {
1040
- console.warn("The soundtrack's mixing worker failed, so the page mixes it:", err);
1041
- s.worker.stop();
1042
- s.worker = null;
1043
- }
1044
- return s.player.mixAudio(range, chain);
1045
- });
1046
- }
1047
- // Idle: the fill chain mixes its next block, every block in order, so
1048
- // its effect state runs on unbroken from the film's start.
1049
- function fillStream(s) {
1050
- if (s.filling || s.fill >= s.total) return;
1051
- enqueue(s, s.fill, "fill", false, !s.pending.has(s.fill));
1052
- }
1053
- function keepBlock(s, i, block, peak) {
1054
- var had = s.blocks.get(i);
1055
- if (!had || !had.exact || block.exact) {
1056
- s.blocks.set(i, block);
1057
- s.order.push(i);
1058
- while (s.order.length > KEEP_BLOCKS) s.blocks.delete(s.order.shift());
1059
- }
1060
- if (s.seen.has(i)) return;
1061
- s.seen.add(i);
1062
- s.peak = Math.max(s.peak, peak);
1063
- var dur = filmSec(), from = Math.floor((i * BLOCK_SEC / dur) * PEAK_BINS);
1064
- var to = Math.min(PEAK_BINS, Math.ceil((Math.min(dur, (i + 1) * BLOCK_SEC) / dur) * PEAK_BINS));
1065
- var perBin = (dur / PEAK_BINS) * block.sampleRate, origin = i * BLOCK_SEC * block.sampleRate;
1066
- for (var b = from; b < to; b++) {
1067
- var lo = Math.max(0, Math.floor(b * perBin - origin)), hi = Math.min(block.left.length, Math.floor((b + 1) * perBin - origin));
1068
- var m = audio.peaks[b];
1069
- for (var k = lo; k < hi; k++) m = Math.max(m, Math.abs(block.left[k]), Math.abs(block.right[k]));
1070
- audio.peaks[b] = Math.min(1, m);
1071
- }
1072
- drawWave();
1073
- if (s.seen.size < s.total) return;
1074
- // The whole film is mixed: master as the export does, or say it's silent.
1075
- s.gain = s.peak > 0.95 ? 0.92 / s.peak : 1;
1076
- if (s.peak < SILENT) setAudioStatus("none");
1077
- }
1078
- function streamFailed(s, err) {
1079
- if (stream !== s) return;
1080
- if (s.worker) s.worker.stop();
1081
- stream = null;
1082
- stopClock();
1083
- console.warn("Mixing the soundtrack in the browser failed, so the preview server mixes it:", err);
1084
- audio.mode = "server";
1085
- setAudioStatus("mixing");
1086
- fetch("/audio/mix", { method: "POST" }).catch(function () {});
1087
- }
1088
- // The master stage on a block: the film's gain, then the export's soft limiter.
1089
- function mastered(channel, gain) {
1090
- var out = new Float32Array(channel.length);
1091
- for (var k = 0; k < channel.length; k++) {
1092
- var v = Math.tanh(channel[k] * gain);
1093
- out[k] = v === v ? v : 0;
1094
- }
1095
- return out;
1096
- }
1097
- function startStream(f0) {
1098
- var s = stream, ctx = audio.ctx;
1099
- ctx.resume();
1100
- var pb = { t0: f0 / meta.fps, startAt: ctx.currentTime + 0.08, next: Math.floor(f0 / meta.fps / BLOCK_SEC), sources: [], timer: 0 };
1101
- playback = pb;
1102
- function tick() {
1103
- if (playback !== pb || stream !== s) return;
1104
- var now = ctx.currentTime - pb.startAt + pb.t0;
1105
- while (pb.next < s.total && pb.next * BLOCK_SEC < now + AHEAD_SEC) {
1106
- var have = s.blocks.get(pb.next);
1107
- if (!have) { streamBlock(s, pb.next, true).then(tick, function () {}); return; }
1108
- scheduleBlock(pb, pb.next, have, s.gain);
1109
- pb.next++;
1110
- }
1111
- }
1112
- pb.timer = setInterval(tick, 250);
1113
- tick();
1114
- return function () { return Math.max(0, ctx.currentTime - pb.startAt); };
1115
- }
1116
- // Block i starts at film second i * BLOCK_SEC: on the clock, that is
1117
- // startAt + (i * BLOCK_SEC - t0). A block that comes late starts part way
1118
- // in, so sound and picture stay on the same clock.
1119
- function scheduleBlock(pb, i, block, gain) {
1120
- var ctx = audio.ctx, buf = ctx.createBuffer(2, block.left.length, block.sampleRate);
1121
- buf.copyToChannel(mastered(block.left, gain), 0);
1122
- buf.copyToChannel(mastered(block.right, gain), 1);
1123
- var src = ctx.createBufferSource();
1124
- src.buffer = buf;
1125
- src.connect(audio.gain);
1126
- var when = pb.startAt + (i * BLOCK_SEC - pb.t0), at = Math.max(when, ctx.currentTime + 0.02), offset = at - when;
1127
- if (offset >= buf.duration) return;
1128
- src.start(at, offset);
1129
- pb.sources.push(src);
1130
- src.onended = function () { pb.sources = pb.sources.filter(function (x) { return x !== src; }); };
1131
- }
1132
- function stopStream() {
1133
- var pb = playback;
1134
- playback = null;
1135
- if (!pb) return;
1136
- clearInterval(pb.timer);
1137
- pb.sources.forEach(function (src) { try { src.stop(); } catch (e) {} src.disconnect(); });
1138
- }
1139
- function loadAudio(status) {
1140
- if (status && status.state === "browser") {
1141
- audio.mode = "browser";
1142
- setAudioStatus("mixing");
1143
- if (player) beginStream(player);
1144
- return;
1145
- }
1146
- stopClock();
1147
- audio.buffer = null;
1148
- audio.peaks = null;
1149
- if (!status || status.state !== "ready") { setAudioStatus(status ? status.state : "none", status && status.error); return; }
1150
- audio.peaks = status.peaks;
1151
- setAudioStatus("loading");
1152
- ensureAudioContext();
1153
- fetch("/audio")
1154
- .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.arrayBuffer(); })
1155
- .then(function (data) { return audio.ctx.decodeAudioData(data); })
1156
- .then(function (buf) { audio.buffer = buf; setAudioStatus("ready"); })
1157
- .catch(function (err) { setAudioStatus("error", String((err && err.message) || err)); });
1158
- }
1159
- function applyVolume() {
1160
- if (audio.gain) audio.gain.gain.value = audio.muted ? 0 : audio.volume;
1161
- $("volIcon").style.display = audio.muted ? "none" : "";
1162
- $("muteIcon").style.display = audio.muted ? "" : "none";
1163
- $("mute").title = audio.muted ? "Unmute" : "Mute";
1164
- $("mute").setAttribute("aria-label", audio.muted ? "Unmute" : "Mute");
1165
- }
1166
- // The clock for real-time playback: the soundtrack when there is one,
1167
- // otherwise the wall clock. Audio as master keeps picture and sound in sync.
1168
- function startClock(f0) {
1169
- if (stream && audio.ctx && audio.state === "ready") return startStream(f0);
1170
- if (audio.buffer && audio.ctx) {
1171
- audio.ctx.resume();
1172
- var src = audio.ctx.createBufferSource();
1173
- src.buffer = audio.buffer;
1174
- src.connect(audio.gain);
1175
- var startAt = audio.ctx.currentTime + 0.06, offset = f0 / meta.fps;
1176
- if (offset < audio.buffer.duration) src.start(startAt, offset);
1177
- audio.src = src;
1178
- return function () { return Math.max(0, audio.ctx.currentTime - startAt); };
1179
- }
1180
- var t0 = performance.now();
1181
- return function () { return (performance.now() - t0) / 1000; };
1182
- }
1183
- function stopClock() {
1184
- stopStream();
1185
- if (!audio.src) return;
1186
- try { audio.src.stop(); } catch (e) {}
1187
- audio.src.disconnect();
1188
- audio.src = null;
1189
- }
1190
-
1191
- // ── Playback ──
1192
- var stalled = false;
1193
- // Waiting on frames mid-playback: the play button and the picture both spin.
1194
- function setStalled(on) {
1195
- stalled = on;
1196
- $("play").classList.toggle("loading", on);
1197
- $("buffering").classList.toggle("on", on);
1198
- }
1199
- function setPlayingUi(on) {
1200
- $("playIcon").style.display = on ? "none" : "";
1201
- $("pauseIcon").style.display = on ? "" : "none";
1202
- $("play").title = on ? "Pause" : "Play";
1203
- $("play").setAttribute("aria-label", on ? "Pause" : "Play");
1204
- }
1205
- function play() {
1206
- if (playing || !player) return;
1207
- playing = true;
1208
- clearTimeout(warmTimer);
1209
- warmToken++;
1210
- var token = ++playToken;
1211
- var alive = function () { return playing && token === playToken; };
1212
- setPlayingUi(true);
1213
- if (openId != null) closePop();
1214
- if (draft && !draft.text.trim()) cancelDraft();
1215
- if (notesOn) renderPins();
1216
- if (frame >= last()) frame = 0;
1217
- playFrames(alive).finally(function () { if (token === playToken && playing) pause(); });
1218
- }
1219
- function stop() {
1220
- playing = false;
1221
- stopClock();
1222
- setStalled(false);
1223
- setPlayingUi(false);
1224
- meter.stop();
1225
- renderFps();
1226
- }
1227
-
1228
- // ── Frame rate reached: distinct frames shown per second while playing,
1229
- // over the last 2 s, stalls included. The film's own rate is in meta ──
1230
- var meter = (function () {
1231
- var started = null, lastFrame = null, samples = [], lastFps = null;
1232
- function read() {
1233
- if (started === null) return { state: "paused", fps: lastFps };
1234
- var now = performance.now(), elapsed = Math.min(2000, now - started);
1235
- samples = samples.filter(function (t) { return t > now - 2000; });
1236
- if (elapsed < 1000) return { state: "measuring", fps: null };
1237
- // Counting whole frames in the window can overshoot the film's rate.
1238
- return { state: "playing", fps: Math.min(meta ? meta.fps : Infinity, (samples.length * 1000) / elapsed) };
1239
- }
1240
- return {
1241
- read: read,
1242
- start: function () { started = performance.now(); lastFrame = null; samples = []; },
1243
- stop: function () {
1244
- if (started === null) return;
1245
- // A playback too short to measure leaves no rate, not an older one.
1246
- var r = read();
1247
- lastFps = r.state === "playing" ? r.fps : null;
1248
- started = null;
1249
- samples = [];
1250
- },
1251
- record: function (f) {
1252
- if (started === null || f === lastFrame) return;
1253
- lastFrame = f;
1254
- samples.push(performance.now());
1255
- }
1256
- };
1257
- })();
1258
- function renderFps() {
1259
- var r = meter.read(), out = $("fps");
1260
- out.hidden = r.state === "paused" && r.fps === null;
1261
- if (out.hidden) return;
1262
- out.textContent = r.state === "measuring" ? "… fps" : r.fps.toFixed(1) + " fps";
1263
- out.title = (r.state === "paused" ? "Frame rate the last playback reached" : "Frame rate playback reaches") + (meta ? "; the film runs at " + meta.fps + " fps" : "");
1264
- }
1265
- setInterval(function () { if (playing) renderFps(); }, 500);
1266
- // Pausing settles on exactly the frame the timecode shows.
1267
- function pause() { stop(); keepPosition(); show(frame).then(scheduleWarm); }
1268
- function togglePlay() { playing ? pause() : play(); }
1269
-
1270
- // Real time: nothing is rendered ahead. Each turn draws the frame due on
1271
- // the clock straight to the screen, and frames that drawing missed are
1272
- // skipped. Playback starts at once and the sound keeps its speed; a device
1273
- // that can't keep up shows fewer frames, and the frame rate says how many.
1274
- async function playFrames(alive) {
1275
- meter.start();
1276
- renderFps();
1277
- // Sound starts with picture: wait for the block at the playhead.
1278
- if (stream && audio.state !== "none") {
1279
- setStalled(true);
1280
- await streamBlock(stream, Math.floor(frame / meta.fps / BLOCK_SEC), true).catch(function () {});
1281
- if (!alive()) return;
1282
- setStalled(false);
1283
- if (stream && audio.state === "mixing") setAudioStatus("ready");
1284
- }
1285
- // A soundtrack still downloading would start late: wait for it.
1286
- if (audio.state === "loading") {
1287
- setStalled(true);
1288
- while (alive() && audio.state === "loading") await sleep(50);
1289
- // A newer playback may be waiting too: only this one's own stall ends here.
1290
- if (!alive()) return;
1291
- setStalled(false);
1292
- }
1293
- var f0 = frame, clock = startClock(f0), presented = -1;
1294
- while (alive()) {
1295
- var due = Math.min(last(), f0 + Math.floor(clock() * meta.fps));
1296
- if (due === presented) { await sleep(4); continue; }
1297
- setTime(due);
1298
- // Each frame is drawn once; caching it would cost a texture and a copy per frame.
1299
- var drawn = await player.render(due, { cache: false });
1300
- if (!alive()) return;
1301
- presented = shown = drawn;
1302
- meter.record(drawn);
1303
- if (drawn >= last()) return;
1304
- }
1305
- }
1306
-
1307
- // ── Timeline ──
1308
- function frameAtX(clientX) {
1309
- var r = tlInner.getBoundingClientRect();
1310
- return clampFrame(Math.min(1, Math.max(0, (clientX - r.left) / r.width)) * last());
1311
- }
1312
- function renderRuler() {
1313
- var ruler = $("ruler"), width = tlInner.clientWidth || 800, dur = last() / meta.fps;
1314
- ruler.replaceChildren();
1315
- if (dur <= 0) return;
1316
- var steps = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
1317
- var major = steps.find(function (s) { return (s / dur) * width >= 80; }) || 1200, minor = major / 5;
1318
- for (var i = 0; i * minor <= dur + 1e-6; i++) {
1319
- var t = i * minor, isMajor = i % 5 === 0, x = (t / dur) * width;
1320
- var tick = el("div", "tick" + (isMajor ? " major" : ""));
1321
- tick.style.left = (t / dur) * 100 + "%";
1322
- if (isMajor && x < width - 34) {
1323
- var m = Math.floor(t / 60), s = Math.round((t - m * 60) * 10) / 10;
1324
- tick.append(el("span", null, m + ":" + (s < 10 ? "0" : "") + s));
1325
- }
1326
- ruler.append(tick);
1327
- }
1328
- }
1329
- function drawWave() {
1330
- var canvas = $("wave"), dpr = window.devicePixelRatio || 1;
1331
- var w = canvas.clientWidth, h = canvas.clientHeight;
1332
- canvas.width = Math.round(w * dpr);
1333
- canvas.height = Math.round(h * dpr);
1334
- var ctx = canvas.getContext("2d");
1335
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1336
- ctx.clearRect(0, 0, w, h);
1337
- var peaks = audio.peaks;
1338
- if (!peaks || !peaks.length || !meta) return;
1339
- var videoSec = Math.max(1e-6, last() / meta.fps);
1340
- var audioSec = audio.buffer ? audio.buffer.duration : videoSec;
1341
- var spanPx = Math.min(1, audioSec / videoSec) * w, mid = h / 2;
1342
- ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--wave").trim();
1343
- for (var x = 0; x < spanPx; x += 3) {
1344
- var v = Math.max(peaks[Math.floor((x / spanPx) * peaks.length)] || 0, 0.015);
1345
- var bh = Math.max(1, Math.sqrt(v) * (h - 8));
1346
- ctx.fillRect(x, mid - bh / 2, 2, bh);
1347
- }
1348
- }
1349
-
1350
- var dragging = false;
1351
- timeline.addEventListener("pointerdown", function (e) {
1352
- if (!meta || e.button !== 0) return;
1353
- timeline.setPointerCapture(e.pointerId);
1354
- dragging = true;
1355
- seek(frameAtX(e.clientX));
1356
- });
1357
- timeline.addEventListener("pointermove", function (e) {
1358
- if (!meta) return;
1359
- var f = frameAtX(e.clientX), r = tlInner.getBoundingClientRect();
1360
- var x = Math.min(r.width, Math.max(0, e.clientX - r.left));
1361
- $("hoverLine").style.display = "block";
1362
- $("hoverLine").style.left = x + "px";
1363
- $("hoverTime").style.display = "block";
1364
- $("hoverTime").style.left = x + "px";
1365
- $("hoverTime").textContent = fmt(f);
1366
- if (dragging && f !== frame) seek(f);
1367
- });
1368
- timeline.addEventListener("pointerleave", function () {
1369
- $("hoverLine").style.display = "none";
1370
- $("hoverTime").style.display = "none";
1371
- });
1372
- timeline.addEventListener("pointerup", function () { dragging = false; });
1373
- timeline.addEventListener("pointercancel", function () { dragging = false; });
1374
-
1375
- // ── Notes: pause, click the picture (or drag an area) and say what should
1376
- // change. Each is saved with its moment, its spot and a picture of the
1377
- // frame with the spot marked, for whoever edits the film ──
1378
- var notes = [], notesOn = false, draft = null, openId = null, saving = false, press = null;
1379
- var pop = $("pop"), pins = $("pins"), NL = String.fromCharCode(10);
1380
- var ICON_CHECK = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5l3 3 6-7"/></svg>';
1381
- var ICON_X = '<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
1382
-
1383
- function pct1(v) { return Math.round(v * 100) + "%"; }
1384
- function whereText(n) {
1385
- return n.w != null ? "area " + pct1(n.x) + "," + pct1(n.y) + " to " + pct1(n.x + n.w) + "," + pct1(n.y + n.h) : "spot " + pct1(n.x) + "," + pct1(n.y);
1386
- }
1387
- function noteById(id) { return notes.find(function (n) { return n.id === id; }); }
1388
- function openCount() { return notes.filter(function (n) { return !n.done; }).length; }
1389
-
1390
- function renderNotes() {
1391
- renderPins();
1392
- renderMarks();
1393
- renderList();
1394
- var open = openCount(), count = $("notesCount");
1395
- count.textContent = String(open || notes.length);
1396
- count.classList.toggle("open", open > 0);
1397
- $("notesBtn").title = open + " open of " + notes.length;
1398
- }
1399
- function renderPins() {
1400
- if (!meta) return;
1401
- stage.classList.toggle("can-note", notesOn && !!player && !playing);
1402
- stage.classList.toggle("hint", notes.length === 0 && !draft);
1403
- pins.replaceChildren();
1404
- if (playing) return;
1405
- var here = notes.filter(function (n) { return n.frame === frame; });
1406
- if (draft && draft.frame === frame) here.push(draft);
1407
- here.forEach(function (n) {
1408
- if (n.w != null) {
1409
- var a = el("div", "area" + (n.done ? " done" : ""));
1410
- a.style.left = n.x * 100 + "%"; a.style.top = n.y * 100 + "%";
1411
- a.style.width = n.w * 100 + "%"; a.style.height = n.h * 100 + "%";
1412
- pins.append(a);
1413
- }
1414
- var pin = el("button", "pin" + (n.done ? " done" : "") + (n === draft ? " draft" : "") + (n.id === openId ? " active" : ""));
1415
- pin.style.left = n.x * 100 + "%"; pin.style.top = n.y * 100 + "%";
1416
- if (n === draft) { pin.setAttribute("aria-label", "New note"); }
1417
- else {
1418
- if (n.done) pin.innerHTML = ICON_CHECK; else pin.textContent = String(n.id);
1419
- pin.title = n.text;
1420
- pin.setAttribute("aria-label", "Note " + n.id);
1421
- pin.addEventListener("click", function (e) { e.stopPropagation(); openCard(n.id); });
1422
- }
1423
- pins.append(pin);
1424
- });
1425
- }
1426
- function renderMarks() {
1427
- var marks = $("noteMarks");
1428
- marks.replaceChildren();
1429
- if (!meta) return;
1430
- // Notes close together stack, up to three high.
1431
- var width = tlInner.clientWidth || 800, lanes = [];
1432
- notes.slice().sort(function (a, b) { return a.frame - b.frame || a.id - b.id; }).forEach(function (n) {
1433
- var x = (pct(n.frame) / 100) * width, lane = lanes.findIndex(function (end) { return x - end >= 16; });
1434
- if (lane < 0) lane = lanes.length < 3 ? lanes.length : 2;
1435
- lanes[lane] = x;
1436
- var m = el("button", "note-mark" + (n.done ? " done" : "") + (lane ? " stacked" : ""), String(n.id));
1437
- m.style.left = pct(n.frame) + "%";
1438
- m.style.top = 4 + lane * 15 + "px";
1439
- m.title = n.id + " · " + fmt(n.frame) + " · " + n.text;
1440
- m.addEventListener("pointerdown", function (e) { e.stopPropagation(); });
1441
- m.addEventListener("click", function (e) { e.stopPropagation(); goToNote(n.id); });
1442
- marks.append(m);
1443
- });
1444
- }
1445
- function renderList() {
1446
- var list = $("notesList");
1447
- list.replaceChildren();
1448
- $("notesCopy").disabled = openCount() === 0;
1449
- if (!notes.length) {
1450
- list.append(el("div", "np-empty", "Pause and click the picture to pin a note to that spot and moment. Drag to mark an area."));
1451
- }
1452
- notes.slice().sort(function (a, b) { return a.frame - b.frame || a.id - b.id; }).forEach(function (n) {
1453
- var row = el("div", "note-row" + (n.done ? " done" : ""));
1454
- var body = el("div");
1455
- body.append(el("div", "when tnum", fmt(n.frame) + " · frame " + n.frame), el("div", "text", n.text));
1456
- var check = el("button", "check" + (n.done ? " on" : ""));
1457
- check.innerHTML = ICON_CHECK;
1458
- check.title = n.done ? "Reopen" : "Mark done";
1459
- check.setAttribute("aria-label", check.title);
1460
- check.addEventListener("click", function (e) { e.stopPropagation(); setDone(n.id, !n.done); });
1461
- row.append(el("span", "badge" + (n.done ? " done" : ""), String(n.id)), body, check);
1462
- row.addEventListener("click", function () { goToNote(n.id); });
1463
- list.append(row);
1464
- });
1465
- var dir = meta && meta.notesDir ? meta.notesDir.split("/").slice(-2).join("/") : "";
1466
- $("notesFoot").textContent = "Saved in " + dir + " with a picture of each spot. Ask your agent to go through them.";
1467
- }
1468
-
1469
- function notesRequest(body) {
1470
- return fetch("/@framefields/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
1471
- .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
1472
- }
1473
- function upsert(note) {
1474
- var i = notes.findIndex(function (n) { return n.id === note.id; });
1475
- if (i >= 0) notes[i] = note; else notes.push(note);
1476
- renderNotes();
1477
- }
1478
- function setDone(id, done) {
1479
- notesRequest({ op: "update", id: id, done: done }).then(function (n) {
1480
- upsert(n);
1481
- if (openId === id) openCard(id);
1482
- }, function () { toast("Couldn't save. Is the preview running?"); });
1483
- }
1484
- function removeNote(id) {
1485
- notesRequest({ op: "remove", id: id }).then(function () {
1486
- notes = notes.filter(function (n) { return n.id !== id; });
1487
- closePop();
1488
- renderNotes();
1489
- }, function () { toast("Couldn't delete. Is the preview running?"); });
1490
- }
1491
- function goToNote(id) {
1492
- var n = noteById(id);
1493
- if (!n) return;
1494
- $("notesPanel").classList.remove("on");
1495
- $("notesBtn").setAttribute("aria-expanded", "false");
1496
- seek(n.frame).then(function () { openCard(id); });
1497
- }
1498
-
1499
- var toastTimer = null;
1500
- function toast(text) {
1501
- var t = $("toast");
1502
- t.textContent = text;
1503
- t.classList.add("on");
1504
- clearTimeout(toastTimer);
1505
- toastTimer = setTimeout(function () { t.classList.remove("on"); }, 2200);
1506
- }
1507
-
1508
- // ── Placing a note on the picture ──
1509
- function fracAt(e) {
1510
- var r = stage.getBoundingClientRect();
1511
- return { x: Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)), y: Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)), r: r };
1512
- }
1513
- stage.addEventListener("pointerdown", function (e) {
1514
- if (e.button !== 0 || !player || e.target.closest(".pin")) return;
1515
- if (playing) { pause(); return; }
1516
- if (!notesOn) return;
1517
- if (draft && pop.querySelector("textarea") && pop.querySelector("textarea").value.trim()) {
1518
- pop.querySelector("textarea").focus();
1519
- return;
1520
- }
1521
- cancelDraft();
1522
- var p = fracAt(e);
1523
- press = { x: p.x, y: p.y, cx: e.clientX, cy: e.clientY, area: null };
1524
- stage.setPointerCapture(e.pointerId);
1525
- e.preventDefault();
1526
- });
1527
- stage.addEventListener("pointermove", function (e) {
1528
- if (!press) return;
1529
- var p = fracAt(e);
1530
- if (!press.area && Math.hypot(e.clientX - press.cx, e.clientY - press.cy) < 6) return;
1531
- press.area = { x: Math.min(press.x, p.x), y: Math.min(press.y, p.y), w: Math.abs(p.x - press.x), h: Math.abs(p.y - press.y) };
1532
- var m = $("marquee");
1533
- m.style.display = "block";
1534
- m.style.left = press.area.x * 100 + "%"; m.style.top = press.area.y * 100 + "%";
1535
- m.style.width = press.area.w * 100 + "%"; m.style.height = press.area.h * 100 + "%";
1536
- });
1537
- function endPress() {
1538
- if (!press) return;
1539
- var a = press.area;
1540
- $("marquee").style.display = "none";
1541
- draft = a && a.w > 0.01 && a.h > 0.01
1542
- ? { frame: frame, x: a.x, y: a.y, w: a.w, h: a.h, text: "" }
1543
- : { frame: frame, x: press.x, y: press.y, text: "" };
1544
- press = null;
1545
- openComposer();
1546
- }
1547
- stage.addEventListener("pointerup", endPress);
1548
- stage.addEventListener("pointercancel", function () { press = null; $("marquee").style.display = "none"; });
1549
-
1550
- // ── The popover: writing a note, or reading one ──
1551
- var popAnchor = null;
1552
- function placePop() {
1553
- if (!popAnchor || !pop.classList.contains("on")) return;
1554
- var r = stage.getBoundingClientRect(), n = popAnchor;
1555
- var ax = r.left + (n.x + (n.w || 0)) * r.width, ay = r.top + (n.y + (n.h || 0)) * r.height;
1556
- var w = pop.offsetWidth, h = pop.offsetHeight, gap = 16;
1557
- var left = ax + gap + w <= innerWidth - 8 ? ax + gap : ax - gap - w - (n.w || 0) * r.width;
1558
- var top = ay + gap + h <= innerHeight - 8 ? ay + gap / 2 : ay - h - gap / 2 - (n.h || 0) * r.height;
1559
- pop.style.left = Math.max(8, Math.min(innerWidth - w - 8, left)) + "px";
1560
- pop.style.top = Math.max(8, Math.min(innerHeight - h - 8, top)) + "px";
1561
- }
1562
- function showPop(anchor) {
1563
- popAnchor = anchor;
1564
- pop.classList.add("on");
1565
- placePop();
1566
- }
1567
- function closePop() {
1568
- pop.classList.remove("on");
1569
- pop.replaceChildren();
1570
- popAnchor = null;
1571
- openId = null;
1572
- renderPins();
1573
- }
1574
- function cancelDraft() {
1575
- if (!draft) return;
1576
- draft = null;
1577
- closePop();
1578
- }
1579
- function openComposer() {
1580
- openId = null;
1581
- var head = el("div", "pop-head");
1582
- head.append(el("span", "badge", "+"), el("span", "tnum", fmt(draft.frame) + " · " + (draft.w != null ? "area" : "spot")));
1583
- var ta = document.createElement("textarea");
1584
- ta.placeholder = "What should change here?";
1585
- ta.rows = 2;
1586
- ta.setAttribute("aria-label", "Note");
1587
- var foot = el("div", "pop-foot"), status = el("span", "grow", "Enter to add");
1588
- var cancel = el("button", "btn", "Cancel"), add = el("button", "btn primary", "Add note");
1589
- add.disabled = true;
1590
- foot.append(status, cancel, add);
1591
- pop.replaceChildren(head, ta, foot);
1592
- ta.addEventListener("input", function () {
1593
- draft.text = ta.value;
1594
- add.disabled = !ta.value.trim() || saving;
1595
- ta.style.height = "auto";
1596
- ta.style.height = Math.min(180, ta.scrollHeight) + "px";
1597
- placePop();
1598
- });
1599
- ta.addEventListener("keydown", function (e) {
1600
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); saveDraft(ta, add, status); }
1601
- if (e.key === "Escape") { e.preventDefault(); cancelDraft(); }
1602
- });
1603
- cancel.addEventListener("click", cancelDraft);
1604
- add.addEventListener("click", function () { saveDraft(ta, add, status); });
1605
- renderPins();
1606
- showPop(draft);
1607
- ta.focus();
1608
- }
1609
- async function saveDraft(ta, add, status) {
1610
- var d = draft, text = ta.value.trim();
1611
- if (!d || !text || saving) return;
1612
- saving = true;
1613
- ta.readOnly = true;
1614
- add.disabled = true;
1615
- status.className = "grow";
1616
- status.textContent = "Saving…";
1617
- try {
1618
- var image = null;
1619
- try { image = await capture(d); } catch (err) { report("warning", ["[framefields] no picture for the note:", err]); }
1620
- var note = await notesRequest({ op: "add", note: { frame: d.frame, time: d.frame / meta.fps, x: d.x, y: d.y, w: d.w, h: d.h, text: text, image: image } });
1621
- if (draft === d) draft = null;
1622
- closePop();
1623
- upsert(note);
1624
- toast("Note " + note.id + " saved");
1625
- } catch (err) {
1626
- status.className = "grow err";
1627
- status.textContent = "Couldn't save. Is the preview running?";
1628
- ta.readOnly = false;
1629
- add.disabled = false;
1630
- } finally {
1631
- saving = false;
1632
- }
1633
- }
1634
- // The frame with the spot marked, small enough to read at a glance.
1635
- async function capture(d) {
1636
- var bmp = await player.snapshot(d.frame);
1637
- var scale = Math.min(1, 1600 / bmp.width), w = Math.round(bmp.width * scale), h = Math.round(bmp.height * scale);
1638
- var c = document.createElement("canvas");
1639
- c.width = w; c.height = h;
1640
- var g = c.getContext("2d");
1641
- g.drawImage(bmp, 0, 0, w, h);
1642
- bmp.close();
1643
- var lw = Math.max(3, w / 400);
1644
- function mark(draw) {
1645
- // Dark, white, then blue: it reads on any picture.
1646
- g.lineWidth = lw + 5; g.strokeStyle = "rgba(0,0,0,0.5)"; draw();
1647
- g.lineWidth = lw + 2.5; g.strokeStyle = "#fff"; draw();
1648
- g.lineWidth = lw; g.strokeStyle = "#3b82f6"; draw();
1649
- }
1650
- if (d.w != null) {
1651
- mark(function () { g.strokeRect(d.x * w, d.y * h, d.w * w, d.h * h); });
1652
- } else {
1653
- var rad = Math.max(16, w / 40);
1654
- mark(function () { g.beginPath(); g.arc(d.x * w, d.y * h, rad, 0, Math.PI * 2); g.stroke(); });
1655
- g.fillStyle = "#3b82f6";
1656
- g.beginPath(); g.arc(d.x * w, d.y * h, lw, 0, Math.PI * 2); g.fill();
1657
- }
1658
- return c.toDataURL("image/jpeg", 0.88);
1659
- }
1660
- function openCard(id) {
1661
- var n = noteById(id);
1662
- if (!n) return;
1663
- if (draft) cancelDraft();
1664
- openId = id;
1665
- var head = el("div", "pop-head");
1666
- var close = el("button", "btn x-btn");
1667
- close.innerHTML = ICON_X;
1668
- close.title = "Close";
1669
- close.setAttribute("aria-label", "Close");
1670
- close.addEventListener("click", closePop);
1671
- head.append(el("span", "badge" + (n.done ? " done" : ""), String(n.id)), el("span", "tnum grow", fmt(n.frame) + " · frame " + n.frame), close);
1672
- var foot = el("div", "pop-foot");
1673
- var done = el("button", "btn");
1674
- done.innerHTML = ICON_CHECK;
1675
- done.append(document.createTextNode(n.done ? "Reopen" : "Mark done"));
1676
- done.addEventListener("click", function () { setDone(n.id, !n.done); });
1677
- var del = el("button", "btn danger", "Delete");
1678
- del.addEventListener("click", function () { removeNote(n.id); });
1679
- foot.append(done, el("span", "grow"), del);
1680
- pop.replaceChildren(head, el("div", "pop-text", n.text), foot);
1681
- renderPins();
1682
- showPop(n);
1683
- }
1684
-
1685
- document.addEventListener("pointerdown", function (e) {
1686
- if (pop.classList.contains("on") && !pop.contains(e.target) && !stage.contains(e.target) && !e.target.closest(".note-mark, .note-row")) {
1687
- if (openId != null) closePop();
1688
- else if (draft && !draft.text.trim()) cancelDraft();
1689
- }
1690
- var panel = $("notesPanel");
1691
- if (panel.classList.contains("on") && !panel.contains(e.target) && !$("notesBtn").contains(e.target)) {
1692
- panel.classList.remove("on");
1693
- $("notesBtn").setAttribute("aria-expanded", "false");
1694
- }
1695
- });
1696
- document.addEventListener("keydown", function (e) {
1697
- if (e.key !== "Escape") return;
1698
- if (openId != null) closePop();
1699
- $("notesPanel").classList.remove("on");
1700
- $("notesBtn").setAttribute("aria-expanded", "false");
1701
- });
1702
- window.addEventListener("resize", placePop);
1703
- new ResizeObserver(placePop).observe(stage);
1704
-
1705
- $("notesBtn").addEventListener("click", function () {
1706
- var on = $("notesPanel").classList.toggle("on");
1707
- $("notesBtn").setAttribute("aria-expanded", String(on));
1708
- });
1709
- $("notesClose").addEventListener("click", function () {
1710
- $("notesPanel").classList.remove("on");
1711
- $("notesBtn").setAttribute("aria-expanded", "false");
1712
- });
1713
- // The open notes as text, for a chat that can't read the notes folder.
1714
- $("notesCopy").addEventListener("click", function () {
1715
- var lines = ["Notes on the preview of " + JSON.stringify(meta.title) + ":"];
1716
- notes.filter(function (n) { return !n.done; }).sort(function (a, b) { return a.frame - b.frame; }).forEach(function (n) {
1717
- lines.push("- Note " + n.id + " at " + fmt(n.frame) + " (frame " + n.frame + "), " + whereText(n) + ": " + n.text + (n.image ? " [picture: " + n.image + "]" : ""));
1718
- });
1719
- var text = lines.join(NL), label = $("notesCopyLabel");
1720
- var done = function () { label.textContent = "Copied"; setTimeout(function () { label.textContent = "Copy"; }, 1600); };
1721
- (navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject()).then(done, function () {
1722
- var t = document.createElement("textarea");
1723
- t.value = text;
1724
- document.body.append(t);
1725
- t.select();
1726
- try { document.execCommand("copy"); done(); } catch (e) {}
1727
- t.remove();
1728
- });
1729
- });
1730
-
1731
- function loadNotes() {
1732
- if (!notesOn) return;
1733
- fetch("/@framefields/notes").then(function (r) { return r.json(); }).then(function (list) {
1734
- notes = Array.isArray(list) ? list : [];
1735
- renderNotes();
1736
- }, function () {});
1737
- }
1738
-
1739
- // ── Wiring ──
1740
- $("play").addEventListener("click", togglePlay);
1741
- $("prev").addEventListener("click", function () { seek(frame - 1); });
1742
- $("next").addEventListener("click", function () { seek(frame + 1); });
1743
- $("mute").addEventListener("click", function () { audio.muted = !audio.muted; applyVolume(); });
1744
- $("volume").addEventListener("input", function (e) { audio.volume = Number(e.target.value); audio.muted = audio.volume === 0; applyVolume(); });
1745
-
1746
- // A hidden tab's timers are throttled, so playback would stutter: pause.
1747
- document.addEventListener("visibilitychange", function () {
1748
- if (document.hidden && playing) pause();
1749
- });
1750
-
1751
- // ── For scripts and agents driving the page: seek, play, and read the
1752
- // state instead of clicking at coordinates ──
1753
- var pageState = "loading";
1754
- window.framefieldsPreview = {
1755
- /** Shows the frame at a time in seconds; resolves once it is drawn. */
1756
- seek: function (sec) { return ready().then(function () { return seek(Number(sec) * meta.fps); }); },
1757
- /** Shows a frame by number; resolves once it is drawn. */
1758
- seekFrame: function (f) { return ready().then(function () { return seek(f); }); },
1759
- play: function () { return ready().then(play); },
1760
- /** Notes pinned on the page, as saved in notes.json. */
1761
- notes: function () { return notes.slice(); },
1762
- pause: function () { if (playing) pause(); },
1763
- /** Where the preview is: loading, ready or error, and what it shows. */
1764
- state: function () {
1765
- var rate = meter.read().fps;
1766
- return {
1767
- state: pageState,
1768
- error: pageState === "error" ? $("stageMsg").textContent : null,
1769
- frame: frame,
1770
- time: meta ? Number((frame / meta.fps).toFixed(3)) : 0,
1771
- playing: playing,
1772
- buffering: stalled,
1773
- audio: audio.state,
1774
- // Soundtrack blocks mixed in this page so far, of the film's.
1775
- audioMixed: stream ? { blocks: stream.seen.size, of: stream.total, exact: Array.from(stream.blocks.values()).filter(function (b) { return b.exact; }).length } : null,
1776
- // Where the soundtrack mixes: "worker" (off the page's thread), "page", or "server".
1777
- audioMixer: stream ? (stream.worker ? "worker" : "page") : audio.mode === "server" ? "server" : null,
1778
- connected: !offline,
1779
- // Frames shown per second over the last 2 s of playback (the last
1780
- // playback's while paused), and how long the last frame took to draw.
1781
- playbackFps: rate == null ? null : Number(rate.toFixed(1)),
1782
- renderMs: player ? Math.round(player.lastRenderMs) : null,
1783
- meta: meta && { title: meta.title, width: meta.width, height: meta.height, fps: meta.fps, frameCount: meta.frameCount, duration: Number((last() / meta.fps).toFixed(3)) }
1784
- };
1785
- }
1786
- };
1787
- var readyWaiters = [];
1788
- function ready() {
1789
- if (player) return Promise.resolve();
1790
- if (pageState === "error") return Promise.reject(new Error($("stageMsg").textContent));
1791
- return new Promise(function (resolve, reject) { readyWaiters.push([resolve, reject]); });
1792
- }
1793
- function settleReady(err) {
1794
- readyWaiters.splice(0).forEach(function (w) { err ? w[1](err) : w[0](); });
1795
- }
1796
-
1797
- function applyMeta(m) {
1798
- meta = m;
1799
- document.title = m.title + " · preview";
1800
- $("title").textContent = m.title;
1801
- $("spec").textContent = m.width + "×" + m.height + " · " + m.fps + " fps · " + fmt(last());
1802
- $("tcDur").textContent = fmt(last());
1803
- stage.style.setProperty("--ar", m.width + " / " + m.height);
1804
- stage.style.setProperty("--ar-n", String(m.width / m.height));
1805
- renderRuler();
1806
- }
1807
-
1808
- new ResizeObserver(function () {
1809
- if (!meta) return;
1810
- renderRuler();
1811
- drawWave();
1812
- renderMarks();
1813
- }).observe(tlInner);
1814
-
1815
- // When the preview command ends the event stream drops and the browser keeps
1816
- // retrying. The next preview of this project (same port) says hello with a
1817
- // new session, and this tab reloads into it.
1818
- var events = new EventSource("/events");
1819
- events.addEventListener("hello", function (e) {
1820
- var s = JSON.parse(e.data).session;
1821
- if (session && s !== session) { location.reload(); return; }
1822
- offline = false;
1823
- renderChip();
1824
- });
1825
- events.addEventListener("error", function () {
1826
- if (!meta) return;
1827
- if (playing) { stop(); keepPosition(); }
1828
- offline = true;
1829
- renderChip();
1830
- });
1831
- events.addEventListener("notes", function (e) {
1832
- notes = JSON.parse(e.data);
1833
- if (openId != null && !noteById(openId)) closePop();
1834
- renderNotes();
1835
- });
1836
- events.addEventListener("audio", function (e) {
1837
- if (meta) loadAudio(JSON.parse(e.data));
1838
- });
1839
-
1840
- fetch("/meta").then(function (r) { return r.json(); }).then(function (m) {
1841
- session = m.session;
1842
- applyMeta(m);
1843
- notesOn = !!m.notesDir;
1844
- $("notesBtn").hidden = !notesOn;
1845
- loadNotes();
1846
- loadAudio(m.audio);
1847
- setTime(startFrame());
1848
- $("buffering").classList.add("on");
1849
- whenPlayer();
1850
- });
1851
-
1852
- // The composition's bundle (loaded below) announces its player.
1853
- function whenPlayer() {
1854
- if (!window.framefieldsPlayer) return;
1855
- window.framefieldsPlayer.then(function (p) {
1856
- player = p;
1857
- if (audio.mode === "browser") beginStream(p);
1858
- $("play").disabled = false;
1859
- show(frame).then(function () {
1860
- pageState = "ready";
1861
- renderPins();
1862
- document.documentElement.dataset.preview = "ready";
1863
- settleReady();
1864
- scheduleWarm();
1865
- });
1866
- }, function (err) {
1867
- showError(err);
1868
- document.documentElement.dataset.preview = "error";
1869
- settleReady(err instanceof Error ? err : new Error(String(err)));
1870
- });
1871
- }
1872
- window.addEventListener("framefields:player", function () { if (meta) whenPlayer(); });
1873
- window.addEventListener("error", function (e) {
1874
- if (!player) showError(e.error || e.message);
1875
- else report("error", [e.error || e.message]);
1876
- });
1877
- window.addEventListener("unhandledrejection", function (e) { report("error", [e.reason]); });
1878
- })();
1879
- <\/script>
1880
- <script type="module" src="/@framefields/player.js"><\/script>
1881
- </body>
1882
- </html>
1883
- `;
1884
-
1885
- //#endregion
1886
- //#region src/preview/peaks.ts
1887
- /** Waveform peaks of a stereo mix: the loudest sample of either channel per bin, 0–1. */
1888
- function computePeaks(left, right, bins) {
1889
- const peaks = new Array(bins).fill(0);
1890
- if (left.length === 0) return peaks;
1891
- const per = left.length / bins;
1892
- for (let b = 0; b < bins; b++) {
1893
- const start = Math.floor(b * per);
1894
- const end = Math.min(left.length, Math.floor((b + 1) * per));
1895
- let max = 0;
1896
- for (let i = start; i < end; i++) {
1897
- const v = Math.max(Math.abs(left[i] ?? 0), Math.abs(right[i] ?? 0));
1898
- if (v > max) max = v;
1899
- }
1900
- peaks[b] = Math.min(1, max);
1901
- }
1902
- return peaks;
1903
- }
1904
- /** Bins the page's waveform draws. */
1905
- const PEAK_BINS = 1200;
1906
- /** A mix counts as silent below this peak. */
1907
- const SILENT_PEAK = .001;
1908
-
1909
- //#endregion
1910
- //#region src/preview/index.ts
1911
- /** Requests carrying this header come from another preview process, not a page. */
1912
- const TAKEOVER_HEADER = "x-framefields-preview";
1913
- const PLAYER_PATH = "/@framefields/player.js";
1914
- /**
1915
- * Serves a localhost player for a composition. The page loads the
1916
- * composition's own code, bundled with the engine, and renders its frames
1917
- * with WebGPU in the browser, like the editor's player; nothing is streamed.
1918
- * The server provides the bundle, the project's files, and the soundtrack,
1919
- * which it mixes with the export engine.
1920
- *
1921
- * The server runs until its tab closes or `close()`. Starting another preview
1922
- * on the same port (the default for a project) stops this one, and open tabs
1923
- * reload into it.
1924
- */
1925
- async function startPreview(source, options = {}) {
1926
- const entry = source.entry instanceof URL || String(source.entry).startsWith("file:") ? fileURLToPath(source.entry) : path.resolve(String(source.entry));
1927
- const exportName = source.export ?? "default";
1928
- const modelsDir = visionModelsDir();
1929
- const ort = onnxRuntimeWeb(path.dirname(entry));
1930
- const roots = [
1931
- ...options.root === void 0 ? [repoRoot(path.dirname(entry))] : Array.isArray(options.root) ? options.root : [options.root],
1932
- modelsDir,
1933
- ort.dir
1934
- ].filter((r) => !!r).map((r) => path.resolve(r));
1935
- const sseClients = /* @__PURE__ */ new Set();
1936
- const idleCloseMs = options.idleCloseMs ?? 5e3;
1937
- let idleTimer;
1938
- const title = options.title ?? "framefields preview";
1939
- const session = randomUUID();
1940
- const log = options.log === false ? () => {} : options.log ?? ((line) => console.error(line));
1941
- const say = (line) => log(`[framefields preview] ${line}`);
1942
- const reported = /* @__PURE__ */ new Set();
1943
- let pageOpened = false;
1944
- const notes = options.notesDir === false ? void 0 : new NoteStore(path.resolve(options.notesDir ?? path.join(process.cwd(), ".framefields", "preview-notes")));
1945
- let noteQueue = Promise.resolve();
1946
- const assets = await openAssetLocator(path.dirname(entry)).catch((err) => {
1947
- say(`pinned assets are off: ${err.message}`);
1948
- });
1949
- if (assets) inputStore.setMissingMediaResolver((file) => assets.url(file));
1950
- const unreadable = /* @__PURE__ */ new Set();
1951
- const target = await loadTarget(entry, exportName);
1952
- const meta = await resolveMeta(target, title, session, notes?.dir ?? null);
1953
- let bundle;
1954
- const playerBundle = () => {
1955
- if (!bundle) {
1956
- const built = bundleProject({
1957
- entry,
1958
- exportName,
1959
- modelsDir,
1960
- ortBase: ort.base
1961
- }).then((b) => b.code);
1962
- bundle = built;
1963
- built.catch((err) => {
1964
- say(`could not build ${path.relative(process.cwd(), entry)}:\n${errorText(err)}`);
1965
- if (bundle === built) bundle = void 0;
1966
- });
1967
- }
1968
- return bundle;
1969
- };
1970
- const engine = engineDir();
1971
- engine.catch(() => {});
1972
- let audio = { state: options.audio === false ? "none" : "browser" };
1973
- let audioWav;
1974
- function broadcast(event, data) {
1975
- for (const client of sseClients) client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1976
- }
1977
- function setAudio(next) {
1978
- audio = next;
1979
- broadcast("audio", audio);
1980
- }
1981
- function startAudio() {
1982
- if (audio.state !== "mixing") return;
1983
- if (!target.toVirtualMediaAsync && !target.toVirtualMedia) {
1984
- setAudio({ state: "none" });
1985
- return;
1986
- }
1987
- const renderer = options.renderer ?? new HeadlessMediaRenderer();
1988
- (async () => {
1989
- const vm = target.toVirtualMediaAsync ? await target.toVirtualMediaAsync() : target.toVirtualMedia();
1990
- await target.prepareMediaTime?.();
1991
- return renderer.renderAudio(vm, { fps: meta.fps });
1992
- })().then(({ channels, sampleRate }) => {
1993
- const left = channels[0] ?? new Float32Array(0);
1994
- const right = channels[1] ?? left;
1995
- const peaks = computePeaks(left, right, PEAK_BINS);
1996
- if (left.length === 0 || Math.max(...peaks) < SILENT_PEAK) {
1997
- setAudio({ state: "none" });
1998
- return;
1999
- }
2000
- audioWav = encodeStereoWav([left, right], { sampleRate });
2001
- setAudio({
2002
- state: "ready",
2003
- durationSec: left.length / sampleRate,
2004
- peaks: peaks.map((p) => Math.round(p * 1e3) / 1e3)
2005
- });
2006
- }, (err) => {
2007
- setAudio({
2008
- state: "error",
2009
- error: err instanceof Error ? err.message : String(err)
2010
- });
2011
- });
2012
- }
2013
- let resolveClosed;
2014
- const closed = new Promise((r) => {
2015
- resolveClosed = r;
2016
- });
2017
- let closing;
2018
- function close(reason = "closed") {
2019
- closing ??= new Promise((resolve$1) => {
2020
- clearTimeout(idleTimer);
2021
- if (reason === "idle") say("stopped: its tab was closed");
2022
- if (reason === "replaced") say("stopped: a newer preview took over");
2023
- for (const client of sseClients) client.end();
2024
- sseClients.clear();
2025
- if (assets) inputStore.setMissingMediaResolver();
2026
- server.close(() => {
2027
- resolve$1();
2028
- resolveClosed(reason);
2029
- });
2030
- server.closeAllConnections();
2031
- });
2032
- return closing;
2033
- }
2034
- const server = http.createServer(async (req, res) => {
2035
- const url$1 = new URL(req.url ?? "/", "http://localhost");
2036
- res.setHeader("cross-origin-opener-policy", "same-origin");
2037
- res.setHeader("cross-origin-embedder-policy", "credentialless");
2038
- try {
2039
- if (req.method === "GET" && url$1.pathname === "/") {
2040
- res.writeHead(200, {
2041
- "content-type": "text/html; charset=utf-8",
2042
- "cache-control": "no-store"
2043
- });
2044
- res.end(previewPage);
2045
- return;
2046
- }
2047
- if (req.method === "GET" && url$1.pathname === "/meta") {
2048
- sendJson(res, 200, {
2049
- ...meta,
2050
- audio
2051
- });
2052
- return;
2053
- }
2054
- if (req.method === "GET" && url$1.pathname.startsWith(`${ENGINE_URL}/`)) {
2055
- const dir = await engine;
2056
- const file = path.resolve(dir, `.${url$1.pathname.slice(ENGINE_URL.length)}`);
2057
- if (isInside(dir, file) && await isFile(file)) {
2058
- await sendFile(req, res, file);
2059
- return;
2060
- }
2061
- sendJson(res, 404, { error: "not found" });
2062
- return;
2063
- }
2064
- if (req.method === "GET" && url$1.pathname === PLAYER_PATH) {
2065
- let code;
2066
- try {
2067
- await engine;
2068
- code = await playerBundle();
2069
- } catch (err) {
2070
- const message = err instanceof Error ? err.message : String(err);
2071
- code = `throw new Error(${JSON.stringify(`Could not build the preview:\n${message}`)});`;
2072
- }
2073
- res.writeHead(200, {
2074
- "content-type": "text/javascript; charset=utf-8",
2075
- "cache-control": "no-store"
2076
- });
2077
- res.end(code);
2078
- return;
2079
- }
2080
- if (req.method === "GET" && url$1.pathname === "/@framefields/stat") {
2081
- const file = path.resolve(url$1.searchParams.get("path") ?? "");
2082
- let size = -1;
2083
- if (roots.some((r) => isInside(r, file))) size = await fs.promises.stat(file).then((st) => st.isFile() ? st.size : -1, () => assets?.entry(file)?.size ?? -1);
2084
- sendJson(res, 200, {
2085
- exists: size >= 0,
2086
- size: Math.max(0, size)
2087
- });
2088
- return;
2089
- }
2090
- if (req.method === "GET" && url$1.pathname === "/events") {
2091
- res.writeHead(200, {
2092
- "content-type": "text/event-stream",
2093
- "cache-control": "no-store",
2094
- connection: "keep-alive"
2095
- });
2096
- res.write("retry: 1000\n");
2097
- res.write(`event: hello\ndata: ${JSON.stringify({ session: meta.session })}\n\n`);
2098
- sseClients.add(res);
2099
- clearTimeout(idleTimer);
2100
- if (!pageOpened) {
2101
- pageOpened = true;
2102
- say(idleCloseMs === false ? "page opened" : `page opened; the server stops ${Number((idleCloseMs / 1e3).toFixed(1))} s after its last tab closes`);
2103
- }
2104
- onClient?.();
2105
- req.on("close", () => {
2106
- sseClients.delete(res);
2107
- if (sseClients.size === 0 && idleCloseMs !== false && !closing) idleTimer = setTimeout(() => {
2108
- if (sseClients.size === 0) close("idle");
2109
- }, idleCloseMs);
2110
- });
2111
- return;
2112
- }
2113
- if (req.method === "POST" && url$1.pathname === "/takeover" && req.headers[TAKEOVER_HEADER] === "1") {
2114
- sendJson(res, 200, { ok: true });
2115
- setImmediate(() => void close("replaced"));
2116
- return;
2117
- }
2118
- if (req.method === "POST" && url$1.pathname === "/@framefields/log" && req.headers["content-type"] === "application/json") {
2119
- const body = await readBody(req, 16 * 1024);
2120
- const { message, level } = JSON.parse(body);
2121
- const text = String(message ?? "").trim();
2122
- if (text && !reported.has(text) && reported.size < 200) {
2123
- reported.add(text);
2124
- say(`${level === "warning" ? "warning" : "error"} in the page: ${text}`);
2125
- }
2126
- res.writeHead(204).end();
2127
- return;
2128
- }
2129
- if (notes && url$1.pathname === "/@framefields/notes") {
2130
- if (req.method === "GET") {
2131
- sendJson(res, 200, await notes.list());
2132
- return;
2133
- }
2134
- if (req.method === "POST" && req.headers["content-type"] === "application/json") {
2135
- const body = JSON.parse(await readBody(req, 16 * 1024 * 1024));
2136
- const change = noteQueue.then(() => changeNote(notes, body, meta.fps, say));
2137
- noteQueue = change.catch(() => {});
2138
- const note = await change;
2139
- broadcast("notes", await notes.list());
2140
- sendJson(res, note ? 200 : 404, note ?? { error: "no such note" });
2141
- return;
2142
- }
2143
- }
2144
- if (req.method === "POST" && url$1.pathname === "/audio/mix") {
2145
- if (audio.state === "browser" || audio.state === "error") {
2146
- audio = { state: "mixing" };
2147
- startAudio();
2148
- }
2149
- sendJson(res, 202, audio);
2150
- return;
2151
- }
2152
- if (req.method === "GET" && url$1.pathname === "/audio") {
2153
- if (audio.state !== "ready" || !audioWav) {
2154
- sendJson(res, 404, { error: `audio is ${audio.state}` });
2155
- return;
2156
- }
2157
- res.writeHead(200, {
2158
- "content-type": "audio/wav",
2159
- "content-length": audioWav.length,
2160
- "cache-control": "no-store"
2161
- });
2162
- res.end(audioWav);
2163
- return;
2164
- }
2165
- const fontMatch = /^\/assets\/fonts\/([^/]+)$/.exec(url$1.pathname);
2166
- if (fontMatch && (req.method === "GET" || req.method === "HEAD")) {
2167
- const file = path.join(process.cwd(), "assets", "fonts", decodeURIComponent(fontMatch[1]));
2168
- if (await isFile(file)) {
2169
- await sendFile(req, res, file);
2170
- return;
2171
- }
2172
- }
2173
- if (req.method === "GET" || req.method === "HEAD") {
2174
- const file = path.resolve(decodeURIComponent(url$1.pathname.replace(/^\/@fs(?=\/)/, "")));
2175
- if (roots.some((r) => isInside(r, file)) && await isFile(file)) {
2176
- await sendFile(req, res, file);
2177
- return;
2178
- }
2179
- if (assets && await redirectToBucket(res, assets, file, unreadable, say)) return;
2180
- }
2181
- sendJson(res, 404, { error: "not found" });
2182
- } catch (err) {
2183
- if (!res.headersSent) sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2184
- else res.end();
2185
- }
2186
- });
2187
- let onClient;
2188
- const host = options.host ?? "127.0.0.1";
2189
- const port = options.port ?? stablePort();
2190
- const listen = (p) => new Promise((resolve$1, reject) => {
2191
- const fail = (err) => {
2192
- server.off("listening", ok);
2193
- reject(err);
2194
- };
2195
- const ok = () => {
2196
- server.off("error", fail);
2197
- resolve$1();
2198
- };
2199
- server.once("error", fail);
2200
- server.once("listening", ok);
2201
- server.listen(p, host);
2202
- });
2203
- try {
2204
- await listen(port);
2205
- } catch (err) {
2206
- if (err.code !== "EADDRINUSE") throw err;
2207
- if (await requestTakeover(host, port)) await listen(port).catch(() => listen(0));
2208
- else if (options.port === void 0) await listen(0);
2209
- else throw err;
2210
- }
2211
- const { port: actualPort } = server.address();
2212
- const url = `http://${host.includes(":") ? `[${host}]` : host}:${actualPort}/`;
2213
- playerBundle().catch(() => {});
2214
- if (options.open === true) {
2215
- const reconnected = await new Promise((resolve$1) => {
2216
- const timer = setTimeout(() => resolve$1(false), 2e3);
2217
- onClient = () => {
2218
- clearTimeout(timer);
2219
- resolve$1(true);
2220
- };
2221
- if (sseClients.size > 0) onClient();
2222
- });
2223
- onClient = void 0;
2224
- if (!reconnected) openBrowser(url);
2225
- }
2226
- return {
2227
- url,
2228
- closed,
2229
- close: () => close()
2230
- };
2231
- }
2232
- async function loadTarget(entry, exportName) {
2233
- const exported = (await import(pathToFileURL(entry).href))[exportName];
2234
- if (exported === void 0) throw new Error(`[framefields] ${entry} has no export named "${exportName}" to preview.`);
2235
- return typeof exported === "function" ? await exported() : exported;
2236
- }
2237
- /**
2238
- * The project holding `dir`: the nearest `.git`, else the nearest
2239
- * `package.json`, else `dir` itself. A repo wins over a nested package, so a
2240
- * monorepo's shared assets stay reachable.
2241
- */
2242
- function repoRoot(dir) {
2243
- let project;
2244
- for (let d = dir;; d = path.dirname(d)) {
2245
- if (fs.existsSync(path.join(d, ".git"))) return d;
2246
- if (!project && fs.existsSync(path.join(d, "package.json"))) project = d;
2247
- if (path.dirname(d) === d) return project ?? dir;
2248
- }
2249
- }
2250
- /** Applies one change from the page and says what it was. */
2251
- async function changeNote(notes, body, fps, say) {
2252
- if (body.op === "add" && body.note && String(body.note.text).trim()) {
2253
- const note = await notes.add(body.note);
2254
- const files = [note.image && `frame with the spot marked: ${note.image}`, `all notes: ${notes.file}`];
2255
- say(`${describeNote(note, fps)}\n ${files.filter(Boolean).join(" · ")}`);
2256
- return note;
2257
- }
2258
- const id = Number(body.id);
2259
- if (body.op === "update") {
2260
- const note = await notes.update(id, body);
2261
- if (note && typeof body.done === "boolean") say(`note ${id} marked ${note.done ? "done" : "open"}`);
2262
- return note;
2263
- }
2264
- if (body.op === "remove") {
2265
- const note = await notes.remove(id);
2266
- if (note) say(`note ${id} deleted`);
2267
- return note;
2268
- }
2269
- }
2270
- function errorText(err) {
2271
- return err instanceof Error ? err.message : String(err);
2272
- }
2273
- function readBody(req, limit) {
2274
- return new Promise((resolve$1, reject) => {
2275
- let body = "";
2276
- req.setEncoding("utf8");
2277
- req.on("data", (chunk) => {
2278
- body += chunk;
2279
- if (body.length > limit) {
2280
- reject(/* @__PURE__ */ new Error("request body too large"));
2281
- req.destroy();
2282
- }
2283
- });
2284
- req.on("end", () => resolve$1(body));
2285
- req.on("error", reject);
2286
- });
2287
- }
2288
- function isInside(root, file) {
2289
- const rel = path.relative(root, file);
2290
- return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
2291
- }
2292
- async function isFile(file) {
2293
- try {
2294
- return (await fs.promises.stat(file)).isFile();
2295
- } catch {
2296
- return false;
2297
- }
2298
- }
2299
- const CONTENT_TYPES = {
2300
- ".png": "image/png",
2301
- ".jpg": "image/jpeg",
2302
- ".jpeg": "image/jpeg",
2303
- ".webp": "image/webp",
2304
- ".gif": "image/gif",
2305
- ".svg": "image/svg+xml",
2306
- ".mp4": "video/mp4",
2307
- ".mov": "video/quicktime",
2308
- ".webm": "video/webm",
2309
- ".mp3": "audio/mpeg",
2310
- ".wav": "audio/wav",
2311
- ".m4a": "audio/mp4",
2312
- ".ogg": "audio/ogg",
2313
- ".ttf": "font/ttf",
2314
- ".otf": "font/otf",
2315
- ".woff": "font/woff",
2316
- ".woff2": "font/woff2",
2317
- ".json": "application/json",
2318
- ".js": "text/javascript; charset=utf-8",
2319
- ".mjs": "text/javascript; charset=utf-8",
2320
- ".wasm": "application/wasm",
2321
- ".onnx": "application/octet-stream",
2322
- ".srt": "text/plain; charset=utf-8",
2323
- ".txt": "text/plain; charset=utf-8"
2324
- };
2325
- /**
2326
- * Sends the page to the bucket for a pinned asset that isn't on disk. The
2327
- * bucket must allow this page's origin (CORS): the engine reads media with
2328
- * `fetch`. True when it answered.
2329
- */
2330
- async function redirectToBucket(res, assets, file, unreadable, say) {
2331
- if (!assets.entry(file)) return false;
2332
- const url = await assets.url(file);
2333
- if (url) {
2334
- res.writeHead(302, {
2335
- location: url,
2336
- "cache-control": "no-store"
2337
- });
2338
- res.end();
2339
- return true;
2340
- }
2341
- const rel = path.relative(assets.root, file);
2342
- if (!unreadable.has(rel)) {
2343
- unreadable.add(rel);
2344
- say(`${rel} is pinned on a private remote and there are no keys here: set its FRAMEFIELDS_STORAGE_* keys, give the remote a publicUrl, or \`npx framefields assets pull\``);
2345
- }
2346
- return false;
2347
- }
2348
- /** Serves a file, with byte ranges so video can seek without loading it whole. */
2349
- async function sendFile(req, res, file) {
2350
- const { size } = await fs.promises.stat(file);
2351
- const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
2352
- const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
2353
- let start = 0;
2354
- let end = size - 1;
2355
- if (range && (range[1] || range[2])) {
2356
- if (range[1]) {
2357
- start = Number(range[1]);
2358
- if (range[2]) end = Math.min(end, Number(range[2]));
2359
- } else start = Math.max(0, size - Number(range[2]));
2360
- if (start > end || start >= size) {
2361
- res.writeHead(416, { "content-range": `bytes */${size}` });
2362
- res.end();
2363
- return;
2364
- }
2365
- }
2366
- res.writeHead(range ? 206 : 200, {
2367
- "content-type": type,
2368
- "content-length": end - start + 1,
2369
- "accept-ranges": "bytes",
2370
- "cache-control": "no-cache",
2371
- ...range && { "content-range": `bytes ${start}-${end}/${size}` }
2372
- });
2373
- if (req.method === "HEAD") {
2374
- res.end();
2375
- return;
2376
- }
2377
- fs.createReadStream(file, {
2378
- start,
2379
- end
2380
- }).pipe(res);
2381
- }
2382
- async function resolveMeta(target, title, session, notesDir) {
2383
- const fps = target.fps ?? 24;
2384
- const durationMs = target.durationMs ?? await target.computeDuration?.() ?? 5e3;
2385
- return {
2386
- session,
2387
- notesDir,
2388
- title,
2389
- fps,
2390
- frameCount: Math.max(1, Math.round(durationMs / 1e3 * fps)),
2391
- width: target.width ?? 1920,
2392
- height: target.height ?? 1080
2393
- };
2394
- }
2395
- /** Same project, same port: 41000–41899, from the working directory. */
2396
- function stablePort() {
2397
- return 41e3 + createHash("sha1").update(process.cwd()).digest().readUInt16BE(0) % 900;
2398
- }
2399
- /** Asks a preview already on `port` to shut down; false if something else is there. */
2400
- async function requestTakeover(host, port) {
2401
- const ok = await new Promise((resolve$1) => {
2402
- const req = http.request({
2403
- host,
2404
- port,
2405
- method: "POST",
2406
- path: "/takeover",
2407
- agent: false,
2408
- headers: { [TAKEOVER_HEADER]: "1" },
2409
- timeout: 1500
2410
- }, (res) => {
2411
- res.resume();
2412
- resolve$1(res.statusCode === 200);
2413
- });
2414
- req.on("timeout", () => req.destroy());
2415
- req.on("error", () => resolve$1(false));
2416
- req.end();
2417
- });
2418
- if (ok) await new Promise((r) => setTimeout(r, 150));
2419
- return ok;
2420
- }
2421
- /** Max absolute amplitude of both channels per bin, 0..1. */
2422
- function sendJson(res, status, body) {
2423
- res.writeHead(status, {
2424
- "content-type": "application/json",
2425
- "cache-control": "no-store"
2426
- });
2427
- res.end(JSON.stringify(body));
2428
- }
2429
- function openBrowser(url) {
2430
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
2431
- "/c",
2432
- "start",
2433
- "",
2434
- url
2435
- ]] : ["xdg-open", [url]];
2436
- try {
2437
- spawn(cmd, args, {
2438
- stdio: "ignore",
2439
- detached: true
2440
- }).on("error", () => {}).unref();
2441
- } catch {}
2442
- }
2443
-
2444
- //#endregion
2445
- export { startPreview as t };
2446
- //# sourceMappingURL=preview-lHH8Tv4j.mjs.map