framefields 2.5.2 → 2.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio/index.d.mts +4 -4
- package/dist/audio/index.mjs +10 -10
- package/dist/{audio-pIgFoKQ2.d.mts → audio-B5mdR8vv.d.mts} +3 -3
- package/dist/{audio-pIgFoKQ2.d.mts.map → audio-B5mdR8vv.d.mts.map} +1 -1
- package/dist/{audio-CZwVENqB.mjs → audio-C9euVW4K.mjs} +4 -4
- package/dist/{audio-CZwVENqB.mjs.map → audio-C9euVW4K.mjs.map} +1 -1
- package/dist/components/index.d.mts +10 -10
- package/dist/components/index.mjs +45 -45
- package/dist/{dist-CQZ5_60A.mjs → dist-BPOi5xb_.mjs} +2 -2
- package/dist/dist-BPOi5xb_.mjs.map +1 -0
- package/dist/{dist-DBWkkSvJ.mjs → dist-BuirkW-f.mjs} +76 -30
- package/dist/dist-BuirkW-f.mjs.map +1 -0
- package/dist/{dist-DFeF-ygz.mjs → dist-D021IFfp.mjs} +3 -1
- package/dist/{dist-DFeF-ygz.mjs.map → dist-D021IFfp.mjs.map} +1 -1
- package/dist/{dist-DHQVWaV2.mjs → dist-DBdxM6xv.mjs} +6 -7
- package/dist/dist-DBdxM6xv.mjs.map +1 -0
- package/dist/effects/index.d.mts +4 -4
- package/dist/effects/index.mjs +35 -35
- package/dist/{effects-DZvq-nWT.mjs → effects-B263lgUi.mjs} +3 -3
- package/dist/{effects-DZvq-nWT.mjs.map → effects-B263lgUi.mjs.map} +1 -1
- package/dist/fonts/index.mjs +2 -2
- package/dist/{fonts-DcsxFpyU.mjs → fonts-DNx6ps2p.mjs} +2 -2
- package/dist/{fonts-DcsxFpyU.mjs.map → fonts-DNx6ps2p.mjs.map} +1 -1
- package/dist/{gpu-Dq_uI_00.mjs → gpu-BRBIoxMC.mjs} +2 -2
- package/dist/{gpu-Dq_uI_00.mjs.map → gpu-BRBIoxMC.mjs.map} +1 -1
- package/dist/{index-Dbwf24s4.d.mts → index-3Vdef2KE.d.mts} +3 -3
- package/dist/{index-Dbwf24s4.d.mts.map → index-3Vdef2KE.d.mts.map} +1 -1
- package/dist/{index-BE1KdTQ7.d.mts → index-B4o6F1JC.d.mts} +2 -2
- package/dist/{index-BE1KdTQ7.d.mts.map → index-B4o6F1JC.d.mts.map} +1 -1
- package/dist/{index-DPP5g-Si.d.mts → index-BhzlG_1Q.d.mts} +2 -2
- package/dist/{index-DPP5g-Si.d.mts.map → index-BhzlG_1Q.d.mts.map} +1 -1
- package/dist/{index-CEsl2BcF.d.mts → index-D-Gnyffj.d.mts} +4 -4
- package/dist/{index-CEsl2BcF.d.mts.map → index-D-Gnyffj.d.mts.map} +1 -1
- package/dist/{index-BlPLU_a1.d.mts → index-N1tMc5Z9.d.mts} +183 -185
- package/dist/index-N1tMc5Z9.d.mts.map +1 -0
- package/dist/{index-BAXaZC6T.d.mts → index-o8LxWdwz.d.mts} +3 -3
- package/dist/{index-BAXaZC6T.d.mts.map → index-o8LxWdwz.d.mts.map} +1 -1
- package/dist/{index-C6awQk4-.d.mts → index-ocZc12ZS.d.mts} +22 -19
- package/dist/index-ocZc12ZS.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +53 -53
- package/dist/preview/index.d.mts +3 -3
- package/dist/preview/index.mjs +10 -10
- package/dist/{preview-CYI4i4m3.mjs → preview-ChR8Z8Fm.mjs} +2 -2
- package/dist/{preview-CYI4i4m3.mjs.map → preview-ChR8Z8Fm.mjs.map} +1 -1
- package/dist/preview-engine/audio.mjs +1 -1
- package/dist/preview-engine/{chunk-CT2WFPYG.mjs → chunk-2LQCQ3OT.mjs} +10 -10
- package/dist/preview-engine/{chunk-AB5VITYM.mjs → chunk-2TCJSHFA.mjs} +1 -1
- package/dist/preview-engine/{chunk-JMOJKT5W.mjs → chunk-2YGR5YCN.mjs} +1 -1
- package/dist/preview-engine/{chunk-LNJYHSYX.mjs → chunk-3KHD67Q3.mjs} +1 -1
- package/dist/preview-engine/chunk-3SR6BI4B.mjs +1 -0
- package/dist/preview-engine/{chunk-NXKXOSE6.mjs → chunk-4INGTMZ5.mjs} +1 -1
- package/dist/preview-engine/{chunk-T4VP45AP.mjs → chunk-4JX5LBE2.mjs} +1 -1
- package/dist/preview-engine/{chunk-7JYMNGNK.mjs → chunk-5NT7WWJR.mjs} +1 -1
- package/dist/preview-engine/{chunk-NB55FW7D.mjs → chunk-6PMATELR.mjs} +1 -1
- package/dist/preview-engine/{chunk-PDM3TJ45.mjs → chunk-AKT4YQWL.mjs} +1 -1
- package/dist/preview-engine/{chunk-RNFYEZLL.mjs → chunk-ALSOO22Q.mjs} +1 -1
- package/dist/preview-engine/{chunk-3AYICEZ5.mjs → chunk-B7J72YSP.mjs} +1 -1
- package/dist/preview-engine/{chunk-W4HIVUUA.mjs → chunk-BBHRXV75.mjs} +1 -1
- package/dist/preview-engine/{chunk-NHQR76WL.mjs → chunk-DDFWN7DI.mjs} +1 -1
- package/dist/preview-engine/{chunk-ZVUTFXDR.mjs → chunk-DOCFOSA6.mjs} +1 -1
- package/dist/preview-engine/{chunk-YJ24W73B.mjs → chunk-FZLKPBKS.mjs} +1 -1
- package/dist/preview-engine/{chunk-EU7CFSB5.mjs → chunk-GQIRV466.mjs} +1 -1
- package/dist/preview-engine/{chunk-DWHQSNSJ.mjs → chunk-IIOHF7MH.mjs} +1 -1
- package/dist/preview-engine/{chunk-WUMVCQYI.mjs → chunk-J24VHZS6.mjs} +1 -1
- package/dist/preview-engine/{chunk-3I6B3YPF.mjs → chunk-JD4TBC6Z.mjs} +8 -8
- package/dist/preview-engine/{chunk-X6BJLPW6.mjs → chunk-JTT3YX7W.mjs} +1 -1
- package/dist/preview-engine/{chunk-XJ4CQIZZ.mjs → chunk-KWTTANYN.mjs} +1 -1
- package/dist/preview-engine/{chunk-QCUGWPYP.mjs → chunk-LN3YAWUD.mjs} +1 -1
- package/dist/preview-engine/{chunk-M6ZKK6ZU.mjs → chunk-LYSU6S4O.mjs} +1 -1
- package/dist/preview-engine/{chunk-TE2QAKKF.mjs → chunk-MJCK5FLZ.mjs} +1 -1
- package/dist/preview-engine/{chunk-24H3QVYB.mjs → chunk-MMVFX42U.mjs} +1 -1
- package/dist/preview-engine/{chunk-25T3M6MI.mjs → chunk-MUOJMF7M.mjs} +1 -1
- package/dist/preview-engine/{chunk-Z4TKEI3I.mjs → chunk-NQZRPPUU.mjs} +1 -1
- package/dist/preview-engine/{chunk-VTE3IISK.mjs → chunk-OXXRKNHW.mjs} +1 -1
- package/dist/preview-engine/{chunk-DUEY7AXY.mjs → chunk-PJMX2GFY.mjs} +1 -1
- package/dist/preview-engine/{chunk-MA4YVA2E.mjs → chunk-Q7JN4EHX.mjs} +1 -1
- package/dist/preview-engine/{chunk-SNASF7OM.mjs → chunk-Q7XC3FI7.mjs} +1 -1
- package/dist/preview-engine/{chunk-UXCTWFKK.mjs → chunk-QPBTLJ5W.mjs} +1 -1
- package/dist/preview-engine/{chunk-LQVACZDE.mjs → chunk-QRYCB6E3.mjs} +1 -1
- package/dist/preview-engine/{chunk-2QQQTZHS.mjs → chunk-RDEQ4LQU.mjs} +1 -1
- package/dist/preview-engine/{chunk-KKBJ7X3T.mjs → chunk-RF62TV3G.mjs} +1 -1
- package/dist/preview-engine/{chunk-CE6CG5BH.mjs → chunk-RHXZXWJ4.mjs} +1 -1
- package/dist/preview-engine/{chunk-BNO5JAEZ.mjs → chunk-RKNV5N7D.mjs} +1 -1
- package/dist/preview-engine/{chunk-PXWCGXTF.mjs → chunk-SP22JWN2.mjs} +1 -1
- package/dist/preview-engine/{chunk-NYEDWUFS.mjs → chunk-SQ52PBF3.mjs} +1 -1
- package/dist/preview-engine/{chunk-QYIKOOFI.mjs → chunk-SVE3XWO7.mjs} +1 -1
- package/dist/preview-engine/{chunk-QJYAIQYU.mjs → chunk-SXNCNG4L.mjs} +1 -1
- package/dist/preview-engine/chunk-SYP4AJMF.mjs +1 -0
- package/dist/preview-engine/{chunk-VH5V54RA.mjs → chunk-TPFDAOW7.mjs} +1 -1
- package/dist/preview-engine/{chunk-ENS4KUJV.mjs → chunk-UEQIO6AH.mjs} +1 -1
- package/dist/preview-engine/{chunk-XYRBFLFF.mjs → chunk-ULPLHKM2.mjs} +1 -1
- package/dist/preview-engine/{chunk-MCR7XMNQ.mjs → chunk-ULVX4VYT.mjs} +1 -1
- package/dist/preview-engine/{chunk-ZAIVXGSS.mjs → chunk-UQD2UE6I.mjs} +1 -1
- package/dist/preview-engine/{chunk-NAISBHLR.mjs → chunk-VY6ST4QX.mjs} +1 -1
- package/dist/preview-engine/{chunk-7BUKVFWB.mjs → chunk-W2EZFV4Q.mjs} +1 -1
- package/dist/preview-engine/{chunk-P734GD7D.mjs → chunk-WDWHOXSA.mjs} +1 -1
- package/dist/preview-engine/{chunk-2RZ6S4SW.mjs → chunk-YEPYVASB.mjs} +1 -1
- package/dist/preview-engine/chunk-ZJLNXN7W.mjs +480 -0
- package/dist/preview-engine/{chunk-6GMEF2QB.mjs → chunk-ZZAZF6VC.mjs} +1 -1
- package/dist/preview-engine/components.mjs +1 -1
- package/dist/preview-engine/effects.mjs +1 -1
- package/dist/preview-engine/fonts.mjs +1 -1
- package/dist/preview-engine/framefields.mjs +1 -1
- package/dist/preview-engine/player.mjs +1 -1
- package/dist/preview-engine/renderers-4CRXZJ5G.mjs +1 -0
- package/dist/preview-engine/{renderers-WPBTAU2E.mjs → renderers-4RVSLTCX.mjs} +1 -1
- package/dist/preview-engine/{renderers-W5CTR4B5.mjs → renderers-56TGEFDK.mjs} +1 -1
- package/dist/preview-engine/renderers-5QF4OLFQ.mjs +1 -0
- package/dist/preview-engine/renderers-5SOW6OVE.mjs +1 -0
- package/dist/preview-engine/renderers-6M2HPY2J.mjs +1 -0
- package/dist/preview-engine/{renderers-DZZFIYYA.mjs → renderers-7GMW52G3.mjs} +1 -1
- package/dist/preview-engine/renderers-7HT5NW7W.mjs +1 -0
- package/dist/preview-engine/{renderers-KV3TTCO6.mjs → renderers-7SW2AHM2.mjs} +1 -1
- package/dist/preview-engine/{renderers-BNXTSIXJ.mjs → renderers-ART3EGSZ.mjs} +1 -1
- package/dist/preview-engine/{renderers-6NFRKHNO.mjs → renderers-ATTGUTYY.mjs} +1 -1
- package/dist/preview-engine/renderers-AYWCRBQC.mjs +1 -0
- package/dist/preview-engine/renderers-BA6ZCPI5.mjs +1 -0
- package/dist/preview-engine/{renderers-AKZAAGR7.mjs → renderers-BAS7RXTC.mjs} +1 -1
- package/dist/preview-engine/{renderers-CLLR5MOQ.mjs → renderers-BWRNCXR6.mjs} +1 -1
- package/dist/preview-engine/{renderers-N7T34SCI.mjs → renderers-C3YXNKNM.mjs} +1 -1
- package/dist/preview-engine/renderers-CZTCW4FC.mjs +1 -0
- package/dist/preview-engine/{renderers-C7SJ3M6S.mjs → renderers-D2XEQ2YY.mjs} +1 -1
- package/dist/preview-engine/renderers-DFIJYBW2.mjs +1 -0
- package/dist/preview-engine/{renderers-DHKB5LI6.mjs → renderers-EJGF3PYQ.mjs} +1 -1
- package/dist/preview-engine/renderers-EJN6M6DO.mjs +1 -0
- package/dist/preview-engine/{renderers-MKEXNBCW.mjs → renderers-EK3HQUUF.mjs} +1 -1
- package/dist/preview-engine/{renderers-O56IOD7J.mjs → renderers-EQDINUMT.mjs} +1 -1
- package/dist/preview-engine/{renderers-VYK2BJID.mjs → renderers-FC7G7KU7.mjs} +1 -1
- package/dist/preview-engine/{renderers-BQLYS4TM.mjs → renderers-FD3ZT73R.mjs} +1 -1
- package/dist/preview-engine/{renderers-TASYN62K.mjs → renderers-FYI6QNUH.mjs} +1 -1
- package/dist/preview-engine/renderers-G34C5J27.mjs +1 -0
- package/dist/preview-engine/{renderers-ROSSJ7Y2.mjs → renderers-GHP7SL5M.mjs} +1 -1
- package/dist/preview-engine/renderers-GKVTNDVH.mjs +1 -0
- package/dist/preview-engine/{renderers-MPCYLXXU.mjs → renderers-GY4R467B.mjs} +1 -1
- package/dist/preview-engine/{renderers-TAMDNTEQ.mjs → renderers-H2UI7YEO.mjs} +1 -1
- package/dist/preview-engine/{renderers-6PVGRTXV.mjs → renderers-HBIBRZD7.mjs} +1 -1
- package/dist/preview-engine/{renderers-GF77N2KM.mjs → renderers-HMNCIEU3.mjs} +1 -1
- package/dist/preview-engine/{renderers-SIBP54WK.mjs → renderers-HZBYQKIQ.mjs} +1 -1
- package/dist/preview-engine/renderers-IK7ZJJKJ.mjs +1 -0
- package/dist/preview-engine/renderers-ISASPH6T.mjs +1 -0
- package/dist/preview-engine/renderers-IW5MWDBI.mjs +1 -0
- package/dist/preview-engine/renderers-K2PI4WJS.mjs +1 -0
- package/dist/preview-engine/renderers-KESHVFL2.mjs +1 -0
- package/dist/preview-engine/renderers-KTZZVAB2.mjs +1 -0
- package/dist/preview-engine/renderers-KY5CJEOV.mjs +1 -0
- package/dist/preview-engine/{renderers-MS4HG3M4.mjs → renderers-LTV5RFDJ.mjs} +1 -1
- package/dist/preview-engine/{renderers-CEJP6Q7F.mjs → renderers-LV6LLULR.mjs} +1 -1
- package/dist/preview-engine/renderers-M5W6SUQD.mjs +1 -0
- package/dist/preview-engine/{renderers-H3ZM2TOA.mjs → renderers-MEM3REMI.mjs} +1 -1
- package/dist/preview-engine/{renderers-HJ6NO5ZD.mjs → renderers-N36R42RL.mjs} +1 -1
- package/dist/preview-engine/{renderers-UVABXXQF.mjs → renderers-NTKSN32T.mjs} +1 -1
- package/dist/preview-engine/renderers-O5XMWFPY.mjs +1 -0
- package/dist/preview-engine/{renderers-QO2RHF7F.mjs → renderers-P2FTCDZ7.mjs} +1 -1
- package/dist/preview-engine/{renderers-L7EITUCD.mjs → renderers-PYDOSDEY.mjs} +1 -1
- package/dist/preview-engine/renderers-QJA35PKC.mjs +1 -0
- package/dist/preview-engine/{renderers-TWZHTKW7.mjs → renderers-RDHHVXRE.mjs} +1 -1
- package/dist/preview-engine/{renderers-RRJ2DX2W.mjs → renderers-SY435YU3.mjs} +1 -1
- package/dist/preview-engine/renderers-TJIFTQHV.mjs +1 -0
- package/dist/preview-engine/renderers-TN4XN436.mjs +1 -0
- package/dist/preview-engine/renderers-UDYLCYCB.mjs +1 -0
- package/dist/preview-engine/renderers-WZ7SFIBZ.mjs +1 -0
- package/dist/preview-engine/renderers-WZEV464O.mjs +228 -0
- package/dist/preview-engine/renderers-X6QNDC6R.mjs +1 -0
- package/dist/preview-engine/renderers-X6RNTJOV.mjs +1 -0
- package/dist/preview-engine/{renderers-QPFZBLDI.mjs → renderers-Y3N7236P.mjs} +1 -1
- package/dist/preview-engine/{renderers-XSPU2BIQ.mjs → renderers-YBTWN76S.mjs} +1 -1
- package/dist/preview-engine/{renderers-T62TXGVO.mjs → renderers-YK74777T.mjs} +1 -1
- package/dist/preview-engine/renderers-YP3ASXIJ.mjs +1 -0
- package/dist/preview-engine/renderers-YVJAG3PD.mjs +1 -0
- package/dist/preview-engine/{renderers-4RFB2LG2.mjs → renderers-ZAMSS7QN.mjs} +1 -1
- package/dist/preview-engine/{renderers-FOHW7RWH.mjs → renderers-ZDMDDWLN.mjs} +1 -1
- package/dist/preview-engine/renderers-ZPPZNMKJ.mjs +1 -0
- package/dist/preview-engine/signals.mjs +1 -1
- package/dist/{program-BdPDtfVv.mjs → program-O2bWX0cs.mjs} +4 -4
- package/dist/{program-BdPDtfVv.mjs.map → program-O2bWX0cs.mjs.map} +1 -1
- package/dist/registry/captions.json +1 -1
- package/dist/registry/card-stack.json +3 -2
- package/dist/registry/chat-thread.json +4 -3
- package/dist/registry/index.json +3 -2
- package/dist/registry/lower-third.json +1 -1
- package/dist/registry/meter.json +1 -1
- package/dist/renderer/index.d.mts +2 -2
- package/dist/renderer/index.mjs +2 -2
- package/dist/renderer-0441BN0U.mjs +5 -0
- package/dist/{renderer-B31zMDE8.mjs → renderer-1NoJLLDa.mjs} +5 -5
- package/dist/{renderer-B31zMDE8.mjs.map → renderer-1NoJLLDa.mjs.map} +1 -1
- package/dist/renderer-3qDgFc-W.mjs +5 -0
- package/dist/renderer-6AIqTN5Z.mjs +5 -0
- package/dist/{renderer-DP41Fokp.mjs → renderer-8_d6YNnE.mjs} +4 -4
- package/dist/{renderer-DP41Fokp.mjs.map → renderer-8_d6YNnE.mjs.map} +1 -1
- package/dist/{renderer-PLoGrFGz.mjs → renderer-B0IJirDs.mjs} +4 -4
- package/dist/{renderer-PLoGrFGz.mjs.map → renderer-B0IJirDs.mjs.map} +1 -1
- package/dist/{renderer-CdUf7ZDJ.mjs → renderer-B2no_9zi.mjs} +4 -4
- package/dist/{renderer-CdUf7ZDJ.mjs.map → renderer-B2no_9zi.mjs.map} +1 -1
- package/dist/{renderer-D6p8b6ql.mjs → renderer-B5ZdkcMi.mjs} +4 -4
- package/dist/{renderer-D6p8b6ql.mjs.map → renderer-B5ZdkcMi.mjs.map} +1 -1
- package/dist/renderer-B7MZ5R7u.mjs +5 -0
- package/dist/{renderer-BBTkfhL8.mjs → renderer-B7vXnBUo.mjs} +4 -4
- package/dist/{renderer-BBTkfhL8.mjs.map → renderer-B7vXnBUo.mjs.map} +1 -1
- package/dist/renderer-BE2rIA4B.mjs +5 -0
- package/dist/renderer-BIQ81ln4.mjs +5 -0
- package/dist/{renderer-l_U6kA8K.mjs → renderer-BKJ8cywK.mjs} +3 -3
- package/dist/{renderer-l_U6kA8K.mjs.map → renderer-BKJ8cywK.mjs.map} +1 -1
- package/dist/renderer-BKm3_Ob3.mjs +5 -0
- package/dist/{renderer-COtZ315B.mjs → renderer-BMiZIGkZ.mjs} +2 -2
- package/dist/renderer-BNdT1Wwf.mjs +5 -0
- package/dist/{renderer-Z39F9QK5.mjs → renderer-BNs31kME.mjs} +4 -4
- package/dist/{renderer-Z39F9QK5.mjs.map → renderer-BNs31kME.mjs.map} +1 -1
- package/dist/renderer-BQycs1q_.mjs +5 -0
- package/dist/{renderer-ZjSP5jw6.mjs → renderer-BStETpqC.mjs} +3 -3
- package/dist/{renderer-ZjSP5jw6.mjs.map → renderer-BStETpqC.mjs.map} +1 -1
- package/dist/{renderer-BmoQJUJO.mjs → renderer-BWNQQZ12.mjs} +4 -4
- package/dist/{renderer-BmoQJUJO.mjs.map → renderer-BWNQQZ12.mjs.map} +1 -1
- package/dist/{renderer-G3px5ODP.mjs → renderer-BZ5uOfoX.mjs} +4 -4
- package/dist/{renderer-G3px5ODP.mjs.map → renderer-BZ5uOfoX.mjs.map} +1 -1
- package/dist/{renderer-D0X7Eh-T.mjs → renderer-Bag1LFos.mjs} +4 -4
- package/dist/{renderer-D0X7Eh-T.mjs.map → renderer-Bag1LFos.mjs.map} +1 -1
- package/dist/{renderer-D5u3GVtD.mjs → renderer-BfQdc3RM.mjs} +74 -74
- package/dist/{renderer-D5u3GVtD.mjs.map → renderer-BfQdc3RM.mjs.map} +1 -1
- package/dist/{renderer-C9r0-KY4.mjs → renderer-BgmO1Dzw.mjs} +11 -2
- package/dist/{renderer-C9r0-KY4.mjs.map → renderer-BgmO1Dzw.mjs.map} +1 -1
- package/dist/renderer-Bj7MxXfk.mjs +5 -0
- package/dist/renderer-BjRAGYYC.mjs +5 -0
- package/dist/{renderer-DQuKNgIx.mjs → renderer-BlU3Sh_0.mjs} +5 -5
- package/dist/{renderer-DQuKNgIx.mjs.map → renderer-BlU3Sh_0.mjs.map} +1 -1
- package/dist/renderer-BqE2BQ_X.mjs +5 -0
- package/dist/{renderer-DkPDYtD3.mjs → renderer-BrKgtYq4.mjs} +4 -4
- package/dist/{renderer-DkPDYtD3.mjs.map → renderer-BrKgtYq4.mjs.map} +1 -1
- package/dist/{renderer-FCqOfN6n.mjs → renderer-Bs6BaMLf.mjs} +4 -4
- package/dist/{renderer-FCqOfN6n.mjs.map → renderer-Bs6BaMLf.mjs.map} +1 -1
- package/dist/renderer-BsewLDHZ.mjs +3 -0
- package/dist/{renderer-Bmj7AyJP.mjs → renderer-BvA4sQz5.mjs} +3 -3
- package/dist/{renderer-Bmj7AyJP.mjs.map → renderer-BvA4sQz5.mjs.map} +1 -1
- package/dist/{renderer-Bw5YP2Db.mjs → renderer-Bvizxddu.mjs} +4 -4
- package/dist/{renderer-Bw5YP2Db.mjs.map → renderer-Bvizxddu.mjs.map} +1 -1
- package/dist/{renderer-WVrUeiLZ.mjs → renderer-Bwxm-kWL.mjs} +2 -2
- package/dist/renderer-Bz5BMkOE.mjs +5 -0
- package/dist/{renderer-vTsc6CpO.d.mts → renderer-C-dcd14F.d.mts} +2 -2
- package/dist/{renderer-vTsc6CpO.d.mts.map → renderer-C-dcd14F.d.mts.map} +1 -1
- package/dist/{renderer-DK34NH92.mjs → renderer-C2MaJKfY.mjs} +4 -4
- package/dist/{renderer-DK34NH92.mjs.map → renderer-C2MaJKfY.mjs.map} +1 -1
- package/dist/{renderer-DyMEAZIm.mjs → renderer-C3tdjg8B.mjs} +5 -5
- package/dist/{renderer-DyMEAZIm.mjs.map → renderer-C3tdjg8B.mjs.map} +1 -1
- package/dist/{renderer-BGNdwdCR.mjs → renderer-C5QYI1Wu.mjs} +4 -4
- package/dist/{renderer-BGNdwdCR.mjs.map → renderer-C5QYI1Wu.mjs.map} +1 -1
- package/dist/{renderer-Cjs1OeBu.mjs → renderer-C6n8y6Pw.mjs} +4 -4
- package/dist/{renderer-Cjs1OeBu.mjs.map → renderer-C6n8y6Pw.mjs.map} +1 -1
- package/dist/{renderer-D2ru2Afi.mjs → renderer-C8pJFxcL.mjs} +4 -4
- package/dist/{renderer-D2ru2Afi.mjs.map → renderer-C8pJFxcL.mjs.map} +1 -1
- package/dist/{renderer-BfrzKmRh.mjs → renderer-CA_CwisV.mjs} +3 -3
- package/dist/{renderer-BfrzKmRh.mjs.map → renderer-CA_CwisV.mjs.map} +1 -1
- package/dist/renderer-CDOSfB9H.mjs +5 -0
- package/dist/{renderer-CWSM0IGG.mjs → renderer-CEAmByVW.mjs} +3 -3
- package/dist/{renderer-CWSM0IGG.mjs.map → renderer-CEAmByVW.mjs.map} +1 -1
- package/dist/{renderer-BN_n6O6P.mjs → renderer-CFCR_7bq.mjs} +4 -4
- package/dist/{renderer-BN_n6O6P.mjs.map → renderer-CFCR_7bq.mjs.map} +1 -1
- package/dist/{renderer-1VKN5j2C.mjs → renderer-CFRTJ7cT.mjs} +4 -4
- package/dist/{renderer-1VKN5j2C.mjs.map → renderer-CFRTJ7cT.mjs.map} +1 -1
- package/dist/{renderer-gjT5v76-.mjs → renderer-CPSg6eEQ.mjs} +3 -3
- package/dist/{renderer-gjT5v76-.mjs.map → renderer-CPSg6eEQ.mjs.map} +1 -1
- package/dist/renderer-CT26yQT-.mjs +5 -0
- package/dist/{renderer-IMg0dK6K.mjs → renderer-CTy0bSsl.mjs} +4 -4
- package/dist/{renderer-IMg0dK6K.mjs.map → renderer-CTy0bSsl.mjs.map} +1 -1
- package/dist/{renderer-BJwOM1Dq.mjs → renderer-CVbNiH03.mjs} +4 -4
- package/dist/{renderer-BJwOM1Dq.mjs.map → renderer-CVbNiH03.mjs.map} +1 -1
- package/dist/renderer-CZeAbz48.mjs +5 -0
- package/dist/{renderer-ChjkppUz.mjs → renderer-CbxfSmYE.mjs} +4 -4
- package/dist/{renderer-ChjkppUz.mjs.map → renderer-CbxfSmYE.mjs.map} +1 -1
- package/dist/{renderer-nLPfvY3-.mjs → renderer-CdxNwGeb.mjs} +2 -2
- package/dist/{renderer-nLPfvY3-.mjs.map → renderer-CdxNwGeb.mjs.map} +1 -1
- package/dist/{renderer-CMEqUhrW.mjs → renderer-Cg2xS8h8.mjs} +4 -4
- package/dist/{renderer-CMEqUhrW.mjs.map → renderer-Cg2xS8h8.mjs.map} +1 -1
- package/dist/{renderer-2bipMLqz.mjs → renderer-CnclEQdA.mjs} +3 -3
- package/dist/{renderer-2bipMLqz.mjs.map → renderer-CnclEQdA.mjs.map} +1 -1
- package/dist/{renderer-kP5Y282J.mjs → renderer-CpDaUeCN.mjs} +5 -5
- package/dist/{renderer-kP5Y282J.mjs.map → renderer-CpDaUeCN.mjs.map} +1 -1
- package/dist/{renderer-vwoyR8pW.mjs → renderer-CqJou_2L.mjs} +3 -3
- package/dist/{renderer-vwoyR8pW.mjs.map → renderer-CqJou_2L.mjs.map} +1 -1
- package/dist/{renderer-VxVi_0c7.mjs → renderer-CrKYlipx.mjs} +3 -3
- package/dist/{renderer-VxVi_0c7.mjs.map → renderer-CrKYlipx.mjs.map} +1 -1
- package/dist/{renderer-Cj1giMd0.mjs → renderer-D3FJcRA3.mjs} +3 -3
- package/dist/{renderer-Cj1giMd0.mjs.map → renderer-D3FJcRA3.mjs.map} +1 -1
- package/dist/renderer-D50b7kip.mjs +5 -0
- package/dist/renderer-D5LmrO96.mjs +5 -0
- package/dist/{renderer-Bo_d7h86.mjs → renderer-D8m3fuRp.mjs} +4 -4
- package/dist/{renderer-Bo_d7h86.mjs.map → renderer-D8m3fuRp.mjs.map} +1 -1
- package/dist/{renderer-Bo1PY0zW.mjs → renderer-DADTT3ym.mjs} +2 -2
- package/dist/renderer-DCvqZ5o_.mjs +5 -0
- package/dist/{renderer-DuQMzR3v.mjs → renderer-DGOY-LBR.mjs} +39 -423
- package/dist/renderer-DGOY-LBR.mjs.map +1 -0
- package/dist/{renderer-BYJS2lhn.mjs → renderer-DHdFVpdH.mjs} +4 -4
- package/dist/{renderer-BYJS2lhn.mjs.map → renderer-DHdFVpdH.mjs.map} +1 -1
- package/dist/renderer-DJGJeGEn.mjs +5 -0
- package/dist/{renderer-CDX2f82b.mjs → renderer-DO1TeUqV.mjs} +4 -4
- package/dist/{renderer-CDX2f82b.mjs.map → renderer-DO1TeUqV.mjs.map} +1 -1
- package/dist/renderer-DONvNNXi.mjs +5 -0
- package/dist/renderer-DPaSo--Y.mjs +6 -0
- package/dist/{renderer-BWnFh7NO.mjs → renderer-DTPmvL1i.mjs} +4 -4
- package/dist/{renderer-BWnFh7NO.mjs.map → renderer-DTPmvL1i.mjs.map} +1 -1
- package/dist/{renderer-DWaqF7-6.mjs → renderer-DVGVcDvB.mjs} +4 -4
- package/dist/{renderer-DWaqF7-6.mjs.map → renderer-DVGVcDvB.mjs.map} +1 -1
- package/dist/{renderer-CXbXF7Dh.mjs → renderer-DW21AyEv.mjs} +4 -4
- package/dist/{renderer-CXbXF7Dh.mjs.map → renderer-DW21AyEv.mjs.map} +1 -1
- package/dist/{renderer-7DyjqxEj.mjs → renderer-DWG6EXGj.mjs} +4 -4
- package/dist/{renderer-7DyjqxEj.mjs.map → renderer-DWG6EXGj.mjs.map} +1 -1
- package/dist/{renderer-CmKdr2nA.mjs → renderer-Daqvhwjp.mjs} +4 -4
- package/dist/{renderer-CmKdr2nA.mjs.map → renderer-Daqvhwjp.mjs.map} +1 -1
- package/dist/{renderer-B4blg067.mjs → renderer-DcApNxER.mjs} +4 -4
- package/dist/{renderer-B4blg067.mjs.map → renderer-DcApNxER.mjs.map} +1 -1
- package/dist/{renderer-CLO8QHWE.mjs → renderer-DdkPY4hO.mjs} +4 -4
- package/dist/{renderer-CLO8QHWE.mjs.map → renderer-DdkPY4hO.mjs.map} +1 -1
- package/dist/renderer-DfYYokhr.mjs +5 -0
- package/dist/{renderer-BWa3CLK7.mjs → renderer-Dk0okoS3.mjs} +3 -3
- package/dist/{renderer-BWa3CLK7.mjs.map → renderer-Dk0okoS3.mjs.map} +1 -1
- package/dist/{renderer-ChCUad83.mjs → renderer-DkM6Ikdf.mjs} +4 -4
- package/dist/{renderer-ChCUad83.mjs.map → renderer-DkM6Ikdf.mjs.map} +1 -1
- package/dist/{renderer-Fsdod0yZ.mjs → renderer-DkOxEQsg.mjs} +4 -4
- package/dist/{renderer-Fsdod0yZ.mjs.map → renderer-DkOxEQsg.mjs.map} +1 -1
- package/dist/renderer-DlIJhdEM.mjs +5 -0
- package/dist/{renderer-Ch7qwjH9.mjs → renderer-DmTxEeMh.mjs} +4 -4
- package/dist/{renderer-Ch7qwjH9.mjs.map → renderer-DmTxEeMh.mjs.map} +1 -1
- package/dist/renderer-DqD3nMtv.mjs +5 -0
- package/dist/renderer-DrIl-uGt.mjs +5 -0
- package/dist/{renderer-kkDfQ56G.mjs → renderer-DsnV8tpW.mjs} +4 -4
- package/dist/{renderer-kkDfQ56G.mjs.map → renderer-DsnV8tpW.mjs.map} +1 -1
- package/dist/{renderer-0cHsK6xR.mjs → renderer-DtqiWq1m.mjs} +4 -4
- package/dist/{renderer-0cHsK6xR.mjs.map → renderer-DtqiWq1m.mjs.map} +1 -1
- package/dist/{renderer-CNs1vBUy.mjs → renderer-Duorbrb8.mjs} +3 -3
- package/dist/{renderer-CNs1vBUy.mjs.map → renderer-Duorbrb8.mjs.map} +1 -1
- package/dist/{renderer-B5YYBBDq.mjs → renderer-Dw3K8KT9.mjs} +3 -3
- package/dist/{renderer-B5YYBBDq.mjs.map → renderer-Dw3K8KT9.mjs.map} +1 -1
- package/dist/{renderer-VdTs-dqH.mjs → renderer-Dwh0jaor.mjs} +4 -4
- package/dist/{renderer-VdTs-dqH.mjs.map → renderer-Dwh0jaor.mjs.map} +1 -1
- package/dist/renderer-DyCB3Q1d.mjs +5 -0
- package/dist/renderer-DyJ-iSv6.mjs +5 -0
- package/dist/{renderer-DqO9gBAi.mjs → renderer-Dz4muMyE.mjs} +4 -4
- package/dist/{renderer-DqO9gBAi.mjs.map → renderer-Dz4muMyE.mjs.map} +1 -1
- package/dist/renderer-Dzs0jV0G.mjs +5 -0
- package/dist/{renderer-BAkPq8qo.mjs → renderer-H4-8sBjl.mjs} +3 -3
- package/dist/{renderer-BAkPq8qo.mjs.map → renderer-H4-8sBjl.mjs.map} +1 -1
- package/dist/renderer-HIKXw5-Y.mjs +5 -0
- package/dist/renderer-Jgt2g8Wp.mjs +5 -0
- package/dist/renderer-KnZWmT-M.mjs +5 -0
- package/dist/renderer-OMSXddG9.mjs +5 -0
- package/dist/renderer-P78xh6OV.mjs +5 -0
- package/dist/{renderer-fH9SCJC2.mjs → renderer-PhjHSS97.mjs} +3 -3
- package/dist/{renderer-fH9SCJC2.mjs.map → renderer-PhjHSS97.mjs.map} +1 -1
- package/dist/{renderer-o_JK4YfO.mjs → renderer-QQDU1b0Z.mjs} +4 -4
- package/dist/{renderer-o_JK4YfO.mjs.map → renderer-QQDU1b0Z.mjs.map} +1 -1
- package/dist/{renderer-DBjV6C2u.mjs → renderer-R5Lg65F8.mjs} +2 -2
- package/dist/{renderer-DHqicGgT.mjs → renderer-RbMM6Njv.mjs} +4 -4
- package/dist/{renderer-DHqicGgT.mjs.map → renderer-RbMM6Njv.mjs.map} +1 -1
- package/dist/{renderer-sg-ffvVd.d.mts → renderer-WTvo3wo9.d.mts} +18 -4
- package/dist/renderer-WTvo3wo9.d.mts.map +1 -0
- package/dist/renderer-WyaqnVMz.mjs +3 -0
- package/dist/{renderer-Db_wphHH.mjs → renderer-YBwN9uyj.mjs} +230 -23
- package/dist/renderer-YBwN9uyj.mjs.map +1 -0
- package/dist/{renderer-BBTdHKH7.mjs → renderer-YFHESw3v.mjs} +4 -4
- package/dist/{renderer-BBTdHKH7.mjs.map → renderer-YFHESw3v.mjs.map} +1 -1
- package/dist/renderer-gaFP1Kak.mjs +5 -0
- package/dist/{renderer-PNIgmWft.mjs → renderer-kFtcuY9J.mjs} +2 -2
- package/dist/{renderer-PNIgmWft.mjs.map → renderer-kFtcuY9J.mjs.map} +1 -1
- package/dist/renderer-lQ8gH7Zd.mjs +5 -0
- package/dist/{renderer-B8LXXvHi.mjs → renderer-oR1LvHeO.mjs} +4 -4
- package/dist/{renderer-B8LXXvHi.mjs.map → renderer-oR1LvHeO.mjs.map} +1 -1
- package/dist/{renderer-CJoa4H2G.mjs → renderer-tcS585Iy.mjs} +3 -3
- package/dist/{renderer-CJoa4H2G.mjs.map → renderer-tcS585Iy.mjs.map} +1 -1
- package/dist/{renderer-BhhweLr_.mjs → renderer-x1xlZaH_.mjs} +4 -4
- package/dist/{renderer-BhhweLr_.mjs.map → renderer-x1xlZaH_.mjs.map} +1 -1
- package/dist/{renderers-CP8CFWDs-DiX2Y4Qp.mjs → renderers-CP8CFWDs-EZZPEbhW.mjs} +4 -4
- package/dist/{renderers-CP8CFWDs-DiX2Y4Qp.mjs.map → renderers-CP8CFWDs-EZZPEbhW.mjs.map} +1 -1
- package/dist/signals/index.d.mts +5 -5
- package/dist/signals/index.mjs +7 -7
- package/dist/{signals-Cf9mfNGE.mjs → signals-BrNF9l9s.mjs} +3 -3
- package/dist/{signals-Cf9mfNGE.mjs.map → signals-BrNF9l9s.mjs.map} +1 -1
- package/dist/{src-Ds9IAk5S.mjs → src-r7Suenhp.mjs} +9 -9
- package/dist/{src-Ds9IAk5S.mjs.map → src-r7Suenhp.mjs.map} +1 -1
- package/package.json +58 -58
- package/dist/dist-CQZ5_60A.mjs.map +0 -1
- package/dist/dist-DBWkkSvJ.mjs.map +0 -1
- package/dist/dist-DHQVWaV2.mjs.map +0 -1
- package/dist/index-BlPLU_a1.d.mts.map +0 -1
- package/dist/index-C6awQk4-.d.mts.map +0 -1
- package/dist/preview-engine/chunk-I6E3AKLS.mjs +0 -1
- package/dist/preview-engine/chunk-X6HUH6K4.mjs +0 -1
- package/dist/preview-engine/chunk-XUPZBI3Y.mjs +0 -474
- package/dist/preview-engine/renderers-2S5ZWL2J.mjs +0 -1
- package/dist/preview-engine/renderers-37CN7O4S.mjs +0 -1
- package/dist/preview-engine/renderers-3UGG2IMP.mjs +0 -1
- package/dist/preview-engine/renderers-7MPBAHYA.mjs +0 -1
- package/dist/preview-engine/renderers-AFTYMS3H.mjs +0 -1
- package/dist/preview-engine/renderers-AYEWAW7F.mjs +0 -1
- package/dist/preview-engine/renderers-DUF2CTX6.mjs +0 -1
- package/dist/preview-engine/renderers-EIJFIY6L.mjs +0 -1
- package/dist/preview-engine/renderers-EOMMSENQ.mjs +0 -1
- package/dist/preview-engine/renderers-JXBZPQKP.mjs +0 -1
- package/dist/preview-engine/renderers-KSQM7EM5.mjs +0 -1
- package/dist/preview-engine/renderers-LD3COS4X.mjs +0 -1
- package/dist/preview-engine/renderers-MCTBJEL7.mjs +0 -1
- package/dist/preview-engine/renderers-OZGEXW2X.mjs +0 -1
- package/dist/preview-engine/renderers-PSEDMUBV.mjs +0 -1
- package/dist/preview-engine/renderers-PVUROEGR.mjs +0 -1
- package/dist/preview-engine/renderers-QBVVTQV6.mjs +0 -1
- package/dist/preview-engine/renderers-R4U2TK6P.mjs +0 -1
- package/dist/preview-engine/renderers-RBLNDAO4.mjs +0 -1
- package/dist/preview-engine/renderers-RM25AXWZ.mjs +0 -1
- package/dist/preview-engine/renderers-RYRFCEYT.mjs +0 -1
- package/dist/preview-engine/renderers-SRU6QXLN.mjs +0 -1
- package/dist/preview-engine/renderers-TBP2EKJ4.mjs +0 -1
- package/dist/preview-engine/renderers-UQHKFGDX.mjs +0 -1
- package/dist/preview-engine/renderers-WDV7ENJA.mjs +0 -1
- package/dist/preview-engine/renderers-WNBDSSJ3.mjs +0 -1
- package/dist/preview-engine/renderers-WTC2RNGU.mjs +0 -1
- package/dist/preview-engine/renderers-XLBHSQZJ.mjs +0 -228
- package/dist/preview-engine/renderers-XVUOO7B6.mjs +0 -1
- package/dist/preview-engine/renderers-YHOQUEHD.mjs +0 -1
- package/dist/preview-engine/renderers-YPFM5UJO.mjs +0 -1
- package/dist/preview-engine/renderers-YW3LBALW.mjs +0 -1
- package/dist/renderer-B-D_VZg3.mjs +0 -5
- package/dist/renderer-B64dhpDX.mjs +0 -5
- package/dist/renderer-BA2Ko4H1.mjs +0 -3
- package/dist/renderer-BDL4jyrb.mjs +0 -5
- package/dist/renderer-BKCZGKkl.mjs +0 -5
- package/dist/renderer-BKs3jI7-.mjs +0 -5
- package/dist/renderer-BW74Zf-u.mjs +0 -5
- package/dist/renderer-BXuQN61O.mjs +0 -5
- package/dist/renderer-BY1pPvzx.mjs +0 -6
- package/dist/renderer-Bahthqvq.mjs +0 -5
- package/dist/renderer-BbaiQG8h.mjs +0 -5
- package/dist/renderer-BhHF-08y.mjs +0 -5
- package/dist/renderer-BnrMcw6C.mjs +0 -5
- package/dist/renderer-BsS61z5l.mjs +0 -5
- package/dist/renderer-Btuq2Eq8.mjs +0 -5
- package/dist/renderer-BvcqncI2.mjs +0 -3
- package/dist/renderer-BxmsEl2x.mjs +0 -5
- package/dist/renderer-CGOyHdKs.mjs +0 -5
- package/dist/renderer-CJjmpmpu.mjs +0 -5
- package/dist/renderer-CQvDDAIu.mjs +0 -5
- package/dist/renderer-CYxigw7S.mjs +0 -5
- package/dist/renderer-CgDofO5z.mjs +0 -5
- package/dist/renderer-CgmQchab.mjs +0 -5
- package/dist/renderer-CjzrLwW_.mjs +0 -5
- package/dist/renderer-ClnYWADT.mjs +0 -5
- package/dist/renderer-CwAd71C6.mjs +0 -5
- package/dist/renderer-D3Z9t-xT.mjs +0 -5
- package/dist/renderer-DKH1oSlW.mjs +0 -5
- package/dist/renderer-DTkf_hwG.mjs +0 -5
- package/dist/renderer-DU0bmOS7.mjs +0 -5
- package/dist/renderer-D_vJHQt5.mjs +0 -5
- package/dist/renderer-Db_wphHH.mjs.map +0 -1
- package/dist/renderer-Dc5n0M6P.mjs +0 -5
- package/dist/renderer-Ddfn0Dha.mjs +0 -5
- package/dist/renderer-DeSoNQeG.mjs +0 -5
- package/dist/renderer-DuQMzR3v.mjs.map +0 -1
- package/dist/renderer-Dzca9Qba.mjs +0 -5
- package/dist/renderer-HRVvtc9c.mjs +0 -5
- package/dist/renderer-M3Qs4f1K.mjs +0 -5
- package/dist/renderer-NDHJt_Ae.mjs +0 -5
- package/dist/renderer-sg-ffvVd.d.mts.map +0 -1
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
{
|
|
25
25
|
"path": "captions/index.ts",
|
|
26
|
-
"content": "/**\n * Captions: word-timed captions for talking-head and social video, in the\n * styles people publish, with exact word highlighting. Each phrase is one\n * text node; per-word colour, pops and markers are GPU glyph animators and\n * keyed plates, timed to the word's own frame.\n *\n * Coordinates are the frame's: the root covers the video (default\n * 1920 × 1080 × theme scale, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n\ttype TextNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\taudioLevelsSchema,\n\tbindComponent,\n\tconstantExpression,\n\tcontrastRatio,\n\tdefineComponent,\n\ttype GlyphAnimator,\n\tglyphAnimator,\n\ttype MeasureOptions,\n\trangeRects,\n\ttype Rect,\n\trendererChars,\n\tscheduleExpression,\n\tstepExpression,\n\tthemeFrames,\n\twindowExpression,\n\tz,\n} from \"framefields/components\";\nimport { parseTranscript, type Word } from \"./parse.js\";\nimport { buildPhrases, type GroupOptions, type Phrase } from \"./phrases.js\";\n\nexport type { Word } from \"./parse.js\";\n\nexport const CAPTION_STYLES = [\n\t\"clean\",\n\t\"karaoke\",\n\t\"highlight\",\n\t\"pop\",\n\t\"bold\",\n\t\"outline\",\n\t\"pill\",\n\t\"gradient\",\n] as const;\nexport type CaptionStyle = (typeof CAPTION_STYLES)[number];\n\nexport interface CaptionsCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each word, from its first frame to its last. */\n\twords: ReadonlyArray<{\n\t\tframe: number;\n\t\tend: number;\n\t\tindex: number;\n\t\ttext: string;\n\t\temphasised: boolean;\n\t}>;\n\t/** Each phrase on screen. */\n\tphrases: ReadonlyArray<{ frame: number; end: number; text: string }>;\n}\n\n/** Per-style grouping and placement defaults. */\nconst GROUP: Record<CaptionStyle, Partial<GroupOptions>> = {\n\tclean: { maxChars: 36, maxLines: 2 },\n\tkaraoke: {},\n\thighlight: {},\n\tpop: { maxChars: 22 },\n\tbold: { maxChars: 14, maxWords: 2, maxLines: 1 },\n\toutline: { maxChars: 22 },\n\tpill: { maxChars: 24, maxLines: 1 },\n\tgradient: {},\n};\n\nconst BASE_GROUP: GroupOptions = {\n\tmaxChars: 28,\n\tmaxWords: 12,\n\tmaxLines: 2,\n\tbreakOnPunctuation: true,\n\tminGap: 0.6,\n};\n\n/** The area captions stay inside: clear of edges and, in portrait, platform overlays. */\nfunction safeArea(\n\tW: number,\n\tH: number,\n\tarea: \"portrait\" | \"landscape\" | Rect | undefined,\n): Rect {\n\tif (area && typeof area === \"object\") return area;\n\tconst portrait = area ? area === \"portrait\" : H > W;\n\treturn portrait\n\t\t? { x: W * 0.08, y: H * 0.12, width: W * 0.84, height: H * 0.66 }\n\t\t: { x: W * 0.1, y: H * 0.08, width: W * 0.8, height: H * 0.82 };\n}\n\nexport const captions = /* @__PURE__ */ defineComponent({\n\tname: \"Captions\",\n\tprops: () => ({\n\t\t/** `{ text, start, end }` in seconds. Or `transcript`. */\n\t\twords: z\n\t\t\t.array(\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\ttext: z.string(),\n\t\t\t\t\t\tstart: z.number().min(0),\n\t\t\t\t\t\tend: z.number().min(0),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.optional(),\n\t\t/** SRT, WebVTT, or word-level JSON (`[{ word | text, start, end }]`). */\n\t\ttranscript: z.string().optional(),\n\t\tstyle: z.enum(CAPTION_STYLES).default(\"karaoke\"),\n\t\t/** The voice's levels (`analyzeAudio`): the active word swells with it. Frame 0 = the component's first frame. */\n\t\tpulse: z.union([audioLevelsSchema(), z.literal(false)]).default(false),\n\t\t/** Scale added at full level. */\n\t\tpulseAmount: z.number().min(0).default(0.12),\n\t\tgroup: z\n\t\t\t.object({\n\t\t\t\tmaxChars: z.number().int().positive().optional(),\n\t\t\t\tmaxWords: z.number().int().positive().optional(),\n\t\t\t\tmaxLines: z.number().int().positive().optional(),\n\t\t\t\tbreakOnPunctuation: z.boolean().optional(),\n\t\t\t\tminGap: z.number().min(0).optional(),\n\t\t\t})\n\t\t\t.strict()\n\t\t\t.default({}),\n\t\t/** Words or patterns in the accent colour, or a test per word. */\n\t\temphasis: z\n\t\t\t.union([\n\t\t\t\tz.array(z.union([z.string(), z.instanceof(RegExp)])),\n\t\t\t\tz.custom<(word: string, index: number) => boolean>(\n\t\t\t\t\t(v) => typeof v === \"function\",\n\t\t\t\t),\n\t\t\t])\n\t\t\t.default([]),\n\t\tcase: z.enum([\"as-is\", \"upper\", \"lower\"]).optional(),\n\t\tplacement: z\n\t\t\t.object({\n\t\t\t\tanchor: z.enum([\"bottom\", \"center\", \"top\"]).optional(),\n\t\t\t\tsafeArea: z\n\t\t\t\t\t.union([\n\t\t\t\t\t\tz.enum([\"portrait\", \"landscape\"]),\n\t\t\t\t\t\tz.object({\n\t\t\t\t\t\t\tx: z.number(),\n\t\t\t\t\t\t\ty: z.number(),\n\t\t\t\t\t\t\twidth: z.number(),\n\t\t\t\t\t\t\theight: z.number(),\n\t\t\t\t\t\t}),\n\t\t\t\t\t])\n\t\t\t\t\t.optional(),\n\t\t\t\t/** Extra px from the anchor edge, toward the centre. */\n\t\t\t\toffset: z.number().default(0),\n\t\t\t})\n\t\t\t.strict()\n\t\t\t.default({ offset: 0 }),\n\t\t/** A clip whose person / subject is cut out and drawn over the captions. */\n\t\tbehind: z\n\t\t\t.object({\n\t\t\t\tvideo: z.string(),\n\t\t\t\tmode: z.enum([\"person\", \"subject\"]).default(\"person\"),\n\t\t\t})\n\t\t\t.strict()\n\t\t\t.optional(),\n\t\t/** A backing plate behind each phrase (`clean`, `karaoke`). */\n\t\tplate: z.boolean().default(false),\n\t\tfontFamily: z.string().optional(),\n\t\t/** Default: fits `group.maxChars` across the safe area. */\n\t\tfontSize: z.number().positive().optional(),\n\t\tfontWeight: z.union([z.number(), z.string()]).optional(),\n\t\tfill: z.string().optional(),\n\t\taccent: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst W = ctx.requested.width ?? 1920 * theme.scale;\n\t\tconst H = ctx.requested.height ?? 1080 * theme.scale;\n\t\tconst style = props.style;\n\n\t\t// ── Words ─────────────────────────────────────────────────────────\n\t\tlet words: Word[];\n\t\tif (props.words) words = props.words.map((w) => ({ ...w }));\n\t\telse if (props.transcript) words = parseTranscript(props.transcript);\n\t\telse throw new Error(\"Captions: give `words` or a `transcript`.\");\n\t\tconst casing = props.case ?? (style === \"bold\" ? \"upper\" : \"as-is\");\n\t\tif (casing !== \"as-is\")\n\t\t\twords = words.map((w) => ({\n\t\t\t\t...w,\n\t\t\t\ttext: casing === \"upper\" ? w.text.toUpperCase() : w.text.toLowerCase(),\n\t\t\t}));\n\t\tconst emphasised = words.map((w, i) =>\n\t\t\ttypeof props.emphasis === \"function\"\n\t\t\t\t? props.emphasis(w.text, i)\n\t\t\t\t: props.emphasis.some((p) =>\n\t\t\t\t\t\ttypeof p === \"string\"\n\t\t\t\t\t\t\t? w.text.toLowerCase().replace(/[^\\p{L}\\p{N}%]/gu, \"\") ===\n\t\t\t\t\t\t\t\tp.toLowerCase()\n\t\t\t\t\t\t\t: new RegExp(p.source, p.flags.replace(\"g\", \"\")).test(w.text),\n\t\t\t\t\t),\n\t\t);\n\n\t\t// ── Layout ────────────────────────────────────────────────────────\n\t\tconst group: GroupOptions = {\n\t\t\t...BASE_GROUP,\n\t\t\t...GROUP[style],\n\t\t\t...stripUndefined(props.group),\n\t\t};\n\t\tconst safe = safeArea(W, H, props.placement.safeArea);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst weight = props.fontWeight ?? (style === \"clean\" ? undefined : 800);\n\t\tconst measureAt = (text: string, size: number, width?: number) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: weight,\n\t\t\t\t...(width !== undefined && { width, align: \"center\" as const }),\n\t\t\t} satisfies MeasureOptions);\n\t\t// Auto size: a line of `maxChars` average glyphs spans the safe width.\n\t\tconst sample = \"nonsense words\".repeat(4).slice(0, group.maxChars);\n\t\tconst fs =\n\t\t\tprops.fontSize ??\n\t\t\tMath.min(\n\t\t\t\t(safe.width / Math.max(1, measureAt(sample, 100).width)) * 100 * 0.94,\n\t\t\t\tsafe.height / (group.maxLines * 1.6),\n\t\t\t);\n\t\tconst fits = (line: string) => measureAt(line, fs).width <= safe.width;\n\t\tconst phrases = buildPhrases(words, group, fits);\n\n\t\tconst ink = props.fill ?? theme.color.ink;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst anchor =\n\t\t\tprops.placement.anchor ?? (style === \"bold\" ? \"center\" : \"bottom\");\n\t\tconst frameOf = (seconds: number) => Math.round(seconds * fps);\n\t\tif (props.pulse && Math.abs(props.pulse.fps - fps) > 1e-6)\n\t\t\tthrow new Error(\n\t\t\t\t`Captions: \\`pulse\\` was analysed at ${props.pulse.fps} fps but the component runs at ${fps}. Analyse with { fps: ${fps} }.`,\n\t\t\t);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst wordCues: Array<CaptionsCues[\"words\"][number]> = [];\n\t\tconst phraseCues: Array<CaptionsCues[\"phrases\"][number]> = [];\n\n\t\tphrases.forEach((phrase, p) => {\n\t\t\tconst layout = phraseLayout(phrase, words);\n\t\t\tconst m = measureAt(layout.text, fs, safe.width);\n\t\t\tconst options: MeasureOptions = {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tfontWeight: weight,\n\t\t\t\twidth: safe.width,\n\t\t\t\talign: \"center\",\n\t\t\t};\n\t\t\tconst y =\n\t\t\t\tanchor === \"bottom\"\n\t\t\t\t\t? safe.y + safe.height - m.height - props.placement.offset\n\t\t\t\t\t: anchor === \"top\"\n\t\t\t\t\t\t? safe.y + props.placement.offset\n\t\t\t\t\t\t: safe.y + (safe.height - m.height) / 2;\n\t\t\tconst start = frameOf(phrase.start);\n\t\t\tconst end = Math.max(start + 1, frameOf(phrase.end));\n\t\t\t// Word windows, local to the phrase (its box starts at `start`).\n\t\t\tconst wStart = layout.words.map(\n\t\t\t\t(w) => frameOf((words[w.index] as Word).start) - start,\n\t\t\t);\n\t\t\tconst wEnd = layout.words.map((w, k) =>\n\t\t\t\tk + 1 < layout.words.length\n\t\t\t\t\t? (wStart[k + 1] as number)\n\t\t\t\t\t: Math.max(\n\t\t\t\t\t\t\t(wStart[k] as number) + 1,\n\t\t\t\t\t\t\tframeOf((words[w.index] as Word).end) - start,\n\t\t\t\t\t\t),\n\t\t\t);\n\t\t\tconst rects = layout.words.map(\n\t\t\t\t(w) => rangeRects(m, w.from, w.to, options)[0] as Rect,\n\t\t\t);\n\t\t\tconst animators: GlyphAnimator[] = [];\n\n\t\t\tconst emphasisWeights = layout.words.map((w) =>\n\t\t\t\temphasised[w.index] ? 1 : 0,\n\t\t\t);\n\t\t\tif (emphasisWeights.some(Boolean))\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"emphasis\",\n\t\t\t\t\t\tconstantExpression(\"word\", emphasisWeights),\n\t\t\t\t\t\t{ fillColor: accent },\n\t\t\t\t\t),\n\t\t\t\t);\n\n\t\t\tconst node: Partial<TextNode> = {\n\t\t\t\tid: ctx.partId(`phrase-${p}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: safe.width,\n\t\t\t\talign: \"center\",\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tfill: ink,\n\t\t\t\t...(weight !== undefined && { fontWeight: weight }),\n\t\t\t};\n\t\t\tconst plates: LayoutNode[] = [];\n\t\t\tconst box = (\n\t\t\t\tid: string,\n\t\t\t\tr: Rect,\n\t\t\t\tpad: number,\n\t\t\t\tbackground: string,\n\t\t\t\tradius: number,\n\t\t\t\textra = {},\n\t\t\t) =>\n\t\t\t\thost.box({\n\t\t\t\t\tid: ctx.partId(`phrase-${p}-${id}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: r.x - pad,\n\t\t\t\t\ty: r.y - pad * 0.5,\n\t\t\t\t\twidth: r.width + pad * 2,\n\t\t\t\t\theight: r.height + pad,\n\t\t\t\t\tbackground,\n\t\t\t\t\tborderRadius: radius,\n\t\t\t\t\t...extra,\n\t\t\t\t});\n\t\t\tconst blockRect: Rect = {\n\t\t\t\tx: Math.min(...rects.map((r) => r.x)),\n\t\t\t\ty: 0,\n\t\t\t\twidth:\n\t\t\t\t\tMath.max(...rects.map((r) => r.x + r.width)) -\n\t\t\t\t\tMath.min(...rects.map((r) => r.x)),\n\t\t\t\theight: m.height,\n\t\t\t};\n\n\t\t\tswitch (style) {\n\t\t\t\tcase \"clean\":\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"karaoke\":\n\t\t\t\t\t// Spoken words turn from muted to ink on their exact frame.\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\"spoken\", stepExpression(\"word\", wStart), {\n\t\t\t\t\t\t\tfillColor: theme.color.muted,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"highlight\":\n\t\t\t\tcase \"pill\": {\n\t\t\t\t\tconst pad = fs * (style === \"pill\" ? 0.22 : 0.16);\n\t\t\t\t\tconst glide = Math.max(2, Math.round(fast * 0.4));\n\t\t\t\t\tif (style === \"pill\")\n\t\t\t\t\t\tplates.push(\n\t\t\t\t\t\t\tbox(\n\t\t\t\t\t\t\t\t\"plate\",\n\t\t\t\t\t\t\t\tblockRect,\n\t\t\t\t\t\t\t\tfs * 0.5,\n\t\t\t\t\t\t\t\ttheme.color.surface,\n\t\t\t\t\t\t\t\ttheme.radius.pill,\n\t\t\t\t\t\t\t\t{ opacity: 0.92 },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\tconst marker = box(\n\t\t\t\t\t\t\"marker\",\n\t\t\t\t\t\trects[0] as Rect,\n\t\t\t\t\t\tpad,\n\t\t\t\t\t\taccent,\n\t\t\t\t\t\tstyle === \"pill\" ? theme.radius.pill : theme.radius.md,\n\t\t\t\t\t);\n\t\t\t\t\tmarker.animate(markerKeys(rects, wStart, pad, glide));\n\t\t\t\t\tplates.push(marker);\n\t\t\t\t\t// Text on the marker reads in the background colour when ink wouldn't.\n\t\t\t\t\tif ((contrastRatio(ink, accent) ?? 21) < 3)\n\t\t\t\t\t\tanimators.push(\n\t\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\t\"on-marker\",\n\t\t\t\t\t\t\t\t// Switch as the marker lands under the word.\n\t\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t\t\twStart.map((f) => f + Math.ceil(glide / 2)),\n\t\t\t\t\t\t\t\t\twEnd.map((f) => f + Math.ceil(glide / 2)),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t{ fillColor: theme.color.bg },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"pop\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"pop\",\n\t\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tstarts: wStart,\n\t\t\t\t\t\t\t\t\tduration: Math.max(3, Math.round(fast * 0.9)),\n\t\t\t\t\t\t\t\t\tease: \"back.out(2.2)\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{ opacity: 0, scale: 0.72, y: fs * 0.12 },\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"bold\":\n\t\t\t\tcase \"outline\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\"active\", windowExpression(\"word\", wStart, wEnd), {\n\t\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"gradient\": {\n\t\t\t\t\t// Active word: accent → accent2 across its letters.\n\t\t\t\t\tconst chars = layout.text.length;\n\t\t\t\t\tconst starts = new Array<number>(chars).fill(-1);\n\t\t\t\t\tconst ends = new Array<number>(chars).fill(-1);\n\t\t\t\t\tconst along = new Array<number>(chars).fill(0);\n\t\t\t\t\tlayout.words.forEach((w, k) => {\n\t\t\t\t\t\tfor (let c = w.from; c < w.to; c++) {\n\t\t\t\t\t\t\tstarts[c] = wStart[k] as number;\n\t\t\t\t\t\t\tends[c] = wEnd[k] as number;\n\t\t\t\t\t\t\talong[c] =\n\t\t\t\t\t\t\t\tw.to - w.from > 1 ? (c - w.from) / (w.to - w.from - 1) : 0;\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"active\",\n\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\trendererChars(layout.text, starts),\n\t\t\t\t\t\t\t\trendererChars(layout.text, ends),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t),\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"active-end\",\n\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\trendererChars(layout.text, starts),\n\t\t\t\t\t\t\t\trendererChars(layout.text, ends),\n\t\t\t\t\t\t\t\trendererChars(layout.text, along),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{ fillColor: theme.color.accent2 },\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (props.plate && (style === \"clean\" || style === \"karaoke\"))\n\t\t\t\tplates.unshift(\n\t\t\t\t\tbox(\n\t\t\t\t\t\t\"plate\",\n\t\t\t\t\t\tblockRect,\n\t\t\t\t\t\tfs * 0.35,\n\t\t\t\t\t\t\"rgba(0,0,0,0.55)\",\n\t\t\t\t\t\ttheme.radius.md,\n\t\t\t\t\t),\n\t\t\t\t);\n\n\t\t\tif (props.pulse) {\n\t\t\t\t// The phrase's slice of the voice level, indexed by its local frame.\n\t\t\t\tconst level = props.pulse.level.slice(start, end);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\"pulse\", pulseExpression(wStart, wEnd, level), {\n\t\t\t\t\t\tscale: 1 + props.pulseAmount,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (animators.length > 0) node.animators = animators as never;\n\t\t\tconst text = host.text(layout.text, node);\n\t\t\t// A heavy outline around the glyphs, never over them.\n\t\t\tif (style === \"outline\")\n\t\t\t\thost.style(text, {\n\t\t\t\t\tstroke: { size: fs * 0.08, position: \"outside\", color: \"#000000\" },\n\t\t\t\t});\n\t\t\tconst holder = host.box({\n\t\t\t\tid: ctx.partId(`phrase-${p}-box`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: safe.x,\n\t\t\t\ty,\n\t\t\t\twidth: safe.width,\n\t\t\t\theight: m.height,\n\t\t\t\tstartFrame: start,\n\t\t\t\tdurationFrames: end - start,\n\t\t\t\tchildren: [...plates, text],\n\t\t\t});\n\t\t\tholder.animate(\n\t\t\t\tphraseMotion(style, p, end - start, fs, fast, { x: safe.x, y }),\n\t\t\t);\n\t\t\tparts[`phrase-${p}`] = holder;\n\t\t\tchildren.push(holder);\n\n\t\t\tphraseCues.push({\n\t\t\t\tframe: start,\n\t\t\t\tend,\n\t\t\t\ttext: layout.text.replace(/\\n/g, \" \"),\n\t\t\t});\n\t\t\tlayout.words.forEach((w, k) => {\n\t\t\t\twordCues.push({\n\t\t\t\t\tframe: start + (wStart[k] as number),\n\t\t\t\t\tend: start + (wEnd[k] as number),\n\t\t\t\t\tindex: w.index,\n\t\t\t\t\ttext: (words[w.index] as Word).text,\n\t\t\t\t\temphasised: emphasised[w.index] ?? false,\n\t\t\t\t});\n\t\t\t});\n\t\t});\n\n\t\tif (props.behind) {\n\t\t\tconst cut = host.cutout(props.behind.video, {\n\t\t\t\tid: ctx.partId(\"behind\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: W,\n\t\t\t\theight: H,\n\t\t\t\tfit: \"cover\",\n\t\t\t\tmode: props.behind.mode,\n\t\t\t});\n\t\t\tparts.behind = cut;\n\t\t\tchildren.push(cut);\n\t\t}\n\n\t\tconst last = phraseCues[phraseCues.length - 1]?.end ?? 1;\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: last,\n\t\t\torigin: [0, 0],\n\t\t\tcues: { words: wordCues, phrases: phraseCues } as CaptionsCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** A phrase's text and each word's character range in it. */\nfunction phraseLayout(phrase: Phrase, words: readonly Word[]) {\n\tconst out: Array<{ index: number; from: number; to: number }> = [];\n\tlet text = \"\";\n\tphrase.lines.forEach((line, l) => {\n\t\tif (l > 0) text += \"\\n\";\n\t\tline.forEach((index, k) => {\n\t\t\tif (k > 0) text += \" \";\n\t\t\tconst from = text.length;\n\t\t\ttext += (words[index] as Word).text;\n\t\t\tout.push({ index, from, to: text.length });\n\t\t});\n\t});\n\treturn { text, words: out };\n}\n\n/** A marker that glides onto each word, arriving on the frame it is spoken. */\nfunction markerKeys(\n\trects: readonly Rect[],\n\tstarts: readonly number[],\n\tpad: number,\n\tglide: number,\n): LayerAnimation {\n\tconst keyed = (value: (r: Rect) => number) => {\n\t\tconst keys: KeyframeTuple[] = [[0, value(rects[0] as Rect)]];\n\t\trects.forEach((r, k) => {\n\t\t\tconst prev = rects[k - 1];\n\t\t\tif (!prev) return;\n\t\t\tconst at = starts[k] as number;\n\t\t\tconst last = (keys[keys.length - 1] as KeyframeTuple)[0];\n\t\t\t// Leave on the word's first frame and land a few frames in: the\n\t\t\t// marker never runs ahead of the voice.\n\t\t\tkeys.push([Math.max(last + 1, at), value(prev)]);\n\t\t\tkeys.push([Math.max(last + 2, at + glide), value(r), \"power3.out\"]);\n\t\t});\n\t\treturn keys;\n\t};\n\treturn LayerAnimation.create()\n\t\t.keys(\n\t\t\t\"x\",\n\t\t\tkeyed((r) => r.x - pad),\n\t\t)\n\t\t.keys(\n\t\t\t\"y\",\n\t\t\tkeyed((r) => r.y - pad * 0.5),\n\t\t)\n\t\t.keys(\n\t\t\t\"width\",\n\t\t\tkeyed((r) => r.width + pad * 2),\n\t\t)\n\t\t.keys(\n\t\t\t\"height\",\n\t\t\tkeyed((r) => r.height + pad),\n\t\t)\n\t\t.keys(\"opacity\", [\n\t\t\t[0, 0],\n\t\t\t[starts[0] ?? 0, 1, \"hold\"],\n\t\t]);\n}\n\n/** How a phrase arrives and leaves. `bold` alternates sides; the rest fade up. */\nfunction phraseMotion(\n\tstyle: CaptionStyle,\n\tindex: number,\n\tlength: number,\n\tfs: number,\n\tfast: number,\n\tat: { x: number; y: number },\n): LayerAnimation {\n\tconst anim = LayerAnimation.create();\n\tconst inF = Math.min(\n\t\tMath.max(2, Math.round(fast * 0.6)),\n\t\tMath.floor(length / 2),\n\t);\n\tconst out = Math.min(\n\t\tMath.max(2, Math.round(fast * 0.5)),\n\t\tMath.floor(length / 3),\n\t);\n\tanim.keys(\"opacity\", [\n\t\t[0, 0],\n\t\t[inF, 1, \"power2.out\"],\n\t\t[length - out, 1],\n\t\t[length, 0, \"power2.in\"],\n\t]);\n\tif (style === \"bold\") {\n\t\tconst side = index % 2 === 0 ? -1 : 1;\n\t\tanim.keys(\"x\", [\n\t\t\t[0, at.x + side * fs * 0.6],\n\t\t\t[inF + 2, at.x, \"back.out(1.8)\"],\n\t\t]);\n\t\tanim.keys(\"scale\", [\n\t\t\t[0, 0.86],\n\t\t\t[inF + 2, 1, \"back.out(1.8)\"],\n\t\t]);\n\t} else if (style !== \"pop\") {\n\t\tanim.keys(\"y\", [\n\t\t\t[0, at.y + fs * 0.14],\n\t\t\t[inF, at.y, \"power3.out\"],\n\t\t]);\n\t}\n\treturn anim;\n}\n\nfunction stripUndefined<T extends object>(o: T): Partial<T> {\n\treturn Object.fromEntries(\n\t\tObject.entries(o).filter(([, v]) => v !== undefined),\n\t) as Partial<T>;\n}\n\n/** The active word's weight is the voice level on that frame (0 elsewhere). */\nfunction pulseExpression(\n\tstarts: readonly number[],\n\tends: readonly number[],\n\tlevel: readonly number[],\n): string {\n\tconst list = (v: readonly number[]) =>\n\t\t`[${v.map((x) => Math.round(x * 1000) / 1000).join(\",\")}]`;\n\treturn `((S,E,L)=>{const s=S[wordIndex];if(s===undefined||frame<s||frame>=E[wordIndex])return 0;return L[floor(frame)]??0;})(${list(starts)},${list(ends)},${list(level)})`;\n}\n\n/**\n * Word-timed captions from words or a transcript (SRT, WebVTT, word JSON) in\n * eight published styles, highlighting each word on its exact frame, optionally\n * tucked behind the speaker.\n */\nexport const Captions = /* @__PURE__ */ bindComponent(captions);\n"
|
|
26
|
+
"content": "/**\n * Captions: word-timed captions for talking-head and social video, in the\n * styles people publish, with exact word highlighting. Each phrase is one\n * text node; per-word colour, pops and markers are GPU glyph animators and\n * keyed plates, timed to the word's own frame.\n *\n * Coordinates are the frame's: the root covers the video (default\n * 1920 × 1080 × theme scale, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n\ttype TextNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\taudioLevelsSchema,\n\tbindComponent,\n\tconstantExpression,\n\tcontrastRatio,\n\tdefineComponent,\n\ttype GlyphAnimator,\n\tglyphAnimator,\n\ttype MeasureOptions,\n\trangeRects,\n\ttype Rect,\n\trendererChars,\n\tscheduleExpression,\n\tstepExpression,\n\tthemeFrames,\n\twindowExpression,\n\tz,\n} from \"framefields/components\";\nimport { parseTranscript, type Word } from \"./parse.js\";\nimport { buildPhrases, type GroupOptions, type Phrase } from \"./phrases.js\";\n\nexport type { Word } from \"./parse.js\";\n\nexport const CAPTION_STYLES = [\n\t\"clean\",\n\t\"karaoke\",\n\t\"highlight\",\n\t\"pop\",\n\t\"bold\",\n\t\"outline\",\n\t\"pill\",\n\t\"gradient\",\n] as const;\nexport type CaptionStyle = (typeof CAPTION_STYLES)[number];\n\nexport interface CaptionsCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each word, from its first frame to its last. */\n\twords: ReadonlyArray<{\n\t\tframe: number;\n\t\tend: number;\n\t\tindex: number;\n\t\ttext: string;\n\t\temphasised: boolean;\n\t}>;\n\t/** Each phrase on screen. */\n\tphrases: ReadonlyArray<{ frame: number; end: number; text: string }>;\n}\n\n/** Per-style grouping and placement defaults. */\nconst GROUP: Record<CaptionStyle, Partial<GroupOptions>> = {\n\tclean: { maxChars: 36, maxLines: 2 },\n\tkaraoke: {},\n\thighlight: {},\n\tpop: { maxChars: 22 },\n\tbold: { maxChars: 14, maxWords: 2, maxLines: 1 },\n\toutline: { maxChars: 22 },\n\tpill: { maxChars: 24, maxLines: 1 },\n\tgradient: {},\n};\n\nconst BASE_GROUP: GroupOptions = {\n\tmaxChars: 28,\n\tmaxWords: 12,\n\tmaxLines: 2,\n\tbreakOnPunctuation: true,\n\tminGap: 0.6,\n};\n\n/** The area captions stay inside: clear of edges and, in portrait, platform overlays. */\nfunction safeArea(\n\tW: number,\n\tH: number,\n\tarea: \"portrait\" | \"landscape\" | Rect | undefined,\n): Rect {\n\tif (area && typeof area === \"object\") return area;\n\tconst portrait = area ? area === \"portrait\" : H > W;\n\treturn portrait\n\t\t? { x: W * 0.08, y: H * 0.12, width: W * 0.84, height: H * 0.66 }\n\t\t: { x: W * 0.1, y: H * 0.08, width: W * 0.8, height: H * 0.82 };\n}\n\nexport const captions = /* @__PURE__ */ defineComponent({\n\tname: \"Captions\",\n\tprops: () => ({\n\t\t/** `{ text, start, end }` in seconds. Or `transcript`. */\n\t\twords: z\n\t\t\t.array(\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\ttext: z.string(),\n\t\t\t\t\t\tstart: z.number().min(0),\n\t\t\t\t\t\tend: z.number().min(0),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.optional(),\n\t\t/** SRT, WebVTT, or word-level JSON (`[{ word | text, start, end }]`). */\n\t\ttranscript: z.string().optional(),\n\t\tstyle: z.enum(CAPTION_STYLES).default(\"karaoke\"),\n\t\t/** The voice's levels (`analyzeAudio`): the active word swells with it. Frame 0 = the component's first frame. */\n\t\tpulse: z.union([audioLevelsSchema(), z.literal(false)]).default(false),\n\t\t/** Scale added at full level. */\n\t\tpulseAmount: z.number().min(0).default(0.12),\n\t\tgroup: z\n\t\t\t.object({\n\t\t\t\tmaxChars: z.number().int().positive().optional(),\n\t\t\t\tmaxWords: z.number().int().positive().optional(),\n\t\t\t\tmaxLines: z.number().int().positive().optional(),\n\t\t\t\tbreakOnPunctuation: z.boolean().optional(),\n\t\t\t\tminGap: z.number().min(0).optional(),\n\t\t\t})\n\t\t\t.strict()\n\t\t\t.default({}),\n\t\t/** Words or patterns in the accent colour, or a test per word. */\n\t\temphasis: z\n\t\t\t.union([\n\t\t\t\tz.array(z.union([z.string(), z.instanceof(RegExp)])),\n\t\t\t\tz.custom<(word: string, index: number) => boolean>(\n\t\t\t\t\t(v) => typeof v === \"function\",\n\t\t\t\t),\n\t\t\t])\n\t\t\t.default([]),\n\t\tcase: z.enum([\"as-is\", \"upper\", \"lower\"]).optional(),\n\t\tplacement: z\n\t\t\t.object({\n\t\t\t\tanchor: z.enum([\"bottom\", \"center\", \"top\"]).optional(),\n\t\t\t\tsafeArea: z\n\t\t\t\t\t.union([\n\t\t\t\t\t\tz.enum([\"portrait\", \"landscape\"]),\n\t\t\t\t\t\tz.object({\n\t\t\t\t\t\t\tx: z.number(),\n\t\t\t\t\t\t\ty: z.number(),\n\t\t\t\t\t\t\twidth: z.number(),\n\t\t\t\t\t\t\theight: z.number(),\n\t\t\t\t\t\t}),\n\t\t\t\t\t])\n\t\t\t\t\t.optional(),\n\t\t\t\t/** Extra px from the anchor edge, toward the centre. */\n\t\t\t\toffset: z.number().default(0),\n\t\t\t})\n\t\t\t.strict()\n\t\t\t.default({ offset: 0 }),\n\t\t/** A clip whose person / subject is cut out and drawn over the captions. */\n\t\tbehind: z\n\t\t\t.object({\n\t\t\t\tvideo: z.string(),\n\t\t\t\tmode: z.enum([\"person\", \"subject\"]).default(\"person\"),\n\t\t\t})\n\t\t\t.strict()\n\t\t\t.optional(),\n\t\t/** A backing plate behind each phrase (`clean`, `karaoke`). */\n\t\tplate: z.boolean().default(false),\n\t\tfontFamily: z.string().optional(),\n\t\t/** Default: fits `group.maxChars` across the safe area. */\n\t\tfontSize: z.number().positive().optional(),\n\t\tfontWeight: z.union([z.number(), z.string()]).optional(),\n\t\tfill: z.string().optional(),\n\t\taccent: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst W = ctx.requested.width ?? 1920 * theme.scale;\n\t\tconst H = ctx.requested.height ?? 1080 * theme.scale;\n\t\tconst style = props.style;\n\n\t\t// ── Words ─────────────────────────────────────────────────────────\n\t\tlet words: Word[];\n\t\tif (props.words) words = props.words.map((w) => ({ ...w }));\n\t\telse if (props.transcript) words = parseTranscript(props.transcript);\n\t\telse throw new Error(\"Captions: give `words` or a `transcript`.\");\n\t\tconst casing = props.case ?? (style === \"bold\" ? \"upper\" : \"as-is\");\n\t\tif (casing !== \"as-is\")\n\t\t\twords = words.map((w) => ({\n\t\t\t\t...w,\n\t\t\t\ttext: casing === \"upper\" ? w.text.toUpperCase() : w.text.toLowerCase(),\n\t\t\t}));\n\t\tconst emphasised = words.map((w, i) =>\n\t\t\ttypeof props.emphasis === \"function\"\n\t\t\t\t? props.emphasis(w.text, i)\n\t\t\t\t: props.emphasis.some((p) =>\n\t\t\t\t\t\ttypeof p === \"string\"\n\t\t\t\t\t\t\t? w.text.toLowerCase().replace(/[^\\p{L}\\p{N}%]/gu, \"\") ===\n\t\t\t\t\t\t\t\tp.toLowerCase()\n\t\t\t\t\t\t\t: new RegExp(p.source, p.flags.replace(\"g\", \"\")).test(w.text),\n\t\t\t\t\t),\n\t\t);\n\n\t\t// ── Layout ────────────────────────────────────────────────────────\n\t\tconst group: GroupOptions = {\n\t\t\t...BASE_GROUP,\n\t\t\t...GROUP[style],\n\t\t\t...stripUndefined(props.group),\n\t\t};\n\t\tconst safe = safeArea(W, H, props.placement.safeArea);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst weight = props.fontWeight ?? (style === \"clean\" ? undefined : 800);\n\t\tconst measureAt = (text: string, size: number, width?: number) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: weight,\n\t\t\t\t...(width !== undefined && { width, align: \"center\" as const }),\n\t\t\t} satisfies MeasureOptions);\n\t\t// Auto size: a line of `maxChars` average glyphs spans the safe width.\n\t\tconst sample = \"nonsense words\".repeat(4).slice(0, group.maxChars);\n\t\tconst fs =\n\t\t\tprops.fontSize ??\n\t\t\tMath.min(\n\t\t\t\t(safe.width / Math.max(1, measureAt(sample, 100).width)) * 100 * 0.94,\n\t\t\t\tsafe.height / (group.maxLines * 1.6),\n\t\t\t);\n\t\tconst fits = (line: string) => measureAt(line, fs).width <= safe.width;\n\t\tconst phrases = buildPhrases(words, group, fits);\n\n\t\tconst ink = props.fill ?? theme.color.ink;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst anchor =\n\t\t\tprops.placement.anchor ?? (style === \"bold\" ? \"center\" : \"bottom\");\n\t\tconst frameOf = (seconds: number) => Math.round(seconds * fps);\n\t\tif (props.pulse && Math.abs(props.pulse.fps - fps) > 1e-6)\n\t\t\tthrow new Error(\n\t\t\t\t`Captions: \\`pulse\\` was analysed at ${props.pulse.fps} fps but the component runs at ${fps}. Analyse with { fps: ${fps} }.`,\n\t\t\t);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst wordCues: Array<CaptionsCues[\"words\"][number]> = [];\n\t\tconst phraseCues: Array<CaptionsCues[\"phrases\"][number]> = [];\n\n\t\tphrases.forEach((phrase, p) => {\n\t\t\tconst layout = phraseLayout(phrase, words);\n\t\t\tconst m = measureAt(layout.text, fs, safe.width);\n\t\t\tconst options: MeasureOptions = {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tfontWeight: weight,\n\t\t\t\twidth: safe.width,\n\t\t\t\talign: \"center\",\n\t\t\t};\n\t\t\tconst y =\n\t\t\t\tanchor === \"bottom\"\n\t\t\t\t\t? safe.y + safe.height - m.height - props.placement.offset\n\t\t\t\t\t: anchor === \"top\"\n\t\t\t\t\t\t? safe.y + props.placement.offset\n\t\t\t\t\t\t: safe.y + (safe.height - m.height) / 2;\n\t\t\tconst start = frameOf(phrase.start);\n\t\t\tconst end = Math.max(start + 1, frameOf(phrase.end));\n\t\t\t// Word windows, local to the phrase (its box starts at `start`).\n\t\t\tconst wStart = layout.words.map(\n\t\t\t\t(w) => frameOf((words[w.index] as Word).start) - start,\n\t\t\t);\n\t\t\tconst wEnd = layout.words.map((w, k) =>\n\t\t\t\tk + 1 < layout.words.length\n\t\t\t\t\t? (wStart[k + 1] as number)\n\t\t\t\t\t: Math.max(\n\t\t\t\t\t\t\t(wStart[k] as number) + 1,\n\t\t\t\t\t\t\tframeOf((words[w.index] as Word).end) - start,\n\t\t\t\t\t\t),\n\t\t\t);\n\t\t\tconst rects = layout.words.map(\n\t\t\t\t(w) => rangeRects(m, w.from, w.to, options)[0] as Rect,\n\t\t\t);\n\t\t\tconst animators: GlyphAnimator[] = [];\n\n\t\t\tconst emphasisWeights = layout.words.map((w) =>\n\t\t\t\temphasised[w.index] ? 1 : 0,\n\t\t\t);\n\t\t\tif (emphasisWeights.some(Boolean))\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"emphasis\",\n\t\t\t\t\t\tconstantExpression(\"word\", emphasisWeights),\n\t\t\t\t\t\t{ fillColor: accent },\n\t\t\t\t\t),\n\t\t\t\t);\n\n\t\t\tconst node: Partial<TextNode> = {\n\t\t\t\tid: ctx.partId(`phrase-${p}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: safe.width,\n\t\t\t\talign: \"center\",\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tfill: ink,\n\t\t\t\t...(weight !== undefined && { fontWeight: weight }),\n\t\t\t};\n\t\t\tconst plates: LayoutNode[] = [];\n\t\t\tconst box = (\n\t\t\t\tid: string,\n\t\t\t\tr: Rect,\n\t\t\t\tpad: number,\n\t\t\t\tbackground: string,\n\t\t\t\tradius: number,\n\t\t\t\textra = {},\n\t\t\t) =>\n\t\t\t\thost.box({\n\t\t\t\t\tid: ctx.partId(`phrase-${p}-${id}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: r.x - pad,\n\t\t\t\t\ty: r.y - pad * 0.5,\n\t\t\t\t\twidth: r.width + pad * 2,\n\t\t\t\t\theight: r.height + pad,\n\t\t\t\t\tbackground,\n\t\t\t\t\tborderRadius: radius,\n\t\t\t\t\t...extra,\n\t\t\t\t});\n\t\t\tconst blockRect: Rect = {\n\t\t\t\tx: Math.min(...rects.map((r) => r.x)),\n\t\t\t\ty: 0,\n\t\t\t\twidth:\n\t\t\t\t\tMath.max(...rects.map((r) => r.x + r.width)) -\n\t\t\t\t\tMath.min(...rects.map((r) => r.x)),\n\t\t\t\theight: m.height,\n\t\t\t};\n\n\t\t\tswitch (style) {\n\t\t\t\tcase \"clean\":\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"karaoke\":\n\t\t\t\t\t// Spoken words turn from muted to ink on their exact frame.\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\"spoken\", stepExpression(\"word\", wStart), {\n\t\t\t\t\t\t\tfillColor: theme.color.muted,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"highlight\":\n\t\t\t\tcase \"pill\": {\n\t\t\t\t\tconst pad = fs * (style === \"pill\" ? 0.22 : 0.16);\n\t\t\t\t\tconst glide = Math.max(2, Math.round(fast * 0.4));\n\t\t\t\t\tif (style === \"pill\")\n\t\t\t\t\t\tplates.push(\n\t\t\t\t\t\t\tbox(\n\t\t\t\t\t\t\t\t\"plate\",\n\t\t\t\t\t\t\t\tblockRect,\n\t\t\t\t\t\t\t\tfs * 0.5,\n\t\t\t\t\t\t\t\ttheme.color.surface,\n\t\t\t\t\t\t\t\ttheme.radius.pill,\n\t\t\t\t\t\t\t\t{ opacity: 0.92 },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\tconst marker = box(\n\t\t\t\t\t\t\"marker\",\n\t\t\t\t\t\trects[0] as Rect,\n\t\t\t\t\t\tpad,\n\t\t\t\t\t\taccent,\n\t\t\t\t\t\tstyle === \"pill\" ? theme.radius.pill : theme.radius.md,\n\t\t\t\t\t);\n\t\t\t\t\tmarker.animate(markerKeys(rects, wStart, pad, glide));\n\t\t\t\t\tplates.push(marker);\n\t\t\t\t\t// Text on the marker must read against it: every word in the\n\t\t\t\t\t// background colour when ink wouldn't, else only the emphasised\n\t\t\t\t\t// ones (accent on the accent marker), in ink.\n\t\t\t\t\tconst inkReads = (contrastRatio(ink, accent) ?? 21) >= 3;\n\t\t\t\t\tif (!inkReads || emphasisWeights.some(Boolean))\n\t\t\t\t\t\tanimators.push(\n\t\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\t\"on-marker\",\n\t\t\t\t\t\t\t\t// Switch as the marker lands under the word.\n\t\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t\t\twStart.map((f) => f + Math.ceil(glide / 2)),\n\t\t\t\t\t\t\t\t\twEnd.map((f) => f + Math.ceil(glide / 2)),\n\t\t\t\t\t\t\t\t\tinkReads ? emphasisWeights : undefined,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t{ fillColor: inkReads ? ink : theme.color.bg },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"pop\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"pop\",\n\t\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tstarts: wStart,\n\t\t\t\t\t\t\t\t\tduration: Math.max(3, Math.round(fast * 0.9)),\n\t\t\t\t\t\t\t\t\tease: \"back.out(2.2)\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{ opacity: 0, scale: 0.72, y: fs * 0.12 },\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"bold\":\n\t\t\t\tcase \"outline\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\"active\", windowExpression(\"word\", wStart, wEnd), {\n\t\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"gradient\": {\n\t\t\t\t\t// Active word: accent → accent2 across its letters.\n\t\t\t\t\tconst chars = layout.text.length;\n\t\t\t\t\tconst starts = new Array<number>(chars).fill(-1);\n\t\t\t\t\tconst ends = new Array<number>(chars).fill(-1);\n\t\t\t\t\tconst along = new Array<number>(chars).fill(0);\n\t\t\t\t\tlayout.words.forEach((w, k) => {\n\t\t\t\t\t\tfor (let c = w.from; c < w.to; c++) {\n\t\t\t\t\t\t\tstarts[c] = wStart[k] as number;\n\t\t\t\t\t\t\tends[c] = wEnd[k] as number;\n\t\t\t\t\t\t\talong[c] =\n\t\t\t\t\t\t\t\tw.to - w.from > 1 ? (c - w.from) / (w.to - w.from - 1) : 0;\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"active\",\n\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\trendererChars(layout.text, starts),\n\t\t\t\t\t\t\t\trendererChars(layout.text, ends),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t),\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"active-end\",\n\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\trendererChars(layout.text, starts),\n\t\t\t\t\t\t\t\trendererChars(layout.text, ends),\n\t\t\t\t\t\t\t\trendererChars(layout.text, along),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{ fillColor: theme.color.accent2 },\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (props.plate && (style === \"clean\" || style === \"karaoke\"))\n\t\t\t\tplates.unshift(\n\t\t\t\t\tbox(\n\t\t\t\t\t\t\"plate\",\n\t\t\t\t\t\tblockRect,\n\t\t\t\t\t\tfs * 0.35,\n\t\t\t\t\t\t\"rgba(0,0,0,0.55)\",\n\t\t\t\t\t\ttheme.radius.md,\n\t\t\t\t\t),\n\t\t\t\t);\n\n\t\t\tif (props.pulse) {\n\t\t\t\t// The phrase's slice of the voice level, indexed by its local frame.\n\t\t\t\tconst level = props.pulse.level.slice(start, end);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\"pulse\", pulseExpression(wStart, wEnd, level), {\n\t\t\t\t\t\tscale: 1 + props.pulseAmount,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (animators.length > 0) node.animators = animators as never;\n\t\t\tconst text = host.text(layout.text, node);\n\t\t\t// A heavy outline around the glyphs, never over them.\n\t\t\tif (style === \"outline\")\n\t\t\t\thost.style(text, {\n\t\t\t\t\tstroke: { size: fs * 0.08, position: \"outside\", color: \"#000000\" },\n\t\t\t\t});\n\t\t\tconst holder = host.box({\n\t\t\t\tid: ctx.partId(`phrase-${p}-box`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: safe.x,\n\t\t\t\ty,\n\t\t\t\twidth: safe.width,\n\t\t\t\theight: m.height,\n\t\t\t\tstartFrame: start,\n\t\t\t\tdurationFrames: end - start,\n\t\t\t\tchildren: [...plates, text],\n\t\t\t});\n\t\t\tholder.animate(\n\t\t\t\tphraseMotion(style, p, end - start, fs, fast, { x: safe.x, y }),\n\t\t\t);\n\t\t\tparts[`phrase-${p}`] = holder;\n\t\t\tchildren.push(holder);\n\n\t\t\tphraseCues.push({\n\t\t\t\tframe: start,\n\t\t\t\tend,\n\t\t\t\ttext: layout.text.replace(/\\n/g, \" \"),\n\t\t\t});\n\t\t\tlayout.words.forEach((w, k) => {\n\t\t\t\twordCues.push({\n\t\t\t\t\tframe: start + (wStart[k] as number),\n\t\t\t\t\tend: start + (wEnd[k] as number),\n\t\t\t\t\tindex: w.index,\n\t\t\t\t\ttext: (words[w.index] as Word).text,\n\t\t\t\t\temphasised: emphasised[w.index] ?? false,\n\t\t\t\t});\n\t\t\t});\n\t\t});\n\n\t\tif (props.behind) {\n\t\t\tconst cut = host.cutout(props.behind.video, {\n\t\t\t\tid: ctx.partId(\"behind\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: W,\n\t\t\t\theight: H,\n\t\t\t\tfit: \"cover\",\n\t\t\t\tmode: props.behind.mode,\n\t\t\t});\n\t\t\tparts.behind = cut;\n\t\t\tchildren.push(cut);\n\t\t}\n\n\t\tconst last = phraseCues[phraseCues.length - 1]?.end ?? 1;\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: last,\n\t\t\torigin: [0, 0],\n\t\t\tcues: { words: wordCues, phrases: phraseCues } as CaptionsCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** A phrase's text and each word's character range in it. */\nfunction phraseLayout(phrase: Phrase, words: readonly Word[]) {\n\tconst out: Array<{ index: number; from: number; to: number }> = [];\n\tlet text = \"\";\n\tphrase.lines.forEach((line, l) => {\n\t\tif (l > 0) text += \"\\n\";\n\t\tline.forEach((index, k) => {\n\t\t\tif (k > 0) text += \" \";\n\t\t\tconst from = text.length;\n\t\t\ttext += (words[index] as Word).text;\n\t\t\tout.push({ index, from, to: text.length });\n\t\t});\n\t});\n\treturn { text, words: out };\n}\n\n/** A marker that glides onto each word, arriving on the frame it is spoken. */\nfunction markerKeys(\n\trects: readonly Rect[],\n\tstarts: readonly number[],\n\tpad: number,\n\tglide: number,\n): LayerAnimation {\n\tconst keyed = (value: (r: Rect) => number) => {\n\t\tconst keys: KeyframeTuple[] = [[0, value(rects[0] as Rect)]];\n\t\trects.forEach((r, k) => {\n\t\t\tconst prev = rects[k - 1];\n\t\t\tif (!prev) return;\n\t\t\tconst at = starts[k] as number;\n\t\t\tconst last = (keys[keys.length - 1] as KeyframeTuple)[0];\n\t\t\t// Leave on the word's first frame and land a few frames in: the\n\t\t\t// marker never runs ahead of the voice.\n\t\t\tkeys.push([Math.max(last + 1, at), value(prev)]);\n\t\t\tkeys.push([Math.max(last + 2, at + glide), value(r), \"power3.out\"]);\n\t\t});\n\t\treturn keys;\n\t};\n\treturn LayerAnimation.create()\n\t\t.keys(\n\t\t\t\"x\",\n\t\t\tkeyed((r) => r.x - pad),\n\t\t)\n\t\t.keys(\n\t\t\t\"y\",\n\t\t\tkeyed((r) => r.y - pad * 0.5),\n\t\t)\n\t\t.keys(\n\t\t\t\"width\",\n\t\t\tkeyed((r) => r.width + pad * 2),\n\t\t)\n\t\t.keys(\n\t\t\t\"height\",\n\t\t\tkeyed((r) => r.height + pad),\n\t\t)\n\t\t.keys(\"opacity\", [\n\t\t\t[0, 0],\n\t\t\t[starts[0] ?? 0, 1, \"hold\"],\n\t\t]);\n}\n\n/** How a phrase arrives and leaves. `bold` alternates sides; the rest fade up. */\nfunction phraseMotion(\n\tstyle: CaptionStyle,\n\tindex: number,\n\tlength: number,\n\tfs: number,\n\tfast: number,\n\tat: { x: number; y: number },\n): LayerAnimation {\n\tconst anim = LayerAnimation.create();\n\tconst inF = Math.min(\n\t\tMath.max(2, Math.round(fast * 0.6)),\n\t\tMath.floor(length / 2),\n\t);\n\tconst out = Math.min(\n\t\tMath.max(2, Math.round(fast * 0.5)),\n\t\tMath.floor(length / 3),\n\t);\n\tanim.keys(\"opacity\", [\n\t\t[0, 0],\n\t\t[inF, 1, \"power2.out\"],\n\t\t[length - out, 1],\n\t\t[length, 0, \"power2.in\"],\n\t]);\n\tif (style === \"bold\") {\n\t\tconst side = index % 2 === 0 ? -1 : 1;\n\t\tanim.keys(\"x\", [\n\t\t\t[0, at.x + side * fs * 0.6],\n\t\t\t[inF + 2, at.x, \"back.out(1.8)\"],\n\t\t]);\n\t\tanim.keys(\"scale\", [\n\t\t\t[0, 0.86],\n\t\t\t[inF + 2, 1, \"back.out(1.8)\"],\n\t\t]);\n\t} else if (style !== \"pop\") {\n\t\tanim.keys(\"y\", [\n\t\t\t[0, at.y + fs * 0.14],\n\t\t\t[inF, at.y, \"power3.out\"],\n\t\t]);\n\t}\n\treturn anim;\n}\n\nfunction stripUndefined<T extends object>(o: T): Partial<T> {\n\treturn Object.fromEntries(\n\t\tObject.entries(o).filter(([, v]) => v !== undefined),\n\t) as Partial<T>;\n}\n\n/** The active word's weight is the voice level on that frame (0 elsewhere). */\nfunction pulseExpression(\n\tstarts: readonly number[],\n\tends: readonly number[],\n\tlevel: readonly number[],\n): string {\n\tconst list = (v: readonly number[]) =>\n\t\t`[${v.map((x) => Math.round(x * 1000) / 1000).join(\",\")}]`;\n\treturn `((S,E,L)=>{const s=S[wordIndex];if(s===undefined||frame<s||frame>=E[wordIndex])return 0;return L[floor(frame)]??0;})(${list(starts)},${list(ends)},${list(level)})`;\n}\n\n/**\n * Word-timed captions from words or a transcript (SRT, WebVTT, word JSON) in\n * eight published styles, highlighting each word on its exact frame, optionally\n * tucked behind the speaker.\n */\nexport const Captions = /* @__PURE__ */ bindComponent(captions);\n"
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
"path": "captions/parse.ts",
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "card-stack",
|
|
3
3
|
"title": "CardStack",
|
|
4
|
-
"summary": "Cards in a depth stack that shuffle: the front card flies
|
|
4
|
+
"summary": "Cards in a depth stack that shuffle: the front card flies, drops, zooms or flips away and returns to the back; cards are dealt onto the pile; or the stack fans out into a hand.",
|
|
5
5
|
"tags": [
|
|
6
6
|
"cards",
|
|
7
7
|
"stack",
|
|
8
8
|
"deck",
|
|
9
9
|
"carousel",
|
|
10
|
+
"fan",
|
|
10
11
|
"testimonials",
|
|
11
12
|
"features",
|
|
12
13
|
"3d"
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"files": [
|
|
25
26
|
{
|
|
26
27
|
"path": "card-stack/index.ts",
|
|
27
|
-
"content": "/**\n * CardStack: cards in a depth stack that shuffle. In `cycle` the front card\n * flies off and returns to the back while the rest step forward; in `deal`\n * cards land one at a time on top of the pile.\n *\n * Draw order is fixed by the tree (the renderer has no animated z-order), so\n * in `cycle` every card sits in a clip that shows only the strip peeking out\n * above the card in front of it: whichever card is drawn later, a card\n * behind can never paint over one in front. The leaving card flies sideways,\n * clear of the stack, before it slots in at the back.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport interface CardStackCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** A move starts; `index` is the card coming to the front. */\n\tchanges: ReadonlyArray<{ frame: number; index: number }>;\n}\n\nconst SIMPLE = z\n\t.object({\n\t\ttitle: z.string(),\n\t\tbody: z.string().optional(),\n\t\timage: z.string().optional(),\n\t\taccent: z.string().optional(),\n\t})\n\t.strict();\ntype SimpleCard = z.infer<typeof SIMPLE>;\n\n/** Slot geometry: card k behind the front is this much smaller and higher. */\nexport const SHRINK = 0.06;\n\nexport const cardStack = /* @__PURE__ */ defineComponent({\n\tname: \"CardStack\",\n\tprops: () => ({\n\t\tcards: z\n\t\t\t.array(\n\t\t\t\tz.union([\n\t\t\t\t\tSIMPLE,\n\t\t\t\t\tz.custom<LayoutNode>(\n\t\t\t\t\t\t(v) => typeof v === \"object\" && v !== null && \"kind\" in v,\n\t\t\t\t\t\t{ message: \"Expected a layer or { title, body?, image? }\" },\n\t\t\t\t\t),\n\t\t\t\t]),\n\t\t\t)\n\t\t\t.min(2),\n\t\tmode: z.enum([\"cycle\", \"deal\"]).default(\"cycle\"),\n\t\t/** Frames between moves. Default 1.5 seconds. */\n\t\tevery: z.number().int().positive().optional(),\n\t\tcycles: z.number().int().positive().default(1),\n\t\t/** Cards shown behind the front one. */\n\t\tvisible: z.number().int().min(0).max(8).default(3),\n\t\tflyTo: z.enum([\"up\", \"left\", \"right\"]).default(\"left\"),\n\t\tcardWidth: z.number().positive().optional(),\n\t\tcardHeight: z.number().positive().optional(),\n\t\ttilt: z\n\t\t\t.union([\n\t\t\t\tz.literal(false),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\trotateX: z.number().default(12),\n\t\t\t\t\t\trotateY: z.number().default(0),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default({ rotateX: 12, rotateY: 0 }),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 30 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst cw = props.cardWidth ?? 640 * s;\n\t\tconst ch = props.cardHeight ?? 400 * s;\n\t\tconst n = props.cards.length;\n\t\tconst every = props.every ?? Math.round(fps * 1.5);\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst tr = Math.min(every - 1, Math.round(base * 1.8));\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst step = 26 * s;\n\t\tconst depth = Math.min(props.visible, n - 1);\n\t\tconst W = ctx.requested.width ?? cw * 1.5;\n\t\tconst H = ctx.requested.height ?? ch + step * depth + ch * 0.4;\n\t\tconst front = { x: (W - cw) / 2, y: H - ch - ch * 0.2 };\n\t\tconst pose = (k: number) => {\n\t\t\tconst shown = Math.min(k, depth);\n\t\t\treturn {\n\t\t\t\ty: front.y - shown * step,\n\t\t\t\tscale: 1 - SHRINK * shown,\n\t\t\t\topacity: k <= depth ? 1 : 0,\n\t\t\t\tdim: Math.min(0.6, 0.14 * shown),\n\t\t\t\t// Local height of the strip that peeks above the card in front.\n\t\t\t\tclip: k === 0 ? ch : step / (1 - SHRINK * shown),\n\t\t\t};\n\t\t};\n\n\t\t// ── Timeline: each card's slot at each move ──────────────────────\n\t\tconst cycle = props.mode === \"cycle\";\n\t\tconst moves = cycle ? n * props.cycles : n - 1;\n\t\tconst changes = Array.from({ length: moves }, (_, m) => ({\n\t\t\tframe: (m + 1) * every,\n\t\t\tindex: cycle ? (m + 1) % n : m + 1,\n\t\t}));\n\t\tconst fly =\n\t\t\tprops.flyTo === \"up\"\n\t\t\t\t? { x: 0, y: -ch * 1.3, rotation: -4 }\n\t\t\t\t: {\n\t\t\t\t\t\tx: (props.flyTo === \"left\" ? -1 : 1) * cw * 1.1,\n\t\t\t\t\t\ty: -ch * 0.15,\n\t\t\t\t\t\trotation: props.flyTo === \"left\" ? -10 : 10,\n\t\t\t\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst nodes: LayoutNode[] = [];\n\t\tprops.cards.forEach((card, c) => {\n\t\t\tconst tracks = {\n\t\t\t\tx: [] as KeyframeTuple[],\n\t\t\t\ty: [] as KeyframeTuple[],\n\t\t\t\tscale: [] as KeyframeTuple[],\n\t\t\t\trotation: [] as KeyframeTuple[],\n\t\t\t\topacity: [] as KeyframeTuple[],\n\t\t\t\tdim: [] as KeyframeTuple[],\n\t\t\t\tclip: [] as KeyframeTuple[],\n\t\t\t};\n\t\t\tconst put = (\n\t\t\t\tf: number,\n\t\t\t\tk: number,\n\t\t\t\te?: string,\n\t\t\t\talso?: Partial<Record<keyof typeof tracks, number>>,\n\t\t\t) => {\n\t\t\t\tconst p = pose(k);\n\t\t\t\tconst v = {\n\t\t\t\t\tx: front.x,\n\t\t\t\t\ty: p.y,\n\t\t\t\t\tscale: p.scale,\n\t\t\t\t\trotation: 0,\n\t\t\t\t\topacity: p.opacity,\n\t\t\t\t\tdim: p.dim,\n\t\t\t\t\tclip: p.clip,\n\t\t\t\t\t...also,\n\t\t\t\t};\n\t\t\t\tfor (const key of Object.keys(tracks) as Array<keyof typeof tracks>)\n\t\t\t\t\ttracks[key].push(e ? [f, round(v[key]), e] : [f, round(v[key])]);\n\t\t\t};\n\t\t\tif (cycle) {\n\t\t\t\tput(0, c);\n\t\t\t\tchanges.forEach(({ frame: t }, m) => {\n\t\t\t\t\tconst k = (((c - m) % n) + n) % n;\n\t\t\t\t\tif (k === 0) {\n\t\t\t\t\t\t// Leaves: flies clear, then slots in at the back unseen and fades up.\n\t\t\t\t\t\tconst away = t + Math.round(tr * 0.55);\n\t\t\t\t\t\tput(t, 0);\n\t\t\t\t\t\tput(away, 0, \"power2.in\", {\n\t\t\t\t\t\t\tx: front.x + fly.x,\n\t\t\t\t\t\t\ty: front.y + fly.y,\n\t\t\t\t\t\t\trotation: fly.rotation,\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tput(away + 1, n - 1, \"hold\", { opacity: 0 });\n\t\t\t\t\t\tput(t + tr, n - 1, \"power2.out\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Steps forward once the leaving card is half way out.\n\t\t\t\t\t\tput(t + Math.round(tr * 0.3), k);\n\t\t\t\t\t\tput(t + tr, k - 1, ease);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// Deal: card c lands on top at move c (card 0 is already down).\n\t\t\t\tconst landAt = c === 0 ? 0 : (changes[c - 1]?.frame as number);\n\t\t\t\tif (c > 0) {\n\t\t\t\t\tput(0, 0, undefined, {\n\t\t\t\t\t\ty: front.y - ch * 1.4,\n\t\t\t\t\t\trotation: 9,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tput(landAt, 0, \"hold\", {\n\t\t\t\t\t\ty: front.y - ch * 1.4,\n\t\t\t\t\t\trotation: 9,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tput(landAt + tr, 0, theme.motion.ease.enter);\n\t\t\t\t} else put(0, 0);\n\t\t\t\t// Pushed back one slot by every later card.\n\t\t\t\tchanges.slice(c).forEach(({ frame: t }, j) => {\n\t\t\t\t\tput(t, j);\n\t\t\t\t\tput(t + tr, j + 1, ease);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst content = isSimple(card) ? simpleCard(ctx, card, c, cw, ch) : card;\n\t\t\tconst dim = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}-dim`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\tbackground: \"#000000\",\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t\topacity: 0,\n\t\t\t});\n\t\t\tdim.animate(LayerAnimation.keys(\"opacity\", ascending(tracks.dim)));\n\t\t\tconst clip = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}-clip`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t\tchildren: [content, dim],\n\t\t\t});\n\t\t\tif (cycle)\n\t\t\t\tclip.animate(LayerAnimation.keys(\"height\", ascending(tracks.clip)));\n\t\t\tconst node = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: front.x,\n\t\t\t\ty: pose(c).y,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: 0,\n\t\t\t\tchildren: [clip],\n\t\t\t});\n\t\t\tconst anim = LayerAnimation.create()\n\t\t\t\t.keys(\"y\", ascending(tracks.y))\n\t\t\t\t.keys(\"scale\", ascending(tracks.scale))\n\t\t\t\t.keys(\"opacity\", ascending(tracks.opacity));\n\t\t\tif (tracks.x.some((k) => k[1] !== front.x))\n\t\t\t\tanim.keys(\"x\", ascending(tracks.x));\n\t\t\tif (tracks.rotation.some((k) => k[1] !== 0))\n\t\t\t\tanim.keys(\"rotation\", ascending(tracks.rotation));\n\t\t\tnode.animate(anim);\n\t\t\thost.style(node, {\n\t\t\t\tdropShadow: {\n\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\topacity: 0.35,\n\t\t\t\t\tangle: 90,\n\t\t\t\t\tdistance: 12 * s,\n\t\t\t\t\tsize: 34 * s,\n\t\t\t\t},\n\t\t\t});\n\t\t\tparts[`card-${c}`] = node;\n\t\t\tparts[`card-${c}-clip`] = clip;\n\t\t\tnodes.push(node);\n\t\t});\n\t\t// Front-most last: cycle starts with card 0 in front, deal ends with the last on top.\n\t\tconst ordered = cycle ? [...nodes].reverse() : nodes;\n\t\tconst children = props.tilt\n\t\t\t? [\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(\"tilt\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\theight: H,\n\t\t\t\t\t\tperspective: 1600 * s,\n\t\t\t\t\t\trotateX: props.tilt.rotateX,\n\t\t\t\t\t\trotateY: props.tilt.rotateY,\n\t\t\t\t\t\tchildren: ordered,\n\t\t\t\t\t}),\n\t\t\t\t]\n\t\t\t: ordered;\n\t\tif (props.tilt) parts.tilt = children[0] as Animatable<LayoutNode>;\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: (changes.at(-1)?.frame ?? 0) + tr,\n\t\t\topenEnded: true,\n\t\t\tcues: { changes } as CardStackCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nconst round = (v: number) => Math.round(v * 1000) / 1000;\n\nconst isSimple = (card: unknown): card is SimpleCard =>\n\ttypeof card === \"object\" &&\n\tcard !== null &&\n\t\"title\" in card &&\n\t!(\"kind\" in card);\n\n/** Later keys on a shared frame win; frames ascend. */\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst byFrame = new Map<number, KeyframeTuple>();\n\tfor (const k of keys) byFrame.set(k[0] as number, k);\n\treturn [...byFrame.values()].sort(\n\t\t(a, b) => (a[0] as number) - (b[0] as number),\n\t);\n}\n\n/** A card drawn from the theme: optional image on top, title, body, accent bar. */\nfunction simpleCard(\n\tctx: Parameters<(typeof cardStack)[\"build\"]>[0],\n\tcard: SimpleCard,\n\tc: number,\n\tw: number,\n\th: number,\n): LayoutNode {\n\tconst { host, theme } = ctx;\n\tconst s = theme.scale;\n\tconst pad = 36 * s;\n\tconst imageH = card.image ? h * 0.48 : 0;\n\tconst kids: LayoutNode[] = [];\n\tif (card.image)\n\t\tkids.push(\n\t\t\thost.media(card.image, {\n\t\t\t\tid: ctx.partId(`card-${c}-image`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: w,\n\t\t\t\theight: imageH,\n\t\t\t\tfit: \"cover\",\n\t\t\t}),\n\t\t);\n\tkids.push(\n\t\thost.box({\n\t\t\tid: ctx.partId(`card-${c}-accent`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: imageH + pad,\n\t\t\twidth: 44 * s,\n\t\t\theight: 5 * s,\n\t\t\tbackground: card.accent ?? theme.color.accent,\n\t\t\tborderRadius: 3 * s,\n\t\t}),\n\t\thost.text(card.title, {\n\t\t\tid: ctx.partId(`card-${c}-title`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: imageH + pad + 22 * s,\n\t\t\twidth: w - pad * 2,\n\t\t\tfontFamily: theme.font.display,\n\t\t\tfontSize: 40 * s,\n\t\t\tfontWeight: 700,\n\t\t\tfill: theme.color.ink,\n\t\t}),\n\t);\n\tif (card.body)\n\t\tkids.push(\n\t\t\thost.text(card.body, {\n\t\t\t\tid: ctx.partId(`card-${c}-body`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: imageH + pad + 80 * s,\n\t\t\t\twidth: w - pad * 2,\n\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\tfontSize: 24 * s,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t}),\n\t\t);\n\treturn host.box({\n\t\tid: ctx.partId(`card-${c}-face`),\n\t\tposition: \"absolute\",\n\t\tx: 0,\n\t\ty: 0,\n\t\twidth: w,\n\t\theight: h,\n\t\tbackground: theme.color.raised,\n\t\tborderRadius: theme.radius.lg,\n\t\tborderColor: theme.color.line,\n\t\tborderWidth: Math.max(1, s),\n\t\tstrokeAlign: \"inside\",\n\t\tchildren: kids,\n\t});\n}\n\n/**\n * Cards in a depth stack that shuffle: the front card flies off and returns to\n * the back, or cards are dealt onto the pile.\n */\nexport const CardStack = /* @__PURE__ */ bindComponent(cardStack);\n"
|
|
28
|
+
"content": "/**\n * CardStack: cards in a depth stack that shuffle. In `cycle` the front card\n * leaves (flies, drops, zooms or flips away) and returns to the back while\n * the rest step forward; in `deal` cards land one at a time on top of the\n * pile; in `fan` the stack spreads into a hand and gathers again.\n *\n * Draw order is fixed by the tree (the renderer has no animated z-order), so\n * in `cycle` every card sits in a clip that shows only the strip peeking out\n * above the card in front of it: whichever card is drawn later, a card\n * behind can never paint over one in front. The leaving card flies sideways,\n * clear of the stack, before it slots in at the back. The card coming to the\n * front shows whole at once: everything drawn after it is either the card\n * leaving (in front of it anyway) or a card already clipped at the back.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport interface CardStackCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** A move starts; `index` is the card coming to the front. */\n\tchanges: ReadonlyArray<{ frame: number; index: number }>;\n}\n\nconst SIMPLE = z\n\t.object({\n\t\ttitle: z.string(),\n\t\tbody: z.string().optional(),\n\t\timage: z.string().optional(),\n\t\taccent: z.string().optional(),\n\t})\n\t.strict();\ntype SimpleCard = z.infer<typeof SIMPLE>;\n\n/** Slot geometry: card k behind the front is this much smaller and higher. */\nexport const SHRINK = 0.06;\n\nexport const cardStack = /* @__PURE__ */ defineComponent({\n\tname: \"CardStack\",\n\tprops: () => ({\n\t\tcards: z\n\t\t\t.array(\n\t\t\t\tz.union([\n\t\t\t\t\tSIMPLE,\n\t\t\t\t\tz.custom<LayoutNode>(\n\t\t\t\t\t\t(v) => typeof v === \"object\" && v !== null && \"kind\" in v,\n\t\t\t\t\t\t{ message: \"Expected a layer or { title, body?, image? }\" },\n\t\t\t\t\t),\n\t\t\t\t]),\n\t\t\t)\n\t\t\t.min(2),\n\t\tmode: z.enum([\"cycle\", \"deal\", \"fan\"]).default(\"cycle\"),\n\t\t/** Frames between moves. Default 1.5 seconds. */\n\t\tevery: z.number().int().positive().optional(),\n\t\tcycles: z.number().int().positive().default(1),\n\t\t/** Cards shown behind the front one. */\n\t\tvisible: z.number().int().min(0).max(8).default(3),\n\t\t/** How the front card leaves in `cycle`. */\n\t\tleave: z.enum([\"fly\", \"drop\", \"zoom\", \"flip\"]).default(\"fly\"),\n\t\t/** Where `fly` sends the card, and which way `drop` tips it. */\n\t\tflyTo: z.enum([\"up\", \"left\", \"right\"]).default(\"left\"),\n\t\t/** Degrees between neighbouring cards in `fan`. */\n\t\tfanAngle: z.number().min(0).max(30).default(8),\n\t\tcardWidth: z.number().positive().optional(),\n\t\tcardHeight: z.number().positive().optional(),\n\t\ttilt: z\n\t\t\t.union([\n\t\t\t\tz.literal(false),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\trotateX: z.number().default(12),\n\t\t\t\t\t\trotateY: z.number().default(0),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default({ rotateX: 12, rotateY: 0 }),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 30 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst cw = props.cardWidth ?? 640 * s;\n\t\tconst ch = props.cardHeight ?? 400 * s;\n\t\tconst n = props.cards.length;\n\t\tconst every = props.every ?? Math.round(fps * 1.5);\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst tr = Math.min(every - 1, Math.round(base * 1.8));\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst step = 26 * s;\n\t\tconst depth = Math.min(props.visible, n - 1);\n\t\tconst fan = props.mode === \"fan\";\n\t\t// In a fan, cards shrink a little and overlap by this much of their width.\n\t\tconst fanScale = 0.82;\n\t\tconst fanStep = cw * fanScale * 0.42;\n\t\tconst W =\n\t\t\tctx.requested.width ??\n\t\t\t(fan\n\t\t\t\t? Math.max(cw * 1.5, cw * fanScale + fanStep * (n - 1) + cw * 0.3)\n\t\t\t\t: cw * 1.5);\n\t\tconst H = ctx.requested.height ?? ch + step * depth + ch * 0.4;\n\t\tconst front = { x: (W - cw) / 2, y: H - ch - ch * 0.2 };\n\t\tconst pose = (k: number) => {\n\t\t\tconst shown = Math.min(k, depth);\n\t\t\treturn {\n\t\t\t\ty: front.y - shown * step,\n\t\t\t\tscale: 1 - SHRINK * shown,\n\t\t\t\topacity: k <= depth ? 1 : 0,\n\t\t\t\tdim: Math.min(0.6, 0.14 * shown),\n\t\t\t\t// Local height of the strip that peeks above the card in front.\n\t\t\t\tclip: k === 0 ? ch : step / (1 - SHRINK * shown),\n\t\t\t};\n\t\t};\n\n\t\t// ── Timeline: each card's slot at each move ──────────────────────\n\t\tconst cycle = props.mode === \"cycle\";\n\t\tconst deal = props.mode === \"deal\";\n\t\t// A fan spreads and gathers once per cycle.\n\t\tconst moves = cycle ? n * props.cycles : fan ? 2 * props.cycles : n - 1;\n\t\tconst changes = Array.from({ length: moves }, (_, m) => ({\n\t\t\tframe: (m + 1) * every,\n\t\t\tindex: cycle ? (m + 1) % n : fan ? 0 : m + 1,\n\t\t}));\n\t\tconst side = props.flyTo === \"right\" ? 1 : -1;\n\t\t// Where the leaving card ends (relative to the front slot), how, and\n\t\t// how much of the move it takes.\n\t\tconst leave: {\n\t\t\tto: Partial<Record<\"x\" | \"y\" | \"rotation\" | \"rotateX\" | \"scale\", number>>;\n\t\t\tease: string;\n\t\t\tshare: number;\n\t\t\t/** Share of its exit it stays opaque, so the next card doesn't ghost through. */\n\t\t\tsolid: number;\n\t\t} = {\n\t\t\tfly: {\n\t\t\t\tto:\n\t\t\t\t\tprops.flyTo === \"up\"\n\t\t\t\t\t\t? { y: -ch * 1.3, rotation: -4 }\n\t\t\t\t\t\t: { x: side * cw * 1.1, y: -ch * 0.15, rotation: side * 10 },\n\t\t\t\tease: \"power2.in\",\n\t\t\t\tshare: 0.55,\n\t\t\t\tsolid: 0.5,\n\t\t\t},\n\t\t\t// Falls out of the bottom, tipping toward `flyTo`.\n\t\t\tdrop: {\n\t\t\t\tto: { x: side * cw * 0.12, y: ch * 1.1, rotation: side * 14 },\n\t\t\t\tease: \"power2.in\",\n\t\t\t\tshare: 0.6,\n\t\t\t\tsolid: 0.55,\n\t\t\t},\n\t\t\t// Toward the viewer, fading as it passes.\n\t\t\tzoom: {\n\t\t\t\tto: { y: ch * 0.06, scale: 1.22 },\n\t\t\t\tease: \"power2.out\",\n\t\t\t\tshare: 0.45,\n\t\t\t\tsolid: 0,\n\t\t\t},\n\t\t\t// Folds back over its top edge.\n\t\t\tflip: {\n\t\t\t\tto: { y: -ch * 0.04, rotateX: 90, scale: 0.96 },\n\t\t\t\tease: \"sine.in\",\n\t\t\t\tshare: 0.55,\n\t\t\t\tsolid: 0.9,\n\t\t\t},\n\t\t}[props.leave];\n\t\t// The fan: an arc, centred, outer cards turned and lowered.\n\t\tconst fanPose = (c: number) => {\n\t\t\tconst off = c - (n - 1) / 2;\n\t\t\treturn {\n\t\t\t\tx: front.x + off * fanStep,\n\t\t\t\ty: front.y + ch * 0.06 + Math.abs(off) ** 2 * ch * 0.02,\n\t\t\t\trotation: off * props.fanAngle,\n\t\t\t\tscale: fanScale,\n\t\t\t\tdim: 0,\n\t\t\t\topacity: 1,\n\t\t\t};\n\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst nodes: LayoutNode[] = [];\n\t\tprops.cards.forEach((card, c) => {\n\t\t\tconst tracks = {\n\t\t\t\tx: [] as KeyframeTuple[],\n\t\t\t\ty: [] as KeyframeTuple[],\n\t\t\t\tscale: [] as KeyframeTuple[],\n\t\t\t\trotation: [] as KeyframeTuple[],\n\t\t\t\trotateX: [] as KeyframeTuple[],\n\t\t\t\topacity: [] as KeyframeTuple[],\n\t\t\t\tdim: [] as KeyframeTuple[],\n\t\t\t\tclip: [] as KeyframeTuple[],\n\t\t\t};\n\t\t\tconst put = (\n\t\t\t\tf: number,\n\t\t\t\tk: number,\n\t\t\t\te?: string,\n\t\t\t\talso?: Partial<Record<keyof typeof tracks, number>>,\n\t\t\t) => {\n\t\t\t\tconst p = pose(k);\n\t\t\t\tconst v = {\n\t\t\t\t\tx: front.x,\n\t\t\t\t\ty: p.y,\n\t\t\t\t\tscale: p.scale,\n\t\t\t\t\trotation: 0,\n\t\t\t\t\trotateX: 0,\n\t\t\t\t\topacity: p.opacity,\n\t\t\t\t\tdim: p.dim,\n\t\t\t\t\tclip: p.clip,\n\t\t\t\t\t...also,\n\t\t\t\t};\n\t\t\t\tfor (const key of Object.keys(tracks) as Array<keyof typeof tracks>)\n\t\t\t\t\ttracks[key].push(e ? [f, round(v[key]), e] : [f, round(v[key])]);\n\t\t\t};\n\t\t\tif (cycle) {\n\t\t\t\tput(0, c);\n\t\t\t\tchanges.forEach(({ frame: t }, m) => {\n\t\t\t\t\tconst k = (((c - m) % n) + n) % n;\n\t\t\t\t\tif (k === 0) {\n\t\t\t\t\t\t// Leaves, then slots in at the back unseen and fades up.\n\t\t\t\t\t\tconst away = t + Math.round(tr * leave.share);\n\t\t\t\t\t\tconst to = leave.to;\n\t\t\t\t\t\tput(t, 0);\n\t\t\t\t\t\tconst solid = t + Math.round((away - t) * leave.solid);\n\t\t\t\t\t\tif (solid > t) tracks.opacity.push([solid, 1]);\n\t\t\t\t\t\tput(away, 0, leave.ease, {\n\t\t\t\t\t\t\tx: front.x + (to.x ?? 0),\n\t\t\t\t\t\t\ty: front.y + (to.y ?? 0),\n\t\t\t\t\t\t\trotation: to.rotation ?? 0,\n\t\t\t\t\t\t\trotateX: to.rotateX ?? 0,\n\t\t\t\t\t\t\tscale: to.scale ?? 1,\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tput(away + 1, n - 1, \"hold\", { opacity: 0 });\n\t\t\t\t\t\tput(t + tr, n - 1, \"power2.out\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Steps forward once the leaving card is under way. Coming to\n\t\t\t\t\t\t// the front, it shows whole from the start.\n\t\t\t\t\t\tconst whole = k === 1 ? { clip: ch } : undefined;\n\t\t\t\t\t\tput(t, k);\n\t\t\t\t\t\tif (whole) tracks.clip.push([t + 1, ch, \"hold\"]);\n\t\t\t\t\t\tput(t + Math.round(tr * 0.25), k, undefined, whole);\n\t\t\t\t\t\tput(t + tr, k - 1, ease);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t} else if (fan) {\n\t\t\t\tput(0, c);\n\t\t\t\tchanges.forEach(({ frame: t }, m) => {\n\t\t\t\t\t// Spreads on even moves, gathers on odd ones, the front card first.\n\t\t\t\t\tconst at = t + c * Math.round(fps * 0.04);\n\t\t\t\t\tconst spread = m % 2 === 0;\n\t\t\t\t\tput(at, c, undefined, spread ? undefined : fanPose(c));\n\t\t\t\t\tif (spread) put(at + tr, c, theme.motion.ease.snap, fanPose(c));\n\t\t\t\t\telse put(at + tr, c, ease);\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// Deal: card c lands on top at move c (card 0 is already down).\n\t\t\t\tconst landAt = c === 0 ? 0 : (changes[c - 1]?.frame as number);\n\t\t\t\tif (c > 0) {\n\t\t\t\t\tput(0, 0, undefined, {\n\t\t\t\t\t\ty: front.y - ch * 1.4,\n\t\t\t\t\t\trotation: 9,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tput(landAt, 0, \"hold\", {\n\t\t\t\t\t\ty: front.y - ch * 1.4,\n\t\t\t\t\t\trotation: 9,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tput(landAt + tr, 0, theme.motion.ease.enter);\n\t\t\t\t} else put(0, 0);\n\t\t\t\t// Pushed back one slot by every later card.\n\t\t\t\tchanges.slice(c).forEach(({ frame: t }, j) => {\n\t\t\t\t\tput(t, j);\n\t\t\t\t\tput(t + tr, j + 1, ease);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst content = isSimple(card) ? simpleCard(ctx, card, c, cw, ch) : card;\n\t\t\tconst dim = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}-dim`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\tbackground: \"#000000\",\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t\topacity: 0,\n\t\t\t});\n\t\t\tdim.animate(LayerAnimation.keys(\"opacity\", ascending(tracks.dim)));\n\t\t\tconst clip = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}-clip`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t\tchildren: [content, dim],\n\t\t\t});\n\t\t\tif (cycle)\n\t\t\t\tclip.animate(LayerAnimation.keys(\"height\", ascending(tracks.clip)));\n\t\t\tconst node = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: front.x,\n\t\t\t\ty: pose(c).y,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: 0,\n\t\t\t\tchildren: [clip],\n\t\t\t});\n\t\t\tconst anim = LayerAnimation.create()\n\t\t\t\t.keys(\"y\", ascending(tracks.y))\n\t\t\t\t.keys(\"scale\", ascending(tracks.scale))\n\t\t\t\t.keys(\"opacity\", ascending(tracks.opacity));\n\t\t\tif (tracks.x.some((k) => k[1] !== front.x))\n\t\t\t\tanim.keys(\"x\", ascending(tracks.x));\n\t\t\tif (tracks.rotation.some((k) => k[1] !== 0))\n\t\t\t\tanim.keys(\"rotation\", ascending(tracks.rotation));\n\t\t\tif (tracks.rotateX.some((k) => k[1] !== 0))\n\t\t\t\tanim.keys(\"rotateX\", ascending(tracks.rotateX));\n\t\t\tnode.animate(anim);\n\t\t\thost.style(node, {\n\t\t\t\tdropShadow: {\n\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\topacity: 0.35,\n\t\t\t\t\tangle: 90,\n\t\t\t\t\tdistance: 12 * s,\n\t\t\t\t\tsize: 34 * s,\n\t\t\t\t},\n\t\t\t});\n\t\t\tparts[`card-${c}`] = node;\n\t\t\tparts[`card-${c}-clip`] = clip;\n\t\t\tnodes.push(node);\n\t\t});\n\t\t// Front-most last: cycle and fan start with card 0 in front, deal ends\n\t\t// with the last on top.\n\t\tconst ordered = deal ? nodes : [...nodes].reverse();\n\t\tconst children = props.tilt\n\t\t\t? [\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(\"tilt\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\theight: H,\n\t\t\t\t\t\tperspective: 1600 * s,\n\t\t\t\t\t\trotateX: props.tilt.rotateX,\n\t\t\t\t\t\trotateY: props.tilt.rotateY,\n\t\t\t\t\t\tchildren: ordered,\n\t\t\t\t\t}),\n\t\t\t\t]\n\t\t\t: ordered;\n\t\tif (props.tilt) parts.tilt = children[0] as Animatable<LayoutNode>;\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: (changes.at(-1)?.frame ?? 0) + tr,\n\t\t\topenEnded: true,\n\t\t\tcues: { changes } as CardStackCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nconst round = (v: number) => Math.round(v * 1000) / 1000;\n\nconst isSimple = (card: unknown): card is SimpleCard =>\n\ttypeof card === \"object\" &&\n\tcard !== null &&\n\t\"title\" in card &&\n\t!(\"kind\" in card);\n\n/** Later keys on a shared frame win; frames ascend. */\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst byFrame = new Map<number, KeyframeTuple>();\n\tfor (const k of keys) byFrame.set(k[0] as number, k);\n\treturn [...byFrame.values()].sort(\n\t\t(a, b) => (a[0] as number) - (b[0] as number),\n\t);\n}\n\n/** A card drawn from the theme: optional image on top, title, body, accent bar. */\nfunction simpleCard(\n\tctx: Parameters<(typeof cardStack)[\"build\"]>[0],\n\tcard: SimpleCard,\n\tc: number,\n\tw: number,\n\th: number,\n): LayoutNode {\n\tconst { host, theme } = ctx;\n\tconst s = theme.scale;\n\tconst pad = 36 * s;\n\tconst imageH = card.image ? h * 0.48 : 0;\n\tconst kids: LayoutNode[] = [];\n\tif (card.image)\n\t\tkids.push(\n\t\t\thost.media(card.image, {\n\t\t\t\tid: ctx.partId(`card-${c}-image`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: w,\n\t\t\t\theight: imageH,\n\t\t\t\tfit: \"cover\",\n\t\t\t}),\n\t\t);\n\tkids.push(\n\t\thost.box({\n\t\t\tid: ctx.partId(`card-${c}-accent`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: imageH + pad,\n\t\t\twidth: 44 * s,\n\t\t\theight: 5 * s,\n\t\t\tbackground: card.accent ?? theme.color.accent,\n\t\t\tborderRadius: 3 * s,\n\t\t}),\n\t\thost.text(card.title, {\n\t\t\tid: ctx.partId(`card-${c}-title`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: imageH + pad + 22 * s,\n\t\t\twidth: w - pad * 2,\n\t\t\tfontFamily: theme.font.display,\n\t\t\tfontSize: 40 * s,\n\t\t\tfontWeight: 700,\n\t\t\tfill: theme.color.ink,\n\t\t}),\n\t);\n\tif (card.body)\n\t\tkids.push(\n\t\t\thost.text(card.body, {\n\t\t\t\tid: ctx.partId(`card-${c}-body`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: imageH + pad + 80 * s,\n\t\t\t\twidth: w - pad * 2,\n\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\tfontSize: 24 * s,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t}),\n\t\t);\n\treturn host.box({\n\t\tid: ctx.partId(`card-${c}-face`),\n\t\tposition: \"absolute\",\n\t\tx: 0,\n\t\ty: 0,\n\t\twidth: w,\n\t\theight: h,\n\t\tbackground: theme.color.raised,\n\t\tborderRadius: theme.radius.lg,\n\t\tborderColor: theme.color.line,\n\t\tborderWidth: Math.max(1, s),\n\t\tstrokeAlign: \"inside\",\n\t\tchildren: kids,\n\t});\n}\n\n/**\n * Cards in a depth stack that shuffle: the front card flies, drops, zooms or\n * flips away and returns to the back; cards are dealt onto the pile; or the\n * stack fans out into a hand.\n */\nexport const CardStack = /* @__PURE__ */ bindComponent(cardStack);\n"
|
|
28
29
|
}
|
|
29
30
|
]
|
|
30
31
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chat-thread",
|
|
3
3
|
"title": "ChatThread",
|
|
4
|
-
"summary": "A messaging conversation: bubbles that spring in from either side, typing dots before replies, names, timestamps, reactions, images and auto-scroll.",
|
|
4
|
+
"summary": "A messaging conversation: bubbles that spring in from either side, typing dots before replies, names, avatars, timestamps, reactions, images and auto-scroll.",
|
|
5
5
|
"tags": [
|
|
6
6
|
"chat",
|
|
7
7
|
"messages",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"column",
|
|
17
17
|
"bubble-<n>",
|
|
18
18
|
"typing-<n>",
|
|
19
|
-
"reaction-<n>"
|
|
19
|
+
"reaction-<n>",
|
|
20
|
+
"avatar-<n>"
|
|
20
21
|
],
|
|
21
22
|
"cues": [
|
|
22
23
|
"messages",
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
"files": [
|
|
27
28
|
{
|
|
28
29
|
"path": "chat-thread/index.ts",
|
|
29
|
-
"content": "/**\n * ChatThread: a messaging conversation. Bubbles arrive from either side\n * with a spring, the other side types first (three bouncing dots), and the\n * thread scrolls to keep the newest message in view. Names, timestamps,\n * reactions and images included.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\trangeRects,\n\tspringKeys,\n\tz,\n} from \"framefields/components\";\n\nexport interface ChatThreadCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each message as it lands. */\n\tmessages: ReadonlyArray<{ frame: number; index: number; from: string }>;\n\t/** Each typing indicator, shown to removed. */\n\ttyping: ReadonlyArray<{ frame: number; end: number; from: string }>;\n}\n\nfunction messageSchema() {\n\treturn z\n\t\t.object({\n\t\t\tfrom: z.string(),\n\t\t\ttext: z.string().optional(),\n\t\t\t/** An image (path or URL) instead of, or above, text. */\n\t\t\timage: z.string().optional(),\n\t\t\t/** Image width / height. */\n\t\t\taspect: z\n\t\t\t\t.number()\n\t\t\t\t.positive()\n\t\t\t\t.default(4 / 3),\n\t\t\t/** Frame it lands. Default: after the previous one has been read. */\n\t\t\tat: z.number().int().min(0).optional(),\n\t\t\t/** A reaction pinned to the bubble (an emoji or short text). */\n\t\t\treaction: z.string().optional(),\n\t\t\t/** A small timestamp under the bubble. */\n\t\t\ttime: z.string().optional(),\n\t\t})\n\t\t.strict()\n\t\t.refine((m) => m.text !== undefined || m.image !== undefined, {\n\t\t\tmessage: \"A message needs `text` or `image`.\",\n\t\t});\n}\n\nconst SPRING = { stiffness: 260, damping: 20 };\n\nexport const chatThread = /* @__PURE__ */ defineComponent({\n\tname: \"ChatThread\",\n\tprops: () => ({\n\t\tmessages: z.array(messageSchema()).min(1),\n\t\t/** Who is \"me\" (right side, accent bubbles). Default the first sender. */\n\t\tme: z.string().optional(),\n\t\t/** Display names and bubble colours per sender. */\n\t\tparticipants: z\n\t\t\t.record(\n\t\t\t\tz.string(),\n\t\t\t\tz\n\t\t\t\t\t.object({ name: z.string().optional(), color: z.string().optional() })\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.default({}),\n\t\t/** Names above the first bubble of each run (default: when more than two people talk). */\n\t\tnames: z.boolean().optional(),\n\t\t/** Others type before they send. */\n\t\ttyping: z.boolean().default(true),\n\t\t/** Reading speed between messages, characters a second. */\n\t\treadCps: z.number().positive().default(28),\n\t\tfontFamily: z.string().optional(),\n\t\tfontSize: z.number().positive().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 720 * s;\n\t\tconst H = ctx.requested.height ?? 960 * s;\n\t\tconst fs = props.fontSize ?? 30 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.body;\n\t\tconst me = props.me ?? props.messages[0]?.from ?? \"me\";\n\t\tconst senders = [...new Set(props.messages.map((m) => m.from))];\n\t\tconst showNames = props.names ?? senders.length > 2;\n\t\tconst pad = fs * 0.8;\n\t\tconst padX = fs * 0.62;\n\t\tconst padY = fs * 0.42;\n\t\tconst maxW = W * 0.74;\n\t\tconst radius = fs * 0.9;\n\t\tconst smallFs = fs * 0.62;\n\n\t\t// ── Timing ────────────────────────────────────────────────────────\n\t\tconst landed: number[] = [];\n\t\tconst typingWindows: Array<{\n\t\t\tframe: number;\n\t\t\tend: number;\n\t\t\tfrom: string;\n\t\t\tindex: number;\n\t\t}> = [];\n\t\tlet t = Math.round(fps * 0.2);\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst prev = props.messages[i - 1];\n\t\t\tif (prev && m.at === undefined) {\n\t\t\t\tconst read = ((prev.text?.length ?? 12) / props.readCps) * fps;\n\t\t\t\tt += Math.round(Math.min(fps * 2.5, Math.max(fps * 0.5, read)));\n\t\t\t}\n\t\t\tlet at = m.at ?? t;\n\t\t\tif (props.typing && m.from !== me && m.at === undefined) {\n\t\t\t\tconst typingFor = Math.round(\n\t\t\t\t\tMath.min(2.2, Math.max(0.7, (m.text?.length ?? 10) / 14)) * fps,\n\t\t\t\t);\n\t\t\t\ttypingWindows.push({\n\t\t\t\t\tframe: at,\n\t\t\t\t\tend: at + typingFor,\n\t\t\t\t\tfrom: m.from,\n\t\t\t\t\tindex: i,\n\t\t\t\t});\n\t\t\t\tat += typingFor;\n\t\t\t}\n\t\t\tlanded.push(at);\n\t\t\tt = at;\n\t\t});\n\n\t\t// ── Layout (top to bottom) ────────────────────────────────────────\n\t\tconst measure = (text: string, size: number, width?: number) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\t...(width !== undefined && { width }),\n\t\t\t});\n\t\tinterface Slot {\n\t\t\tindex: number;\n\t\t\ty: number;\n\t\t\twidth: number;\n\t\t\theight: number;\n\t\t\tright: boolean;\n\t\t\tnameY?: number;\n\t\t\ttimeY?: number;\n\t\t\ttextH: number;\n\t\t\timageH: number;\n\t\t}\n\t\tconst slots: Slot[] = [];\n\t\tlet y = pad;\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst prev = props.messages[i - 1];\n\t\t\tconst right = m.from === me;\n\t\t\tconst newRun = !prev || prev.from !== m.from;\n\t\t\tif (prev) y += newRun ? fs * 0.75 : fs * 0.22;\n\t\t\tlet nameY: number | undefined;\n\t\t\tif (showNames && newRun && !right) {\n\t\t\t\tnameY = y;\n\t\t\t\ty += measure(\"Ag\", smallFs).height + fs * 0.12;\n\t\t\t}\n\t\t\tconst inner = maxW - padX * 2;\n\t\t\tlet textW = 0;\n\t\t\tlet textH = 0;\n\t\t\tif (m.text) {\n\t\t\t\tconst one = measure(m.text, fs);\n\t\t\t\tif (one.width <= inner) {\n\t\t\t\t\ttextW = one.width;\n\t\t\t\t\ttextH = one.height;\n\t\t\t\t} else {\n\t\t\t\t\tconst wrapped = measure(m.text, fs, inner);\n\t\t\t\t\ttextW = Math.max(\n\t\t\t\t\t\t...rangeRects(wrapped, 0, m.text.length, {\n\t\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\t\tfontSize: fs,\n\t\t\t\t\t\t\twidth: inner,\n\t\t\t\t\t\t}).map((r) => r.width),\n\t\t\t\t\t);\n\t\t\t\t\ttextH = wrapped.height;\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst imageW = m.image ? maxW : 0;\n\t\t\tconst imageH = m.image ? imageW / m.aspect : 0;\n\t\t\tconst width = m.image ? maxW : textW + padX * 2;\n\t\t\tconst height = imageH + (m.text ? textH + padY * 2 : 0);\n\t\t\tslots.push({ index: i, y, width, height, right, nameY, textH, imageH });\n\t\t\ty += height;\n\t\t\tif (m.time) {\n\t\t\t\t(slots[slots.length - 1] as Slot).timeY = y + fs * 0.12;\n\t\t\t\ty += measure(m.time, smallFs * 0.9).height + fs * 0.12;\n\t\t\t}\n\t\t});\n\n\t\t// ── Nodes ─────────────────────────────────────────────────────────\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst content: LayoutNode[] = [];\n\t\tconst colorOf = (from: string) =>\n\t\t\tprops.participants[from]?.color ??\n\t\t\t(from === me ? theme.color.accent : theme.color.raised);\n\t\tconst ink = (from: string) =>\n\t\t\tfrom === me && !props.participants[from]?.color\n\t\t\t\t? \"#ffffff\"\n\t\t\t\t: theme.color.ink;\n\t\tconst entry = (node: Animatable<LayoutNode>, at: number, baseY: number) =>\n\t\t\tnode.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"scale\", springKeys(at, 0.6, 1, fps, SPRING))\n\t\t\t\t\t.keys(\"y\", springKeys(at, baseY + fs * 0.5, baseY, fps, SPRING))\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[at, 0],\n\t\t\t\t\t\t[at + Math.max(2, Math.round(fps * 0.12)), 1, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t);\n\n\t\tslots.forEach((slot) => {\n\t\t\tconst m = props.messages[slot.index] as (typeof props.messages)[number];\n\t\t\tconst at = landed[slot.index] as number;\n\t\t\tconst x = slot.right ? W - pad - slot.width : pad;\n\t\t\tconst children: LayoutNode[] = [];\n\t\t\tif (m.image) {\n\t\t\t\tchildren.push(\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(`image-${slot.index}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: slot.width,\n\t\t\t\t\t\theight: slot.imageH,\n\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t\tborderRadius: radius,\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\thost.media(m.image, {\n\t\t\t\t\t\t\t\tid: ctx.partId(`media-${slot.index}`),\n\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\t\twidth: slot.width,\n\t\t\t\t\t\t\t\theight: slot.imageH,\n\t\t\t\t\t\t\t\tfit: \"cover\",\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t],\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (m.text)\n\t\t\t\tchildren.push(\n\t\t\t\t\thost.text(m.text, {\n\t\t\t\t\t\tid: ctx.partId(`text-${slot.index}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: padX,\n\t\t\t\t\t\ty: slot.imageH + padY,\n\t\t\t\t\t\twidth: slot.width - padX * 2 + 1,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: fs,\n\t\t\t\t\t\tfill: ink(m.from),\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\tconst bubble = host.box({\n\t\t\t\tid: ctx.partId(`bubble-${slot.index}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: slot.y,\n\t\t\t\twidth: slot.width,\n\t\t\t\theight: slot.height,\n\t\t\t\tbackground: m.image && !m.text ? \"rgba(0,0,0,0)\" : colorOf(m.from),\n\t\t\t\tborderRadius: radius,\n\t\t\t\t// Springs grow from the corner the bubble comes from.\n\t\t\t\tanchorX: slot.right ? 1 : 0,\n\t\t\t\tanchorY: 1,\n\t\t\t\tchildren,\n\t\t\t});\n\t\t\tentry(bubble, at, slot.y);\n\t\t\tcontent.push(bubble);\n\t\t\tparts[`bubble-${slot.index}`] = bubble;\n\n\t\t\tconst small = (\n\t\t\t\tid: string,\n\t\t\t\ttext: string,\n\t\t\t\tsize: number,\n\t\t\t\ttx: number,\n\t\t\t\tty: number,\n\t\t\t) => {\n\t\t\t\tconst node = host.text(text, {\n\t\t\t\t\tid: ctx.partId(id),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: tx,\n\t\t\t\t\ty: ty,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: size,\n\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t});\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[at, 0],\n\t\t\t\t\t\t[at + Math.round(fps * 0.25), 1, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tcontent.push(node);\n\t\t\t};\n\t\t\tif (slot.nameY !== undefined)\n\t\t\t\tsmall(\n\t\t\t\t\t`name-${slot.index}`,\n\t\t\t\t\tprops.participants[m.from]?.name ?? m.from,\n\t\t\t\t\tsmallFs,\n\t\t\t\t\tx + padX * 0.5,\n\t\t\t\t\tslot.nameY,\n\t\t\t\t);\n\t\t\tif (slot.timeY !== undefined && m.time) {\n\t\t\t\tconst tw = measure(m.time, smallFs * 0.9).width;\n\t\t\t\tsmall(\n\t\t\t\t\t`time-${slot.index}`,\n\t\t\t\t\tm.time,\n\t\t\t\t\tsmallFs * 0.9,\n\t\t\t\t\tslot.right ? x + slot.width - tw - padX * 0.3 : x + padX * 0.3,\n\t\t\t\t\tslot.timeY,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (m.reaction) {\n\t\t\t\t// Hearts are drawn (most fonts have no emoji); anything else is text.\n\t\t\t\tconst heart = /^(❤️?|♥️?|heart)$/u.test(m.reaction.trim());\n\t\t\t\tconst rm = measure(heart ? \"M\" : m.reaction, fs * 0.7);\n\t\t\t\tconst rh = rm.height + fs * 0.16;\n\t\t\t\tconst rw = heart ? rh * 1.3 : rm.width + fs * 0.5;\n\t\t\t\tconst rAt = at + Math.round(fps * 0.6);\n\t\t\t\tconst pill = host.box({\n\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: slot.right ? x - rw * 0.35 : x + slot.width - rw * 0.65,\n\t\t\t\t\ty: slot.y + slot.height - rh * 0.45,\n\t\t\t\t\twidth: rw,\n\t\t\t\t\theight: rh,\n\t\t\t\t\tbackground: theme.color.surface,\n\t\t\t\t\tborderRadius: rh / 2,\n\t\t\t\t\tborderColor: theme.color.bg,\n\t\t\t\t\tborderWidth: Math.max(2, fs * 0.08),\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\theart\n\t\t\t\t\t\t\t? host.shape(\"path\", {\n\t\t\t\t\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}-heart`),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: (rw - rh * 0.56) / 2,\n\t\t\t\t\t\t\t\t\ty: rh * 0.24,\n\t\t\t\t\t\t\t\t\twidth: rh * 0.56,\n\t\t\t\t\t\t\t\t\theight: rh * 0.52,\n\t\t\t\t\t\t\t\t\td: heartPath(rh * 0.56),\n\t\t\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\t\t\tfillColor: theme.color.negative,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t: host.text(m.reaction, {\n\t\t\t\t\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}-text`),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: fs * 0.25,\n\t\t\t\t\t\t\t\t\ty: fs * 0.08,\n\t\t\t\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\t\t\t\tfontSize: fs * 0.7,\n\t\t\t\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t],\n\t\t\t\t});\n\t\t\t\tpill.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\n\t\t\t\t\t\t\t\"scale\",\n\t\t\t\t\t\t\tspringKeys(rAt, 0, 1, fps, { stiffness: 320, damping: 14 }),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[rAt, 1, \"hold\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tcontent.push(pill);\n\t\t\t}\n\t\t});\n\n\t\t// Typing indicators sit where the message will land.\n\t\tconst dot = fs * 0.24;\n\t\ttypingWindows.forEach((w, k) => {\n\t\t\tconst slot = slots[w.index] as Slot;\n\t\t\tconst bw = dot * 3 + dot * 1.1 * 2 + padX * 2;\n\t\t\tconst bh = Math.max(fs + padY * 2 - fs * 0.3, dot + padY * 2);\n\t\t\tconst dots: LayoutNode[] = [0, 1, 2].map((d) => {\n\t\t\t\tconst node = host.box({\n\t\t\t\t\tid: ctx.partId(`typing-${k}-dot-${d}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: padX + d * dot * 2.1,\n\t\t\t\t\ty: (bh - dot) / 2,\n\t\t\t\t\twidth: dot,\n\t\t\t\t\theight: dot,\n\t\t\t\t\tbackground: theme.color.muted,\n\t\t\t\t\tborderRadius: dot / 2,\n\t\t\t\t});\n\t\t\t\t// A wave through the dots, one bounce every 0.6 s.\n\t\t\t\tconst period = Math.round(fps * 0.6);\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, (bh - dot) / 2]];\n\t\t\t\tfor (\n\t\t\t\t\tlet f = w.frame + d * Math.round(period / 5);\n\t\t\t\t\tf < w.end;\n\t\t\t\t\tf += period\n\t\t\t\t)\n\t\t\t\t\tkeys.push(\n\t\t\t\t\t\t[f, (bh - dot) / 2],\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\tf + Math.round(period / 4),\n\t\t\t\t\t\t\t(bh - dot) / 2 - dot * 0.7,\n\t\t\t\t\t\t\t\"sine.out\",\n\t\t\t\t\t\t],\n\t\t\t\t\t\t[f + Math.round(period / 2), (bh - dot) / 2, \"sine.in\"],\n\t\t\t\t\t);\n\t\t\t\tnode.animate(LayerAnimation.keys(\"y\", ascending(keys)));\n\t\t\t\treturn node;\n\t\t\t});\n\t\t\tconst bubble = host.box({\n\t\t\t\tid: ctx.partId(`typing-${k}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: slot.y,\n\t\t\t\twidth: bw,\n\t\t\t\theight: bh,\n\t\t\t\tbackground: colorOf(w.from),\n\t\t\t\tborderRadius: bh / 2,\n\t\t\t\tanchorX: 0,\n\t\t\t\tanchorY: 1,\n\t\t\t\tchildren: dots,\n\t\t\t});\n\t\t\tbubble.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"scale\", springKeys(w.frame, 0.5, 1, fps, SPRING))\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[w.frame, 1, \"hold\"],\n\t\t\t\t\t\t[w.end, 0, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(bubble);\n\t\t});\n\n\t\t// ── Scroll: keep the newest bubble (or typing dots) in view ───────\n\t\tconst events = [\n\t\t\t...slots.map((sl) => ({\n\t\t\t\tframe: landed[sl.index] as number,\n\t\t\t\tbottom: sl.y + sl.height + (sl.timeY !== undefined ? fs : 0),\n\t\t\t})),\n\t\t\t...typingWindows.map((w) => ({\n\t\t\t\tframe: w.frame,\n\t\t\t\tbottom: (slots[w.index] as Slot).y + fs * 1.6,\n\t\t\t})),\n\t\t].sort((a, b) => a.frame - b.frame);\n\t\tconst scroll: KeyframeTuple[] = [[0, 0]];\n\t\tlet top = 0;\n\t\tconst glide = Math.round(fps * 0.35);\n\t\tfor (const e of events) {\n\t\t\tconst want = Math.min(0, H - pad - e.bottom);\n\t\t\tif (want >= top) continue;\n\t\t\tconst last = scroll[scroll.length - 1] as KeyframeTuple;\n\t\t\tif (e.frame > last[0]) scroll.push([e.frame, top]);\n\t\t\tscroll.push([Math.max(e.frame, last[0]) + glide, want, \"power3.out\"]);\n\t\t\ttop = want;\n\t\t}\n\t\tconst column = host.box({\n\t\t\tid: ctx.partId(\"column\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: W,\n\t\t\theight: Math.max(H, y + pad),\n\t\t\tchildren: content,\n\t\t});\n\t\tcolumn.animate(LayerAnimation.keys(\"y\", ascending(scroll)));\n\t\tconst viewport = host.box({\n\t\t\tid: ctx.partId(\"viewport\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\toverflow: \"hidden\",\n\t\t\tchildren: [column],\n\t\t});\n\t\tparts.column = column;\n\t\tparts.viewport = viewport;\n\n\t\tconst lastAt = Math.max(...landed);\n\t\treturn {\n\t\t\tchildren: [viewport],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: lastAt + Math.round(fps * 0.8),\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\tmessages: landed.map((frame, index) => ({\n\t\t\t\t\tframe,\n\t\t\t\t\tindex,\n\t\t\t\t\tfrom: props.messages[index]?.from ?? \"\",\n\t\t\t\t})),\n\t\t\t\ttyping: typingWindows.map(({ frame, end, from }) => ({\n\t\t\t\t\tframe,\n\t\t\t\t\tend,\n\t\t\t\t\tfrom,\n\t\t\t\t})),\n\t\t\t} as ChatThreadCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** A heart `size` wide, top-left at 0, 0 (two lobes and a point). */\nfunction heartPath(size: number): string {\n\tconst w = size;\n\tconst h = size * 0.92;\n\treturn [\n\t\t`M${w / 2} ${h}`,\n\t\t`C${w * 0.18} ${h * 0.72} 0 ${h * 0.5} 0 ${h * 0.28}`,\n\t\t`C0 ${h * 0.1} ${w * 0.14} 0 ${w * 0.28} 0`,\n\t\t`C${w * 0.39} 0 ${w * 0.47} ${h * 0.07} ${w / 2} ${h * 0.16}`,\n\t\t`C${w * 0.53} ${h * 0.07} ${w * 0.61} 0 ${w * 0.72} 0`,\n\t\t`C${w * 0.86} 0 ${w} ${h * 0.1} ${w} ${h * 0.28}`,\n\t\t`C${w} ${h * 0.5} ${w * 0.82} ${h * 0.72} ${w / 2} ${h} Z`,\n\t].join(\" \");\n}\n\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst out: KeyframeTuple[] = [];\n\tfor (const k of [...keys].sort((a, b) => a[0] - b[0])) {\n\t\twhile (out.length > 0 && (out[out.length - 1] as KeyframeTuple)[0] >= k[0])\n\t\t\tout.pop();\n\t\tout.push(k);\n\t}\n\treturn out;\n}\n\n/**\n * A messaging conversation: bubbles that spring in from either side, typing\n * dots before replies, names, timestamps, reactions, images and auto-scroll.\n */\nexport const ChatThread = /* @__PURE__ */ bindComponent(chatThread);\n"
|
|
30
|
+
"content": "/**\n * ChatThread: a messaging conversation. Bubbles arrive from either side\n * with a spring, the other side types first (three bouncing dots), and the\n * thread scrolls to keep the newest message in view. Names, avatars,\n * timestamps, reactions and images included.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\trangeRects,\n\tspringKeys,\n\tz,\n} from \"framefields/components\";\n\nexport interface ChatThreadCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each message as it lands. */\n\tmessages: ReadonlyArray<{ frame: number; index: number; from: string }>;\n\t/** Each typing indicator, shown to removed. */\n\ttyping: ReadonlyArray<{ frame: number; end: number; from: string }>;\n}\n\nfunction messageSchema() {\n\treturn z\n\t\t.object({\n\t\t\tfrom: z.string(),\n\t\t\ttext: z.string().optional(),\n\t\t\t/** An image (path or URL) instead of, or above, text. */\n\t\t\timage: z.string().optional(),\n\t\t\t/** Image width / height. */\n\t\t\taspect: z\n\t\t\t\t.number()\n\t\t\t\t.positive()\n\t\t\t\t.default(4 / 3),\n\t\t\t/** Frame it lands. Default: after the previous one has been read. */\n\t\t\tat: z.number().int().min(0).optional(),\n\t\t\t/** A reaction pinned to the bubble (an emoji or short text). */\n\t\t\treaction: z.string().optional(),\n\t\t\t/** A small timestamp under the bubble. */\n\t\t\ttime: z.string().optional(),\n\t\t})\n\t\t.strict()\n\t\t.refine((m) => m.text !== undefined || m.image !== undefined, {\n\t\t\tmessage: \"A message needs `text` or `image`.\",\n\t\t});\n}\n\nconst SPRING = { stiffness: 260, damping: 20 };\n/** Livelier, for reactions. */\nconst POP = { stiffness: 320, damping: 14 };\n\nexport const chatThread = /* @__PURE__ */ defineComponent({\n\tname: \"ChatThread\",\n\tprops: () => ({\n\t\tmessages: z.array(messageSchema()).min(1),\n\t\t/** Who is \"me\" (right side, accent bubbles). Default the first sender. */\n\t\tme: z.string().optional(),\n\t\t/** Display names and bubble colours per sender. */\n\t\tparticipants: z\n\t\t\t.record(\n\t\t\t\tz.string(),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\tname: z.string().optional(),\n\t\t\t\t\t\tcolor: z.string().optional(),\n\t\t\t\t\t\t/** A picture (path or URL); without one the avatar shows initials. */\n\t\t\t\t\t\tavatar: z.string().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.default({}),\n\t\t/**\n\t\t * Avatars beside each run of bubbles, at its newest one. Default:\n\t\t * `\"others\"` when any participant has an `avatar`, else `\"none\"`.\n\t\t */\n\t\tavatars: z.enum([\"others\", \"everyone\", \"none\"]).optional(),\n\t\t/** Names above the first bubble of each run (default: when more than two people talk). */\n\t\tnames: z.boolean().optional(),\n\t\t/** Others type before they send. */\n\t\ttyping: z.boolean().default(true),\n\t\t/** Reading speed between messages, characters a second. */\n\t\treadCps: z.number().positive().default(28),\n\t\tfontFamily: z.string().optional(),\n\t\tfontSize: z.number().positive().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 720 * s;\n\t\tconst H = ctx.requested.height ?? 960 * s;\n\t\tconst fs = props.fontSize ?? 30 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.body;\n\t\tconst me = props.me ?? props.messages[0]?.from ?? \"me\";\n\t\tconst senders = [...new Set(props.messages.map((m) => m.from))];\n\t\tconst showNames = props.names ?? senders.length > 2;\n\t\tconst avatars =\n\t\t\tprops.avatars ??\n\t\t\t(Object.values(props.participants).some((p) => p.avatar)\n\t\t\t\t? \"others\"\n\t\t\t\t: \"none\");\n\t\tconst hasAvatar = (from: string) =>\n\t\t\tavatars === \"everyone\" || (avatars === \"others\" && from !== me);\n\t\tconst pad = fs * 0.8;\n\t\tconst padX = fs * 0.62;\n\t\tconst padY = fs * 0.42;\n\t\tconst avatarSize = fs * 1.5;\n\t\t// Bubbles on a side with avatars move in by this much.\n\t\tconst inset = (from: string) =>\n\t\t\thasAvatar(from) ? avatarSize + fs * 0.3 : 0;\n\t\tconst maxW = Math.min(\n\t\t\tW * 0.74,\n\t\t\tW - pad * 2 - Math.max(...senders.map(inset)),\n\t\t);\n\t\tconst radius = fs * 0.9;\n\t\tconst smallFs = fs * 0.62;\n\n\t\t// ── Timing ────────────────────────────────────────────────────────\n\t\tconst landed: number[] = [];\n\t\tconst typingWindows: Array<{\n\t\t\tframe: number;\n\t\t\tend: number;\n\t\t\tfrom: string;\n\t\t\tindex: number;\n\t\t}> = [];\n\t\tlet t = Math.round(fps * 0.2);\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst prev = props.messages[i - 1];\n\t\t\tif (prev && m.at === undefined) {\n\t\t\t\tconst read = ((prev.text?.length ?? 12) / props.readCps) * fps;\n\t\t\t\tt += Math.round(Math.min(fps * 2.5, Math.max(fps * 0.5, read)));\n\t\t\t}\n\t\t\tlet at = m.at ?? t;\n\t\t\tif (props.typing && m.from !== me && m.at === undefined) {\n\t\t\t\tconst typingFor = Math.round(\n\t\t\t\t\tMath.min(2.2, Math.max(0.7, (m.text?.length ?? 10) / 14)) * fps,\n\t\t\t\t);\n\t\t\t\ttypingWindows.push({\n\t\t\t\t\tframe: at,\n\t\t\t\t\tend: at + typingFor,\n\t\t\t\t\tfrom: m.from,\n\t\t\t\t\tindex: i,\n\t\t\t\t});\n\t\t\t\tat += typingFor;\n\t\t\t}\n\t\t\tlanded.push(at);\n\t\t\tt = at;\n\t\t});\n\n\t\t// ── Layout (top to bottom) ────────────────────────────────────────\n\t\tconst measure = (text: string, size: number, width?: number) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\t...(width !== undefined && { width }),\n\t\t\t});\n\t\tinterface Slot {\n\t\t\tindex: number;\n\t\t\ty: number;\n\t\t\twidth: number;\n\t\t\theight: number;\n\t\t\tright: boolean;\n\t\t\tnameY?: number;\n\t\t\ttimeY?: number;\n\t\t\ttextH: number;\n\t\t\timageH: number;\n\t\t}\n\t\tconst slots: Slot[] = [];\n\t\tlet y = pad;\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst prev = props.messages[i - 1];\n\t\t\tconst right = m.from === me;\n\t\t\tconst newRun = !prev || prev.from !== m.from;\n\t\t\tif (prev) y += newRun ? fs * 0.75 : fs * 0.22;\n\t\t\t// A reaction sits on the bubble's top corner; leave it room.\n\t\t\tif (prev && m.reaction) y += fs * 0.5;\n\t\t\tlet nameY: number | undefined;\n\t\t\tif (showNames && newRun && !right) {\n\t\t\t\tnameY = y;\n\t\t\t\ty += measure(\"Ag\", smallFs).height + fs * 0.12;\n\t\t\t}\n\t\t\tconst inner = maxW - padX * 2;\n\t\t\tlet textW = 0;\n\t\t\tlet textH = 0;\n\t\t\tif (m.text) {\n\t\t\t\tconst one = measure(m.text, fs);\n\t\t\t\tif (one.width <= inner) {\n\t\t\t\t\ttextW = one.width;\n\t\t\t\t\ttextH = one.height;\n\t\t\t\t} else {\n\t\t\t\t\tconst wrapped = measure(m.text, fs, inner);\n\t\t\t\t\ttextW = Math.max(\n\t\t\t\t\t\t...rangeRects(wrapped, 0, m.text.length, {\n\t\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\t\tfontSize: fs,\n\t\t\t\t\t\t\twidth: inner,\n\t\t\t\t\t\t}).map((r) => r.width),\n\t\t\t\t\t);\n\t\t\t\t\ttextH = wrapped.height;\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst imageW = m.image ? maxW : 0;\n\t\t\tconst imageH = m.image ? imageW / m.aspect : 0;\n\t\t\tconst width = m.image ? maxW : textW + padX * 2;\n\t\t\tconst height = imageH + (m.text ? textH + padY * 2 : 0);\n\t\t\tslots.push({ index: i, y, width, height, right, nameY, textH, imageH });\n\t\t\ty += height;\n\t\t\tif (m.time) {\n\t\t\t\t(slots[slots.length - 1] as Slot).timeY = y + fs * 0.12;\n\t\t\t\ty += measure(m.time, smallFs * 0.9).height + fs * 0.12;\n\t\t\t}\n\t\t});\n\n\t\t// ── Nodes ─────────────────────────────────────────────────────────\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst content: LayoutNode[] = [];\n\t\tconst colorOf = (from: string) =>\n\t\t\tprops.participants[from]?.color ??\n\t\t\t(from === me ? theme.color.accent : theme.color.raised);\n\t\tconst ink = (from: string) =>\n\t\t\tfrom === me && !props.participants[from]?.color\n\t\t\t\t? \"#ffffff\"\n\t\t\t\t: theme.color.ink;\n\t\tconst fade = Math.max(2, Math.round(fps * 0.12));\n\t\t// The typing bubble: three dots in a pill, where the message will land.\n\t\tconst dot = fs * 0.24;\n\t\tconst typingW = dot * 3 + dot * 1.1 * 2 + padX * 2;\n\t\tconst typingH = Math.max(fs + padY * 2 - fs * 0.3, dot + padY * 2);\n\t\tconst typingOf = (index: number) =>\n\t\t\ttypingWindows.find((w) => w.index === index);\n\n\t\tslots.forEach((slot) => {\n\t\t\tconst m = props.messages[slot.index] as (typeof props.messages)[number];\n\t\t\tconst at = landed[slot.index] as number;\n\t\t\tconst x = slot.right\n\t\t\t\t? W - pad - inset(m.from) - slot.width\n\t\t\t\t: pad + inset(m.from);\n\t\t\tconst children: LayoutNode[] = [];\n\t\t\tif (m.image) {\n\t\t\t\tchildren.push(\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(`image-${slot.index}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: slot.width,\n\t\t\t\t\t\theight: slot.imageH,\n\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t\tborderRadius: radius,\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\thost.media(m.image, {\n\t\t\t\t\t\t\t\tid: ctx.partId(`media-${slot.index}`),\n\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\t\twidth: slot.width,\n\t\t\t\t\t\t\t\theight: slot.imageH,\n\t\t\t\t\t\t\t\tfit: \"cover\",\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t],\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst typed = typingOf(slot.index);\n\t\t\t// A typed text reply grows out of its typing bubble: same place,\n\t\t\t// same colour, so the dots become the message.\n\t\t\tconst morph = typed !== undefined && !m.image;\n\t\t\tif (m.text) {\n\t\t\t\tconst ty = slot.imageH + padY;\n\t\t\t\tconst text = host.text(m.text, {\n\t\t\t\t\tid: ctx.partId(`text-${slot.index}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: padX,\n\t\t\t\t\ty: ty,\n\t\t\t\t\twidth: slot.width - padX * 2 + 1,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: fs,\n\t\t\t\t\tfill: ink(m.from),\n\t\t\t\t});\n\t\t\t\t// The words settle in just after their bubble opens.\n\t\t\t\tconst t0 = at + (morph ? Math.round(fps * 0.12) : 1);\n\t\t\t\ttext.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[t0, 0],\n\t\t\t\t\t\t\t[t0 + fade, 1, \"power2.out\"],\n\t\t\t\t\t\t])\n\t\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t\t[0, ty + fs * 0.2],\n\t\t\t\t\t\t\t[t0, ty + fs * 0.2],\n\t\t\t\t\t\t\t[t0 + Math.round(fps * 0.3), ty, \"power3.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tchildren.push(text);\n\t\t\t}\n\t\t\tconst bubble = host.box({\n\t\t\t\tid: ctx.partId(`bubble-${slot.index}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: slot.y,\n\t\t\t\twidth: slot.width,\n\t\t\t\theight: slot.height,\n\t\t\t\tbackground: m.image && !m.text ? \"rgba(0,0,0,0)\" : colorOf(m.from),\n\t\t\t\tborderRadius: radius,\n\t\t\t\t// Springs grow from the corner the bubble comes from.\n\t\t\t\tanchorX: slot.right ? 1 : 0,\n\t\t\t\tanchorY: 1,\n\t\t\t\tchildren,\n\t\t\t});\n\t\t\tif (morph)\n\t\t\t\tbubble.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"width\", springKeys(at, typingW, slot.width, fps, SPRING))\n\t\t\t\t\t\t.keys(\"height\", springKeys(at, typingH, slot.height, fps, SPRING))\n\t\t\t\t\t\t.keys(\n\t\t\t\t\t\t\t\"borderRadius\",\n\t\t\t\t\t\t\tspringKeys(at, typingH / 2, radius, fps, SPRING),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[at, 1, \"hold\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\telse {\n\t\t\t\t// Sent bubbles rise from below, as if from the compose field;\n\t\t\t\t// received ones pop from their corner.\n\t\t\t\tconst [scale, lift] = slot.right ? [0.86, fs * 1.1] : [0.6, fs * 0.5];\n\t\t\t\tbubble.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"scale\", springKeys(at, scale, 1, fps, SPRING))\n\t\t\t\t\t\t.keys(\"y\", springKeys(at, slot.y + lift, slot.y, fps, SPRING))\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[at, 0],\n\t\t\t\t\t\t\t[at + fade, 1, \"power2.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t}\n\t\t\tcontent.push(bubble);\n\t\t\tparts[`bubble-${slot.index}`] = bubble;\n\n\t\t\tconst small = (\n\t\t\t\tid: string,\n\t\t\t\ttext: string,\n\t\t\t\tsize: number,\n\t\t\t\ttx: number,\n\t\t\t\tty: number,\n\t\t\t\tfrom = at,\n\t\t\t) => {\n\t\t\t\tconst node = host.text(text, {\n\t\t\t\t\tid: ctx.partId(id),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: tx,\n\t\t\t\t\ty: ty,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: size,\n\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t});\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[from, 0],\n\t\t\t\t\t\t[from + Math.round(fps * 0.25), 1, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tcontent.push(node);\n\t\t\t};\n\t\t\tif (slot.nameY !== undefined)\n\t\t\t\tsmall(\n\t\t\t\t\t`name-${slot.index}`,\n\t\t\t\t\tprops.participants[m.from]?.name ?? m.from,\n\t\t\t\t\tsmallFs,\n\t\t\t\t\tx + padX * 0.5,\n\t\t\t\t\tslot.nameY,\n\t\t\t\t\t// With the run's typing dots, when it starts with them.\n\t\t\t\t\ttyped?.frame ?? at,\n\t\t\t\t);\n\t\t\tif (slot.timeY !== undefined && m.time) {\n\t\t\t\tconst tw = measure(m.time, smallFs * 0.9).width;\n\t\t\t\tsmall(\n\t\t\t\t\t`time-${slot.index}`,\n\t\t\t\t\tm.time,\n\t\t\t\t\tsmallFs * 0.9,\n\t\t\t\t\tslot.right ? x + slot.width - tw - padX * 0.3 : x + padX * 0.3,\n\t\t\t\t\tslot.timeY,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (m.reaction) {\n\t\t\t\t// Hearts are drawn, so they show without an emoji font; anything\n\t\t\t\t// else is text (emoji need one registered).\n\t\t\t\tconst heart = /^(❤️?|♥️?|heart)$/u.test(m.reaction.trim());\n\t\t\t\tconst rfs = fs * 0.9;\n\t\t\t\tconst rm = measure(m.reaction, rfs);\n\t\t\t\tconst rh = fs * 1.5;\n\t\t\t\tconst rw = heart ? rh : Math.max(rh, rm.width + fs * 0.6);\n\t\t\t\tconst rAt = at + Math.round(fps * 0.6);\n\t\t\t\tconst border = Math.max(2, fs * 0.1);\n\t\t\t\tconst hs = rh * 0.46;\n\t\t\t\t// On the outer top corner, like a tapback.\n\t\t\t\tconst pill = host.box({\n\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: slot.right ? x - rw * 0.45 : x + slot.width - rw * 0.55,\n\t\t\t\t\ty: slot.y - rh * 0.6,\n\t\t\t\t\twidth: rw,\n\t\t\t\t\theight: rh,\n\t\t\t\t\tbackground: theme.color.raised,\n\t\t\t\t\tborderRadius: rh / 2,\n\t\t\t\t\tborderColor: theme.color.bg,\n\t\t\t\t\tborderWidth: border,\n\t\t\t\t\tanchorX: 0.5,\n\t\t\t\t\tanchorY: 1,\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\theart\n\t\t\t\t\t\t\t? host.shape(\"path\", {\n\t\t\t\t\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}-heart`),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: (rw - hs) / 2,\n\t\t\t\t\t\t\t\t\ty: (rh - hs * 0.92) / 2 + hs * 0.04,\n\t\t\t\t\t\t\t\t\twidth: hs,\n\t\t\t\t\t\t\t\t\theight: hs * 0.92,\n\t\t\t\t\t\t\t\t\td: heartPath(hs),\n\t\t\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\t\t\tfillColor: theme.color.negative,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t: host.text(m.reaction, {\n\t\t\t\t\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}-text`),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: (rw - rm.width) / 2,\n\t\t\t\t\t\t\t\t\ty: (rh - rm.height) / 2,\n\t\t\t\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\t\t\t\tfontSize: rfs,\n\t\t\t\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t],\n\t\t\t\t});\n\t\t\t\tpill.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"scale\", springKeys(rAt, 0, 1, fps, POP))\n\t\t\t\t\t\t.keys(\"rotation\", springKeys(rAt, -24, 0, fps, POP))\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[rAt, 1, \"hold\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tcontent.push(pill);\n\t\t\t}\n\t\t});\n\n\t\t// Typing indicators sit where the message will land.\n\t\ttypingWindows.forEach((w, k) => {\n\t\t\tconst slot = slots[w.index] as Slot;\n\t\t\tconst bw = typingW;\n\t\t\tconst bh = typingH;\n\t\t\tconst dots: LayoutNode[] = [0, 1, 2].map((d) => {\n\t\t\t\tconst node = host.box({\n\t\t\t\t\tid: ctx.partId(`typing-${k}-dot-${d}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: padX + d * dot * 2.1,\n\t\t\t\t\ty: (bh - dot) / 2,\n\t\t\t\t\twidth: dot,\n\t\t\t\t\theight: dot,\n\t\t\t\t\tbackground: theme.color.muted,\n\t\t\t\t\tborderRadius: dot / 2,\n\t\t\t\t});\n\t\t\t\t// A wave through the dots, one bounce every 0.6 s.\n\t\t\t\tconst period = Math.round(fps * 0.6);\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, (bh - dot) / 2]];\n\t\t\t\tconst glow: KeyframeTuple[] = [[0, 0.45]];\n\t\t\t\tfor (\n\t\t\t\t\tlet f = w.frame + d * Math.round(period / 5);\n\t\t\t\t\tf < w.end;\n\t\t\t\t\tf += period\n\t\t\t\t) {\n\t\t\t\t\tkeys.push(\n\t\t\t\t\t\t[f, (bh - dot) / 2],\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\tf + Math.round(period / 4),\n\t\t\t\t\t\t\t(bh - dot) / 2 - dot * 0.7,\n\t\t\t\t\t\t\t\"sine.out\",\n\t\t\t\t\t\t],\n\t\t\t\t\t\t[f + Math.round(period / 2), (bh - dot) / 2, \"sine.in\"],\n\t\t\t\t\t);\n\t\t\t\t\t// Each dot brightens at the top of its bounce.\n\t\t\t\t\tglow.push(\n\t\t\t\t\t\t[f, 0.45],\n\t\t\t\t\t\t[f + Math.round(period / 4), 1, \"sine.out\"],\n\t\t\t\t\t\t[f + Math.round(period / 2), 0.45, \"sine.in\"],\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"y\", ascending(keys))\n\t\t\t\t\t\t.keys(\"opacity\", ascending(glow)),\n\t\t\t\t);\n\t\t\t\treturn node;\n\t\t\t});\n\t\t\tconst bubble = host.box({\n\t\t\t\tid: ctx.partId(`typing-${k}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad + inset(w.from),\n\t\t\t\ty: slot.y,\n\t\t\t\twidth: bw,\n\t\t\t\theight: bh,\n\t\t\t\tbackground: colorOf(w.from),\n\t\t\t\tborderRadius: bh / 2,\n\t\t\t\tanchorX: 0,\n\t\t\t\tanchorY: 1,\n\t\t\t\tchildren: dots,\n\t\t\t});\n\t\t\tbubble.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"scale\", springKeys(w.frame, 0.5, 1, fps, SPRING))\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[w.frame, 1, \"hold\"],\n\t\t\t\t\t\t[w.end, 0, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(bubble);\n\t\t});\n\n\t\tconst avatar = (\n\t\t\tfrom: string,\n\t\t\tid: string,\n\t\t\tx: number,\n\t\t\ty: number,\n\t\t): Animatable<LayoutNode> => {\n\t\t\tconst p = props.participants[from];\n\t\t\tconst size = avatarSize;\n\t\t\tconst base = {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\" as const,\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\twidth: size,\n\t\t\t\theight: size,\n\t\t\t\tborderRadius: size / 2,\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: 1,\n\t\t\t};\n\t\t\tif (p?.avatar)\n\t\t\t\treturn host.box({\n\t\t\t\t\t...base,\n\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\tbackground: theme.color.raised,\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\thost.media(p.avatar, {\n\t\t\t\t\t\t\tid: ctx.partId(`${id}-image`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: size,\n\t\t\t\t\t\t\theight: size,\n\t\t\t\t\t\t\tfit: \"cover\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t],\n\t\t\t\t});\n\t\t\tconst letters = initials(p?.name ?? from);\n\t\t\tconst ifs = size * (letters.length > 1 ? 0.38 : 0.44);\n\t\t\tconst m = host.measure(letters, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: ifs,\n\t\t\t\tfontWeight: 600,\n\t\t\t});\n\t\t\treturn host.box({\n\t\t\t\t...base,\n\t\t\t\tbackground: colorOf(from),\n\t\t\t\tchildren: [\n\t\t\t\t\thost.text(letters, {\n\t\t\t\t\t\tid: ctx.partId(`${id}-initials`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: (size - m.width) / 2,\n\t\t\t\t\t\ty: (size - m.height) / 2,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: ifs,\n\t\t\t\t\t\tfontWeight: 600,\n\t\t\t\t\t\tfill: ink(from),\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\t\t};\n\n\t\t// One avatar per run of bubbles: it arrives with the run's typing dots\n\t\t// or first bubble and moves down to each newer bubble, iMessage style.\n\t\tconst runs: number[][] = [];\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst run = runs[runs.length - 1];\n\t\t\tif (run && props.messages[run[0] as number]?.from === m.from) run.push(i);\n\t\t\telse runs.push([i]);\n\t\t});\n\t\truns.forEach((run) => {\n\t\t\tconst from = props.messages[run[0] as number]?.from ?? \"\";\n\t\t\tif (!hasAvatar(from)) return;\n\t\t\tconst r = run[0] as number;\n\t\t\tconst stops: Array<[number, number]> = [];\n\t\t\tfor (const i of run) {\n\t\t\t\tconst slot = slots[i] as Slot;\n\t\t\t\tconst w = typingWindows.find((tw) => tw.index === i);\n\t\t\t\tif (w) stops.push([w.frame, slot.y + typingH - avatarSize]);\n\t\t\t\tstops.push([landed[i] as number, slot.y + slot.height - avatarSize]);\n\t\t\t}\n\t\t\tconst [start, startY] = stops[0] as [number, number];\n\t\t\t// Same spring as the bubbles, so it stays level with their bottoms.\n\t\t\tconst y: KeyframeTuple[] = [[0, startY]];\n\t\t\tlet current = startY;\n\t\t\tfor (const [f, to] of stops.slice(1)) {\n\t\t\t\tif (to === current) continue;\n\t\t\t\ty.push(...springKeys(f, current, to, fps, SPRING));\n\t\t\t\tcurrent = to;\n\t\t\t}\n\t\t\tconst node = avatar(\n\t\t\t\tfrom,\n\t\t\t\t`avatar-${r}`,\n\t\t\t\tfrom === me ? W - pad - avatarSize : pad,\n\t\t\t\tstartY,\n\t\t\t);\n\t\t\tnode.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"y\", ascending(y))\n\t\t\t\t\t.keys(\"scale\", springKeys(start, 0.4, 1, fps, SPRING))\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[start, 0],\n\t\t\t\t\t\t[start + Math.max(2, Math.round(fps * 0.12)), 1, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(node);\n\t\t\tparts[`avatar-${r}`] = node;\n\t\t});\n\n\t\t// ── Scroll: keep the newest bubble (or typing dots) in view ───────\n\t\tconst events = [\n\t\t\t...slots.map((sl) => ({\n\t\t\t\tframe: landed[sl.index] as number,\n\t\t\t\tbottom: sl.y + sl.height + (sl.timeY !== undefined ? fs : 0),\n\t\t\t})),\n\t\t\t...typingWindows.map((w) => ({\n\t\t\t\tframe: w.frame,\n\t\t\t\tbottom: (slots[w.index] as Slot).y + fs * 1.6,\n\t\t\t})),\n\t\t].sort((a, b) => a.frame - b.frame);\n\t\tconst scroll: KeyframeTuple[] = [[0, 0]];\n\t\tlet top = 0;\n\t\tconst glide = Math.round(fps * 0.35);\n\t\tfor (const e of events) {\n\t\t\tconst want = Math.min(0, H - pad - e.bottom);\n\t\t\tif (want >= top) continue;\n\t\t\tconst last = scroll[scroll.length - 1] as KeyframeTuple;\n\t\t\tif (e.frame > last[0]) scroll.push([e.frame, top]);\n\t\t\tscroll.push([Math.max(e.frame, last[0]) + glide, want, \"power3.out\"]);\n\t\t\ttop = want;\n\t\t}\n\t\tconst column = host.box({\n\t\t\tid: ctx.partId(\"column\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: W,\n\t\t\theight: Math.max(H, y + pad),\n\t\t\tchildren: content,\n\t\t});\n\t\tcolumn.animate(LayerAnimation.keys(\"y\", ascending(scroll)));\n\t\tconst viewport = host.box({\n\t\t\tid: ctx.partId(\"viewport\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\toverflow: \"hidden\",\n\t\t\tchildren: [column],\n\t\t});\n\t\tparts.column = column;\n\t\tparts.viewport = viewport;\n\n\t\tconst lastAt = Math.max(...landed);\n\t\treturn {\n\t\t\tchildren: [viewport],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: lastAt + Math.round(fps * 0.8),\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\tmessages: landed.map((frame, index) => ({\n\t\t\t\t\tframe,\n\t\t\t\t\tindex,\n\t\t\t\t\tfrom: props.messages[index]?.from ?? \"\",\n\t\t\t\t})),\n\t\t\t\ttyping: typingWindows.map(({ frame, end, from }) => ({\n\t\t\t\t\tframe,\n\t\t\t\t\tend,\n\t\t\t\t\tfrom,\n\t\t\t\t})),\n\t\t\t} as ChatThreadCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** \"Ana Lima\" → \"AL\", \"kai\" → \"K\". */\nfunction initials(name: string): string {\n\tconst words = name.trim().split(/\\s+/u).filter(Boolean);\n\tconst first = (w: string | undefined) => [...(w ?? \"\")][0] ?? \"\";\n\treturn (\n\t\tfirst(words[0]) + (words.length > 1 ? first(words[words.length - 1]) : \"\")\n\t).toUpperCase();\n}\n\n/** A heart `size` wide, top-left at 0, 0 (two lobes and a point). */\nfunction heartPath(size: number): string {\n\tconst w = size;\n\tconst h = size * 0.92;\n\treturn [\n\t\t`M${w / 2} ${h}`,\n\t\t`C${w * 0.18} ${h * 0.72} 0 ${h * 0.5} 0 ${h * 0.28}`,\n\t\t`C0 ${h * 0.1} ${w * 0.14} 0 ${w * 0.28} 0`,\n\t\t`C${w * 0.39} 0 ${w * 0.47} ${h * 0.07} ${w / 2} ${h * 0.16}`,\n\t\t`C${w * 0.53} ${h * 0.07} ${w * 0.61} 0 ${w * 0.72} 0`,\n\t\t`C${w * 0.86} 0 ${w} ${h * 0.1} ${w} ${h * 0.28}`,\n\t\t`C${w} ${h * 0.5} ${w * 0.82} ${h * 0.72} ${w / 2} ${h} Z`,\n\t].join(\" \");\n}\n\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst out: KeyframeTuple[] = [];\n\tfor (const k of [...keys].sort((a, b) => a[0] - b[0])) {\n\t\twhile (out.length > 0 && (out[out.length - 1] as KeyframeTuple)[0] >= k[0])\n\t\t\tout.pop();\n\t\tout.push(k);\n\t}\n\treturn out;\n}\n\n/**\n * A messaging conversation: bubbles that spring in from either side, typing\n * dots before replies, names, avatars, timestamps, reactions, images and\n * auto-scroll.\n */\nexport const ChatThread = /* @__PURE__ */ bindComponent(chatThread);\n"
|
|
30
31
|
}
|
|
31
32
|
]
|
|
32
33
|
}
|
package/dist/registry/index.json
CHANGED
|
@@ -93,12 +93,13 @@
|
|
|
93
93
|
{
|
|
94
94
|
"name": "card-stack",
|
|
95
95
|
"title": "CardStack",
|
|
96
|
-
"summary": "Cards in a depth stack that shuffle: the front card flies
|
|
96
|
+
"summary": "Cards in a depth stack that shuffle: the front card flies, drops, zooms or flips away and returns to the back; cards are dealt onto the pile; or the stack fans out into a hand.",
|
|
97
97
|
"tags": [
|
|
98
98
|
"cards",
|
|
99
99
|
"stack",
|
|
100
100
|
"deck",
|
|
101
101
|
"carousel",
|
|
102
|
+
"fan",
|
|
102
103
|
"testimonials",
|
|
103
104
|
"features",
|
|
104
105
|
"3d"
|
|
@@ -109,7 +110,7 @@
|
|
|
109
110
|
{
|
|
110
111
|
"name": "chat-thread",
|
|
111
112
|
"title": "ChatThread",
|
|
112
|
-
"summary": "A messaging conversation: bubbles that spring in from either side, typing dots before replies, names, timestamps, reactions, images and auto-scroll.",
|
|
113
|
+
"summary": "A messaging conversation: bubbles that spring in from either side, typing dots before replies, names, avatars, timestamps, reactions, images and auto-scroll.",
|
|
113
114
|
"tags": [
|
|
114
115
|
"chat",
|
|
115
116
|
"messages",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"files": [
|
|
29
29
|
{
|
|
30
30
|
"path": "lower-third/index.ts",
|
|
31
|
-
"content": "/**\n * LowerThird: the name-and-role bar of interviews and talks. Plates are\n * measured to their text, placed in the safe area's bottom corner, slide in\n * through their own box, and leave on their own after `hold`.\n *\n * Coordinates are the frame's: the root covers the video (default\n * 1920 × 1080 × theme scale, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\ttype Rect,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport const LOWER_THIRD_LAYOUTS = [\"bar\", \"stacked\", \"tag\", \"line\"] as const;\nexport type LowerThirdLayout = (typeof LOWER_THIRD_LAYOUTS)[number];\n\nexport interface LowerThirdCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Fully on screen. */\n\tin: ReadonlyArray<{ frame: number }>;\n\t/** Starts to leave (empty with `stay`). */\n\tout: ReadonlyArray<{ frame: number }>;\n}\n\nfunction safeArea(\n\tW: number,\n\tH: number,\n\tarea: \"portrait\" | \"landscape\" | Rect | undefined,\n): Rect {\n\tif (area && typeof area === \"object\") return area;\n\tconst portrait = area ? area === \"portrait\" : H > W;\n\treturn portrait\n\t\t? { x: W * 0.08, y: H * 0.1, width: W * 0.84, height: H * 0.66 }\n\t\t: { x: W * 0.06, y: H * 0.08, width: W * 0.88, height: H * 0.82 };\n}\n\nexport const lowerThird = /* @__PURE__ */ defineComponent({\n\tname: \"LowerThird\",\n\tprops: () => ({\n\t\tname: z.string().min(1),\n\t\trole: z.string().optional(),\n\t\tlayout: z.enum(LOWER_THIRD_LAYOUTS).default(\"bar\"),\n\t\tside: z.enum([\"left\", \"right\"]).default(\"left\"),\n\t\tsafeArea: z\n\t\t\t.union([\n\t\t\t\tz.enum([\"portrait\", \"landscape\"]),\n\t\t\t\tz.object({\n\t\t\t\t\tx: z.number(),\n\t\t\t\t\ty: z.number(),\n\t\t\t\t\twidth: z.number(),\n\t\t\t\t\theight: z.number(),\n\t\t\t\t}),\n\t\t\t])\n\t\t\t.optional(),\n\t\t/** Stay on screen instead of leaving after `hold`. */\n\t\tstay: z.boolean().default(false),\n\t\taccent: z.string().optional(),\n\t\tplate: z.string().optional(),\n\t\tnameSize: z.number().positive().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 90 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 1920 * s;\n\t\tconst H = ctx.requested.height ?? 1080 * s;\n\t\tconst safe = safeArea(W, H, props.safeArea);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst nameSize = props.nameSize ?? 44 * s;\n\t\tconst roleSize = nameSize * 0.62;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst plateColor = props.plate ?? theme.color.surface;\n\t\tconst right = props.side === \"right\";\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst enter = theme.motion.ease.enter;\n\n\t\tconst nameM = host.measure(props.name, {\n\t\t\tfontFamily,\n\t\t\tfontSize: nameSize,\n\t\t\tfontWeight: 700,\n\t\t});\n\t\tconst roleM = props.role\n\t\t\t? host.measure(props.role, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: roleSize,\n\t\t\t\t\tfontWeight: 500,\n\t\t\t\t})\n\t\t\t: null;\n\t\tconst padX = nameSize * 0.55;\n\t\tconst padY = nameSize * 0.36;\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\t/** Keys per node and prop, merged into one track each at the end. */\n\t\tconst tracks = new Map<\n\t\t\tAnimatable<LayoutNode>,\n\t\t\tMap<string, KeyframeTuple[]>\n\t\t>();\n\t\tconst key = (\n\t\t\tnode: Animatable<LayoutNode>,\n\t\t\tprop: string,\n\t\t\tkeys: KeyframeTuple[],\n\t\t) => {\n\t\t\tconst byProp = tracks.get(node) ?? new Map<string, KeyframeTuple[]>();\n\t\t\ttracks.set(node, byProp);\n\t\t\tbyProp.set(prop, [...(byProp.get(prop) ?? []), ...keys]);\n\t\t};\n\n\t\tconst text = (\n\t\t\tid: string,\n\t\t\tcontent: string,\n\t\t\tsize: number,\n\t\t\tweight: number,\n\t\t\tfill: string,\n\t\t\tx: number,\n\t\t\ty: number,\n\t\t) =>\n\t\t\thost.text(content, {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: weight,\n\t\t\t\tfill,\n\t\t\t});\n\n\t\t/**\n\t\t * A plate that slides in through its own box: the background travels\n\t\t * in from the side, its text rises a beat later. Returns the timing.\n\t\t */\n\t\tconst plate = (\n\t\t\tid: string,\n\t\t\trect: Rect,\n\t\t\tbackground: string,\n\t\t\tradius: number,\n\t\t\tcontent: Array<Animatable<LayoutNode>>,\n\t\t\tat: number,\n\t\t) => {\n\t\t\tconst fill = host.box({\n\t\t\t\tid: ctx.partId(`${id}-fill`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: rect.width,\n\t\t\t\theight: rect.height,\n\t\t\t\tbackground,\n\t\t\t\tborderRadius: radius,\n\t\t\t});\n\t\t\tconst travel = rect.width * (right ? 1 : -1);\n\t\t\tconst slide = Math.round(base * 1.1);\n\t\t\tkey(fill, \"x\", [\n\t\t\t\t[at, travel],\n\t\t\t\t[at + slide, 0, enter],\n\t\t\t]);\n\t\t\tconst textAt = at + Math.round(fast * 0.7);\n\t\t\tfor (const node of content) {\n\t\t\t\tconst y0 = (node as unknown as { y: number }).y;\n\t\t\t\tkey(node, \"y\", [\n\t\t\t\t\t[textAt, y0 + rect.height * 0.5],\n\t\t\t\t\t[textAt + base, y0, enter],\n\t\t\t\t]);\n\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t[textAt, 0],\n\t\t\t\t\t[textAt + Math.round(base * 0.6), 1, \"power2.out\"],\n\t\t\t\t]);\n\t\t\t}\n\t\t\tconst clip = host.box({\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\t...rect,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tborderRadius: radius,\n\t\t\t\tchildren: [fill, ...content],\n\t\t\t});\n\t\t\tchildren.push(clip);\n\t\t\tparts[id] = clip;\n\t\t\treturn {\n\t\t\t\tclip,\n\t\t\t\tfill,\n\t\t\t\tcontent,\n\t\t\t\ttravel,\n\t\t\t\tsettled: Math.max(at + slide, textAt + base),\n\t\t\t};\n\t\t};\n\n\t\t// ── Layouts ───────────────────────────────────────────────────────\n\t\tconst bottom = safe.y + safe.height;\n\t\tconst xFor = (width: number) =>\n\t\t\tright ? safe.x + safe.width - width : safe.x;\n\t\tconst plates: Array<ReturnType<typeof plate>> = [];\n\t\tlet accentLine: Animatable<LayoutNode> | null = null;\n\t\tconst lineW = Math.max(3, nameSize * 0.09);\n\n\t\tswitch (props.layout) {\n\t\t\tcase \"bar\": {\n\t\t\t\tconst edge = Math.max(4, nameSize * 0.14);\n\t\t\t\tconst innerW = Math.max(nameM.width, roleM?.width ?? 0);\n\t\t\t\tconst h = padY * 2 + nameM.height + (roleM ? roleM.height * 0.95 : 0);\n\t\t\t\tconst w = innerW + padX * 2 + edge;\n\t\t\t\tconst rect = { x: xFor(w), y: bottom - h, width: w, height: h };\n\t\t\t\tconst tx = right ? padX : padX + edge;\n\t\t\t\tconst content = [\n\t\t\t\t\ttext(\"name\", props.name, nameSize, 700, theme.color.ink, tx, padY),\n\t\t\t\t];\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t500,\n\t\t\t\t\t\t\ttheme.color.muted,\n\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\tpadY + nameM.height * 0.95,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\tconst p = plate(\"plate\", rect, plateColor, theme.radius.sm, content, 0);\n\t\t\t\tconst strip = host.box({\n\t\t\t\t\tid: ctx.partId(\"edge\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: right ? rect.x + rect.width - edge : rect.x,\n\t\t\t\t\ty: rect.y + rect.height,\n\t\t\t\t\twidth: edge,\n\t\t\t\t\theight: 0,\n\t\t\t\t\tbackground: accent,\n\t\t\t\t\tborderRadius: theme.radius.sm,\n\t\t\t\t});\n\t\t\t\tconst grow = Math.round(base * 0.8);\n\t\t\t\tkey(strip, \"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[grow, rect.height, enter],\n\t\t\t\t]);\n\t\t\t\tkey(strip, \"y\", [\n\t\t\t\t\t[0, rect.y + rect.height],\n\t\t\t\t\t[grow, rect.y, enter],\n\t\t\t\t]);\n\t\t\t\tchildren.push(strip);\n\t\t\t\tparts.edge = strip;\n\t\t\t\tplates.push(p);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"stacked\": {\n\t\t\t\tconst nameW = nameM.width + padX * 2;\n\t\t\t\tconst nameH = nameM.height + padY * 2;\n\t\t\t\tconst roleH = roleM ? roleM.height + padY * 1.2 : 0;\n\t\t\t\tconst roleW = roleM ? roleM.width + padX * 1.6 : 0;\n\t\t\t\tconst inset = nameSize * 0.4;\n\t\t\t\tconst total = nameH + (roleM ? roleH * 0.9 : 0);\n\t\t\t\tconst top = bottom - total;\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"name-plate\",\n\t\t\t\t\t\t{ x: xFor(nameW), y: top, width: nameW, height: nameH },\n\t\t\t\t\t\tplateColor,\n\t\t\t\t\t\ttheme.radius.sm,\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\t\"name\",\n\t\t\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\t\t\tnameSize,\n\t\t\t\t\t\t\t\t700,\n\t\t\t\t\t\t\t\ttheme.color.ink,\n\t\t\t\t\t\t\t\tpadX,\n\t\t\t\t\t\t\t\tpadY,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t],\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tplates.push(\n\t\t\t\t\t\tplate(\n\t\t\t\t\t\t\t\"role-plate\",\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tx: right ? xFor(roleW) - inset : xFor(roleW) + inset,\n\t\t\t\t\t\t\t\ty: top + nameH - roleH * 0.1,\n\t\t\t\t\t\t\t\twidth: roleW,\n\t\t\t\t\t\t\t\theight: roleH,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\taccent,\n\t\t\t\t\t\t\ttheme.radius.sm,\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t\t\t600,\n\t\t\t\t\t\t\t\t\ttheme.color.bg,\n\t\t\t\t\t\t\t\t\tpadX * 0.8,\n\t\t\t\t\t\t\t\t\tpadY * 0.6,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tMath.round(fast * 0.8),\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"tag\": {\n\t\t\t\t// One line, one node: bold name, a dot, the role a size down.\n\t\t\t\tconst sep = \" · \";\n\t\t\t\tconst sepW = props.role\n\t\t\t\t\t? host.measure(sep, { fontFamily, fontSize: roleSize }).width\n\t\t\t\t\t: 0;\n\t\t\t\tconst h = nameM.height + padY * 1.4;\n\t\t\t\tconst w = nameM.width + (roleM ? sepW + roleM.width : 0) + h;\n\t\t\t\tconst line = host.text(\n\t\t\t\t\tprops.role ? `${props.name}${sep}${props.role}` : props.name,\n\t\t\t\t\t{\n\t\t\t\t\t\tid: ctx.partId(\"name\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: h / 2,\n\t\t\t\t\t\ty: padY * 0.7,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: nameSize,\n\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\tspans: [\n\t\t\t\t\t\t\t{ text: props.name, fontWeight: 700, fill: theme.color.ink },\n\t\t\t\t\t\t\t...(props.role\n\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\ttext: `${sep}${props.role}`,\n\t\t\t\t\t\t\t\t\t\t\tfontSize: roleSize,\n\t\t\t\t\t\t\t\t\t\t\tfontWeight: 500,\n\t\t\t\t\t\t\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"tag\",\n\t\t\t\t\t\t{ x: xFor(w), y: bottom - h, width: w, height: h },\n\t\t\t\t\t\tplateColor,\n\t\t\t\t\t\ttheme.radius.pill,\n\t\t\t\t\t\t[line],\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"line\": {\n\t\t\t\tconst innerW = Math.max(nameM.width, roleM?.width ?? 0);\n\t\t\t\tconst h = nameM.height + (roleM ? roleM.height : 0);\n\t\t\t\tconst gap = nameSize * 0.4;\n\t\t\t\tconst w = innerW + gap + lineW;\n\t\t\t\tconst x = xFor(w);\n\t\t\t\tconst lineX = right ? x + w - lineW : x;\n\t\t\t\tconst tx = right ? 0 : lineW + gap;\n\t\t\t\taccentLine = host.box({\n\t\t\t\t\tid: ctx.partId(\"line\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: lineX,\n\t\t\t\t\ty: bottom,\n\t\t\t\t\twidth: lineW,\n\t\t\t\t\theight: 0,\n\t\t\t\t\tbackground: accent,\n\t\t\t\t\tborderRadius: lineW / 2,\n\t\t\t\t});\n\t\t\t\tconst grow = Math.round(base * 1.1);\n\t\t\t\tkey(accentLine, \"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[grow, h, enter],\n\t\t\t\t]);\n\t\t\t\tkey(accentLine, \"y\", [\n\t\t\t\t\t[0, bottom],\n\t\t\t\t\t[grow, bottom - h, enter],\n\t\t\t\t]);\n\t\t\t\tchildren.push(accentLine);\n\t\t\t\tparts.line = accentLine;\n\t\t\t\tconst content = [\n\t\t\t\t\ttext(\"name\", props.name, nameSize, 700, theme.color.ink, tx, 0),\n\t\t\t\t];\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t500,\n\t\t\t\t\t\t\ttheme.color.muted,\n\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\tnameM.height,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t// No plate: the text rises through a clear box.\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"text\",\n\t\t\t\t\t\t{ x, y: bottom - h, width: w, height: h },\n\t\t\t\t\t\t\"rgba(0,0,0,0)\",\n\t\t\t\t\t\t0,\n\t\t\t\t\t\tcontent,\n\t\t\t\t\t\tMath.round(fast * 0.6),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tconst settled = Math.max(...plates.map((p) => p.settled));\n\t\t// ── Out: text first, then the plates slide back the way they came ──\n\t\tlet afterHold = 0;\n\t\tconst outAt = settled + ctx.hold;\n\t\tif (!props.stay) {\n\t\t\tconst fadeOut = Math.round(fast * 0.9);\n\t\t\tconst slide = Math.round(base * 0.9);\n\t\t\tplates.forEach((p, k) => {\n\t\t\t\tconst at = outAt + k * Math.round(fast * 0.4);\n\t\t\t\tfor (const node of p.content)\n\t\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t\t[at, 1],\n\t\t\t\t\t\t[at + fadeOut, 0, \"power2.in\"],\n\t\t\t\t\t]);\n\t\t\t\tkey(p.fill, \"x\", [\n\t\t\t\t\t[at + fadeOut - 2, 0],\n\t\t\t\t\t[at + fadeOut - 2 + slide, p.travel, theme.motion.ease.exit],\n\t\t\t\t]);\n\t\t\t\tafterHold = Math.max(afterHold, at + fadeOut - 2 + slide - outAt);\n\t\t\t});\n\t\t\tfor (const node of [accentLine, parts.edge])\n\t\t\t\tif (node)\n\t\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t\t[outAt, 1],\n\t\t\t\t\t\t[outAt + afterHold, 0, \"power2.in\"],\n\t\t\t\t\t]);\n\t\t}\n\t\tfor (const [node, byProp] of tracks) {\n\t\t\tconst a = LayerAnimation.create();\n\t\t\tfor (const [prop, keys] of byProp)\n\t\t\t\ta.keys(\n\t\t\t\t\tprop as \"x\",\n\t\t\t\t\t[...keys].sort((x, y) => x[0] - y[0]),\n\t\t\t\t);\n\t\t\tnode.animate(a);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: settled,\n\t\t\topenEnded: props.stay,\n\t\t\tafterHold,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\tin: [{ frame: settled }],\n\t\t\t\tout: props.stay ? [] : [{ frame: outAt }],\n\t\t\t} as LowerThirdCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * The name-and-role bar of interviews and talks: measured plates in the safe\n * area that slide in and leave on their own.\n */\nexport const LowerThird = /* @__PURE__ */ bindComponent(lowerThird);\n"
|
|
31
|
+
"content": "/**\n * LowerThird: the name-and-role bar of interviews and talks. Plates are\n * measured to their text, placed in the safe area's bottom corner, slide in\n * through their own box, and leave on their own after `hold`.\n *\n * Coordinates are the frame's: the root covers the video (default\n * 1920 × 1080 × theme scale, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\ttype Rect,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport const LOWER_THIRD_LAYOUTS = [\"bar\", \"stacked\", \"tag\", \"line\"] as const;\n\n/** Cap height as a share of the font size (Inter and most UI sans faces). */\nconst CAP_HEIGHT = 0.72;\nexport type LowerThirdLayout = (typeof LOWER_THIRD_LAYOUTS)[number];\n\nexport interface LowerThirdCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Fully on screen. */\n\tin: ReadonlyArray<{ frame: number }>;\n\t/** Starts to leave (empty with `stay`). */\n\tout: ReadonlyArray<{ frame: number }>;\n}\n\nfunction safeArea(\n\tW: number,\n\tH: number,\n\tarea: \"portrait\" | \"landscape\" | Rect | undefined,\n): Rect {\n\tif (area && typeof area === \"object\") return area;\n\tconst portrait = area ? area === \"portrait\" : H > W;\n\treturn portrait\n\t\t? { x: W * 0.08, y: H * 0.1, width: W * 0.84, height: H * 0.66 }\n\t\t: { x: W * 0.06, y: H * 0.08, width: W * 0.88, height: H * 0.82 };\n}\n\nexport const lowerThird = /* @__PURE__ */ defineComponent({\n\tname: \"LowerThird\",\n\tprops: () => ({\n\t\tname: z.string().min(1),\n\t\trole: z.string().optional(),\n\t\tlayout: z.enum(LOWER_THIRD_LAYOUTS).default(\"bar\"),\n\t\tside: z.enum([\"left\", \"right\"]).default(\"left\"),\n\t\tsafeArea: z\n\t\t\t.union([\n\t\t\t\tz.enum([\"portrait\", \"landscape\"]),\n\t\t\t\tz.object({\n\t\t\t\t\tx: z.number(),\n\t\t\t\t\ty: z.number(),\n\t\t\t\t\twidth: z.number(),\n\t\t\t\t\theight: z.number(),\n\t\t\t\t}),\n\t\t\t])\n\t\t\t.optional(),\n\t\t/** Stay on screen instead of leaving after `hold`. */\n\t\tstay: z.boolean().default(false),\n\t\taccent: z.string().optional(),\n\t\tplate: z.string().optional(),\n\t\tnameSize: z.number().positive().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 90 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 1920 * s;\n\t\tconst H = ctx.requested.height ?? 1080 * s;\n\t\tconst safe = safeArea(W, H, props.safeArea);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst nameSize = props.nameSize ?? 44 * s;\n\t\tconst roleSize = nameSize * 0.62;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst plateColor = props.plate ?? theme.color.surface;\n\t\tconst right = props.side === \"right\";\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst enter = theme.motion.ease.enter;\n\n\t\tconst nameM = host.measure(props.name, {\n\t\t\tfontFamily,\n\t\t\tfontSize: nameSize,\n\t\t\tfontWeight: 700,\n\t\t});\n\t\tconst roleM = props.role\n\t\t\t? host.measure(props.role, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: roleSize,\n\t\t\t\t\tfontWeight: 500,\n\t\t\t\t})\n\t\t\t: null;\n\t\tconst padX = nameSize * 0.55;\n\t\tconst padY = nameSize * 0.36;\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\t/** Keys per node and prop, merged into one track each at the end. */\n\t\tconst tracks = new Map<\n\t\t\tAnimatable<LayoutNode>,\n\t\t\tMap<string, KeyframeTuple[]>\n\t\t>();\n\t\tconst key = (\n\t\t\tnode: Animatable<LayoutNode>,\n\t\t\tprop: string,\n\t\t\tkeys: KeyframeTuple[],\n\t\t) => {\n\t\t\tconst byProp = tracks.get(node) ?? new Map<string, KeyframeTuple[]>();\n\t\t\ttracks.set(node, byProp);\n\t\t\tbyProp.set(prop, [...(byProp.get(prop) ?? []), ...keys]);\n\t\t};\n\n\t\tconst text = (\n\t\t\tid: string,\n\t\t\tcontent: string,\n\t\t\tsize: number,\n\t\t\tweight: number,\n\t\t\tfill: string,\n\t\t\tx: number,\n\t\t\ty: number,\n\t\t) =>\n\t\t\thost.text(content, {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: weight,\n\t\t\t\tfill,\n\t\t\t});\n\n\t\t/**\n\t\t * A plate that slides in through its own box: the background travels\n\t\t * in from the side, its text rises a beat later. Returns the timing.\n\t\t */\n\t\tconst plate = (\n\t\t\tid: string,\n\t\t\trect: Rect,\n\t\t\tbackground: string,\n\t\t\tradius: number,\n\t\t\tcontent: Array<Animatable<LayoutNode>>,\n\t\t\tat: number,\n\t\t) => {\n\t\t\tconst fill = host.box({\n\t\t\t\tid: ctx.partId(`${id}-fill`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: rect.width,\n\t\t\t\theight: rect.height,\n\t\t\t\tbackground,\n\t\t\t\tborderRadius: radius,\n\t\t\t});\n\t\t\tconst travel = rect.width * (right ? 1 : -1);\n\t\t\tconst slide = Math.round(base * 1.1);\n\t\t\tkey(fill, \"x\", [\n\t\t\t\t[at, travel],\n\t\t\t\t[at + slide, 0, enter],\n\t\t\t]);\n\t\t\tconst textAt = at + Math.round(fast * 0.7);\n\t\t\tfor (const node of content) {\n\t\t\t\tconst y0 = (node as unknown as { y: number }).y;\n\t\t\t\tkey(node, \"y\", [\n\t\t\t\t\t[textAt, y0 + rect.height * 0.5],\n\t\t\t\t\t[textAt + base, y0, enter],\n\t\t\t\t]);\n\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t[textAt, 0],\n\t\t\t\t\t[textAt + Math.round(base * 0.6), 1, \"power2.out\"],\n\t\t\t\t]);\n\t\t\t}\n\t\t\tconst clip = host.box({\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\t...rect,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tborderRadius: radius,\n\t\t\t\tchildren: [fill, ...content],\n\t\t\t});\n\t\t\tchildren.push(clip);\n\t\t\tparts[id] = clip;\n\t\t\treturn {\n\t\t\t\tclip,\n\t\t\t\tfill,\n\t\t\t\tcontent,\n\t\t\t\ttravel,\n\t\t\t\tsettled: Math.max(at + slide, textAt + base),\n\t\t\t};\n\t\t};\n\n\t\t// ── Layouts ───────────────────────────────────────────────────────\n\t\tconst bottom = safe.y + safe.height;\n\t\tconst xFor = (width: number) =>\n\t\t\tright ? safe.x + safe.width - width : safe.x;\n\t\tconst plates: Array<ReturnType<typeof plate>> = [];\n\t\tlet accentLine: Animatable<LayoutNode> | null = null;\n\t\tconst lineW = Math.max(3, nameSize * 0.09);\n\n\t\tswitch (props.layout) {\n\t\t\tcase \"bar\": {\n\t\t\t\tconst edge = Math.max(4, nameSize * 0.14);\n\t\t\t\tconst innerW = Math.max(nameM.width, roleM?.width ?? 0);\n\t\t\t\tconst h = padY * 2 + nameM.height + (roleM ? roleM.height * 0.95 : 0);\n\t\t\t\tconst w = innerW + padX * 2 + edge;\n\t\t\t\tconst rect = { x: xFor(w), y: bottom - h, width: w, height: h };\n\t\t\t\tconst tx = right ? padX : padX + edge;\n\t\t\t\tconst content = [\n\t\t\t\t\ttext(\"name\", props.name, nameSize, 700, theme.color.ink, tx, padY),\n\t\t\t\t];\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t500,\n\t\t\t\t\t\t\ttheme.color.muted,\n\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\tpadY + nameM.height * 0.95,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\tconst p = plate(\"plate\", rect, plateColor, theme.radius.sm, content, 0);\n\t\t\t\tconst strip = host.box({\n\t\t\t\t\tid: ctx.partId(\"edge\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: right ? rect.x + rect.width - edge : rect.x,\n\t\t\t\t\ty: rect.y + rect.height,\n\t\t\t\t\twidth: edge,\n\t\t\t\t\theight: 0,\n\t\t\t\t\tbackground: accent,\n\t\t\t\t\tborderRadius: theme.radius.sm,\n\t\t\t\t});\n\t\t\t\tconst grow = Math.round(base * 0.8);\n\t\t\t\tkey(strip, \"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[grow, rect.height, enter],\n\t\t\t\t]);\n\t\t\t\tkey(strip, \"y\", [\n\t\t\t\t\t[0, rect.y + rect.height],\n\t\t\t\t\t[grow, rect.y, enter],\n\t\t\t\t]);\n\t\t\t\tchildren.push(strip);\n\t\t\t\tparts.edge = strip;\n\t\t\t\tplates.push(p);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"stacked\": {\n\t\t\t\tconst nameW = nameM.width + padX * 2;\n\t\t\t\tconst nameH = nameM.height + padY * 2;\n\t\t\t\tconst roleH = roleM ? roleM.height + padY * 1.2 : 0;\n\t\t\t\tconst roleW = roleM ? roleM.width + padX * 1.6 : 0;\n\t\t\t\tconst inset = nameSize * 0.4;\n\t\t\t\tconst total = nameH + (roleM ? roleH * 0.9 : 0);\n\t\t\t\tconst top = bottom - total;\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"name-plate\",\n\t\t\t\t\t\t{ x: xFor(nameW), y: top, width: nameW, height: nameH },\n\t\t\t\t\t\tplateColor,\n\t\t\t\t\t\ttheme.radius.sm,\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\t\"name\",\n\t\t\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\t\t\tnameSize,\n\t\t\t\t\t\t\t\t700,\n\t\t\t\t\t\t\t\ttheme.color.ink,\n\t\t\t\t\t\t\t\tpadX,\n\t\t\t\t\t\t\t\tpadY,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t],\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tplates.push(\n\t\t\t\t\t\tplate(\n\t\t\t\t\t\t\t\"role-plate\",\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tx: right ? xFor(roleW) - inset : xFor(roleW) + inset,\n\t\t\t\t\t\t\t\ty: top + nameH - roleH * 0.1,\n\t\t\t\t\t\t\t\twidth: roleW,\n\t\t\t\t\t\t\t\theight: roleH,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\taccent,\n\t\t\t\t\t\t\ttheme.radius.sm,\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t\t\t600,\n\t\t\t\t\t\t\t\t\ttheme.color.bg,\n\t\t\t\t\t\t\t\t\tpadX * 0.8,\n\t\t\t\t\t\t\t\t\tpadY * 0.6,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tMath.round(fast * 0.8),\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"tag\": {\n\t\t\t\t// One line, one node: bold name, a dot, the role a size down.\n\t\t\t\tconst sep = \" · \";\n\t\t\t\tconst sepW = props.role\n\t\t\t\t\t? host.measure(sep, { fontFamily, fontSize: roleSize }).width\n\t\t\t\t\t: 0;\n\t\t\t\tconst h = nameM.height + padY * 1.4;\n\t\t\t\tconst w = nameM.width + (roleM ? sepW + roleM.width : 0) + h;\n\t\t\t\tconst line = host.text(\n\t\t\t\t\tprops.role ? `${props.name}${sep}${props.role}` : props.name,\n\t\t\t\t\t{\n\t\t\t\t\t\tid: ctx.partId(\"name\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: h / 2,\n\t\t\t\t\t\ty: padY * 0.7,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: nameSize,\n\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\tspans: [\n\t\t\t\t\t\t\t{ text: props.name, fontWeight: 700, fill: theme.color.ink },\n\t\t\t\t\t\t\t...(props.role\n\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\ttext: `${sep}${props.role}`,\n\t\t\t\t\t\t\t\t\t\t\tfontSize: roleSize,\n\t\t\t\t\t\t\t\t\t\t\tfontWeight: 500,\n\t\t\t\t\t\t\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t\t\t\t\t\t\t\t// Centred on the name's capitals, not on its baseline: one\n\t\t\t\t\t\t\t\t\t\t\t// line in a pill reads as aligned when the caps share a middle.\n\t\t\t\t\t\t\t\t\t\t\tbaselineShift: -(nameSize - roleSize) * CAP_HEIGHT * 0.5,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"tag\",\n\t\t\t\t\t\t{ x: xFor(w), y: bottom - h, width: w, height: h },\n\t\t\t\t\t\tplateColor,\n\t\t\t\t\t\ttheme.radius.pill,\n\t\t\t\t\t\t[line],\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"line\": {\n\t\t\t\tconst innerW = Math.max(nameM.width, roleM?.width ?? 0);\n\t\t\t\tconst h = nameM.height + (roleM ? roleM.height : 0);\n\t\t\t\tconst gap = nameSize * 0.4;\n\t\t\t\tconst w = innerW + gap + lineW;\n\t\t\t\tconst x = xFor(w);\n\t\t\t\tconst lineX = right ? x + w - lineW : x;\n\t\t\t\tconst tx = right ? 0 : lineW + gap;\n\t\t\t\taccentLine = host.box({\n\t\t\t\t\tid: ctx.partId(\"line\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: lineX,\n\t\t\t\t\ty: bottom,\n\t\t\t\t\twidth: lineW,\n\t\t\t\t\theight: 0,\n\t\t\t\t\tbackground: accent,\n\t\t\t\t\tborderRadius: lineW / 2,\n\t\t\t\t});\n\t\t\t\tconst grow = Math.round(base * 1.1);\n\t\t\t\tkey(accentLine, \"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[grow, h, enter],\n\t\t\t\t]);\n\t\t\t\tkey(accentLine, \"y\", [\n\t\t\t\t\t[0, bottom],\n\t\t\t\t\t[grow, bottom - h, enter],\n\t\t\t\t]);\n\t\t\t\tchildren.push(accentLine);\n\t\t\t\tparts.line = accentLine;\n\t\t\t\tconst content = [\n\t\t\t\t\ttext(\"name\", props.name, nameSize, 700, theme.color.ink, tx, 0),\n\t\t\t\t];\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t500,\n\t\t\t\t\t\t\ttheme.color.muted,\n\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\tnameM.height,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t// No plate: the text rises through a clear box.\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"text\",\n\t\t\t\t\t\t{ x, y: bottom - h, width: w, height: h },\n\t\t\t\t\t\t\"rgba(0,0,0,0)\",\n\t\t\t\t\t\t0,\n\t\t\t\t\t\tcontent,\n\t\t\t\t\t\tMath.round(fast * 0.6),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tconst settled = Math.max(...plates.map((p) => p.settled));\n\t\t// ── Out: text first, then the plates slide back the way they came ──\n\t\tlet afterHold = 0;\n\t\tconst outAt = settled + ctx.hold;\n\t\tif (!props.stay) {\n\t\t\tconst fadeOut = Math.round(fast * 0.9);\n\t\t\tconst slide = Math.round(base * 0.9);\n\t\t\tplates.forEach((p, k) => {\n\t\t\t\tconst at = outAt + k * Math.round(fast * 0.4);\n\t\t\t\tfor (const node of p.content)\n\t\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t\t[at, 1],\n\t\t\t\t\t\t[at + fadeOut, 0, \"power2.in\"],\n\t\t\t\t\t]);\n\t\t\t\tkey(p.fill, \"x\", [\n\t\t\t\t\t[at + fadeOut - 2, 0],\n\t\t\t\t\t[at + fadeOut - 2 + slide, p.travel, theme.motion.ease.exit],\n\t\t\t\t]);\n\t\t\t\tafterHold = Math.max(afterHold, at + fadeOut - 2 + slide - outAt);\n\t\t\t});\n\t\t\tfor (const node of [accentLine, parts.edge])\n\t\t\t\tif (node)\n\t\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t\t[outAt, 1],\n\t\t\t\t\t\t[outAt + afterHold, 0, \"power2.in\"],\n\t\t\t\t\t]);\n\t\t}\n\t\tfor (const [node, byProp] of tracks) {\n\t\t\tconst a = LayerAnimation.create();\n\t\t\tfor (const [prop, keys] of byProp)\n\t\t\t\ta.keys(\n\t\t\t\t\tprop as \"x\",\n\t\t\t\t\t[...keys].sort((x, y) => x[0] - y[0]),\n\t\t\t\t);\n\t\t\tnode.animate(a);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: settled,\n\t\t\topenEnded: props.stay,\n\t\t\tafterHold,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\tin: [{ frame: settled }],\n\t\t\t\tout: props.stay ? [] : [{ frame: outAt }],\n\t\t\t} as LowerThirdCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * The name-and-role bar of interviews and talks: measured plates in the safe\n * area that slide in and leave on their own.\n */\nexport const LowerThird = /* @__PURE__ */ bindComponent(lowerThird);\n"
|
|
32
32
|
}
|
|
33
33
|
]
|
|
34
34
|
}
|
package/dist/registry/meter.json
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"files": [
|
|
30
30
|
{
|
|
31
31
|
"path": "meter/index.ts",
|
|
32
|
-
"content": "/**\n * Meter: progress and scores as a ring, an arc gauge, a bar, lit segments or\n * a star rating, filling on the same value timeline as its `Counter`.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbind,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\ttoPathData,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\nimport { counter } from \"../counter/index.js\";\nimport { type ValueKey, valueAt } from \"../counter/plan.js\";\n\nexport const METER_KINDS = [\"ring\", \"arc\", \"bar\", \"segments\", \"stars\"] as const;\nexport type MeterKind = (typeof METER_KINDS)[number];\n\nexport interface MeterCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The fill (and counter) is at rest. */\n\tsettled: ReadonlyArray<{ frame: number }>;\n\t/** Each segment or star completing (fills only; empties are not cued). */\n\tcrossings: ReadonlyArray<{ frame: number; segment: number }>;\n}\n\n/** Points along a circular arc, angles in degrees (0 = right, clockwise). */\nfunction arcPoints(c: Vec2, r: number, from: number, to: number): Vec2[] {\n\tconst steps = Math.max(8, Math.ceil(Math.abs(to - from) / 6));\n\treturn Array.from({ length: steps + 1 }, (_, k) => {\n\t\tconst a = ((from + ((to - from) * k) / steps) * Math.PI) / 180;\n\t\treturn [c[0] + Math.cos(a) * r, c[1] + Math.sin(a) * r] as Vec2;\n\t});\n}\n\n/** A five-point star inside a `size` box. */\nfunction starPath(size: number): string {\n\tconst c = size / 2;\n\tconst pts: Vec2[] = Array.from({ length: 10 }, (_, k) => {\n\t\tconst r = k % 2 === 0 ? size * 0.5 : size * 0.21;\n\t\tconst a = -Math.PI / 2 + (k * Math.PI) / 5;\n\t\treturn [c + Math.cos(a) * r, c + Math.sin(a) * r * 1.02 + size * 0.03];\n\t});\n\treturn toPathData(pts, true);\n}\n\nexport const meter = /* @__PURE__ */ defineComponent({\n\tname: \"Meter\",\n\tprops: () => ({\n\t\tvalue: z.number().optional(),\n\t\t/** Multi-step values: `[frame, value, ease?]`, frames ascending. */\n\t\tvalues: z\n\t\t\t.array(\n\t\t\t\tz.union([\n\t\t\t\t\tz.tuple([z.number().int().min(0), z.number()]),\n\t\t\t\t\tz.tuple([z.number().int().min(0), z.number(), z.string()]),\n\t\t\t\t]),\n\t\t\t)\n\t\t\t.min(1)\n\t\t\t.optional(),\n\t\tfrom: z.number().default(0),\n\t\t/** Default 100 (5 for stars). */\n\t\tmax: z.number().optional(),\n\t\tkind: z.enum(METER_KINDS).default(\"ring\"),\n\t\t/** Cells for `segments` (default 10), stars for `stars` (default `max`). */\n\t\tsegments: z.number().int().positive().optional(),\n\t\t/** Width (bar, segments, stars) or diameter (ring, arc). */\n\t\tsize: z.number().positive().optional(),\n\t\tthickness: z.number().positive().optional(),\n\t\tcounter: z.boolean().optional(),\n\t\tformat: z.string().default(\",d\"),\n\t\tsuffix: z.string().optional(),\n\t\tlabel: z.string().optional(),\n\t\tcolor: z.string().optional(),\n\t\ttrack: z.string().optional(),\n\t\tcountDuration: z.number().int().positive().optional(),\n\t\tease: z.string().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst kind = props.kind;\n\t\tconst max = props.max ?? (kind === \"stars\" ? 5 : 100);\n\t\tconst from = props.from;\n\t\tconst ease = props.ease ?? theme.motion.ease.move;\n\t\tconst color = props.color ?? theme.color.accent;\n\t\tconst trackColor = props.track ?? theme.color.line;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tif (props.value === undefined && !props.values)\n\t\t\tthrow new Error(\"Meter: give `value` (or `values`).\");\n\t\tconst keys: ValueKey[] = props.values\n\t\t\t? props.values.map((v) => [...v] as unknown as ValueKey)\n\t\t\t: [\n\t\t\t\t\t[0, from],\n\t\t\t\t\t[\n\t\t\t\t\t\tprops.countDuration ?? themeFrames(theme, \"slow\", fps) * 2,\n\t\t\t\t\t\tprops.value as number,\n\t\t\t\t\t\tease,\n\t\t\t\t\t],\n\t\t\t\t];\n\t\tconst last = (keys[keys.length - 1] as ValueKey)[0];\n\t\tconst fraction = (v: number) =>\n\t\t\tMath.max(0, Math.min(1, (v - from) / (max - from || 1)));\n\t\t/** Keys of `f(fraction)`, on exactly the value's keyframes and eases. */\n\t\tconst fillKeys = (f: (x: number) => number): KeyframeTuple[] =>\n\t\t\tkeys.map((k, i) =>\n\t\t\t\ti === 0\n\t\t\t\t\t? [k[0], f(fraction(k[1]))]\n\t\t\t\t\t: [k[0], f(fraction(k[1])), k[2] ?? ease],\n\t\t\t);\n\t\tconst perFrame = Array.from({ length: last + 1 }, (_, t) =>\n\t\t\tvalueAt(keys, t, ease),\n\t\t);\n\n\t\tconst showCounter = props.counter ?? kind !== \"stars\";\n\t\tconst suffix = props.suffix ?? (max === 100 ? \"%\" : \"\");\n\t\tconst makeCounter = bind(host, counter);\n\t\tconst number = (size: number, align: \"start\" | \"center\" | \"end\") =>\n\t\t\tmakeCounter({\n\t\t\t\tid: ctx.partId(\"counter\"),\n\t\t\t\tvalues: keys.map((k) => [...k]) as never,\n\t\t\t\tformat: props.format,\n\t\t\t\tsuffix,\n\t\t\t\taffixScale: 0.6,\n\t\t\t\tdigitStagger: 0,\n\t\t\t\tease,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: 700,\n\t\t\t\talign,\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\ttheme,\n\t\t\t\tfps,\n\t\t\t});\n\t\tconst label = (text: string, size: number) =>\n\t\t\thost.text(text, {\n\t\t\t\tid: ctx.partId(\"label\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t});\n\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst crossings: Array<{ frame: number; segment: number }> = [];\n\t\tlet W = 0;\n\t\tlet H = 0;\n\n\t\tconst stroke = (\n\t\t\tid: string,\n\t\t\td: string,\n\t\t\tw: number,\n\t\t\th: number,\n\t\t\tpaint: string,\n\t\t\tthickness: number,\n\t\t) =>\n\t\t\thost.shape(\"path\", {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: w,\n\t\t\t\theight: h,\n\t\t\t\td,\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: paint,\n\t\t\t\tstrokeWidth: thickness,\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t});\n\n\t\tswitch (kind) {\n\t\t\tcase \"ring\":\n\t\t\tcase \"arc\": {\n\t\t\t\tconst D = props.size ?? 260 * s;\n\t\t\t\tconst T = props.thickness ?? D * (kind === \"ring\" ? 0.085 : 0.1);\n\t\t\t\tconst r = (D - T) / 2;\n\t\t\t\tconst c: Vec2 = [D / 2, D / 2];\n\t\t\t\t// Ring: a full turn from the top. Arc: 240° from lower left, over the top.\n\t\t\t\tconst [a0, a1] = kind === \"ring\" ? [-90, 269.9] : [150, 390];\n\t\t\t\tconst path = toPathData(arcPoints(c, r, a0, a1));\n\t\t\t\tW = D;\n\t\t\t\tH = kind === \"ring\" ? D : D * 0.86;\n\t\t\t\tconst track = stroke(\"track\", path, W, H, trackColor, T);\n\t\t\t\tconst fill = stroke(\"fill\", path, W, H, color, T);\n\t\t\t\tfill.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"trimEnd\",\n\t\t\t\t\t\tfillKeys((x) => x),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tchildren.push(track, fill);\n\t\t\t\tparts.track = track;\n\t\t\t\tparts.fill = fill;\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst n = number(D * (kind === \"ring\" ? 0.24 : 0.22), \"center\");\n\t\t\t\t\tconst nn = n as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tnn.x = (W - nn.width) / 2;\n\t\t\t\t\tnn.y =\n\t\t\t\t\t\t(kind === \"ring\" ? D / 2 : D * 0.48) -\n\t\t\t\t\t\tnn.height / 2 -\n\t\t\t\t\t\t(props.label ? D * 0.04 : 0);\n\t\t\t\t\tchildren.push(n);\n\t\t\t\t\tparts.counter = n;\n\t\t\t\t\tif (props.label) {\n\t\t\t\t\t\tconst size = D * 0.075;\n\t\t\t\t\t\tconst m = host.measure(props.label, { fontFamily, fontSize: size });\n\t\t\t\t\t\tconst l = label(props.label, size);\n\t\t\t\t\t\tObject.assign(l, { x: (W - m.width) / 2, y: nn.y + nn.height });\n\t\t\t\t\t\tchildren.push(l);\n\t\t\t\t\t\tparts.label = l;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"bar\": {\n\t\t\t\tconst w = props.size ?? 640 * s;\n\t\t\t\tconst T = props.thickness ?? 16 * s;\n\t\t\t\tconst top = props.label ? 34 * s : 0;\n\t\t\t\tconst track = host.box({\n\t\t\t\t\tid: ctx.partId(\"track\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: top,\n\t\t\t\t\twidth: w,\n\t\t\t\t\theight: T,\n\t\t\t\t\tbackground: trackColor,\n\t\t\t\t\tborderRadius: T / 2,\n\t\t\t\t});\n\t\t\t\tconst fill = host.box({\n\t\t\t\t\tid: ctx.partId(\"fill\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: top,\n\t\t\t\t\twidth: 0,\n\t\t\t\t\theight: T,\n\t\t\t\t\tbackground: color,\n\t\t\t\t\tborderRadius: T / 2,\n\t\t\t\t});\n\t\t\t\tfill.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"width\",\n\t\t\t\t\t\tfillKeys((x) => x * w),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tchildren.push(track, fill);\n\t\t\t\tparts.track = track;\n\t\t\t\tparts.fill = fill;\n\t\t\t\tW = w;\n\t\t\t\tH = top + T;\n\t\t\t\tif (props.label) {\n\t\t\t\t\tconst l = label(props.label, 24 * s);\n\t\t\t\t\tObject.assign(l, { x: 0, y: 0 });\n\t\t\t\t\tchildren.push(l);\n\t\t\t\t\tparts.label = l;\n\t\t\t\t}\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst n = number(Math.max(T * 2.2, 30 * s), \"end\");\n\t\t\t\t\tconst nn = n as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tnn.x = w + 18 * s;\n\t\t\t\t\tnn.y = top + T / 2 - nn.height / 2;\n\t\t\t\t\tW = nn.x + nn.width;\n\t\t\t\t\tH = Math.max(H, nn.y + nn.height);\n\t\t\t\t\tchildren.push(n);\n\t\t\t\t\tparts.counter = n;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"segments\":\n\t\t\tcase \"stars\": {\n\t\t\t\tconst n = props.segments ?? (kind === \"stars\" ? Math.round(max) : 10);\n\t\t\t\tconst w = props.size ?? (kind === \"stars\" ? 300 : 640) * s;\n\t\t\t\tconst gap = kind === \"stars\" ? w * 0.03 : w * 0.012;\n\t\t\t\tconst cell = (w - gap * (n - 1)) / n;\n\t\t\t\tconst h = kind === \"stars\" ? cell : (props.thickness ?? 22 * s);\n\t\t\t\t// How full each cell is, per frame.\n\t\t\t\tconst fill = (k: number) =>\n\t\t\t\t\tperFrame.map((v) => Math.max(0, Math.min(1, fraction(v) * n - k)));\n\t\t\t\tfor (let k = 0; k < n; k++) {\n\t\t\t\t\tconst x = k * (cell + gap);\n\t\t\t\t\tconst amount = fill(k);\n\t\t\t\t\tconst full = amount.findIndex((a) => a >= 1);\n\t\t\t\t\tif (full >= 0) crossings.push({ frame: full, segment: k });\n\t\t\t\t\tif (kind === \"segments\") {\n\t\t\t\t\t\tconst base = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`cell-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: h,\n\t\t\t\t\t\t\tbackground: trackColor,\n\t\t\t\t\t\t\tborderRadius: Math.min(h, cell) * 0.25,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst lit = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`lit-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: h,\n\t\t\t\t\t\t\tbackground: color,\n\t\t\t\t\t\t\tborderRadius: Math.min(h, cell) * 0.25,\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t});\n\t\t\t\t\t\t// Lights on the frame the value reaches the cell, with a small pop.\n\t\t\t\t\t\tconst on = amount.map((a) => (a >= 1 ? 1 : 0));\n\t\t\t\t\t\tconst opacity: KeyframeTuple[] = [[0, on[0] ?? 0]];\n\t\t\t\t\t\tconst scale: KeyframeTuple[] = [[0, 1]];\n\t\t\t\t\t\ton.forEach((v, t) => {\n\t\t\t\t\t\t\tif (t > 0 && v !== on[t - 1]) {\n\t\t\t\t\t\t\t\topacity.push([t, v, \"hold\"]);\n\t\t\t\t\t\t\t\tif (v === 1) scale.push([t, 1.18], [t + 6, 1, \"power2.out\"]);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t\tlit.animate(\n\t\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t\t.keys(\"opacity\", opacity)\n\t\t\t\t\t\t\t\t.keys(\"scale\", ascending(scale)),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tchildren.push(base, lit);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst d = starPath(cell);\n\t\t\t\t\t\tconst base = host.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\td,\n\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\tfillColor: trackColor,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst star = host.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}-fill`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\td,\n\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\tfillColor: color,\n\t\t\t\t\t\t});\n\t\t\t\t\t\t// Fractional ratings fill a star partway: a clip that widens.\n\t\t\t\t\t\tconst clip = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}-clip`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: 0,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t\t\tchildren: [star],\n\t\t\t\t\t\t});\n\t\t\t\t\t\tclip.animate(\n\t\t\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\t\t\"width\",\n\t\t\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\t\t\tamount.map((a) => a * cell),\n\t\t\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tchildren.push(base, clip);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tW = w;\n\t\t\t\tH = h;\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst c = number(Math.max(h * 1.4, 30 * s), \"end\");\n\t\t\t\t\tconst cc = c as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tcc.x = w + 18 * s;\n\t\t\t\t\tcc.y = h / 2 - cc.height / 2;\n\t\t\t\t\tW = cc.x + cc.width;\n\t\t\t\t\tH = Math.max(H, cc.y + cc.height);\n\t\t\t\t\tchildren.push(c);\n\t\t\t\t\tparts.counter = c;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tconst settled = Math.max(\n\t\t\tlast,\n\t\t\t(\n\t\t\t\tparts.counter as unknown as {\n\t\t\t\t\tcues?: { settled: Array<{ frame: number }> };\n\t\t\t\t}\n\t\t\t)?.cues?.settled[0]?.frame ?? 0,\n\t\t);\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: settled + 1,\n\t\t\topenEnded: true,\n\t\t\tcues: { settled: [{ frame: settled }], crossings } as MeterCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst out: KeyframeTuple[] = [];\n\tfor (const k of [...keys].sort((a, b) => a[0] - b[0])) {\n\t\twhile (out.length > 0 && (out[out.length - 1] as KeyframeTuple)[0] >= k[0])\n\t\t\tout.pop();\n\t\tout.push(k);\n\t}\n\treturn out;\n}\n\n/**\n * Progress and scores as a ring, arc gauge, bar, lit segments or star rating,\n * filling on the same timeline as its counter.\n */\nexport const Meter = /* @__PURE__ */ bindComponent(meter);\n"
|
|
32
|
+
"content": "/**\n * Meter: progress and scores as a ring, an arc gauge, a bar, lit segments or\n * a star rating, filling on the same value timeline as its `Counter`.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbind,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\ttoPathData,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\nimport { counter } from \"../counter/index.js\";\nimport { type ValueKey, valueAt } from \"../counter/plan.js\";\n\nexport const METER_KINDS = [\"ring\", \"arc\", \"bar\", \"segments\", \"stars\"] as const;\nexport type MeterKind = (typeof METER_KINDS)[number];\n\nexport interface MeterCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The fill (and counter) is at rest. */\n\tsettled: ReadonlyArray<{ frame: number }>;\n\t/** Each segment or star completing (fills only; empties are not cued). */\n\tcrossings: ReadonlyArray<{ frame: number; segment: number }>;\n}\n\n/** Points along a circular arc, angles in degrees (0 = right, clockwise). */\nfunction arcPoints(c: Vec2, r: number, from: number, to: number): Vec2[] {\n\tconst steps = Math.max(8, Math.ceil(Math.abs(to - from) / 6));\n\treturn Array.from({ length: steps + 1 }, (_, k) => {\n\t\tconst a = ((from + ((to - from) * k) / steps) * Math.PI) / 180;\n\t\treturn [c[0] + Math.cos(a) * r, c[1] + Math.sin(a) * r] as Vec2;\n\t});\n}\n\n/** A five-point star inside a `size` box. */\nfunction starPath(size: number): string {\n\tconst c = size / 2;\n\tconst pts: Vec2[] = Array.from({ length: 10 }, (_, k) => {\n\t\tconst r = k % 2 === 0 ? size * 0.5 : size * 0.21;\n\t\tconst a = -Math.PI / 2 + (k * Math.PI) / 5;\n\t\treturn [c + Math.cos(a) * r, c + Math.sin(a) * r * 1.02 + size * 0.03];\n\t});\n\treturn toPathData(pts, true);\n}\n\nexport const meter = /* @__PURE__ */ defineComponent({\n\tname: \"Meter\",\n\tprops: () => ({\n\t\tvalue: z.number().optional(),\n\t\t/** Multi-step values: `[frame, value, ease?]`, frames ascending. */\n\t\tvalues: z\n\t\t\t.array(\n\t\t\t\tz.union([\n\t\t\t\t\tz.tuple([z.number().int().min(0), z.number()]),\n\t\t\t\t\tz.tuple([z.number().int().min(0), z.number(), z.string()]),\n\t\t\t\t]),\n\t\t\t)\n\t\t\t.min(1)\n\t\t\t.optional(),\n\t\tfrom: z.number().default(0),\n\t\t/** Default 100 (5 for stars). */\n\t\tmax: z.number().optional(),\n\t\tkind: z.enum(METER_KINDS).default(\"ring\"),\n\t\t/** Cells for `segments` (default 10), stars for `stars` (default `max`). */\n\t\tsegments: z.number().int().positive().optional(),\n\t\t/** Width (bar, segments, stars) or diameter (ring, arc). */\n\t\tsize: z.number().positive().optional(),\n\t\tthickness: z.number().positive().optional(),\n\t\tcounter: z.boolean().optional(),\n\t\tformat: z.string().default(\",d\"),\n\t\tsuffix: z.string().optional(),\n\t\tlabel: z.string().optional(),\n\t\tcolor: z.string().optional(),\n\t\ttrack: z.string().optional(),\n\t\tcountDuration: z.number().int().positive().optional(),\n\t\tease: z.string().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst kind = props.kind;\n\t\tconst max = props.max ?? (kind === \"stars\" ? 5 : 100);\n\t\tconst from = props.from;\n\t\tconst ease = props.ease ?? theme.motion.ease.move;\n\t\tconst color = props.color ?? theme.color.accent;\n\t\tconst trackColor = props.track ?? theme.color.line;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tif (props.value === undefined && !props.values)\n\t\t\tthrow new Error(\"Meter: give `value` (or `values`).\");\n\t\tconst keys: ValueKey[] = props.values\n\t\t\t? props.values.map((v) => [...v] as unknown as ValueKey)\n\t\t\t: [\n\t\t\t\t\t[0, from],\n\t\t\t\t\t[\n\t\t\t\t\t\tprops.countDuration ?? themeFrames(theme, \"slow\", fps) * 2,\n\t\t\t\t\t\tprops.value as number,\n\t\t\t\t\t\tease,\n\t\t\t\t\t],\n\t\t\t\t];\n\t\tconst last = (keys[keys.length - 1] as ValueKey)[0];\n\t\tconst fraction = (v: number) =>\n\t\t\tMath.max(0, Math.min(1, (v - from) / (max - from || 1)));\n\t\t/** Keys of `f(fraction)`, on exactly the value's keyframes and eases. */\n\t\tconst fillKeys = (f: (x: number) => number): KeyframeTuple[] =>\n\t\t\tkeys.map((k, i) =>\n\t\t\t\ti === 0\n\t\t\t\t\t? [k[0], f(fraction(k[1]))]\n\t\t\t\t\t: [k[0], f(fraction(k[1])), k[2] ?? ease],\n\t\t\t);\n\t\tconst perFrame = Array.from({ length: last + 1 }, (_, t) =>\n\t\t\tvalueAt(keys, t, ease),\n\t\t);\n\n\t\tconst showCounter = props.counter ?? kind !== \"stars\";\n\t\tconst suffix = props.suffix ?? (max === 100 ? \"%\" : \"\");\n\t\tconst makeCounter = bind(host, counter);\n\t\tconst number = (size: number, align: \"start\" | \"center\" | \"end\") =>\n\t\t\tmakeCounter({\n\t\t\t\tid: ctx.partId(\"counter\"),\n\t\t\t\tvalues: keys.map((k) => [...k]) as never,\n\t\t\t\tformat: props.format,\n\t\t\t\tsuffix,\n\t\t\t\taffixScale: 0.6,\n\t\t\t\tdigitStagger: 0,\n\t\t\t\tease,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: 700,\n\t\t\t\talign,\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\ttheme,\n\t\t\t\tfps,\n\t\t\t});\n\t\tconst label = (text: string, size: number) =>\n\t\t\thost.text(text, {\n\t\t\t\tid: ctx.partId(\"label\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t});\n\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst crossings: Array<{ frame: number; segment: number }> = [];\n\t\tlet W = 0;\n\t\tlet H = 0;\n\n\t\tconst stroke = (\n\t\t\tid: string,\n\t\t\td: string,\n\t\t\tw: number,\n\t\t\th: number,\n\t\t\tpaint: string,\n\t\t\tthickness: number,\n\t\t) =>\n\t\t\thost.shape(\"path\", {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: w,\n\t\t\t\theight: h,\n\t\t\t\td,\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: paint,\n\t\t\t\tstrokeWidth: thickness,\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t});\n\n\t\tswitch (kind) {\n\t\t\tcase \"ring\":\n\t\t\tcase \"arc\": {\n\t\t\t\tconst D = props.size ?? 260 * s;\n\t\t\t\tconst T = props.thickness ?? D * (kind === \"ring\" ? 0.085 : 0.1);\n\t\t\t\tconst r = (D - T) / 2;\n\t\t\t\tconst c: Vec2 = [D / 2, D / 2];\n\t\t\t\t// Ring: a full turn from the top. Arc: 240° from lower left, over the top.\n\t\t\t\tconst [a0, a1] = kind === \"ring\" ? [-90, 269.9] : [150, 390];\n\t\t\t\tconst path = toPathData(arcPoints(c, r, a0, a1));\n\t\t\t\tW = D;\n\t\t\t\tH = kind === \"ring\" ? D : D * 0.86;\n\t\t\t\tconst track = stroke(\"track\", path, W, H, trackColor, T);\n\t\t\t\tconst fill = stroke(\"fill\", path, W, H, color, T);\n\t\t\t\tfill.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"trimEnd\",\n\t\t\t\t\t\tfillKeys((x) => x),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tchildren.push(track, fill);\n\t\t\t\tparts.track = track;\n\t\t\t\tparts.fill = fill;\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst n = number(D * (kind === \"ring\" ? 0.24 : 0.22), \"center\");\n\t\t\t\t\tconst nn = n as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tnn.x = (W - nn.width) / 2;\n\t\t\t\t\tnn.y =\n\t\t\t\t\t\t(kind === \"ring\" ? D / 2 : D * 0.48) -\n\t\t\t\t\t\tnn.height / 2 -\n\t\t\t\t\t\t(props.label ? D * 0.04 : 0);\n\t\t\t\t\tchildren.push(n);\n\t\t\t\t\tparts.counter = n;\n\t\t\t\t\tif (props.label) {\n\t\t\t\t\t\tconst size = D * 0.075;\n\t\t\t\t\t\tconst m = host.measure(props.label, { fontFamily, fontSize: size });\n\t\t\t\t\t\tconst l = label(props.label, size);\n\t\t\t\t\t\tObject.assign(l, { x: (W - m.width) / 2, y: nn.y + nn.height });\n\t\t\t\t\t\tchildren.push(l);\n\t\t\t\t\t\tparts.label = l;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"bar\": {\n\t\t\t\tconst w = props.size ?? 640 * s;\n\t\t\t\tconst T = props.thickness ?? 16 * s;\n\t\t\t\tconst top = props.label ? 34 * s : 0;\n\t\t\t\tconst track = host.box({\n\t\t\t\t\tid: ctx.partId(\"track\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: top,\n\t\t\t\t\twidth: w,\n\t\t\t\t\theight: T,\n\t\t\t\t\tbackground: trackColor,\n\t\t\t\t\tborderRadius: T / 2,\n\t\t\t\t});\n\t\t\t\tconst fill = host.box({\n\t\t\t\t\tid: ctx.partId(\"fill\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: top,\n\t\t\t\t\twidth: 0,\n\t\t\t\t\theight: T,\n\t\t\t\t\tbackground: color,\n\t\t\t\t\tborderRadius: T / 2,\n\t\t\t\t});\n\t\t\t\tfill.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"width\",\n\t\t\t\t\t\tfillKeys((x) => x * w),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tchildren.push(track, fill);\n\t\t\t\tparts.track = track;\n\t\t\t\tparts.fill = fill;\n\t\t\t\tW = w;\n\t\t\t\tH = top + T;\n\t\t\t\tif (props.label) {\n\t\t\t\t\tconst l = label(props.label, 24 * s);\n\t\t\t\t\tObject.assign(l, { x: 0, y: 0 });\n\t\t\t\t\tchildren.push(l);\n\t\t\t\t\tparts.label = l;\n\t\t\t\t}\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst n = number(Math.max(T * 2.2, 30 * s), \"end\");\n\t\t\t\t\tconst nn = n as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tnn.x = w + 18 * s;\n\t\t\t\t\tnn.y = top + T / 2 - nn.height / 2;\n\t\t\t\t\tW = nn.x + nn.width;\n\t\t\t\t\tH = Math.max(H, nn.y + nn.height);\n\t\t\t\t\tchildren.push(n);\n\t\t\t\t\tparts.counter = n;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"segments\":\n\t\t\tcase \"stars\": {\n\t\t\t\tconst n = props.segments ?? (kind === \"stars\" ? Math.round(max) : 10);\n\t\t\t\tconst w = props.size ?? (kind === \"stars\" ? 300 : 640) * s;\n\t\t\t\tconst gap = kind === \"stars\" ? w * 0.03 : w * 0.012;\n\t\t\t\tconst cell = (w - gap * (n - 1)) / n;\n\t\t\t\tconst h = kind === \"stars\" ? cell : (props.thickness ?? 22 * s);\n\t\t\t\t// How full each cell is, per frame.\n\t\t\t\tconst fill = (k: number) =>\n\t\t\t\t\tperFrame.map((v) => Math.max(0, Math.min(1, fraction(v) * n - k)));\n\t\t\t\tfor (let k = 0; k < n; k++) {\n\t\t\t\t\tconst x = k * (cell + gap);\n\t\t\t\t\tconst amount = fill(k);\n\t\t\t\t\tconst full = amount.findIndex((a) => a >= 1);\n\t\t\t\t\tif (full >= 0) crossings.push({ frame: full, segment: k });\n\t\t\t\t\tif (kind === \"segments\") {\n\t\t\t\t\t\tconst base = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`cell-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: h,\n\t\t\t\t\t\t\tbackground: trackColor,\n\t\t\t\t\t\t\tborderRadius: Math.min(h, cell) * 0.25,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst lit = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`lit-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: h,\n\t\t\t\t\t\t\tbackground: color,\n\t\t\t\t\t\t\tborderRadius: Math.min(h, cell) * 0.25,\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t});\n\t\t\t\t\t\t// Lights on the frame the value reaches the cell, with a small pop.\n\t\t\t\t\t\tconst on = amount.map((a) => (a >= 1 ? 1 : 0));\n\t\t\t\t\t\tconst opacity: KeyframeTuple[] = [[0, on[0] ?? 0]];\n\t\t\t\t\t\tconst scale: KeyframeTuple[] = [[0, 1]];\n\t\t\t\t\t\ton.forEach((v, t) => {\n\t\t\t\t\t\t\tif (t > 0 && v !== on[t - 1]) {\n\t\t\t\t\t\t\t\topacity.push([t, v, \"hold\"]);\n\t\t\t\t\t\t\t\tif (v === 1) scale.push([t, 1.18], [t + 6, 1, \"power2.out\"]);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t\tlit.animate(\n\t\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t\t.keys(\"opacity\", opacity)\n\t\t\t\t\t\t\t\t.keys(\"scale\", ascending(scale)),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tchildren.push(base, lit);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst d = starPath(cell);\n\t\t\t\t\t\tconst base = host.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\td,\n\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\tfillColor: trackColor,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst star = host.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}-fill`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\td,\n\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\tfillColor: color,\n\t\t\t\t\t\t});\n\t\t\t\t\t\t// Fractional ratings fill a star partway: a clip that widens.\n\t\t\t\t\t\tconst clip = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}-clip`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: 0,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t\t\tchildren: [star],\n\t\t\t\t\t\t});\n\t\t\t\t\t\tclip.animate(\n\t\t\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\t\t\"width\",\n\t\t\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\t\t\tamount.map((a) => a * cell),\n\t\t\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tchildren.push(base, clip);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tW = w;\n\t\t\t\tH = h;\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst c = number(Math.max(h * 1.4, 30 * s), \"end\");\n\t\t\t\t\tconst cc = c as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tcc.x = w + 18 * s;\n\t\t\t\t\tcc.y = h / 2 - cc.height / 2;\n\t\t\t\t\tW = cc.x + cc.width;\n\t\t\t\t\tH = Math.max(H, cc.y + cc.height);\n\t\t\t\t\tchildren.push(c);\n\t\t\t\t\tparts.counter = c;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\t// Centring the counter on a shorter row can put it above the top; the\n\t\t// root box clips what's outside it, so move everything down instead.\n\t\tconst top = Math.min(\n\t\t\t0,\n\t\t\t...children.map((n) => (n as unknown as { y?: number }).y ?? 0),\n\t\t);\n\t\tif (top < 0) {\n\t\t\tfor (const n of children) {\n\t\t\t\tconst node = n as unknown as { y?: number };\n\t\t\t\tnode.y = (node.y ?? 0) - top;\n\t\t\t}\n\t\t\tH -= top;\n\t\t}\n\n\t\tconst settled = Math.max(\n\t\t\tlast,\n\t\t\t(\n\t\t\t\tparts.counter as unknown as {\n\t\t\t\t\tcues?: { settled: Array<{ frame: number }> };\n\t\t\t\t}\n\t\t\t)?.cues?.settled[0]?.frame ?? 0,\n\t\t);\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: settled + 1,\n\t\t\topenEnded: true,\n\t\t\tcues: { settled: [{ frame: settled }], crossings } as MeterCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst out: KeyframeTuple[] = [];\n\tfor (const k of [...keys].sort((a, b) => a[0] - b[0])) {\n\t\twhile (out.length > 0 && (out[out.length - 1] as KeyframeTuple)[0] >= k[0])\n\t\t\tout.pop();\n\t\tout.push(k);\n\t}\n\treturn out;\n}\n\n/**\n * Progress and scores as a ring, arc gauge, bar, lit segments or star rating,\n * filling on the same timeline as its counter.\n */\nexport const Meter = /* @__PURE__ */ bindComponent(meter);\n"
|
|
33
33
|
}
|
|
34
34
|
]
|
|
35
35
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { F as describeGpu, L as initHeadlessWebGPU, p as GpuAdapterDescription } from "../index-
|
|
2
|
-
import { _ as renderSemaphore, a as renderFrameGrid, c as HeadlessMediaRenderer, d as QaSegment, f as RenderProgress, g as formatQaReport, h as VideoQaStats, i as computeGridLayout, l as HeadlessWebGPURenderer, m as VideoQaReport, n as FrameGridRenderable, o as resolveSamplePoints, p as VideoQaOptions, r as FrameSamplePoint, s as AudioQaStats, t as FrameGridOptions, u as QaIssue, v as resetRenderCaches } from "../index-
|
|
1
|
+
import { F as describeGpu, L as initHeadlessWebGPU, p as GpuAdapterDescription } from "../index-ocZc12ZS.mjs";
|
|
2
|
+
import { _ as renderSemaphore, a as renderFrameGrid, c as HeadlessMediaRenderer, d as QaSegment, f as RenderProgress, g as formatQaReport, h as VideoQaStats, i as computeGridLayout, l as HeadlessWebGPURenderer, m as VideoQaReport, n as FrameGridRenderable, o as resolveSamplePoints, p as VideoQaOptions, r as FrameSamplePoint, s as AudioQaStats, t as FrameGridOptions, u as QaIssue, v as resetRenderCaches } from "../index-BhzlG_1Q.mjs";
|
|
3
3
|
export { AudioQaStats, FrameGridOptions, FrameGridRenderable, FrameSamplePoint, GpuAdapterDescription, HeadlessMediaRenderer, HeadlessWebGPURenderer, QaIssue, QaSegment, RenderProgress, VideoQaOptions, VideoQaReport, VideoQaStats, computeGridLayout, describeGpu, formatQaReport, initHeadlessWebGPU, renderFrameGrid, renderSemaphore, resetRenderCaches, resolveSamplePoints };
|
package/dist/renderer/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "../chunk-D7gtBK74.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { a as HeadlessMediaRenderer, c as renderSemaphore, i as resetRenderCaches, n as renderFrameGrid, o as HeadlessWebGPURenderer, r as resolveSamplePoints, s as formatQaReport, t as computeGridLayout } from "../renderer-
|
|
2
|
+
import { R as initHeadlessWebGPU, x as describeGpu } from "../dist-BuirkW-f.mjs";
|
|
3
|
+
import { a as HeadlessMediaRenderer, c as renderSemaphore, i as resetRenderCaches, n as renderFrameGrid, o as HeadlessWebGPURenderer, r as resolveSamplePoints, s as formatQaReport, t as computeGridLayout } from "../renderer-BfQdc3RM.mjs";
|
|
4
4
|
|
|
5
5
|
export { HeadlessMediaRenderer, HeadlessWebGPURenderer, computeGridLayout, describeGpu, formatQaReport, initHeadlessWebGPU, renderFrameGrid, renderSemaphore, resetRenderCaches, resolveSamplePoints };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { a as configBuilder, i as VideoResultSchema, n as ImageResultSchema } from "./dist-
|
|
3
|
-
import {
|
|
4
|
-
import { a as createFullscreenPipeline, c as drawTextureToTarget, d as signalTextureView, f as signalTiming, l as fragmentBindGroupLayout, o as drawChildToTexture, p as texturesLayout, s as drawFullscreenPass, t as FULLSCREEN_QUAD_WGSL, u as perDevice } from "./gpu-
|
|
1
|
+
import { n as defineRenderer, u as unwrapFrameParam } from "./renderer-BgmO1Dzw.mjs";
|
|
2
|
+
import { a as configBuilder, i as VideoResultSchema, n as ImageResultSchema } from "./dist-BPOi5xb_.mjs";
|
|
3
|
+
import { In as signalRegistry } from "./dist-BuirkW-f.mjs";
|
|
4
|
+
import { a as createFullscreenPipeline, c as drawTextureToTarget, d as signalTextureView, f as signalTiming, l as fragmentBindGroupLayout, o as drawChildToTexture, p as texturesLayout, s as drawFullscreenPass, t as FULLSCREEN_QUAD_WGSL, u as perDevice } from "./gpu-BRBIoxMC.mjs";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
|
|
7
7
|
//#region ../../nodes/node-blur/dist/renderers-rcrwEq-B.mjs
|
|
@@ -845,4 +845,4 @@ var renderers_default = defineRenderer({ WebGPURenderer: BlurWebGPURenderer });
|
|
|
845
845
|
|
|
846
846
|
//#endregion
|
|
847
847
|
export { renderers_default as t };
|
|
848
|
-
//# sourceMappingURL=renderer-
|
|
848
|
+
//# sourceMappingURL=renderer-1NoJLLDa.mjs.map
|