framefields 2.4.3 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/audio/index.d.mts +4 -4
- package/dist/audio/index.mjs +13 -13
- package/dist/{audio-DJeVoE8b.mjs → audio-BluXgSeG.mjs} +1 -1
- package/dist/{audio-DJeVoE8b.mjs.map → audio-BluXgSeG.mjs.map} +1 -1
- package/dist/{audio-BymXfAvH.d.mts → audio-C1A4N3Wh.d.mts} +3 -3
- package/dist/{audio-BymXfAvH.d.mts.map → audio-C1A4N3Wh.d.mts.map} +1 -1
- package/dist/{audio-BzHHpImj.mjs → audio-CZwVENqB.mjs} +4 -4
- package/dist/{audio-BzHHpImj.mjs.map → audio-CZwVENqB.mjs.map} +1 -1
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +221 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/components/index.d.mts +12 -0
- package/dist/components/index.mjs +49 -0
- package/dist/{dist-Bo2_zeCn.mjs → dist-CQZ5_60A.mjs} +2 -2
- package/dist/{dist-Bo2_zeCn.mjs.map → dist-CQZ5_60A.mjs.map} +1 -1
- package/dist/{dist-0QGroRYZ.mjs → dist-C_SMBmHr.mjs} +1 -1
- package/dist/{dist-0QGroRYZ.mjs.map → dist-C_SMBmHr.mjs.map} +1 -1
- package/dist/{dist-hmWTJPik.mjs → dist-DBWkkSvJ.mjs} +113 -68
- package/dist/dist-DBWkkSvJ.mjs.map +1 -0
- package/dist/{dist-TrtLGPvc.mjs → dist-DFeF-ygz.mjs} +1 -1
- package/dist/{dist-TrtLGPvc.mjs.map → dist-DFeF-ygz.mjs.map} +1 -1
- package/dist/{dist-DMa2BbYs.mjs → dist-DHQVWaV2.mjs} +53 -21
- package/dist/dist-DHQVWaV2.mjs.map +1 -0
- package/dist/effects/index.d.mts +4 -4
- package/dist/effects/index.mjs +36 -36
- package/dist/{effects-CiZjSwIh.mjs → effects-DZvq-nWT.mjs} +3 -3
- package/dist/{effects-CiZjSwIh.mjs.map → effects-DZvq-nWT.mjs.map} +1 -1
- package/dist/fonts/index.d.mts +1 -1
- package/dist/fonts/index.mjs +3 -3
- package/dist/{fonts-Da1JDKpc.mjs → fonts-DcsxFpyU.mjs} +3 -2
- package/dist/fonts-DcsxFpyU.mjs.map +1 -0
- package/dist/{gpu-CHIgM_YH.mjs → gpu-Dq_uI_00.mjs} +2 -2
- package/dist/{gpu-CHIgM_YH.mjs.map → gpu-Dq_uI_00.mjs.map} +1 -1
- package/dist/{index-DIvWBm-q.d.mts → index-B-KVpJMb.d.mts} +2 -2
- package/dist/{index-DIvWBm-q.d.mts.map → index-B-KVpJMb.d.mts.map} +1 -1
- package/dist/{index-WMofVN02.d.mts → index-BpdVOlsV.d.mts} +3 -3
- package/dist/{index-WMofVN02.d.mts.map → index-BpdVOlsV.d.mts.map} +1 -1
- package/dist/index-C2f4Hm-p.d.mts +89 -0
- package/dist/index-C2f4Hm-p.d.mts.map +1 -0
- package/dist/{index-B-55dXDu.d.mts → index-CalU-mpZ.d.mts} +1 -1
- package/dist/{index-B-55dXDu.d.mts.map → index-CalU-mpZ.d.mts.map} +1 -1
- package/dist/{index-CJkihekp.d.mts → index-D6-lH1Mx.d.mts} +189 -189
- package/dist/index-D6-lH1Mx.d.mts.map +1 -0
- package/dist/index-DKssQ_wn.d.mts +5968 -0
- package/dist/index-DKssQ_wn.d.mts.map +1 -0
- package/dist/{index-Dwf76Qep.d.mts → index-DNKM3to5.d.mts} +3 -3
- package/dist/{index-Dwf76Qep.d.mts.map → index-DNKM3to5.d.mts.map} +1 -1
- package/dist/{index-DGe83M-p.d.mts → index-fymoeGNW.d.mts} +6 -1
- package/dist/index-fymoeGNW.d.mts.map +1 -0
- package/dist/index.d.mts +13 -4659
- package/dist/index.mjs +59 -6654
- package/dist/preview/index.d.mts +4 -90
- package/dist/preview/index.mjs +12 -12
- package/dist/{preview-5E4B96RD.mjs → preview-CYI4i4m3.mjs} +5 -4
- package/dist/preview-CYI4i4m3.mjs.map +1 -0
- package/dist/preview-engine/audio.mjs +1 -1
- package/dist/preview-engine/{chunk-TFFG5YFI.mjs → chunk-24H3QVYB.mjs} +1 -1
- package/dist/preview-engine/{chunk-7J4OK4VE.mjs → chunk-25T3M6MI.mjs} +1 -1
- package/dist/preview-engine/{chunk-O3NOB5IB.mjs → chunk-2QQQTZHS.mjs} +1 -1
- package/dist/preview-engine/{chunk-XTPEGKOZ.mjs → chunk-2RZ6S4SW.mjs} +1 -1
- package/dist/preview-engine/{chunk-37P2KIFE.mjs → chunk-3AYICEZ5.mjs} +1 -1
- package/dist/preview-engine/{chunk-JTUK7ZXK.mjs → chunk-3I6B3YPF.mjs} +1 -1
- package/dist/preview-engine/{chunk-LQBDZSQD.mjs → chunk-6GMEF2QB.mjs} +1 -1
- package/dist/preview-engine/{chunk-5CWQHYQP.mjs → chunk-7BUKVFWB.mjs} +1 -1
- package/dist/preview-engine/{chunk-AWX4YGC5.mjs → chunk-7JYMNGNK.mjs} +1 -1
- package/dist/preview-engine/chunk-AB5VITYM.mjs +1974 -0
- package/dist/preview-engine/{chunk-LSBCFJZM.mjs → chunk-BNO5JAEZ.mjs} +1 -1
- package/dist/preview-engine/{chunk-6WKWYUOM.mjs → chunk-CE6CG5BH.mjs} +1 -1
- package/dist/preview-engine/{chunk-LQENNFBA.mjs → chunk-CT2WFPYG.mjs} +11 -11
- package/dist/preview-engine/{chunk-5JSSXOBB.mjs → chunk-DUEY7AXY.mjs} +1 -1
- package/dist/preview-engine/{chunk-UDDK5QZF.mjs → chunk-DWHQSNSJ.mjs} +1 -1
- package/dist/preview-engine/{chunk-AY2ZIXST.mjs → chunk-ENS4KUJV.mjs} +1 -1
- package/dist/preview-engine/chunk-EU7CFSB5.mjs +1 -0
- package/dist/preview-engine/chunk-I6E3AKLS.mjs +1 -0
- package/dist/preview-engine/{chunk-7QE6II7R.mjs → chunk-JMOJKT5W.mjs} +1 -1
- package/dist/preview-engine/{chunk-NIRW77QA.mjs → chunk-KJGNTREI.mjs} +1 -1
- package/dist/preview-engine/{chunk-ILBZQLUF.mjs → chunk-KKBJ7X3T.mjs} +1 -1
- package/dist/preview-engine/{chunk-GQN35VLT.mjs → chunk-LNJYHSYX.mjs} +1 -1
- package/dist/preview-engine/{chunk-6TGNQ5FD.mjs → chunk-LQVACZDE.mjs} +1 -1
- package/dist/preview-engine/{chunk-YQYLBXOX.mjs → chunk-M6ZKK6ZU.mjs} +1 -1
- package/dist/preview-engine/{chunk-EEOUHDUD.mjs → chunk-MA4YVA2E.mjs} +1 -1
- package/dist/preview-engine/{chunk-JAJOZG5P.mjs → chunk-MBJXS3D7.mjs} +1 -1
- package/dist/preview-engine/{chunk-BIS3W4Z5.mjs → chunk-MCR7XMNQ.mjs} +1 -1
- package/dist/preview-engine/{chunk-C45ROXI6.mjs → chunk-NAISBHLR.mjs} +11 -11
- package/dist/preview-engine/{chunk-AKYUVDKM.mjs → chunk-NB55FW7D.mjs} +1 -1
- package/dist/preview-engine/{chunk-3SOD7OTO.mjs → chunk-NHQR76WL.mjs} +1 -1
- package/dist/preview-engine/{chunk-MJN3LOZ7.mjs → chunk-NKGXINCU.mjs} +1 -1
- package/dist/preview-engine/{chunk-FDNR4PRC.mjs → chunk-NXKXOSE6.mjs} +1 -1
- package/dist/preview-engine/{chunk-N3PRZLZJ.mjs → chunk-NYEDWUFS.mjs} +1 -1
- package/dist/preview-engine/{chunk-W3QGP5XF.mjs → chunk-P734GD7D.mjs} +1 -1
- package/dist/preview-engine/{chunk-YJ6GWVL4.mjs → chunk-PDM3TJ45.mjs} +1 -1
- package/dist/preview-engine/{chunk-KJ2DTA77.mjs → chunk-PXWCGXTF.mjs} +1 -1
- package/dist/preview-engine/{chunk-V6LCSK6Q.mjs → chunk-QCUGWPYP.mjs} +1 -1
- package/dist/preview-engine/{chunk-JJYNYYPA.mjs → chunk-QJYAIQYU.mjs} +1 -1
- package/dist/preview-engine/{chunk-VUFDQJ2B.mjs → chunk-QKQYHPBK.mjs} +1 -1
- package/dist/preview-engine/{chunk-NVTPJUNG.mjs → chunk-QVW5METG.mjs} +1 -1
- package/dist/preview-engine/{chunk-UMF7NNUA.mjs → chunk-QYIKOOFI.mjs} +1 -1
- package/dist/preview-engine/{chunk-FSAELHVT.mjs → chunk-RNFYEZLL.mjs} +1 -1
- package/dist/preview-engine/{chunk-CEIOI4HO.mjs → chunk-SIXIF5QZ.mjs} +1 -1
- package/dist/preview-engine/chunk-SNASF7OM.mjs +3 -0
- package/dist/preview-engine/{chunk-A2775RSO.mjs → chunk-T4VP45AP.mjs} +1 -1
- package/dist/preview-engine/{chunk-5EVEGKWT.mjs → chunk-TE2QAKKF.mjs} +1 -1
- package/dist/preview-engine/{chunk-6MBYREBG.mjs → chunk-UXCTWFKK.mjs} +1 -1
- package/dist/preview-engine/{chunk-V6DAD4S4.mjs → chunk-V3FODTPI.mjs} +2 -2
- package/dist/preview-engine/{chunk-JJA4G3OU.mjs → chunk-VH5V54RA.mjs} +1 -1
- package/dist/preview-engine/{chunk-GIH5QURF.mjs → chunk-VTE3IISK.mjs} +1 -1
- package/dist/preview-engine/chunk-W4HIVUUA.mjs +4 -0
- package/dist/preview-engine/{chunk-O5LA63CK.mjs → chunk-WUMVCQYI.mjs} +1 -1
- package/dist/preview-engine/{chunk-U4SQUTDV.mjs → chunk-X6BJLPW6.mjs} +1 -1
- package/dist/preview-engine/{chunk-54HE34HO.mjs → chunk-XJ4CQIZZ.mjs} +1 -1
- package/dist/preview-engine/{chunk-6AEU4DC7.mjs → chunk-XUPZBI3Y.mjs} +6 -6
- package/dist/preview-engine/{chunk-LMZHCWXP.mjs → chunk-XYRBFLFF.mjs} +1 -1
- package/dist/preview-engine/{chunk-S7NG26UG.mjs → chunk-YJ24W73B.mjs} +1 -1
- package/dist/preview-engine/{chunk-5SVJIGPK.mjs → chunk-Z4TKEI3I.mjs} +1 -1
- package/dist/preview-engine/{chunk-UVWGZUKZ.mjs → chunk-ZAIVXGSS.mjs} +1 -1
- package/dist/preview-engine/{chunk-OZDZQMTB.mjs → chunk-ZVUTFXDR.mjs} +1 -1
- package/dist/preview-engine/components.mjs +1 -0
- package/dist/preview-engine/effects.mjs +1 -1
- package/dist/preview-engine/fonts.mjs +1 -1
- package/dist/preview-engine/framefields.mjs +1 -1971
- package/dist/preview-engine/player.mjs +5 -5
- package/dist/preview-engine/renderers-2S5ZWL2J.mjs +1 -0
- package/dist/preview-engine/renderers-37CN7O4S.mjs +1 -0
- package/dist/preview-engine/renderers-3UGG2IMP.mjs +1 -0
- package/dist/preview-engine/{renderers-2A7YDJMJ.mjs → renderers-4RFB2LG2.mjs} +1 -1
- package/dist/preview-engine/renderers-6NFRKHNO.mjs +1 -0
- package/dist/preview-engine/{renderers-QJNGN722.mjs → renderers-6PVGRTXV.mjs} +1 -1
- package/dist/preview-engine/renderers-7MPBAHYA.mjs +1 -0
- package/dist/preview-engine/renderers-AFTYMS3H.mjs +1 -0
- package/dist/preview-engine/renderers-AKZAAGR7.mjs +1 -0
- package/dist/preview-engine/renderers-AYEWAW7F.mjs +1 -0
- package/dist/preview-engine/{renderers-5OGAF66H.mjs → renderers-BNXTSIXJ.mjs} +1 -1
- package/dist/preview-engine/{renderers-XPO2WQNF.mjs → renderers-BQLYS4TM.mjs} +1 -1
- package/dist/preview-engine/renderers-C7SJ3M6S.mjs +1 -0
- package/dist/preview-engine/{renderers-VD5GHHK6.mjs → renderers-CEJP6Q7F.mjs} +1 -1
- package/dist/preview-engine/{renderers-KW75FYFG.mjs → renderers-CLLR5MOQ.mjs} +1 -1
- package/dist/preview-engine/{renderers-PM4Q5QOW.mjs → renderers-DHKB5LI6.mjs} +1 -1
- package/dist/preview-engine/renderers-DUF2CTX6.mjs +1 -0
- package/dist/preview-engine/{renderers-XTRFEQO4.mjs → renderers-DZZFIYYA.mjs} +1 -1
- package/dist/preview-engine/renderers-EIJFIY6L.mjs +1 -0
- package/dist/preview-engine/renderers-EOMMSENQ.mjs +1 -0
- package/dist/preview-engine/{renderers-NJAKGI25.mjs → renderers-FOHW7RWH.mjs} +1 -1
- package/dist/preview-engine/{renderers-K23EAM2J.mjs → renderers-GF77N2KM.mjs} +1 -1
- package/dist/preview-engine/renderers-H3ZM2TOA.mjs +1 -0
- package/dist/preview-engine/{renderers-NPKRNXAT.mjs → renderers-HJ6NO5ZD.mjs} +1 -1
- package/dist/preview-engine/renderers-JXBZPQKP.mjs +1 -0
- package/dist/preview-engine/renderers-KSQM7EM5.mjs +1 -0
- package/dist/preview-engine/{renderers-WNMRLGZS.mjs → renderers-KV3TTCO6.mjs} +1 -1
- package/dist/preview-engine/{renderers-U2ZWRL25.mjs → renderers-L7EITUCD.mjs} +1 -1
- package/dist/preview-engine/renderers-LD3COS4X.mjs +1 -0
- package/dist/preview-engine/renderers-MCTBJEL7.mjs +1 -0
- package/dist/preview-engine/renderers-MKEXNBCW.mjs +1 -0
- package/dist/preview-engine/{renderers-ZRR7BLB7.mjs → renderers-MPCYLXXU.mjs} +1 -1
- package/dist/preview-engine/{renderers-DKOWLKDE.mjs → renderers-MS4HG3M4.mjs} +1 -1
- package/dist/preview-engine/{renderers-MHDLRY3S.mjs → renderers-N7T34SCI.mjs} +1 -1
- package/dist/preview-engine/{renderers-2GE5HE56.mjs → renderers-O56IOD7J.mjs} +1 -1
- package/dist/preview-engine/renderers-OZGEXW2X.mjs +1 -0
- package/dist/preview-engine/renderers-PSEDMUBV.mjs +1 -0
- package/dist/preview-engine/renderers-PVUROEGR.mjs +1 -0
- package/dist/preview-engine/renderers-QBVVTQV6.mjs +1 -0
- package/dist/preview-engine/{renderers-7M3YOI7E.mjs → renderers-QO2RHF7F.mjs} +1 -1
- package/dist/preview-engine/renderers-QPFZBLDI.mjs +1 -0
- package/dist/preview-engine/renderers-R4U2TK6P.mjs +1 -0
- package/dist/preview-engine/renderers-RBLNDAO4.mjs +1 -0
- package/dist/preview-engine/renderers-RM25AXWZ.mjs +1 -0
- package/dist/preview-engine/{renderers-DFJDEPCU.mjs → renderers-ROSSJ7Y2.mjs} +1 -1
- package/dist/preview-engine/{renderers-4H34AFW2.mjs → renderers-RRJ2DX2W.mjs} +1 -1
- package/dist/preview-engine/renderers-RYRFCEYT.mjs +1 -0
- package/dist/preview-engine/renderers-SIBP54WK.mjs +1 -0
- package/dist/preview-engine/renderers-SRU6QXLN.mjs +1 -0
- package/dist/preview-engine/{renderers-TFEDHXPY.mjs → renderers-T62TXGVO.mjs} +1 -1
- package/dist/preview-engine/{renderers-6GYRMHGQ.mjs → renderers-TAMDNTEQ.mjs} +1 -1
- package/dist/preview-engine/{renderers-LF6W5P2I.mjs → renderers-TASYN62K.mjs} +1 -1
- package/dist/preview-engine/renderers-TBP2EKJ4.mjs +1 -0
- package/dist/preview-engine/{renderers-FNPXT2EH.mjs → renderers-TWZHTKW7.mjs} +1 -1
- package/dist/preview-engine/renderers-UQHKFGDX.mjs +1 -0
- package/dist/preview-engine/{renderers-35G4KCQ2.mjs → renderers-UVABXXQF.mjs} +1 -1
- package/dist/preview-engine/{renderers-XTCBHTHC.mjs → renderers-VYK2BJID.mjs} +1 -1
- package/dist/preview-engine/{renderers-Q6AO5M54.mjs → renderers-W5CTR4B5.mjs} +1 -1
- package/dist/preview-engine/renderers-WDV7ENJA.mjs +1 -0
- package/dist/preview-engine/renderers-WNBDSSJ3.mjs +1 -0
- package/dist/preview-engine/{renderers-ATL5ARUV.mjs → renderers-WPBTAU2E.mjs} +1 -1
- package/dist/preview-engine/renderers-WTC2RNGU.mjs +1 -0
- package/dist/preview-engine/renderers-XLBHSQZJ.mjs +228 -0
- package/dist/preview-engine/{renderers-WOLTH5VI.mjs → renderers-XSPU2BIQ.mjs} +1 -1
- package/dist/preview-engine/renderers-XVUOO7B6.mjs +1 -0
- package/dist/preview-engine/renderers-YHOQUEHD.mjs +1 -0
- package/dist/preview-engine/renderers-YPFM5UJO.mjs +1 -0
- package/dist/preview-engine/renderers-YW3LBALW.mjs +1 -0
- package/dist/preview-engine/signals.mjs +1 -1
- package/dist/{program-KwDdbC0-.mjs → program-BdPDtfVv.mjs} +133 -30
- package/dist/program-BdPDtfVv.mjs.map +1 -0
- package/dist/registry/annotate.json +30 -0
- package/dist/registry/app-frame.json +38 -0
- package/dist/registry/backdrop.json +37 -0
- package/dist/registry/before-after.json +33 -0
- package/dist/registry/callout.json +30 -0
- package/dist/registry/captions.json +37 -0
- package/dist/registry/card-stack.json +30 -0
- package/dist/registry/chat-thread.json +32 -0
- package/dist/registry/code-block.json +45 -0
- package/dist/registry/confetti.json +29 -0
- package/dist/registry/counter.json +36 -0
- package/dist/registry/device-frame.json +36 -0
- package/dist/registry/focus-zoom.json +32 -0
- package/dist/registry/index.json +408 -0
- package/dist/registry/logo-reveal.json +37 -0
- package/dist/registry/lower-third.json +34 -0
- package/dist/registry/meter.json +35 -0
- package/dist/registry/mouse-pointer.json +44 -0
- package/dist/registry/selection.json +31 -0
- package/dist/registry/spotlight.json +28 -0
- package/dist/registry/terminal.json +40 -0
- package/dist/registry/text-reveal.json +39 -0
- package/dist/registry/title-card.json +34 -0
- package/dist/registry/toasts.json +28 -0
- package/dist/registry/touch-point.json +33 -0
- package/dist/registry/waveform.json +29 -0
- package/dist/registry/word-cycle.json +32 -0
- package/dist/renderer/index.d.mts +2 -2
- package/dist/renderer/index.mjs +3 -3
- package/dist/{renderer-Bhfl-WFX.mjs → renderer-0cHsK6xR.mjs} +5 -5
- package/dist/{renderer-Bhfl-WFX.mjs.map → renderer-0cHsK6xR.mjs.map} +1 -1
- package/dist/{renderer-D4No_jA4.mjs → renderer-1VKN5j2C.mjs} +4 -4
- package/dist/{renderer-D4No_jA4.mjs.map → renderer-1VKN5j2C.mjs.map} +1 -1
- package/dist/{renderer-qYhNf6iB.mjs → renderer-2bipMLqz.mjs} +3 -3
- package/dist/{renderer-qYhNf6iB.mjs.map → renderer-2bipMLqz.mjs.map} +1 -1
- package/dist/{renderer-DK9etIAz.mjs → renderer-7DyjqxEj.mjs} +5 -5
- package/dist/{renderer-DK9etIAz.mjs.map → renderer-7DyjqxEj.mjs.map} +1 -1
- package/dist/renderer-B-D_VZg3.mjs +5 -0
- package/dist/{renderer-Ci6YMa4u.mjs → renderer-B31zMDE8.mjs} +5 -5
- package/dist/{renderer-Ci6YMa4u.mjs.map → renderer-B31zMDE8.mjs.map} +1 -1
- package/dist/{renderer-DP_In2uT.mjs → renderer-B4blg067.mjs} +4 -4
- package/dist/{renderer-DP_In2uT.mjs.map → renderer-B4blg067.mjs.map} +1 -1
- package/dist/{renderer-kNjMhwiZ.mjs → renderer-B5YYBBDq.mjs} +3 -3
- package/dist/{renderer-kNjMhwiZ.mjs.map → renderer-B5YYBBDq.mjs.map} +1 -1
- package/dist/renderer-B64dhpDX.mjs +5 -0
- package/dist/{renderer-mqUBkx-a.mjs → renderer-B8LXXvHi.mjs} +4 -4
- package/dist/{renderer-mqUBkx-a.mjs.map → renderer-B8LXXvHi.mjs.map} +1 -1
- package/dist/renderer-BA2Ko4H1.mjs +3 -0
- package/dist/{renderer-B4ydDdo6.mjs → renderer-BAkPq8qo.mjs} +3 -3
- package/dist/{renderer-B4ydDdo6.mjs.map → renderer-BAkPq8qo.mjs.map} +1 -1
- package/dist/{renderer-OEpwc9Zk.mjs → renderer-BBTdHKH7.mjs} +5 -5
- package/dist/{renderer-OEpwc9Zk.mjs.map → renderer-BBTdHKH7.mjs.map} +1 -1
- package/dist/{renderer-DGT141pN.mjs → renderer-BBTkfhL8.mjs} +5 -5
- package/dist/{renderer-DGT141pN.mjs.map → renderer-BBTkfhL8.mjs.map} +1 -1
- package/dist/renderer-BDL4jyrb.mjs +5 -0
- package/dist/{renderer-BtWWpFvl.mjs → renderer-BGNdwdCR.mjs} +4 -4
- package/dist/{renderer-BtWWpFvl.mjs.map → renderer-BGNdwdCR.mjs.map} +1 -1
- package/dist/{renderer-DXSIstnn.mjs → renderer-BJwOM1Dq.mjs} +4 -4
- package/dist/{renderer-DXSIstnn.mjs.map → renderer-BJwOM1Dq.mjs.map} +1 -1
- package/dist/renderer-BKCZGKkl.mjs +5 -0
- package/dist/renderer-BKs3jI7-.mjs +5 -0
- package/dist/{renderer-C5WkQZGR.mjs → renderer-BN_n6O6P.mjs} +5 -5
- package/dist/{renderer-C5WkQZGR.mjs.map → renderer-BN_n6O6P.mjs.map} +1 -1
- package/dist/renderer-BW74Zf-u.mjs +5 -0
- package/dist/{renderer-C1U85w9f.mjs → renderer-BWa3CLK7.mjs} +3 -3
- package/dist/{renderer-C1U85w9f.mjs.map → renderer-BWa3CLK7.mjs.map} +1 -1
- package/dist/{renderer-plx7_lUQ.mjs → renderer-BWnFh7NO.mjs} +5 -5
- package/dist/{renderer-plx7_lUQ.mjs.map → renderer-BWnFh7NO.mjs.map} +1 -1
- package/dist/renderer-BXuQN61O.mjs +5 -0
- package/dist/renderer-BY1pPvzx.mjs +6 -0
- package/dist/{renderer-CwO8T8l8.mjs → renderer-BYJS2lhn.mjs} +4 -4
- package/dist/{renderer-CwO8T8l8.mjs.map → renderer-BYJS2lhn.mjs.map} +1 -1
- package/dist/renderer-Bahthqvq.mjs +5 -0
- package/dist/renderer-BbaiQG8h.mjs +5 -0
- package/dist/{renderer-gm0zoV4t.mjs → renderer-BfrzKmRh.mjs} +3 -3
- package/dist/{renderer-gm0zoV4t.mjs.map → renderer-BfrzKmRh.mjs.map} +1 -1
- package/dist/renderer-BhHF-08y.mjs +5 -0
- package/dist/{renderer-DQeUc1yY.mjs → renderer-BhhweLr_.mjs} +4 -4
- package/dist/{renderer-DQeUc1yY.mjs.map → renderer-BhhweLr_.mjs.map} +1 -1
- package/dist/{renderer-OsA-Lg4d.mjs → renderer-Bmj7AyJP.mjs} +3 -3
- package/dist/{renderer-OsA-Lg4d.mjs.map → renderer-Bmj7AyJP.mjs.map} +1 -1
- package/dist/{renderer-DZhIe8Gj.mjs → renderer-BmoQJUJO.mjs} +4 -4
- package/dist/{renderer-DZhIe8Gj.mjs.map → renderer-BmoQJUJO.mjs.map} +1 -1
- package/dist/renderer-BnrMcw6C.mjs +5 -0
- package/dist/{renderer-Blm4Dv_h.mjs → renderer-Bo1PY0zW.mjs} +3 -3
- package/dist/{renderer-DcOT5c0t.mjs → renderer-Bo_d7h86.mjs} +4 -4
- package/dist/{renderer-DcOT5c0t.mjs.map → renderer-Bo_d7h86.mjs.map} +1 -1
- package/dist/renderer-BsS61z5l.mjs +5 -0
- package/dist/renderer-Btuq2Eq8.mjs +5 -0
- package/dist/renderer-BvcqncI2.mjs +3 -0
- package/dist/{renderer-C4xvz_Ft.mjs → renderer-Bw5YP2Db.mjs} +4 -4
- package/dist/{renderer-C4xvz_Ft.mjs.map → renderer-Bw5YP2Db.mjs.map} +1 -1
- package/dist/renderer-BxmsEl2x.mjs +5 -0
- package/dist/{renderer-BSitV56h.mjs → renderer-C9r0-KY4.mjs} +10 -1
- package/dist/{renderer-BSitV56h.mjs.map → renderer-C9r0-KY4.mjs.map} +1 -1
- package/dist/{renderer-B4jxP8vF.mjs → renderer-CDX2f82b.mjs} +5 -5
- package/dist/{renderer-B4jxP8vF.mjs.map → renderer-CDX2f82b.mjs.map} +1 -1
- package/dist/renderer-CGOyHdKs.mjs +5 -0
- package/dist/renderer-CJjmpmpu.mjs +5 -0
- package/dist/{renderer-P4OPsD82.mjs → renderer-CJoa4H2G.mjs} +3 -3
- package/dist/{renderer-P4OPsD82.mjs.map → renderer-CJoa4H2G.mjs.map} +1 -1
- package/dist/{renderer-1P3yS8YM.mjs → renderer-CLO8QHWE.mjs} +4 -4
- package/dist/{renderer-1P3yS8YM.mjs.map → renderer-CLO8QHWE.mjs.map} +1 -1
- package/dist/{renderer-CjQAYeYz.mjs → renderer-CMEqUhrW.mjs} +5 -5
- package/dist/{renderer-CjQAYeYz.mjs.map → renderer-CMEqUhrW.mjs.map} +1 -1
- package/dist/{renderer-CzgDJcot.mjs → renderer-CNs1vBUy.mjs} +4 -4
- package/dist/{renderer-CzgDJcot.mjs.map → renderer-CNs1vBUy.mjs.map} +1 -1
- package/dist/{index-B0BZMhtw.d.mts → renderer-COXKPofU.d.mts} +13 -13
- package/dist/renderer-COXKPofU.d.mts.map +1 -0
- package/dist/{renderer-CrwYWVsV.mjs → renderer-COtZ315B.mjs} +3 -3
- package/dist/renderer-CQvDDAIu.mjs +5 -0
- package/dist/{renderer-DOvzHN60.mjs → renderer-CWSM0IGG.mjs} +4 -4
- package/dist/{renderer-DOvzHN60.mjs.map → renderer-CWSM0IGG.mjs.map} +1 -1
- package/dist/{renderer-DVFFVy9v.mjs → renderer-CXbXF7Dh.mjs} +5 -5
- package/dist/{renderer-DVFFVy9v.mjs.map → renderer-CXbXF7Dh.mjs.map} +1 -1
- package/dist/renderer-CYxigw7S.mjs +5 -0
- package/dist/{renderer-DOxmmN6Q.mjs → renderer-CdUf7ZDJ.mjs} +5 -5
- package/dist/{renderer-DOxmmN6Q.mjs.map → renderer-CdUf7ZDJ.mjs.map} +1 -1
- package/dist/renderer-CgDofO5z.mjs +5 -0
- package/dist/renderer-CgmQchab.mjs +5 -0
- package/dist/{renderer-CS8VMTp1.mjs → renderer-Ch7qwjH9.mjs} +5 -5
- package/dist/{renderer-CS8VMTp1.mjs.map → renderer-Ch7qwjH9.mjs.map} +1 -1
- package/dist/{renderer-PuN2_AL_.mjs → renderer-ChCUad83.mjs} +5 -5
- package/dist/{renderer-PuN2_AL_.mjs.map → renderer-ChCUad83.mjs.map} +1 -1
- package/dist/{renderer-BaJQx0Bp.mjs → renderer-ChjkppUz.mjs} +5 -5
- package/dist/{renderer-BaJQx0Bp.mjs.map → renderer-ChjkppUz.mjs.map} +1 -1
- package/dist/{renderer-CTYKUnDq.mjs → renderer-Cj1giMd0.mjs} +3 -3
- package/dist/{renderer-CTYKUnDq.mjs.map → renderer-Cj1giMd0.mjs.map} +1 -1
- package/dist/{renderer-DWG4Podv.mjs → renderer-Cjs1OeBu.mjs} +6 -6
- package/dist/{renderer-DWG4Podv.mjs.map → renderer-Cjs1OeBu.mjs.map} +1 -1
- package/dist/renderer-CjzrLwW_.mjs +5 -0
- package/dist/renderer-ClnYWADT.mjs +5 -0
- package/dist/{renderer-B9ssmkbx.mjs → renderer-CmKdr2nA.mjs} +5 -5
- package/dist/{renderer-B9ssmkbx.mjs.map → renderer-CmKdr2nA.mjs.map} +1 -1
- package/dist/renderer-CwAd71C6.mjs +5 -0
- package/dist/{renderer-BT0x6KPK.mjs → renderer-D0X7Eh-T.mjs} +4 -4
- package/dist/{renderer-BT0x6KPK.mjs.map → renderer-D0X7Eh-T.mjs.map} +1 -1
- package/dist/{renderer-DYbgd6Ye.mjs → renderer-D2ru2Afi.mjs} +4 -4
- package/dist/{renderer-DYbgd6Ye.mjs.map → renderer-D2ru2Afi.mjs.map} +1 -1
- package/dist/renderer-D3Z9t-xT.mjs +5 -0
- package/dist/{renderer-CLxQ4rU6.mjs → renderer-D5u3GVtD.mjs} +74 -74
- package/dist/{renderer-CLxQ4rU6.mjs.map → renderer-D5u3GVtD.mjs.map} +1 -1
- package/dist/{renderer-DCsTCvdo.mjs → renderer-D6p8b6ql.mjs} +4 -4
- package/dist/{renderer-DCsTCvdo.mjs.map → renderer-D6p8b6ql.mjs.map} +1 -1
- package/dist/{renderer-Bkq1PT0g.mjs → renderer-DBjV6C2u.mjs} +3 -3
- package/dist/{renderer-BAoKytKL.mjs → renderer-DHqicGgT.mjs} +4 -4
- package/dist/{renderer-BAoKytKL.mjs.map → renderer-DHqicGgT.mjs.map} +1 -1
- package/dist/{renderer-B_b8-ztW.mjs → renderer-DK34NH92.mjs} +5 -5
- package/dist/{renderer-B_b8-ztW.mjs.map → renderer-DK34NH92.mjs.map} +1 -1
- package/dist/renderer-DKH1oSlW.mjs +5 -0
- package/dist/{renderer-I3Wxk1tL.mjs → renderer-DP41Fokp.mjs} +4 -4
- package/dist/{renderer-I3Wxk1tL.mjs.map → renderer-DP41Fokp.mjs.map} +1 -1
- package/dist/{renderer-CPLfsRe1.mjs → renderer-DQuKNgIx.mjs} +6 -6
- package/dist/{renderer-CPLfsRe1.mjs.map → renderer-DQuKNgIx.mjs.map} +1 -1
- package/dist/renderer-DTkf_hwG.mjs +5 -0
- package/dist/renderer-DU0bmOS7.mjs +5 -0
- package/dist/{renderer-GapR_An3.mjs → renderer-DWaqF7-6.mjs} +4 -4
- package/dist/{renderer-GapR_An3.mjs.map → renderer-DWaqF7-6.mjs.map} +1 -1
- package/dist/renderer-D_vJHQt5.mjs +5 -0
- package/dist/{renderer-GbVNyv-E.mjs → renderer-Db_wphHH.mjs} +192 -27
- package/dist/renderer-Db_wphHH.mjs.map +1 -0
- package/dist/renderer-Dc5n0M6P.mjs +5 -0
- package/dist/renderer-Ddfn0Dha.mjs +5 -0
- package/dist/renderer-DeSoNQeG.mjs +5 -0
- package/dist/{renderer-C0ds-UfM.mjs → renderer-DkPDYtD3.mjs} +5 -5
- package/dist/{renderer-C0ds-UfM.mjs.map → renderer-DkPDYtD3.mjs.map} +1 -1
- package/dist/{renderer-BCLmYH4S.mjs → renderer-DqO9gBAi.mjs} +4 -4
- package/dist/{renderer-BCLmYH4S.mjs.map → renderer-DqO9gBAi.mjs.map} +1 -1
- package/dist/{renderer-BUwHWIf7.mjs → renderer-DuQMzR3v.mjs} +4 -4
- package/dist/{renderer-BUwHWIf7.mjs.map → renderer-DuQMzR3v.mjs.map} +1 -1
- package/dist/{renderer-DBtt-7wt.d.mts → renderer-Dx28wTi1.d.mts} +2 -2
- package/dist/{renderer-DBtt-7wt.d.mts.map → renderer-Dx28wTi1.d.mts.map} +1 -1
- package/dist/{renderer-f7A01ArI.mjs → renderer-DyMEAZIm.mjs} +5 -5
- package/dist/{renderer-f7A01ArI.mjs.map → renderer-DyMEAZIm.mjs.map} +1 -1
- package/dist/renderer-Dzca9Qba.mjs +5 -0
- package/dist/{renderer-DBKyuPl1.mjs → renderer-FCqOfN6n.mjs} +5 -5
- package/dist/{renderer-DBKyuPl1.mjs.map → renderer-FCqOfN6n.mjs.map} +1 -1
- package/dist/{renderer-D96Pu8yX.mjs → renderer-Fsdod0yZ.mjs} +5 -5
- package/dist/{renderer-D96Pu8yX.mjs.map → renderer-Fsdod0yZ.mjs.map} +1 -1
- package/dist/{renderer-Du8eOWWU.mjs → renderer-G3px5ODP.mjs} +4 -4
- package/dist/{renderer-Du8eOWWU.mjs.map → renderer-G3px5ODP.mjs.map} +1 -1
- package/dist/renderer-HRVvtc9c.mjs +5 -0
- package/dist/{renderer-CUPWHfEY.mjs → renderer-IMg0dK6K.mjs} +5 -5
- package/dist/{renderer-CUPWHfEY.mjs.map → renderer-IMg0dK6K.mjs.map} +1 -1
- package/dist/renderer-M3Qs4f1K.mjs +5 -0
- package/dist/renderer-NDHJt_Ae.mjs +5 -0
- package/dist/{renderer-y6k54AM6.mjs → renderer-PLoGrFGz.mjs} +4 -4
- package/dist/{renderer-y6k54AM6.mjs.map → renderer-PLoGrFGz.mjs.map} +1 -1
- package/dist/{renderer-DO5r7ZV-.mjs → renderer-PNIgmWft.mjs} +2 -2
- package/dist/{renderer-DO5r7ZV-.mjs.map → renderer-PNIgmWft.mjs.map} +1 -1
- package/dist/{renderer-CDky5Gva.mjs → renderer-VdTs-dqH.mjs} +4 -4
- package/dist/{renderer-CDky5Gva.mjs.map → renderer-VdTs-dqH.mjs.map} +1 -1
- package/dist/{renderer-QaN-aejD.mjs → renderer-VxVi_0c7.mjs} +3 -3
- package/dist/{renderer-QaN-aejD.mjs.map → renderer-VxVi_0c7.mjs.map} +1 -1
- package/dist/{renderer-BMLJgnbU.mjs → renderer-WVrUeiLZ.mjs} +3 -3
- package/dist/{renderer-BHLYKy8b.mjs → renderer-Z39F9QK5.mjs} +5 -5
- package/dist/{renderer-BHLYKy8b.mjs.map → renderer-Z39F9QK5.mjs.map} +1 -1
- package/dist/{renderer-DSbRWfgl.mjs → renderer-ZjSP5jw6.mjs} +3 -3
- package/dist/{renderer-DSbRWfgl.mjs.map → renderer-ZjSP5jw6.mjs.map} +1 -1
- package/dist/{renderer-CE83KHgr.mjs → renderer-fH9SCJC2.mjs} +3 -3
- package/dist/{renderer-CE83KHgr.mjs.map → renderer-fH9SCJC2.mjs.map} +1 -1
- package/dist/{renderer-Cy4a31rZ.mjs → renderer-gjT5v76-.mjs} +3 -3
- package/dist/{renderer-Cy4a31rZ.mjs.map → renderer-gjT5v76-.mjs.map} +1 -1
- package/dist/{renderer-C-z6bxj9.mjs → renderer-kP5Y282J.mjs} +5 -5
- package/dist/{renderer-C-z6bxj9.mjs.map → renderer-kP5Y282J.mjs.map} +1 -1
- package/dist/{renderer-BI8QhEj9.mjs → renderer-kkDfQ56G.mjs} +5 -5
- package/dist/{renderer-BI8QhEj9.mjs.map → renderer-kkDfQ56G.mjs.map} +1 -1
- package/dist/{renderer-DHUlVqST.mjs → renderer-l_U6kA8K.mjs} +3 -3
- package/dist/{renderer-DHUlVqST.mjs.map → renderer-l_U6kA8K.mjs.map} +1 -1
- package/dist/{renderer-BajIOuZH.mjs → renderer-nLPfvY3-.mjs} +2 -2
- package/dist/{renderer-BajIOuZH.mjs.map → renderer-nLPfvY3-.mjs.map} +1 -1
- package/dist/{renderer-C4vHq0X8.mjs → renderer-o_JK4YfO.mjs} +5 -5
- package/dist/{renderer-C4vHq0X8.mjs.map → renderer-o_JK4YfO.mjs.map} +1 -1
- package/dist/{renderer-CAi-lhR2.mjs → renderer-vwoyR8pW.mjs} +4 -4
- package/dist/{renderer-CAi-lhR2.mjs.map → renderer-vwoyR8pW.mjs.map} +1 -1
- package/dist/{renderers-CP8CFWDs-D1FHpA7I.mjs → renderers-CP8CFWDs-DiX2Y4Qp.mjs} +4 -4
- package/dist/{renderers-CP8CFWDs-D1FHpA7I.mjs.map → renderers-CP8CFWDs-DiX2Y4Qp.mjs.map} +1 -1
- package/dist/signals/index.d.mts +6 -6
- package/dist/signals/index.mjs +8 -8
- package/dist/{signals-BwdVfK3p.mjs → signals-Cf9mfNGE.mjs} +4 -4
- package/dist/{signals-BwdVfK3p.mjs.map → signals-Cf9mfNGE.mjs.map} +1 -1
- package/dist/src-Ds9IAk5S.mjs +8373 -0
- package/dist/src-Ds9IAk5S.mjs.map +1 -0
- package/package.json +68 -58
- package/dist/dist-DMa2BbYs.mjs.map +0 -1
- package/dist/dist-hmWTJPik.mjs.map +0 -1
- package/dist/fonts-Da1JDKpc.mjs.map +0 -1
- package/dist/index-B0BZMhtw.d.mts.map +0 -1
- package/dist/index-CJkihekp.d.mts.map +0 -1
- package/dist/index-DGe83M-p.d.mts.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/preview/index.d.mts.map +0 -1
- package/dist/preview-5E4B96RD.mjs.map +0 -1
- package/dist/preview-engine/chunk-3T4LB4ZH.mjs +0 -3
- package/dist/preview-engine/chunk-E7DH7EPX.mjs +0 -1
- package/dist/preview-engine/chunk-PCWWKYFO.mjs +0 -1
- package/dist/preview-engine/chunk-RQLMW6MA.mjs +0 -4
- package/dist/preview-engine/chunk-XN56POGE.mjs +0 -1
- package/dist/preview-engine/renderers-2AD6AHAO.mjs +0 -1
- package/dist/preview-engine/renderers-37UYW266.mjs +0 -1
- package/dist/preview-engine/renderers-3YHETAPE.mjs +0 -1
- package/dist/preview-engine/renderers-43LBWRY2.mjs +0 -1
- package/dist/preview-engine/renderers-4I4UDLDR.mjs +0 -1
- package/dist/preview-engine/renderers-5EITT2FW.mjs +0 -1
- package/dist/preview-engine/renderers-7UKFQ75V.mjs +0 -1
- package/dist/preview-engine/renderers-AIRDDXRB.mjs +0 -1
- package/dist/preview-engine/renderers-BUOGMWAA.mjs +0 -1
- package/dist/preview-engine/renderers-C4XIAG3F.mjs +0 -1
- package/dist/preview-engine/renderers-ELARGDRC.mjs +0 -1
- package/dist/preview-engine/renderers-EU5VM2S2.mjs +0 -1
- package/dist/preview-engine/renderers-GEPP3YKK.mjs +0 -1
- package/dist/preview-engine/renderers-IQNZQWYU.mjs +0 -1
- package/dist/preview-engine/renderers-J6DEIWTU.mjs +0 -1
- package/dist/preview-engine/renderers-JQ7AI473.mjs +0 -228
- package/dist/preview-engine/renderers-JTVCE5PV.mjs +0 -1
- package/dist/preview-engine/renderers-KCNUETAD.mjs +0 -1
- package/dist/preview-engine/renderers-KKBHXAWS.mjs +0 -1
- package/dist/preview-engine/renderers-LER7SUT2.mjs +0 -1
- package/dist/preview-engine/renderers-MOERLDWF.mjs +0 -1
- package/dist/preview-engine/renderers-O57YIIAO.mjs +0 -1
- package/dist/preview-engine/renderers-OPUIAD7I.mjs +0 -1
- package/dist/preview-engine/renderers-PENUAXDD.mjs +0 -1
- package/dist/preview-engine/renderers-PFCI7ZFW.mjs +0 -1
- package/dist/preview-engine/renderers-Q4HDP2IE.mjs +0 -1
- package/dist/preview-engine/renderers-QMHUOKMM.mjs +0 -1
- package/dist/preview-engine/renderers-R26YPECG.mjs +0 -1
- package/dist/preview-engine/renderers-TAFDMFEA.mjs +0 -1
- package/dist/preview-engine/renderers-TMZLWQP2.mjs +0 -1
- package/dist/preview-engine/renderers-TP6ZMGU4.mjs +0 -1
- package/dist/preview-engine/renderers-TY63VTZB.mjs +0 -1
- package/dist/preview-engine/renderers-UBO4ZNE6.mjs +0 -1
- package/dist/preview-engine/renderers-UCNQ536S.mjs +0 -1
- package/dist/preview-engine/renderers-WOZQDE4A.mjs +0 -1
- package/dist/preview-engine/renderers-X2XGDF3R.mjs +0 -1
- package/dist/preview-engine/renderers-XG3BDROH.mjs +0 -1
- package/dist/preview-engine/renderers-XS4CFZWR.mjs +0 -1
- package/dist/preview-engine/renderers-ZQDMRWN7.mjs +0 -1
- package/dist/program-KwDdbC0-.mjs.map +0 -1
- package/dist/renderer-5C41E6gT.mjs +0 -5
- package/dist/renderer-6SXF9Ykm.mjs +0 -5
- package/dist/renderer-B2C2w3Wr.mjs +0 -5
- package/dist/renderer-B2QSfPYZ.mjs +0 -5
- package/dist/renderer-B5cQcMl1.mjs +0 -5
- package/dist/renderer-BDhD8Yab.mjs +0 -3
- package/dist/renderer-BSDdTHDh.mjs +0 -5
- package/dist/renderer-BTUjk8uY.mjs +0 -5
- package/dist/renderer-Bcey5Gvk.mjs +0 -5
- package/dist/renderer-Bd2MuOeJ.mjs +0 -5
- package/dist/renderer-Bd5A0zer.mjs +0 -5
- package/dist/renderer-CC-WlYa2.mjs +0 -5
- package/dist/renderer-Cb0qf1UG.mjs +0 -5
- package/dist/renderer-CfX13dgQ.mjs +0 -5
- package/dist/renderer-ChFkRLrv.mjs +0 -5
- package/dist/renderer-CjkfJj5R.mjs +0 -5
- package/dist/renderer-Ck7Lu6c9.mjs +0 -5
- package/dist/renderer-CuquqG_c.mjs +0 -5
- package/dist/renderer-Cwz2E4QD.mjs +0 -5
- package/dist/renderer-Czkt9YYp.mjs +0 -5
- package/dist/renderer-DFTIhSdJ.mjs +0 -5
- package/dist/renderer-DFaa-Ats.mjs +0 -5
- package/dist/renderer-DIHXKvPl.mjs +0 -5
- package/dist/renderer-DMzOqVM3.mjs +0 -5
- package/dist/renderer-DOId5U8x.mjs +0 -5
- package/dist/renderer-DQXFOup8.mjs +0 -5
- package/dist/renderer-DRt3BSTj.mjs +0 -5
- package/dist/renderer-Di_5vQ81.mjs +0 -6
- package/dist/renderer-DkmpsPIa.mjs +0 -5
- package/dist/renderer-Ds6wd9Gk.mjs +0 -5
- package/dist/renderer-DuuOlYIG.mjs +0 -3
- package/dist/renderer-Dv4p0lp9.mjs +0 -5
- package/dist/renderer-DzWoOlVc.mjs +0 -5
- package/dist/renderer-GbVNyv-E.mjs.map +0 -1
- package/dist/renderer-IOz63OWg.mjs +0 -5
- package/dist/renderer-JkSrsUa5.mjs +0 -5
- package/dist/renderer-Ocs1Djwu.mjs +0 -5
- package/dist/renderer-Wdn7m_uN.mjs +0 -5
- package/dist/renderer-mKpjaoE3.mjs +0 -5
- /package/dist/{chunk-GBe2k3y8.mjs → chunk-D7gtBK74.mjs} +0 -0
- /package/dist/preview-engine/{chunk-Q4Z54DQ7.mjs → chunk-LXHECDOB.mjs} +0 -0
- /package/dist/{renderer-B-BcgA6d.mjs → renderer-B7fX0C62.mjs} +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "annotate",
|
|
3
|
+
"title": "Annotate",
|
|
4
|
+
"summary": "Marks that draw on to point at something: underline, circle, box, highlight, strike, cross, bracket, arrow or squiggle, clean or hand-drawn with a boil, with an optional label.",
|
|
5
|
+
"tags": [
|
|
6
|
+
"annotation",
|
|
7
|
+
"underline",
|
|
8
|
+
"highlight",
|
|
9
|
+
"arrow",
|
|
10
|
+
"hand-drawn",
|
|
11
|
+
"explainer"
|
|
12
|
+
],
|
|
13
|
+
"example": "Annotate({ target: { text: headline, match: \"faster\" }, mark: \"circle\" })",
|
|
14
|
+
"parts": [
|
|
15
|
+
"stroke-<n>",
|
|
16
|
+
"head",
|
|
17
|
+
"label"
|
|
18
|
+
],
|
|
19
|
+
"cues": [
|
|
20
|
+
"drawn",
|
|
21
|
+
"labelIn"
|
|
22
|
+
],
|
|
23
|
+
"registryDependencies": [],
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
26
|
+
"path": "annotate/index.ts",
|
|
27
|
+
"content": "/**\n * Annotate: marks that draw on to point at something. Underline a phrase,\n * circle a number, strike a claim, box a region, draw an arrow; clean\n * geometry or hand-drawn, with a boil that keeps hand-drawn marks alive.\n *\n * Coordinates are the parent's: the root sits at 0, 0, so targets resolve\n * where they are drawn. Text targets give one mark per line.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\tanchorPoint,\n\ttype Animatable,\n\ttype ArrowCurve,\n\tarrowPaths,\n\tbindComponent,\n\tboilKeys,\n\tboundsOf,\n\tboxPath,\n\tcontrastRatio,\n\tdefineComponent,\n\tellipsePath,\n\tlinePath,\n\ttype Rect,\n\tresolveTarget,\n\tscaleFrames,\n\tsquigglePath,\n\ttype Target,\n\ttargetSchema,\n\tthemeFrames,\n\ttoPathData,\n\ttype Vec2,\n\twobblePoints,\n\tz,\n} from \"framefields/components\";\n\nexport const MARKS = [\n\t\"underline\",\n\t\"circle\",\n\t\"box\",\n\t\"highlight\",\n\t\"strike\",\n\t\"cross\",\n\t\"bracket\",\n\t\"arrow\",\n\t\"squiggle\",\n] as const;\nexport type Mark = (typeof MARKS)[number];\n\nconst TEXTURE = {\n\t/** Even, confident line. */\n\tink: { width: 1, opacity: 1 },\n\t/** Broad felt tip. */\n\tmarker: { width: 1.5, opacity: 0.94 },\n\t/** Thin and light. */\n\tpencil: { width: 0.55, opacity: 0.85 },\n} as const;\n\nexport interface AnnotateCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The mark is fully drawn. */\n\tdrawn: ReadonlyArray<{ frame: number }>;\n\t/** The label starts to appear (empty without a label). */\n\tlabelIn: ReadonlyArray<{ frame: number }>;\n}\n\n/** One stroke of a mark: its path for a boil variant, and a length for pacing. */\ninterface Stroke {\n\tpath(variant: number): string;\n\tlength: number;\n\tkind: \"line\" | \"fill\" | \"head\";\n}\n\nexport const annotate = /* @__PURE__ */ defineComponent({\n\tname: \"Annotate\",\n\tprops: () => ({\n\t\t/** What to mark. Rects (e.g. `TextReveal.rects(\"word\")`) give one mark per rect for line marks. */\n\t\ttarget: z.union([\n\t\t\tz.array(\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\ttargetSchema(),\n\t\t]),\n\t\tmark: z.enum(MARKS).default(\"underline\"),\n\t\t/** Where an arrow starts (it points at `target`). */\n\t\tfrom: targetSchema().optional(),\n\t\tcurve: z.enum([\"straight\", \"arc\", \"swoop\"]).default(\"arc\"),\n\t\tcolor: z.string().optional(),\n\t\t/** Stroke width before the texture's factor. Default the theme's bold stroke. */\n\t\twidth: z.number().positive().optional(),\n\t\t/** `wobble` 0–1 (how loose the hand is); `boil`: frames between re-poses, 0 = still. */\n\t\thand: 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\twobble: z.number().min(0).max(1).default(0.6),\n\t\t\t\t\t\tboil: z.number().int().min(0).default(4),\n\t\t\t\t\t\tseed: z.number().int().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default({ wobble: 0.6, boil: 4 }),\n\t\ttexture: z.enum([\"ink\", \"marker\", \"pencil\"]).default(\"marker\"),\n\t\t/** Frames the mark takes to draw. Default the theme's base speed (× 1.5 for arrows). */\n\t\tdrawDuration: z.number().int().positive().optional(),\n\t\tlabel: z\n\t\t\t.union([\n\t\t\t\tz.string(),\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\tside: z\n\t\t\t\t\t\t\t.enum([\"auto\", \"top\", \"bottom\", \"left\", \"right\"])\n\t\t\t\t\t\t\t.default(\"auto\"),\n\t\t\t\t\t\tfont: z.string().optional(),\n\t\t\t\t\t\tsize: z.number().positive().optional(),\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/** Space between the target and the mark. */\n\t\tpadding: z.number().min(0).default(8),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 45 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst pad = props.padding * s;\n\t\tconst hand = props.hand === false ? null : props.hand;\n\t\tconst wobble = (hand?.wobble ?? 0) * 5 * s;\n\t\tconst seed = hand?.seed ?? ctx.seed;\n\t\tconst texture = TEXTURE[props.texture];\n\t\tconst strokeW = (props.width ?? theme.stroke.bold * s) * texture.width;\n\t\tconst color = props.color ?? theme.color.highlight;\n\n\t\t// ── Geometry ─────────────────────────────────────────────────────\n\t\tconst resolve = (t: Target) => {\n\t\t\tconst r = resolveTarget(t, { measure: host.measure, fps });\n\t\t\tif (r.kind !== \"static\")\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"Annotate: targets must be static (points, rects, nodes, text). Moving targets arrive with Callout.\",\n\t\t\t\t);\n\t\t\treturn r;\n\t\t};\n\t\tconst isRects =\n\t\t\tArray.isArray(props.target) && typeof props.target[0] === \"object\";\n\t\tconst resolved = isRects ? null : resolve(props.target as Target);\n\t\tconst rects: Rect[] = isRects\n\t\t\t? (props.target as Rect[])\n\t\t\t: (resolved?.rects ?? []);\n\t\tif (rects.length === 0)\n\t\t\tthrow new Error(\"Annotate: the target covers nothing.\");\n\t\tconst isText =\n\t\t\tisRects ||\n\t\t\t(typeof props.target === \"object\" &&\n\t\t\t\tprops.target !== null &&\n\t\t\t\t\"text\" in (props.target as object));\n\t\tconst b = boundsOf(rects);\n\t\tconst hw = { wobble, seed };\n\n\t\tconst strokes: Stroke[] = [];\n\t\t/** Points outside the target the strokes reach (an arrow's tail). */\n\t\tconst reach: Vec2[] = [];\n\t\tconst lineAt = (y: (r: Rect) => number, extend: number) =>\n\t\t\trects.forEach((r) => {\n\t\t\t\tconst from: Vec2 = [r.x - extend, y(r)];\n\t\t\t\tconst to: Vec2 = [r.x + r.width + extend, y(r)];\n\t\t\t\tstrokes.push({\n\t\t\t\t\tpath: (v) => linePath(from, to, hw, v + strokes.length * 7),\n\t\t\t\t\tlength: to[0] - from[0],\n\t\t\t\t\tkind: \"line\",\n\t\t\t\t});\n\t\t\t});\n\t\t// Text rects are a line tall: the baseline sits near 80 % of it.\n\t\tconst under = (r: Rect) =>\n\t\t\tisText ? r.y + r.height * 0.84 + pad * 0.4 : r.y + r.height + pad;\n\n\t\tswitch (props.mark) {\n\t\t\tcase \"underline\":\n\t\t\t\tlineAt(under, pad * 0.25);\n\t\t\t\tbreak;\n\t\t\tcase \"strike\":\n\t\t\t\tlineAt((r) => r.y + r.height * (isText ? 0.54 : 0.5), pad * 0.4);\n\t\t\t\tbreak;\n\t\t\tcase \"squiggle\":\n\t\t\t\trects.forEach((r, i) => {\n\t\t\t\t\tconst from: Vec2 = [r.x, under(r)];\n\t\t\t\t\tconst to: Vec2 = [r.x + r.width, under(r)];\n\t\t\t\t\tstrokes.push({\n\t\t\t\t\t\tpath: (v) =>\n\t\t\t\t\t\t\tsquigglePath(\n\t\t\t\t\t\t\t\tfrom,\n\t\t\t\t\t\t\t\tto,\n\t\t\t\t\t\t\t\t{ ...hw, amplitude: 4 * s + strokeW * 0.4 },\n\t\t\t\t\t\t\t\tv + i * 7,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\tlength: r.width * 1.3,\n\t\t\t\t\t\tkind: \"line\",\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"highlight\":\n\t\t\t\trects.forEach((r, i) => {\n\t\t\t\t\tconst y = r.y + r.height * (isText ? 0.56 : 0.5);\n\t\t\t\t\tconst from: Vec2 = [r.x - pad * 0.4, y];\n\t\t\t\t\tconst to: Vec2 = [r.x + r.width + pad * 0.4, y];\n\t\t\t\t\tstrokes.push({\n\t\t\t\t\t\tpath: (v) =>\n\t\t\t\t\t\t\tlinePath(from, to, { ...hw, wobble: wobble * 0.5 }, v + i * 7),\n\t\t\t\t\t\tlength: to[0] - from[0],\n\t\t\t\t\t\tkind: \"fill\",\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"circle\":\n\t\t\t\tstrokes.push({\n\t\t\t\t\tpath: (v) => ellipsePath(b, { ...hw, padding: pad * 1.5 }, v),\n\t\t\t\t\tlength: Math.PI * (b.width + b.height + pad * 3),\n\t\t\t\t\tkind: \"line\",\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"box\":\n\t\t\t\tstrokes.push({\n\t\t\t\t\tpath: (v) => boxPath(b, { ...hw, padding: pad }, v),\n\t\t\t\t\tlength: 2 * (b.width + b.height + pad * 4),\n\t\t\t\t\tkind: \"line\",\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"cross\": {\n\t\t\t\tconst x0 = b.x - pad;\n\t\t\t\tconst y0 = b.y - pad;\n\t\t\t\tconst x1 = b.x + b.width + pad;\n\t\t\t\tconst y1 = b.y + b.height + pad;\n\t\t\t\tconst d = Math.hypot(x1 - x0, y1 - y0);\n\t\t\t\tstrokes.push(\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: (v) => linePath([x0, y0], [x1, y1], hw, v),\n\t\t\t\t\t\tlength: d,\n\t\t\t\t\t\tkind: \"line\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: (v) => linePath([x1, y0], [x0, y1], hw, v + 11),\n\t\t\t\t\t\tlength: d,\n\t\t\t\t\t\tkind: \"line\",\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"bracket\": {\n\t\t\t\tconst x = b.x - pad * 1.5;\n\t\t\t\tconst lip = Math.min(18 * s, b.height * 0.2);\n\t\t\t\tconst pts: Vec2[] = [\n\t\t\t\t\t[x + lip, b.y - pad * 0.5],\n\t\t\t\t\t[x, b.y - pad * 0.5],\n\t\t\t\t\t[x, b.y + b.height + pad * 0.5],\n\t\t\t\t\t[x + lip, b.y + b.height + pad * 0.5],\n\t\t\t\t];\n\t\t\t\tstrokes.push({\n\t\t\t\t\tpath: (v) => toPathData(wobblePoints(densify(pts, 6), hw, v)),\n\t\t\t\t\tlength: b.height + lip * 2 + pad,\n\t\t\t\t\tkind: \"line\",\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"arrow\": {\n\t\t\t\tif (!props.from)\n\t\t\t\t\tthrow new Error('Annotate: mark \"arrow\" needs `from`.');\n\t\t\t\tconst start = resolve(props.from);\n\t\t\t\tconst tail = anchorPoint(boundsOf(start.rects), start.anchor);\n\t\t\t\tconst tip = edgeToward(b, tail, pad);\n\t\t\t\treach.push(tail);\n\t\t\t\tconst headSize =\n\t\t\t\t\t(14 * s + strokeW * 1.6) * (props.texture === \"pencil\" ? 0.8 : 1);\n\t\t\t\tconst geometry = (v: number) =>\n\t\t\t\t\tarrowPaths(\n\t\t\t\t\t\ttail,\n\t\t\t\t\t\ttip,\n\t\t\t\t\t\t{ ...hw, curve: props.curve as ArrowCurve, headSize },\n\t\t\t\t\t\tv,\n\t\t\t\t\t);\n\t\t\t\tconst length = Math.hypot(tip[0] - tail[0], tip[1] - tail[1]);\n\t\t\t\tstrokes.push(\n\t\t\t\t\t{ path: (v) => geometry(v).shaft, length, kind: \"line\" },\n\t\t\t\t\t{ path: (v) => geometry(v).head, length: headSize * 2, kind: \"head\" },\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\t// ── Timing ───────────────────────────────────────────────────────\n\t\tconst drawFrames =\n\t\t\tprops.drawDuration ??\n\t\t\tMath.round(\n\t\t\t\tthemeFrames(theme, \"base\", fps) * (props.mark === \"arrow\" ? 1.5 : 1.2),\n\t\t\t);\n\t\tconst bodies = strokes.filter((st) => st.kind !== \"head\");\n\t\tconst total = bodies.reduce((a, st) => a + Math.max(1, st.length), 0);\n\t\tlet cursor = 0;\n\t\tconst windows: Array<{ start: number; end: number }> = [];\n\t\tfor (const st of strokes) {\n\t\t\tconst prev = windows[windows.length - 1];\n\t\t\tif (st.kind === \"head\" && prev) {\n\t\t\t\t// The head flicks on as the shaft reaches 92 %.\n\t\t\t\tconst start = Math.round(prev.start + (prev.end - prev.start) * 0.92);\n\t\t\t\twindows.push({ start, end: start + Math.max(3, scaleFrames(5, fps)) });\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst start = Math.round(cursor);\n\t\t\tcursor += (drawFrames * Math.max(1, st.length)) / total;\n\t\t\twindows.push({ start, end: Math.max(start + 2, Math.round(cursor)) });\n\t\t}\n\t\tconst drawn = Math.max(...windows.map((w) => w.end));\n\n\t\t// ── Nodes ────────────────────────────────────────────────────────\n\t\tconst lightBg =\n\t\t\t(contrastRatio(theme.color.bg, \"#000000\") ?? 0) >\n\t\t\t(contrastRatio(theme.color.bg, \"#ffffff\") ?? 0);\n\t\t// The shapes' box must hold every stroke, or the renderer clips it.\n\t\tconst margin = pad * 4 + strokeW * 4;\n\t\tconst extentX = Math.max(b.x + b.width, ...reach.map((p) => p[0])) + margin;\n\t\tconst extentY =\n\t\t\tMath.max(b.y + b.height, ...reach.map((p) => p[1])) + margin;\n\t\tconst boil =\n\t\t\thand && hand.boil > 0 && wobble > 0 ? scaleFrames(hand.boil, fps) : 0;\n\t\t// Marks stay up: boil through the window, or a while past the hold.\n\t\tconst boilEnd =\n\t\t\t(props as { duration?: number }).duration ??\n\t\t\tdrawn + Math.max(ctx.hold, fps * 6);\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tlet maxX = extentX;\n\t\tlet maxY = extentY;\n\n\t\tstrokes.forEach((st, i) => {\n\t\t\tconst w = windows[i] as { start: number; end: number };\n\t\t\tconst fill = st.kind === \"fill\";\n\t\t\tconst rect = rects[i] ?? b;\n\t\t\tconst node = host.shape(\"path\", {\n\t\t\t\tid: ctx.partId(st.kind === \"head\" ? \"head\" : `stroke-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: extentX,\n\t\t\t\theight: extentY,\n\t\t\t\td: st.path(0),\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: color,\n\t\t\t\tstrokeWidth: fill ? rect.height * (isText ? 0.62 : 1) : strokeW,\n\t\t\t\tstrokeLineCap: fill ? \"butt\" : \"round\",\n\t\t\t\tstrokeLineJoin: \"round\",\n\t\t\t\ttrimEnd: 0,\n\t\t\t\topacity: fill ? (lightBg ? 0.9 : 0.38) : texture.opacity,\n\t\t\t\t...(fill && lightBg && { blendMode: \"multiply\" }),\n\t\t\t});\n\t\t\tconst anim = LayerAnimation.create().keys(\"trimEnd\", [\n\t\t\t\t[w.start, 0],\n\t\t\t\t[w.end, 1, st.kind === \"head\" ? \"power2.out\" : \"power2.inOut\"],\n\t\t\t]);\n\t\t\tif (boil > 0)\n\t\t\t\tanim.keys(\n\t\t\t\t\t\"d\",\n\t\t\t\t\tboilKeys(st.path, { start: 0, end: boilEnd, every: boil }),\n\t\t\t\t);\n\t\t\tnode.animate(anim);\n\t\t\tparts[st.kind === \"head\" ? \"head\" : `stroke-${i}`] = node;\n\t\t\tchildren.push(node);\n\t\t});\n\n\t\t// ── Label ────────────────────────────────────────────────────────\n\t\tlet labelIn: number | null = null;\n\t\tif (props.label !== undefined) {\n\t\t\tconst o =\n\t\t\t\ttypeof props.label === \"string\"\n\t\t\t\t\t? { text: props.label, side: \"auto\" as const }\n\t\t\t\t\t: props.label;\n\t\t\tconst font =\n\t\t\t\t(\"font\" in o && o.font) || theme.font.hand || theme.font.body;\n\t\t\tconst size = (\"size\" in o && o.size) || 34 * s;\n\t\t\tconst m = host.measure(o.text, { fontFamily: font, fontSize: size });\n\t\t\tconst gap = 10 * s + pad;\n\t\t\tlet x: number;\n\t\t\tlet y: number;\n\t\t\tif (props.mark === \"arrow\" && props.from) {\n\t\t\t\tconst start = resolve(props.from);\n\t\t\t\tconst tail = anchorPoint(boundsOf(start.rects), start.anchor);\n\t\t\t\tconst tip = edgeToward(b, tail, pad);\n\t\t\t\tconst len = Math.hypot(tip[0] - tail[0], tip[1] - tail[1]) || 1;\n\t\t\t\tconst dx = (tip[0] - tail[0]) / len;\n\t\t\t\tconst dy = (tip[1] - tail[1]) / len;\n\t\t\t\tconst reach =\n\t\t\t\t\tMath.abs(dx) * (m.width / 2) +\n\t\t\t\t\tMath.abs(dy) * (m.height / 2) +\n\t\t\t\t\tgap * 0.5;\n\t\t\t\tx = tail[0] - dx * reach - m.width / 2;\n\t\t\t\ty = tail[1] - dy * reach - m.height / 2;\n\t\t\t} else {\n\t\t\t\tconst side =\n\t\t\t\t\to.side === \"auto\"\n\t\t\t\t\t\t? b.y - gap - m.height >= 0\n\t\t\t\t\t\t\t? \"top\"\n\t\t\t\t\t\t\t: \"bottom\"\n\t\t\t\t\t\t: o.side;\n\t\t\t\tconst top = props.mark === \"circle\" ? pad * 1.5 : pad;\n\t\t\t\tx =\n\t\t\t\t\tside === \"left\"\n\t\t\t\t\t\t? b.x - top - gap - m.width\n\t\t\t\t\t\t: side === \"right\"\n\t\t\t\t\t\t\t? b.x + b.width + top + gap\n\t\t\t\t\t\t\t: b.x + b.width - m.width;\n\t\t\t\ty =\n\t\t\t\t\tside === \"top\"\n\t\t\t\t\t\t? b.y - top - gap - m.height\n\t\t\t\t\t\t: side === \"bottom\"\n\t\t\t\t\t\t\t? b.y + b.height + top + gap\n\t\t\t\t\t\t\t: b.y + (b.height - m.height) / 2;\n\t\t\t}\n\t\t\tconst label = host.text(o.text, {\n\t\t\t\tid: ctx.partId(\"label\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\tfontFamily: font,\n\t\t\t\tfontSize: size,\n\t\t\t\tfill: props.mark === \"highlight\" ? theme.color.ink : color,\n\t\t\t});\n\t\t\tlabelIn = Math.max(0, drawn - scaleFrames(4, fps));\n\t\t\tconst intro = scaleFrames(10, fps);\n\t\t\tlabel.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[labelIn, 0],\n\t\t\t\t\t\t[labelIn + intro, 1, \"power2.out\"],\n\t\t\t\t\t])\n\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t[labelIn, y + 8 * s],\n\t\t\t\t\t\t[labelIn + intro, y, theme.motion.ease.enter],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tparts.label = label;\n\t\t\tchildren.push(label);\n\t\t\tmaxX = Math.max(maxX, x + m.width + 8);\n\t\t\tmaxY = Math.max(maxY, y + m.height + 8);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: maxX,\n\t\t\theight: maxY,\n\t\t\tduration: Math.max(\n\t\t\t\tdrawn,\n\t\t\t\tlabelIn === null ? 0 : labelIn + scaleFrames(10, fps),\n\t\t\t),\n\t\t\topenEnded: true,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\tdrawn: [{ frame: drawn }],\n\t\t\t\tlabelIn: labelIn === null ? [] : [{ frame: labelIn }],\n\t\t\t} as AnnotateCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** Where a mark around `rect` (+ gap) meets the line toward `from`. */\nfunction edgeToward(rect: Rect, from: Vec2, gap: number): Vec2 {\n\tconst cx = rect.x + rect.width / 2;\n\tconst cy = rect.y + rect.height / 2;\n\tconst dx = from[0] - cx;\n\tconst dy = from[1] - cy;\n\tconst hx = rect.width / 2 + gap;\n\tconst hy = rect.height / 2 + gap;\n\tconst t = Math.min(\n\t\tdx === 0 ? Number.POSITIVE_INFINITY : hx / Math.abs(dx),\n\t\tdy === 0 ? Number.POSITIVE_INFINITY : hy / Math.abs(dy),\n\t);\n\treturn Number.isFinite(t) ? [cx + dx * t, cy + dy * t] : [cx, cy - hy];\n}\n\n/** Extra points along each segment, so a wobble bends lines instead of corners only. */\nfunction densify(points: readonly Vec2[], per: number): Vec2[] {\n\tconst out: Vec2[] = [];\n\tpoints.forEach((p, i) => {\n\t\tconst next = points[i + 1];\n\t\tif (!next) {\n\t\t\tout.push(p);\n\t\t\treturn;\n\t\t}\n\t\tfor (let k = 0; k < per; k++) {\n\t\t\tconst t = k / per;\n\t\t\tout.push([p[0] + (next[0] - p[0]) * t, p[1] + (next[1] - p[1]) * t]);\n\t\t}\n\t});\n\treturn out;\n}\n\n/**\n * Marks that draw on to point at something: underline, circle, box, highlight,\n * strike, cross, bracket, arrow or squiggle, clean or hand-drawn with a boil,\n * with an optional label.\n */\nexport const Annotate = /* @__PURE__ */ bindComponent(annotate);\n"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "app-frame",
|
|
3
|
+
"title": "AppFrame",
|
|
4
|
+
"summary": "Window chrome around any content — desktop window, browser with a typed URL, terminal, or bare frame — and the coordinate system for pointing into it.",
|
|
5
|
+
"tags": [
|
|
6
|
+
"window",
|
|
7
|
+
"browser",
|
|
8
|
+
"mockup",
|
|
9
|
+
"ui",
|
|
10
|
+
"screen recording",
|
|
11
|
+
"device"
|
|
12
|
+
],
|
|
13
|
+
"example": "AppFrame({ kind: \"browser\", url: \"acme.dev\", content: \"assets/recording.mp4\" })",
|
|
14
|
+
"parts": [
|
|
15
|
+
"shadow",
|
|
16
|
+
"chrome",
|
|
17
|
+
"bar",
|
|
18
|
+
"divider",
|
|
19
|
+
"content",
|
|
20
|
+
"url-field",
|
|
21
|
+
"url",
|
|
22
|
+
"caret",
|
|
23
|
+
"load",
|
|
24
|
+
"tab-<n>",
|
|
25
|
+
"light-<n>"
|
|
26
|
+
],
|
|
27
|
+
"cues": [
|
|
28
|
+
"urlTyped",
|
|
29
|
+
"loaded"
|
|
30
|
+
],
|
|
31
|
+
"registryDependencies": [],
|
|
32
|
+
"files": [
|
|
33
|
+
{
|
|
34
|
+
"path": "app-frame/index.ts",
|
|
35
|
+
"content": "/**\n * AppFrame: window chrome around any content (a recording, a mock UI, a\n * still): a desktop window, a browser with a URL bar, a terminal, or a bare\n * rounded frame. It is also the coordinate system for what points at the\n * content: `frame.at(x, y)` maps content pixels to the parent's.\n */\nimport {\n\ttype AnimatableProp,\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tcontrastRatio,\n\tdefineComponent,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nconst KINDS = [\"window\", \"browser\", \"terminal\", \"minimal\"] as const;\nexport type AppFrameKind = (typeof KINDS)[number];\n\nexport interface AppFrameCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The URL finished typing (browser with `url.typeAt`). */\n\turlTyped: ReadonlyArray<{ frame: number }>;\n\t/** The load bar finished. */\n\tloaded: ReadonlyArray<{ frame: number }>;\n}\n\nexport interface AppFrameApi {\n\t/** The content area in the parent's coordinates. */\n\treadonly contentRect: { x: number; y: number; width: number; height: number };\n\t/** Content-local (x, y) → the parent's coordinates. */\n\tat(x: number, y: number): Vec2;\n\t/** Add layers to the content area (after creation, e.g. once cues are known). */\n\tfill(...nodes: LayoutNode[]): AppFrameApi;\n}\n\n/** Chrome palettes, tuned to sit with real app UIs. */\nconst PALETTES = {\n\tdark: {\n\t\tchrome: \"#1b1c20\",\n\t\tfield: \"#26272c\",\n\t\tline: \"#2e3036\",\n\t\tedge: \"#3a3c44\",\n\t\ttext: \"#e8e8ec\",\n\t\tmuted: \"#8d8f99\",\n\t\tcontent: \"#111215\",\n\t},\n\tlight: {\n\t\tchrome: \"#f2f2f4\",\n\t\tfield: \"#ffffff\",\n\t\tline: \"#e1e1e6\",\n\t\tedge: \"#d4d4da\",\n\t\ttext: \"#1c1c1f\",\n\t\tmuted: \"#7d7e87\",\n\t\tcontent: \"#ffffff\",\n\t},\n} as const;\n\nconst LIGHTS = [\"#ff5f57\", \"#febc2e\", \"#28c840\"] as const;\n\nexport const appFrame = /* @__PURE__ */ defineComponent({\n\tname: \"AppFrame\",\n\tprops: () => ({\n\t\tkind: z.enum(KINDS).default(\"window\"),\n\t\t/** What the frame shows: layers (laid out in content pixels) or a media path. */\n\t\tcontent: z\n\t\t\t.custom<LayoutNode | LayoutNode[] | string>(\n\t\t\t\t(v) =>\n\t\t\t\t\ttypeof v === \"string\" ||\n\t\t\t\t\tArray.isArray(v) ||\n\t\t\t\t\t(typeof v === \"object\" && v !== null),\n\t\t\t\t{ message: \"Expected a layer, an array of layers, or a media path\" },\n\t\t\t)\n\t\t\t.optional(),\n\t\t/** How media content fits the content area. */\n\t\tfit: z.enum([\"cover\", \"contain\", \"fill\"]).default(\"cover\"),\n\t\ttitle: z.string().optional(),\n\t\t/** Browser address. `typeAt` (local frame) types it in and plays a load bar. */\n\t\turl: z\n\t\t\t.union([\n\t\t\t\tz.string(),\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\ttypeAt: z.number().int().min(0).optional(),\n\t\t\t\t\t\t/** Characters per second while typing. */\n\t\t\t\t\t\tcps: z.number().positive().default(22),\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/** Browser tabs; the first is active. */\n\t\ttabs: z.array(z.string()).optional(),\n\t\t/** Chrome colors. Default: follows the theme background. */\n\t\tappearance: z.enum([\"dark\", \"light\"]).optional(),\n\t\t/** Content-area background. Default: the appearance's. */\n\t\tcontentBackground: z.string().optional(),\n\t\t/** Shadow depth, 0 (flat) to 3 (floating). */\n\t\televation: z.number().int().min(0).max(3).default(2),\n\t\t/** Corner radius in px. Default 14 × theme.scale. */\n\t\tradius: z.number().min(0).optional(),\n\t\t/** Present the frame at a 3D angle. */\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(8),\n\t\t\t\t\t\trotateY: z.number().default(-12),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default(false),\n\t}),\n\tdefaults: { enter: \"rise\" },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst width = ctx.requested.width ?? 1440 * s;\n\t\tconst height = ctx.requested.height ?? 900 * s;\n\t\tconst dark =\n\t\t\tprops.appearance === \"dark\" ||\n\t\t\t(props.appearance === undefined &&\n\t\t\t\t(contrastRatio(theme.color.bg, \"#000000\") ?? 1) < 4);\n\t\tconst pal = dark ? PALETTES.dark : PALETTES.light;\n\t\tconst radius = props.radius ?? 14 * s;\n\t\tconst hasTabs = props.kind === \"browser\" && (props.tabs?.length ?? 0) > 0;\n\t\tconst barH =\n\t\t\tprops.kind === \"minimal\"\n\t\t\t\t? 0\n\t\t\t\t: props.kind === \"browser\"\n\t\t\t\t\t? (hasTabs ? 92 : 56) * s\n\t\t\t\t\t: props.kind === \"terminal\"\n\t\t\t\t\t\t? 40 * s\n\t\t\t\t\t\t: 46 * s;\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst cues = {\n\t\t\turlTyped: [] as Array<{ frame: number }>,\n\t\t\tloaded: [] as Array<{ frame: number }>,\n\t\t};\n\t\tconst font = theme.font.body;\n\n\t\t// Content is laid out from the bar down, in content pixels.\n\t\tconst build = (W: number, H: number) => {\n\t\t\tconst bar: LayoutNode[] = [];\n\t\t\tconst lightsY = (hasTabs ? 46 * s : barH) / 2;\n\t\t\tif (props.kind !== \"minimal\") {\n\t\t\t\tLIGHTS.forEach((color, i) => {\n\t\t\t\t\tbar.push(\n\t\t\t\t\t\thost.shape(\"circle\", {\n\t\t\t\t\t\t\tid: ctx.partId(`light-${i}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx: 20 * s + i * 20 * s,\n\t\t\t\t\t\t\ty: lightsY - 6 * s,\n\t\t\t\t\t\t\twidth: 12 * s,\n\t\t\t\t\t\t\theight: 12 * s,\n\t\t\t\t\t\t\tfillColor: color,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (props.kind === \"browser\") {\n\t\t\t\tconst rowY = hasTabs ? 46 * s : 0;\n\t\t\t\tconst rowH = hasTabs ? barH - 46 * s : barH;\n\t\t\t\tif (hasTabs)\n\t\t\t\t\tbar.push(\n\t\t\t\t\t\t...tabStrip(ctx, props.tabs ?? [], {\n\t\t\t\t\t\t\tx: 92 * s,\n\t\t\t\t\t\t\tH: 46 * s,\n\t\t\t\t\t\t\ts,\n\t\t\t\t\t\t\tpal,\n\t\t\t\t\t\t\tfont,\n\t\t\t\t\t\t\tW,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t// Back / forward.\n\t\t\t\tconst navX = hasTabs ? 20 * s : 92 * s;\n\t\t\t\t[0, 1].forEach((i) => {\n\t\t\t\t\tconst cx = navX + i * 30 * s + 8 * s;\n\t\t\t\t\tconst cy = rowY + rowH / 2;\n\t\t\t\t\tconst dir = i === 0 ? -1 : 1;\n\t\t\t\t\tbar.push(\n\t\t\t\t\t\thost.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`nav-${i}`),\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: W,\n\t\t\t\t\t\t\theight: barH,\n\t\t\t\t\t\t\td: `M${cx - dir * 3 * s} ${cy - 6 * s} L${cx + dir * 3 * s} ${cy} L${cx - dir * 3 * s} ${cy + 6 * s}`,\n\t\t\t\t\t\t\tfillType: \"none\",\n\t\t\t\t\t\t\tfillColor: pal.muted,\n\t\t\t\t\t\t\tstrokeColor: i === 0 ? pal.muted : pal.edge,\n\t\t\t\t\t\t\tstrokeWidth: 1.8 * s,\n\t\t\t\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\t\t\t\tstrokeLineJoin: \"round\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t\t\tif (props.url !== undefined) {\n\t\t\t\t\tbar.push(\n\t\t\t\t\t\t...urlField(ctx, props.url, {\n\t\t\t\t\t\t\tW,\n\t\t\t\t\t\t\trowY,\n\t\t\t\t\t\t\trowH,\n\t\t\t\t\t\t\ts,\n\t\t\t\t\t\t\tpal,\n\t\t\t\t\t\t\tfont,\n\t\t\t\t\t\t\tfps,\n\t\t\t\t\t\t\tcues,\n\t\t\t\t\t\t\taccent: theme.color.accent,\n\t\t\t\t\t\t\tparts,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t} else if (props.title) {\n\t\t\t\tbar.push(\n\t\t\t\t\thost.text(props.title, {\n\t\t\t\t\t\tid: ctx.partId(\"title\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: barH / 2 - 9 * s,\n\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\talign: \"center\",\n\t\t\t\t\t\tfontFamily: props.kind === \"terminal\" ? theme.font.mono : font,\n\t\t\t\t\t\tfontSize: 13 * s,\n\t\t\t\t\t\tfontWeight: 500,\n\t\t\t\t\t\tfill: pal.muted,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst contentChildren: LayoutNode[] =\n\t\t\t\tprops.content === undefined\n\t\t\t\t\t? []\n\t\t\t\t\t: typeof props.content === \"string\"\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\thost.media(props.content, {\n\t\t\t\t\t\t\t\t\tid: ctx.partId(\"media\"),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\t\t\t\theight: H - barH,\n\t\t\t\t\t\t\t\t\tfit: props.fit,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: Array.isArray(props.content)\n\t\t\t\t\t\t\t? props.content\n\t\t\t\t\t\t\t: [props.content];\n\t\t\tconst content = host.box({\n\t\t\t\tid: ctx.partId(\"content\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: barH,\n\t\t\t\twidth: W,\n\t\t\t\theight: H - barH,\n\t\t\t\tbackground:\n\t\t\t\t\tprops.contentBackground ??\n\t\t\t\t\t(props.kind === \"terminal\" ? \"#0c0c0e\" : pal.content),\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tchildren: contentChildren,\n\t\t\t});\n\t\t\tconst chrome = host.box({\n\t\t\t\tid: ctx.partId(\"chrome\"),\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\tbackground: props.kind === \"terminal\" ? \"#16161a\" : pal.chrome,\n\t\t\t\tborderRadius: radius,\n\t\t\t\tborderColor: pal.edge,\n\t\t\t\tborderWidth: 1,\n\t\t\t\tstrokeAlign: \"inside\",\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tchildren: [\n\t\t\t\t\t...(barH > 0\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\thost.box({\n\t\t\t\t\t\t\t\t\tid: ctx.partId(\"bar\"),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\t\t\t\theight: barH,\n\t\t\t\t\t\t\t\t\tchildren: bar,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\thost.box({\n\t\t\t\t\t\t\t\t\tid: ctx.partId(\"divider\"),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\t\t\ty: barH - 1,\n\t\t\t\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\t\t\t\theight: 1,\n\t\t\t\t\t\t\t\t\tbackground: pal.line,\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\tcontent,\n\t\t\t\t],\n\t\t\t});\n\t\t\tconst children: LayoutNode[] = [];\n\t\t\tif (props.elevation > 0) {\n\t\t\t\tconst e = props.elevation;\n\t\t\t\t// The shadow lives on its own plate: the chrome clips its content.\n\t\t\t\tconst plate = host.box({\n\t\t\t\t\tid: ctx.partId(\"shadow\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tbackground: pal.chrome,\n\t\t\t\t\tborderRadius: radius,\n\t\t\t\t});\n\t\t\t\thost.style(plate, {\n\t\t\t\t\tdropShadow: {\n\t\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\t\topacity: [0, 0.3, 0.45, 0.6][e],\n\t\t\t\t\t\tangle: 90,\n\t\t\t\t\t\tdistance: [0, 8, 22, 40][e],\n\t\t\t\t\t\tsize: [0, 22, 48, 80][e],\n\t\t\t\t\t\tspread: 0,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\tchildren.push(plate);\n\t\t\t\tparts.shadow = plate;\n\t\t\t}\n\t\t\tchildren.push(chrome);\n\t\t\tObject.assign(parts, { chrome, content });\n\t\t\treturn { children, content };\n\t\t};\n\n\t\treturn {\n\t\t\twidth,\n\t\t\theight,\n\t\t\tduration: null,\n\t\t\tcues: cues as AppFrameCues,\n\t\t\tparts,\n\t\t\tchildren: props.tilt\n\t\t\t\t? [\n\t\t\t\t\t\t// The presentation angle: a perspective box round the whole frame.\n\t\t\t\t\t\thost.box({\n\t\t\t\t\t\t\tid: ctx.partId(\"tilt\"),\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,\n\t\t\t\t\t\t\theight,\n\t\t\t\t\t\t\tperspective: 1800 * s,\n\t\t\t\t\t\t\trotateX: props.tilt.rotateX,\n\t\t\t\t\t\t\trotateY: props.tilt.rotateY,\n\t\t\t\t\t\t\tchildren: build(width, height).children,\n\t\t\t\t\t\t}),\n\t\t\t\t\t]\n\t\t\t\t: build(width, height).children,\n\t\t\tapi: (placement): AppFrameApi => {\n\t\t\t\tconst content = parts.content as Animatable<LayoutNode> & {\n\t\t\t\t\tchildren?: LayoutNode[];\n\t\t\t\t};\n\t\t\t\tconst rect = {\n\t\t\t\t\tx: placement.x,\n\t\t\t\t\ty: placement.y + barH,\n\t\t\t\t\twidth: placement.width,\n\t\t\t\t\theight: placement.height - barH,\n\t\t\t\t};\n\t\t\t\tconst api: AppFrameApi = {\n\t\t\t\t\tcontentRect: rect,\n\t\t\t\t\tat: (x, y) => [rect.x + x, rect.y + y],\n\t\t\t\t\tfill: (...nodes) => {\n\t\t\t\t\t\tcontent.children = [...(content.children ?? []), ...nodes];\n\t\t\t\t\t\treturn api;\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t\treturn api;\n\t\t\t},\n\t\t};\n\t},\n});\n\nfunction tabStrip(\n\tctx: Parameters<typeof appFrame.build>[0],\n\ttabs: string[],\n\to: {\n\t\tx: number;\n\t\tH: number;\n\t\ts: number;\n\t\tpal: (typeof PALETTES)[\"dark\" | \"light\"];\n\t\tfont: string;\n\t\tW: number;\n\t},\n): LayoutNode[] {\n\tconst { host } = ctx;\n\tconst out: LayoutNode[] = [];\n\tlet x = o.x;\n\ttabs.forEach((label, i) => {\n\t\tconst active = i === 0;\n\t\tconst fontSize = 12.5 * o.s;\n\t\tconst m = host.measure(label, {\n\t\t\tfontFamily: o.font,\n\t\t\tfontSize,\n\t\t\tfontWeight: 500,\n\t\t});\n\t\tconst w = Math.min(240 * o.s, Math.max(120 * o.s, m.width + 40 * o.s));\n\t\tout.push(\n\t\t\thost.box({\n\t\t\t\tid: ctx.partId(`tab-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: 8 * o.s,\n\t\t\t\twidth: w,\n\t\t\t\theight: o.H - 8 * o.s,\n\t\t\t\tbackground: active ? o.pal.field : \"transparent\",\n\t\t\t\tborderRadius: 9 * o.s,\n\t\t\t\tchildren: [\n\t\t\t\t\thost.text(label, {\n\t\t\t\t\t\tid: ctx.partId(`tab-${i}-text`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 14 * o.s,\n\t\t\t\t\t\ty: (o.H - 8 * o.s) / 2 - fontSize * 0.62,\n\t\t\t\t\t\twidth: w - 28 * o.s,\n\t\t\t\t\t\tfontFamily: o.font,\n\t\t\t\t\t\tfontSize,\n\t\t\t\t\t\tfontWeight: 500,\n\t\t\t\t\t\tfill: active ? o.pal.text : o.pal.muted,\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t}),\n\t\t);\n\t\tx += w + 4 * o.s;\n\t});\n\treturn out;\n}\n\nfunction urlField(\n\tctx: Parameters<typeof appFrame.build>[0],\n\turl: string | { text: string; typeAt?: number; cps: number },\n\to: {\n\t\tW: number;\n\t\trowY: number;\n\t\trowH: number;\n\t\ts: number;\n\t\tpal: (typeof PALETTES)[\"dark\" | \"light\"];\n\t\tfont: string;\n\t\tfps: number;\n\t\tcues: {\n\t\t\turlTyped: Array<{ frame: number }>;\n\t\t\tloaded: Array<{ frame: number }>;\n\t\t};\n\t\taccent: string;\n\t\tparts: Record<string, Animatable<LayoutNode>>;\n\t},\n): LayoutNode[] {\n\tconst { host } = ctx;\n\tconst text = typeof url === \"string\" ? url : url.text;\n\tconst fieldW = Math.min(620 * o.s, o.W * 0.46);\n\tconst fieldH = 32 * o.s;\n\tconst fieldX = (o.W - fieldW) / 2;\n\tconst fieldY = o.rowY + (o.rowH - fieldH) / 2;\n\tconst fontSize = 13.5 * o.s;\n\tconst m = host.measure(text, { fontFamily: o.font, fontSize });\n\tconst lockW = 14 * o.s;\n\tconst textX = (fieldW - m.width - lockW) / 2 + lockW;\n\tconst out: LayoutNode[] = [];\n\n\tconst label = host.text(text, {\n\t\tid: ctx.partId(\"url\"),\n\t\tposition: \"absolute\",\n\t\tx: textX,\n\t\ty: fieldH / 2 - m.lineHeight / 2,\n\t\tfontFamily: o.font,\n\t\tfontSize,\n\t\tfill: o.pal.text,\n\t});\n\t// A small padlock: shackle + body.\n\tconst lx = textX - lockW;\n\tconst ly = fieldH / 2;\n\tconst lock = host.shape(\"path\", {\n\t\tid: ctx.partId(\"lock\"),\n\t\tposition: \"absolute\",\n\t\tx: 0,\n\t\ty: 0,\n\t\twidth: fieldW,\n\t\theight: fieldH,\n\t\td: `M${lx + 2.2 * o.s} ${ly - 1 * o.s} L${lx + 2.2 * o.s} ${ly - 3.4 * o.s} C${lx + 2.2 * o.s} ${ly - 6.4 * o.s} ${lx + 7.8 * o.s} ${ly - 6.4 * o.s} ${lx + 7.8 * o.s} ${ly - 3.4 * o.s} L${lx + 7.8 * o.s} ${ly - 1 * o.s}`,\n\t\tfillType: \"none\",\n\t\tfillColor: o.pal.muted,\n\t\tstrokeColor: o.pal.muted,\n\t\tstrokeWidth: 1.4 * o.s,\n\t\tstrokeLineCap: \"round\",\n\t});\n\tconst lockBody = host.box({\n\t\tid: ctx.partId(\"lock-body\"),\n\t\tposition: \"absolute\",\n\t\tx: lx + 0.6 * o.s,\n\t\ty: ly - 1.4 * o.s,\n\t\twidth: 8.8 * o.s,\n\t\theight: 6.6 * o.s,\n\t\tbackground: o.pal.muted,\n\t\tborderRadius: 1.6 * o.s,\n\t});\n\tconst fieldChildren: LayoutNode[] = [lock, lockBody, label];\n\to.parts.url = label;\n\n\tif (typeof url === \"object\" && url.typeAt !== undefined) {\n\t\tconst perChar = Math.max(1, Math.round(o.fps / url.cps));\n\t\tconst start = url.typeAt;\n\t\t// The text track is a reveal amount: ≤ 1 is a fraction, > 1 a count.\n\t\tconst keys: KeyframeTuple[] = [[0, 0]];\n\t\tfor (let i = 1; i <= text.length; i++) {\n\t\t\tkeys.push([start + i * perChar, i === 1 ? 1 / text.length : i, \"hold\"]);\n\t\t}\n\t\tlabel.animate(LayerAnimation.keys(\"text\" as AnimatableProp, keys));\n\t\tconst typed = start + text.length * perChar;\n\t\t// Caret follows the last typed glyph, then blinks a few times.\n\t\tconst caretKeys: KeyframeTuple[] = [[0, textX]];\n\t\tfor (let i = 1; i <= text.length; i++)\n\t\t\tcaretKeys.push([start + i * perChar, textX + m.caretX(i), \"hold\"]);\n\t\tconst caret = host.box({\n\t\t\tid: ctx.partId(\"caret\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: textX,\n\t\t\ty: fieldH / 2 - fontSize * 0.7,\n\t\t\twidth: 1.6 * o.s,\n\t\t\theight: fontSize * 1.4,\n\t\t\tbackground: o.accent,\n\t\t});\n\t\tconst blink: KeyframeTuple[] = [\n\t\t\t[0, 0],\n\t\t\t[start, 1, \"hold\"],\n\t\t];\n\t\tconst half = Math.round(o.fps * 0.5);\n\t\tfor (\n\t\t\tlet f = typed + half, on = false;\n\t\t\tf < typed + half * 6;\n\t\t\tf += half, on = !on\n\t\t)\n\t\t\tblink.push([f, on ? 1 : 0, \"hold\"]);\n\t\tblink.push([typed + half * 6, 0, \"hold\"]);\n\t\tcaret.animate(\n\t\t\tLayerAnimation.keys(\"x\", caretKeys),\n\t\t\tLayerAnimation.keys(\"opacity\", blink),\n\t\t);\n\t\tfieldChildren.push(caret);\n\t\to.parts.caret = caret;\n\t\to.cues.urlTyped.push({ frame: typed });\n\n\t\t// Load bar along the bottom of the toolbar after \"enter\".\n\t\tconst loadStart = typed + Math.round(o.fps * 0.15);\n\t\tconst loadEnd = loadStart + Math.round(o.fps * 0.9);\n\t\tconst bar = host.box({\n\t\t\tid: ctx.partId(\"load\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: o.rowY + o.rowH - 2 * o.s,\n\t\t\twidth: 0,\n\t\t\theight: 2 * o.s,\n\t\t\tbackground: o.accent,\n\t\t});\n\t\tbar.animate(\n\t\t\tLayerAnimation.keys(\"width\", [\n\t\t\t\t[0, 0],\n\t\t\t\t[loadStart, 0, \"hold\"],\n\t\t\t\t[loadStart + Math.round(o.fps * 0.35), o.W * 0.62, \"power2.out\"],\n\t\t\t\t[loadEnd, o.W, \"power3.inOut\"],\n\t\t\t]),\n\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t[0, 0],\n\t\t\t\t[loadStart, 1, \"hold\"],\n\t\t\t\t[loadEnd, 1],\n\t\t\t\t[loadEnd + Math.round(o.fps * 0.3), 0, \"power2.out\"],\n\t\t\t]),\n\t\t);\n\t\tout.push(bar);\n\t\to.parts.load = bar;\n\t\to.cues.loaded.push({ frame: loadEnd });\n\t}\n\n\tconst field = host.box({\n\t\tid: ctx.partId(\"url-field\"),\n\t\tposition: \"absolute\",\n\t\tx: fieldX,\n\t\ty: fieldY,\n\t\twidth: fieldW,\n\t\theight: fieldH,\n\t\tbackground: o.pal.field,\n\t\tborderRadius: 9 * o.s,\n\t\toverflow: \"hidden\",\n\t\tchildren: fieldChildren,\n\t});\n\to.parts[\"url-field\"] = field;\n\tout.unshift(field);\n\treturn out;\n}\n\n/**\n * Window chrome around any content — desktop window, browser with a typed URL,\n * terminal, or bare frame — and the coordinate system for pointing into it.\n */\nexport const AppFrame = /* @__PURE__ */ bindComponent(appFrame);\n"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "backdrop",
|
|
3
|
+
"title": "Backdrop",
|
|
4
|
+
"summary": "The surface behind a scene: a drifting mesh gradient, a grid or a dot field, with vignette and film grain, looping exactly.",
|
|
5
|
+
"tags": [
|
|
6
|
+
"background",
|
|
7
|
+
"backdrop",
|
|
8
|
+
"gradient",
|
|
9
|
+
"grid",
|
|
10
|
+
"dots",
|
|
11
|
+
"grain",
|
|
12
|
+
"loop"
|
|
13
|
+
],
|
|
14
|
+
"example": "Backdrop({ kind: \"mesh\" })",
|
|
15
|
+
"parts": [
|
|
16
|
+
"surface",
|
|
17
|
+
"background",
|
|
18
|
+
"blob-0",
|
|
19
|
+
"blob-0-x",
|
|
20
|
+
"pattern",
|
|
21
|
+
"pattern-lit",
|
|
22
|
+
"glow-matte",
|
|
23
|
+
"glow",
|
|
24
|
+
"vignette",
|
|
25
|
+
"grain"
|
|
26
|
+
],
|
|
27
|
+
"cues": [
|
|
28
|
+
"loops"
|
|
29
|
+
],
|
|
30
|
+
"registryDependencies": [],
|
|
31
|
+
"files": [
|
|
32
|
+
{
|
|
33
|
+
"path": "backdrop/index.ts",
|
|
34
|
+
"content": "/**\n * Backdrop: the surface behind a scene. A drifting mesh gradient, a grid or\n * a dot field, with a vignette and film grain. Every motion is periodic in\n * `loop`, so frame f and frame f + loop are the same picture and the\n * backdrop can sit under any length of scene.\n *\n * Keys sit on each motion's extremes with `sine.inOut` (an exact cosine\n * between extremes), so a loop costs a few keys per axis.\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\tz,\n} from \"framefields/components\";\n\nexport const BACKDROP_KINDS = [\"mesh\", \"grid\", \"dots\", \"solid\"] as const;\nexport type BackdropKind = (typeof BACKDROP_KINDS)[number];\n\nexport interface BackdropCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The first frame of every loop. */\n\tloops: ReadonlyArray<{ frame: number }>;\n}\n\n/**\n * Keys per track, to bound program size on very long scenes (past it the\n * last loop holds). A layer carries at most two looping tracks, so it stays\n * under the `large_animation` lint threshold of 2000.\n */\nconst MAX_KEYS = 960;\n\nexport const backdrop = /* @__PURE__ */ defineComponent({\n\tname: \"Backdrop\",\n\tprops: () => ({\n\t\tkind: z.enum(BACKDROP_KINDS).default(\"mesh\"),\n\t\t/** Blob colours (mesh); the first is the grid / dot colour. */\n\t\tcolors: z.array(z.string()).min(1).optional(),\n\t\tbackground: z.string().optional(),\n\t\t/** Frames of one loop. Default 10 seconds. */\n\t\tloop: z.number().int().min(2).optional(),\n\t\t/** Drift amplitude multiplier (the period stays `loop`). */\n\t\tspeed: z.number().min(0).default(1),\n\t\t/** Grid cell / dot pitch. */\n\t\tcell: z.number().positive().optional(),\n\t\tintensity: z.number().min(0).max(2).default(1),\n\t\t/** A soft light drifting over the grid or dots. */\n\t\tglow: z.boolean().optional(),\n\t\tvignette: z.number().min(0).max(1).default(0.45),\n\t\tgrain: z.union([z.number().min(0).max(1), z.literal(false)]).default(0.35),\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 ?? 1920 * s;\n\t\tconst H = ctx.requested.height ?? 1080 * s;\n\t\tconst c = theme.color;\n\t\tconst loop = props.loop ?? fps * 10;\n\t\tconst colors = props.colors ?? [c.accent, c.accent2, c.highlight];\n\t\tconst cell = props.cell ?? 64 * s;\n\t\t// Motion is keyed for the window, or a minute, in whole loops.\n\t\tconst span =\n\t\t\tMath.max(1, Math.ceil((ctx.requested.duration ?? fps * 60) / loop)) *\n\t\t\tloop;\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst add = (name: string, node: Animatable<LayoutNode>) => {\n\t\t\tparts[name] = node;\n\t\t\tchildren.push(node);\n\t\t\treturn node;\n\t\t};\n\n\t\tadd(\n\t\t\t\"background\",\n\t\t\thost.box({\n\t\t\t\tid: ctx.partId(\"background\"),\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\tbackground: props.background ?? c.bg,\n\t\t\t}),\n\t\t);\n\n\t\tif (props.kind === \"mesh\") {\n\t\t\tconst count = Math.max(3, colors.length + 1);\n\t\t\tconst r = ctx.rng(\"mesh\");\n\t\t\tfor (let i = 0; i < count; i++) {\n\t\t\t\tconst size = Math.max(W, H) * (0.75 + r.next() * 0.35);\n\t\t\t\tconst cx = W * (0.15 + 0.7 * ((i + r.next() * 0.6) / count));\n\t\t\t\tconst cy = H * (0.25 + r.next() * 0.5);\n\t\t\t\tconst ax = W * 0.16 * props.speed;\n\t\t\t\tconst ay = H * 0.14 * props.speed;\n\t\t\t\tconst color = colors[i % colors.length] as string;\n\t\t\t\tconst blob = host.box({\n\t\t\t\t\tid: ctx.partId(`blob-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: size,\n\t\t\t\t\theight: size,\n\t\t\t\t\tbackground: `radial-gradient(closest-side, ${color} 0%, ${transparent(color)} 100%)`,\n\t\t\t\t\topacity: Math.min(1, 0.6 * props.intensity),\n\t\t\t\t});\n\t\t\t\t// y on the blob, x on its carrier: one track each, each under the key limit.\n\t\t\t\tblob.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"y\",\n\t\t\t\t\t\tcosineKeys(\n\t\t\t\t\t\t\tcy - size / 2,\n\t\t\t\t\t\t\tay,\n\t\t\t\t\t\t\t2,\n\t\t\t\t\t\t\tr.next() * Math.PI * 2,\n\t\t\t\t\t\t\tloop,\n\t\t\t\t\t\t\tspan,\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tconst carrier = host.box({\n\t\t\t\t\tid: ctx.partId(`blob-${i}-x`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tchildren: [blob],\n\t\t\t\t});\n\t\t\t\tcarrier.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"x\",\n\t\t\t\t\t\tcosineKeys(\n\t\t\t\t\t\t\tcx - size / 2,\n\t\t\t\t\t\t\tax * (r.next() < 0.5 ? -1 : 1),\n\t\t\t\t\t\t\t1,\n\t\t\t\t\t\t\tr.next() * Math.PI * 2,\n\t\t\t\t\t\t\tloop,\n\t\t\t\t\t\t\tspan,\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tparts[`blob-${i}`] = blob;\n\t\t\t\tadd(`blob-${i}-x`, carrier);\n\t\t\t}\n\t\t}\n\n\t\tif (props.kind === \"grid\" || props.kind === \"dots\") {\n\t\t\tconst color = colors[0] as string;\n\t\t\t// Dots are bullet glyphs on a lattice: one text node draws hundreds\n\t\t\t// of them in a few milliseconds (a path of circles takes seconds).\n\t\t\tconst dotSize = Math.max(6, cell * 0.3);\n\t\t\tconst advance = host.measure(\"•\", {\n\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\tfontSize: dotSize,\n\t\t\t}).width;\n\t\t\tconst dots = () => {\n\t\t\t\tconst cols = Math.ceil(W / cell) + 3;\n\t\t\t\tconst rows = Math.ceil(H / cell) + 3;\n\t\t\t\treturn Array.from({ length: rows }, () => \"•\".repeat(cols)).join(\"\\n\");\n\t\t\t};\n\t\t\tconst pattern = (name: string, opacity: number) => {\n\t\t\t\tconst place = {\n\t\t\t\t\tid: ctx.partId(name),\n\t\t\t\t\tposition: \"absolute\" as const,\n\t\t\t\t\tx: -cell,\n\t\t\t\t\ty: -cell,\n\t\t\t\t\topacity: Math.min(1, opacity * props.intensity),\n\t\t\t\t};\n\t\t\t\tconst node =\n\t\t\t\t\tprops.kind === \"grid\"\n\t\t\t\t\t\t? host.shape(\"path\", {\n\t\t\t\t\t\t\t\t...place,\n\t\t\t\t\t\t\t\twidth: W + cell * 2,\n\t\t\t\t\t\t\t\theight: H + cell * 2,\n\t\t\t\t\t\t\t\td: gridPath(W, H, cell),\n\t\t\t\t\t\t\t\tfillType: \"none\",\n\t\t\t\t\t\t\t\tstrokeColor: color,\n\t\t\t\t\t\t\t\tstrokeWidth: Math.max(1, s),\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: host.text(dots(), {\n\t\t\t\t\t\t\t\t...place,\n\t\t\t\t\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\t\t\t\t\tfontSize: dotSize,\n\t\t\t\t\t\t\t\tfill: color,\n\t\t\t\t\t\t\t\tletterSpacing: cell - advance,\n\t\t\t\t\t\t\t\tlineHeight: cell,\n\t\t\t\t\t\t\t});\n\t\t\t\t// One cell per loop, diagonally, snapping back on the loop frame.\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"x\", slideKeys(-cell, cell, loop, span))\n\t\t\t\t\t\t.keys(\"y\", slideKeys(-cell, cell, loop, span)),\n\t\t\t\t);\n\t\t\t\treturn add(name, node);\n\t\t\t};\n\t\t\tpattern(\"pattern\", props.kind === \"grid\" ? 0.1 : 0.16);\n\t\t\tif (props.glow ?? true) {\n\t\t\t\t// A brighter copy of the pattern, seen through a drifting light.\n\t\t\t\tconst lit = pattern(\"pattern-lit\", 0.5);\n\t\t\t\tconst size = Math.max(W, H) * 0.7;\n\t\t\t\tconst r = ctx.rng(\"glow\");\n\t\t\t\tconst lightKeys = {\n\t\t\t\t\tx: cosineKeys(\n\t\t\t\t\t\tW / 2 - size / 2,\n\t\t\t\t\t\tW * 0.3 * props.speed,\n\t\t\t\t\t\t1,\n\t\t\t\t\t\tr.next() * Math.PI * 2,\n\t\t\t\t\t\tloop,\n\t\t\t\t\t\tspan,\n\t\t\t\t\t),\n\t\t\t\t\ty: cosineKeys(\n\t\t\t\t\t\tH / 2 - size / 2,\n\t\t\t\t\t\tH * 0.25 * props.speed,\n\t\t\t\t\t\t1,\n\t\t\t\t\t\tr.next() * Math.PI * 2,\n\t\t\t\t\t\tloop,\n\t\t\t\t\t\tspan,\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t\tconst light = host.box({\n\t\t\t\t\tid: ctx.partId(\"glow-matte\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: lightKeys.x[0]?.[1] as number,\n\t\t\t\t\ty: lightKeys.y[0]?.[1] as number,\n\t\t\t\t\twidth: size,\n\t\t\t\t\theight: size,\n\t\t\t\t\tbackground:\n\t\t\t\t\t\t\"radial-gradient(closest-side, #fff 0%, rgba(255,255,255,0) 100%)\",\n\t\t\t\t});\n\t\t\t\tlight.animate(\n\t\t\t\t\tLayerAnimation.create().keys(\"x\", lightKeys.x).keys(\"y\", lightKeys.y),\n\t\t\t\t);\n\t\t\t\thost.matte(lit, light);\n\t\t\t\tparts[\"glow-matte\"] = light;\n\t\t\t\t// A faint halo of the pattern colour where the light is.\n\t\t\t\tconst halo = host.box({\n\t\t\t\t\tid: ctx.partId(\"glow\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: lightKeys.x[0]?.[1] as number,\n\t\t\t\t\ty: lightKeys.y[0]?.[1] as number,\n\t\t\t\t\twidth: size,\n\t\t\t\t\theight: size,\n\t\t\t\t\tbackground: `radial-gradient(closest-side, ${color} 0%, ${transparent(color)} 100%)`,\n\t\t\t\t\topacity: 0.18 * props.intensity,\n\t\t\t\t});\n\t\t\t\thalo.animate(\n\t\t\t\t\tLayerAnimation.create().keys(\"x\", lightKeys.x).keys(\"y\", lightKeys.y),\n\t\t\t\t);\n\t\t\t\tadd(\"glow\", halo);\n\t\t\t}\n\t\t}\n\n\t\tif (props.vignette > 0)\n\t\t\tadd(\n\t\t\t\t\"vignette\",\n\t\t\t\thost.box({\n\t\t\t\t\tid: ctx.partId(\"vignette\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tbackground: `radial-gradient(rgba(0,0,0,0) 45%, rgba(0,0,0,${round(props.vignette)}) 100%)`,\n\t\t\t\t}),\n\t\t\t);\n\n\t\t// Grain is its own soft-light layer over everything else.\n\t\tif (props.grain !== false && props.grain > 0)\n\t\t\tadd(\n\t\t\t\t\"grain\",\n\t\t\t\thost.grain({\n\t\t\t\t\tid: ctx.partId(\"grain\"),\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tstrength: round(props.grain * 100),\n\t\t\t\t}),\n\t\t\t);\n\t\tconst surface = host.box({\n\t\t\tid: ctx.partId(\"surface\"),\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,\n\t\t});\n\t\tparts.surface = surface;\n\n\t\treturn {\n\t\t\tchildren: [surface],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: null,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\tloops: Array.from({ length: span / loop }, (_, k) => ({\n\t\t\t\t\tframe: k * loop,\n\t\t\t\t})),\n\t\t\t} as BackdropCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nconst round = (v: number) => Math.round(v * 1000) / 1000;\n\n/** `#rrggbb` / `#rgb` at alpha 0 (same hue, so the fade has no dark fringe). */\nfunction transparent(color: string): string {\n\tconst hex = color.trim().replace(/^#/, \"\");\n\tconst full =\n\t\thex.length === 3\n\t\t\t? hex\n\t\t\t\t\t.split(\"\")\n\t\t\t\t\t.map((h) => h + h)\n\t\t\t\t\t.join(\"\")\n\t\t\t: hex.slice(0, 6);\n\tif (!/^[0-9a-f]{6}$/i.test(full)) return \"rgba(0,0,0,0)\";\n\tconst n = Number.parseInt(full, 16);\n\treturn `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},0)`;\n}\n\n/**\n * Keys for `centre + amplitude × cos(2π · cycles · f / loop + phase)` over\n * `span` frames (whole loops): the value at every loop start, and at each\n * extreme inside the loop. Between extremes `sine.inOut` is the cosine\n * exactly; the partial pieces at loop starts use `sine.out` / `sine.in`.\n * Every loop repeats the same keys, so the motion is exactly periodic.\n */\nexport function cosineKeys(\n\tcentre: number,\n\tamplitude: number,\n\tcycles: number,\n\tphase: number,\n\tloop: number,\n\tspan: number,\n): KeyframeTuple[] {\n\tconst at = (f: number) =>\n\t\tround(\n\t\t\tcentre + amplitude * Math.cos((2 * Math.PI * cycles * f) / loop + phase),\n\t\t);\n\tif (amplitude === 0) return [[0, round(centre)]];\n\t// Extremes: 2π·cycles·f/loop + phase = nπ.\n\tconst extremes: number[] = [];\n\tfor (let n = -2; n <= cycles * 2 + 2; n++) {\n\t\tconst f = Math.round(\n\t\t\t((n * Math.PI - phase) * loop) / (2 * Math.PI * cycles),\n\t\t);\n\t\tif (f > 0 && f < loop && !extremes.includes(f)) extremes.push(f);\n\t}\n\textremes.sort((a, b) => a - b);\n\tconst perLoop: KeyframeTuple[] = extremes.map((f, i) => [\n\t\tf,\n\t\tat(f),\n\t\ti === 0 ? \"sine.out\" : \"sine.inOut\",\n\t]);\n\tconst loops = Math.max(\n\t\t1,\n\t\tMath.min(span / loop, Math.floor(MAX_KEYS / (perLoop.length + 1))),\n\t);\n\tconst keys: KeyframeTuple[] = [];\n\tfor (let k = 0; k < loops; k++) {\n\t\tkeys.push(k === 0 ? [0, at(0)] : [k * loop, at(0), \"sine.in\"]);\n\t\tfor (const [f, v, e] of perLoop)\n\t\t\tkeys.push([k * loop + (f as number), v, e] as KeyframeTuple);\n\t}\n\tkeys.push([loops * loop, at(0), \"sine.in\"]);\n\treturn keys;\n}\n\n/** `from` → `from + by` linearly over each loop, snapping back on the loop frame. */\nexport function slideKeys(\n\tfrom: number,\n\tby: number,\n\tloop: number,\n\tspan: number,\n): KeyframeTuple[] {\n\tconst loops = Math.max(1, Math.min(span / loop, Math.floor(MAX_KEYS / 2)));\n\tconst keys: KeyframeTuple[] = [[0, from]];\n\tfor (let k = 0; k < loops; k++) {\n\t\tkeys.push([\n\t\t\t(k + 1) * loop - 1,\n\t\t\tround(from + (by * (loop - 1)) / loop),\n\t\t\t\"linear\",\n\t\t]);\n\t\tkeys.push([(k + 1) * loop, from, \"hold\"]);\n\t}\n\treturn keys;\n}\n\nfunction gridPath(W: number, H: number, cell: number): string {\n\tconst w = W + cell * 2;\n\tconst h = H + cell * 2;\n\tconst parts: string[] = [];\n\tfor (let x = 0; x <= w + 0.01; x += cell)\n\t\tparts.push(`M${round(x)} 0L${round(x)} ${round(h)}`);\n\tfor (let y = 0; y <= h + 0.01; y += cell)\n\t\tparts.push(`M0 ${round(y)}L${round(w)} ${round(y)}`);\n\treturn parts.join(\"\");\n}\n\n/**\n * The surface behind a scene: a drifting mesh gradient, a grid or a dot field,\n * with vignette and film grain, looping exactly.\n */\nexport const Backdrop = /* @__PURE__ */ bindComponent(backdrop);\n"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "before-after",
|
|
3
|
+
"title": "BeforeAfter",
|
|
4
|
+
"summary": "Two versions of one picture split by a moving divider with a grip and labels: a clip with and without an effect, a redesign, a cleanup.",
|
|
5
|
+
"tags": [
|
|
6
|
+
"before after",
|
|
7
|
+
"comparison",
|
|
8
|
+
"split",
|
|
9
|
+
"wipe",
|
|
10
|
+
"slider",
|
|
11
|
+
"effects"
|
|
12
|
+
],
|
|
13
|
+
"example": "BeforeAfter({ before: \"assets/raw.mp4\", after: graded })",
|
|
14
|
+
"parts": [
|
|
15
|
+
"after",
|
|
16
|
+
"before",
|
|
17
|
+
"before-clip",
|
|
18
|
+
"divider",
|
|
19
|
+
"handle",
|
|
20
|
+
"label-0",
|
|
21
|
+
"label-1"
|
|
22
|
+
],
|
|
23
|
+
"cues": [
|
|
24
|
+
"settled"
|
|
25
|
+
],
|
|
26
|
+
"registryDependencies": [],
|
|
27
|
+
"files": [
|
|
28
|
+
{
|
|
29
|
+
"path": "before-after/index.ts",
|
|
30
|
+
"content": "/**\n * BeforeAfter: two versions of one picture split by a moving divider. The\n * `before` side is clipped to the divider; both sides are laid out at the\n * full size, so they line up pixel for pixel. Give one clip twice, once\n * with an effect, to show what the effect does.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tdefineComponent,\n\tevaluateEase,\n\tscaleFrames,\n\tz,\n} from \"framefields/components\";\n\nexport interface BeforeAfterCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The divider's last move ends. */\n\tsettled: ReadonlyArray<{ frame: number }>;\n}\n\nconst side = () =>\n\tz.custom<LayoutNode | LayoutNode[] | string>(\n\t\t(v) =>\n\t\t\ttypeof v === \"string\" ||\n\t\t\tArray.isArray(v) ||\n\t\t\t(typeof v === \"object\" && v !== null),\n\t\t{ message: \"Expected layers or a media path for this side\" },\n\t);\n\nexport const beforeAfter = /* @__PURE__ */ defineComponent({\n\tname: \"BeforeAfter\",\n\tprops: () => ({\n\t\tbefore: side(),\n\t\tafter: side(),\n\t\t/** Divider position 0–1, or keys `[frame, value, ease?]`. Default: a sweep. */\n\t\tsplit: z\n\t\t\t.union([\n\t\t\t\tz.number().min(0).max(1),\n\t\t\t\tz\n\t\t\t\t\t.array(\n\t\t\t\t\t\tz.union([\n\t\t\t\t\t\t\tz.tuple([z.number().int().min(0), z.number().min(0).max(1)]),\n\t\t\t\t\t\t\tz.tuple([\n\t\t\t\t\t\t\t\tz.number().int().min(0),\n\t\t\t\t\t\t\t\tz.number().min(0).max(1),\n\t\t\t\t\t\t\t\tz.string(),\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\t.min(1),\n\t\t\t])\n\t\t\t.optional(),\n\t\tdirection: z.enum([\"horizontal\", \"vertical\"]).default(\"horizontal\"),\n\t\tlabels: z\n\t\t\t.union([z.tuple([z.string(), z.string()]), z.literal(false)])\n\t\t\t.default([\"Before\", \"After\"]),\n\t\thandle: z.boolean().default(true),\n\t\tfit: z.enum([\"cover\", \"contain\", \"fill\"]).default(\"cover\"),\n\t\tcolor: z.string().default(\"#ffffff\"),\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 ?? 1280 * s;\n\t\tconst H = ctx.requested.height ?? 720 * s;\n\t\tconst across = props.direction === \"horizontal\";\n\t\tconst L = across ? W : H;\n\t\tconst f = (n: number) => scaleFrames(n, fps);\n\t\tconst keys: KeyframeTuple[] =\n\t\t\ttypeof props.split === \"number\"\n\t\t\t\t? [[0, props.split]]\n\t\t\t\t: ((props.split as KeyframeTuple[] | undefined) ?? [\n\t\t\t\t\t\t[0, 0.5],\n\t\t\t\t\t\t[f(20), 0.5],\n\t\t\t\t\t\t[f(50), 0.15, \"power2.inOut\"],\n\t\t\t\t\t\t[f(100), 0.85, \"power2.inOut\"],\n\t\t\t\t\t\t[f(135), 0.5, \"power2.inOut\"],\n\t\t\t\t\t]);\n\t\tconst sorted = [...keys].sort(\n\t\t\t(a, b) => (a[0] as number) - (b[0] as number),\n\t\t);\n\t\tconst px = (k: KeyframeTuple[], by = 0): KeyframeTuple[] =>\n\t\t\tk.map(([fr, v, e]) =>\n\t\t\t\te\n\t\t\t\t\t? [fr, round((v as number) * L + by), e]\n\t\t\t\t\t: [fr, round((v as number) * L + by)],\n\t\t\t) as KeyframeTuple[];\n\t\tconst settled = sorted[sorted.length - 1]?.[0] as number;\n\n\t\tconst fill = (name: string, content: LayoutNode | LayoutNode[] | string) =>\n\t\t\ttypeof content === \"string\"\n\t\t\t\t? [\n\t\t\t\t\t\thost.media(content, {\n\t\t\t\t\t\t\tid: ctx.partId(`${name}-media`),\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: W,\n\t\t\t\t\t\t\theight: H,\n\t\t\t\t\t\t\tfit: props.fit,\n\t\t\t\t\t\t}),\n\t\t\t\t\t]\n\t\t\t\t: Array.isArray(content)\n\t\t\t\t\t? content\n\t\t\t\t\t: [content];\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst add = <T extends Animatable<LayoutNode>>(name: string, node: T) => {\n\t\t\tparts[name] = node;\n\t\t\treturn node;\n\t\t};\n\t\tconst after = add(\n\t\t\t\"after\",\n\t\t\thost.box({\n\t\t\t\tid: ctx.partId(\"after\"),\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\toverflow: \"hidden\",\n\t\t\t\tchildren: fill(\"after\", props.after),\n\t\t\t}),\n\t\t);\n\t\tconst start = (sorted[0]?.[1] as number) * L;\n\t\tconst clip = add(\n\t\t\t\"before-clip\",\n\t\t\thost.box({\n\t\t\t\tid: ctx.partId(\"before-clip\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: across ? start : W,\n\t\t\t\theight: across ? H : start,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tchildren: [\n\t\t\t\t\tadd(\n\t\t\t\t\t\t\"before\",\n\t\t\t\t\t\thost.box({\n\t\t\t\t\t\t\tid: ctx.partId(\"before\"),\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: W,\n\t\t\t\t\t\t\theight: H,\n\t\t\t\t\t\t\tchildren: fill(\"before\", props.before),\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);\n\t\tif (sorted.length > 1)\n\t\t\tclip.animate(\n\t\t\t\tLayerAnimation.keys(across ? \"width\" : \"height\", px(sorted)),\n\t\t\t);\n\t\tconst children: LayoutNode[] = [after, clip];\n\n\t\t// ── Divider and handle ───────────────────────────────────────────\n\t\tconst thick = Math.max(2, 3 * s);\n\t\tconst line = add(\n\t\t\t\"divider\",\n\t\t\thost.box({\n\t\t\t\tid: ctx.partId(\"divider\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: across ? start - thick / 2 : 0,\n\t\t\t\ty: across ? 0 : start - thick / 2,\n\t\t\t\twidth: across ? thick : W,\n\t\t\t\theight: across ? H : thick,\n\t\t\t\tbackground: props.color,\n\t\t\t}),\n\t\t);\n\t\thost.style(line, {\n\t\t\tdropShadow: {\n\t\t\t\tcolor: \"#000000\",\n\t\t\t\topacity: 0.35,\n\t\t\t\tangle: 90,\n\t\t\t\tdistance: 0,\n\t\t\t\tsize: 12 * s,\n\t\t\t},\n\t\t});\n\t\tif (sorted.length > 1)\n\t\t\tline.animate(\n\t\t\t\tLayerAnimation.keys(across ? \"x\" : \"y\", px(sorted, -thick / 2)),\n\t\t\t);\n\t\tchildren.push(line);\n\t\tif (props.handle) {\n\t\t\tconst d = 52 * s;\n\t\t\tconst c = d / 2;\n\t\t\tconst a = d * 0.12;\n\t\t\tconst chevrons = across\n\t\t\t\t? `M${c - a * 1.2} ${c - a}L${c - a * 2.2} ${c}L${c - a * 1.2} ${c + a}M${c + a * 1.2} ${c - a}L${c + a * 2.2} ${c}L${c + a * 1.2} ${c + a}`\n\t\t\t\t: `M${c - a} ${c - a * 1.2}L${c} ${c - a * 2.2}L${c + a} ${c - a * 1.2}M${c - a} ${c + a * 1.2}L${c} ${c + a * 2.2}L${c + a} ${c + a * 1.2}`;\n\t\t\tconst grip = add(\n\t\t\t\t\"handle\",\n\t\t\t\thost.box({\n\t\t\t\t\tid: ctx.partId(\"handle\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: across ? start - c : W / 2 - c,\n\t\t\t\t\ty: across ? H / 2 - c : start - c,\n\t\t\t\t\twidth: d,\n\t\t\t\t\theight: d,\n\t\t\t\t\tbackground: props.color,\n\t\t\t\t\tborderRadius: c,\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\thost.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(\"handle-arrows\"),\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: d,\n\t\t\t\t\t\t\theight: d,\n\t\t\t\t\t\t\td: chevrons,\n\t\t\t\t\t\t\tfillType: \"none\",\n\t\t\t\t\t\t\tstrokeColor: \"#111111\",\n\t\t\t\t\t\t\tstrokeWidth: Math.max(1.5, 2.4 * s),\n\t\t\t\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\t\t\t\tstrokeLineJoin: \"round\",\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\thost.style(grip, {\n\t\t\t\tdropShadow: {\n\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\topacity: 0.4,\n\t\t\t\t\tangle: 90,\n\t\t\t\t\tdistance: 3 * s,\n\t\t\t\t\tsize: 14 * s,\n\t\t\t\t},\n\t\t\t});\n\t\t\tif (sorted.length > 1)\n\t\t\t\tgrip.animate(LayerAnimation.keys(across ? \"x\" : \"y\", px(sorted, -c)));\n\t\t\tchildren.push(grip);\n\t\t}\n\n\t\t// ── Labels: each fades as the divider covers it ─────────────────\n\t\tif (props.labels) {\n\t\t\tconst fs = 22 * s;\n\t\t\tconst pad = 14 * s;\n\t\t\tconst margin = 24 * s;\n\t\t\tconst length = Math.max(1, settled);\n\t\t\tconst splitAt = (fr: number) => valueAt(sorted, fr) * L;\n\t\t\tprops.labels.forEach((text, i) => {\n\t\t\t\tconst m = host.measure(text, {\n\t\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\t\tfontSize: fs,\n\t\t\t\t\tfontWeight: 600,\n\t\t\t\t});\n\t\t\t\tconst w = m.width + pad * 2;\n\t\t\t\tconst h = m.height + pad * 0.9;\n\t\t\t\tconst x = across ? (i === 0 ? margin : W - margin - w) : margin;\n\t\t\t\tconst y = across ? margin : i === 0 ? margin : H - margin - h;\n\t\t\t\t// How far the label's far edge is inside its own side.\n\t\t\t\tconst visible = (fr: number) => {\n\t\t\t\t\tconst at = splitAt(fr);\n\t\t\t\t\tconst edge = across ? (i === 0 ? x + w : x) : i === 0 ? y + h : y;\n\t\t\t\t\tconst inside = i === 0 ? at - edge : edge - at;\n\t\t\t\t\treturn Math.max(0, Math.min(1, inside / (24 * s) + 1));\n\t\t\t\t};\n\t\t\t\tconst pill = add(\n\t\t\t\t\t`label-${i}`,\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(`label-${i}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx,\n\t\t\t\t\t\ty,\n\t\t\t\t\t\twidth: w,\n\t\t\t\t\t\theight: h,\n\t\t\t\t\t\tbackground: \"rgba(0,0,0,0.55)\",\n\t\t\t\t\t\tborderRadius: h / 2,\n\t\t\t\t\t\topacity: visible(0),\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\thost.text(text, {\n\t\t\t\t\t\t\t\tid: ctx.partId(`label-${i}-text`),\n\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\tx: pad,\n\t\t\t\t\t\t\t\ty: pad * 0.45,\n\t\t\t\t\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\t\t\t\t\tfontSize: fs,\n\t\t\t\t\t\t\t\tfontWeight: 600,\n\t\t\t\t\t\t\t\tfill: \"#ffffff\",\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\tif (sorted.length > 1)\n\t\t\t\t\tpill.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\t\"opacity\",\n\t\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\t\tArray.from({ length: length + 1 }, (_, fr) => visible(fr)),\n\t\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t\t{ tolerance: 0.02 },\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\tchildren.push(pill);\n\t\t\t});\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: null,\n\t\t\tcues: { settled: [{ frame: settled }] } as BeforeAfterCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nconst round = (v: number) => Math.round(v * 1000) / 1000;\n\n/** The split keys' value at frame `f` (each segment eased by its end key). */\nfunction valueAt(keys: readonly KeyframeTuple[], f: number): number {\n\tconst first = keys[0] as KeyframeTuple;\n\tif (f <= (first[0] as number)) return first[1] as number;\n\tfor (let i = 0; i < keys.length - 1; i++) {\n\t\tconst [f0, v0] = keys[i] as KeyframeTuple;\n\t\tconst [f1, v1, e] = keys[i + 1] as KeyframeTuple;\n\t\tif (f >= (f0 as number) && f <= (f1 as number)) {\n\t\t\tconst t = evaluateEase(\n\t\t\t\te as string | undefined,\n\t\t\t\t(f - (f0 as number)) / Math.max(1, (f1 as number) - (f0 as number)),\n\t\t\t);\n\t\t\treturn (v0 as number) + ((v1 as number) - (v0 as number)) * t;\n\t\t}\n\t}\n\treturn keys[keys.length - 1]?.[1] as number;\n}\n\n/**\n * Two versions of one picture split by a moving divider with a grip and labels:\n * a clip with and without an effect, a redesign, a cleanup.\n */\nexport const BeforeAfter = /* @__PURE__ */ bindComponent(beforeAfter);\n"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "callout",
|
|
3
|
+
"title": "Callout",
|
|
4
|
+
"summary": "A label with a leader line pinned to a point, node, text or a moving tracked object; picks the open side and stays inside the frame.",
|
|
5
|
+
"tags": [
|
|
6
|
+
"callout",
|
|
7
|
+
"label",
|
|
8
|
+
"leader line",
|
|
9
|
+
"annotation",
|
|
10
|
+
"tracking",
|
|
11
|
+
"explainer"
|
|
12
|
+
],
|
|
13
|
+
"example": "Callout({ target: [820, 410], title: \"New\", text: \"Faster renderer\" })",
|
|
14
|
+
"parts": [
|
|
15
|
+
"leader",
|
|
16
|
+
"leader-2",
|
|
17
|
+
"dot",
|
|
18
|
+
"label"
|
|
19
|
+
],
|
|
20
|
+
"cues": [
|
|
21
|
+
"in"
|
|
22
|
+
],
|
|
23
|
+
"registryDependencies": [],
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
26
|
+
"path": "callout/index.ts",
|
|
27
|
+
"content": "/**\n * Callout: a label with a leader line pinned to a point, a node, text, or a\n * moving tracked object. It picks the open side, keeps the label inside the\n * frame, and follows moving targets smoothly (decisions are eased, never\n * snapped).\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 { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tdefineComponent,\n\tevaluateEase,\n\tresolveTarget,\n\tspringAt,\n\ttargetPoint,\n\ttargetSchema,\n\tthemeFrames,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nexport const CALLOUT_SIDES = [\n\t\"auto\",\n\t\"top\",\n\t\"bottom\",\n\t\"left\",\n\t\"right\",\n\t\"top-left\",\n\t\"top-right\",\n\t\"bottom-left\",\n\t\"bottom-right\",\n] as const;\nexport type CalloutSide = (typeof CALLOUT_SIDES)[number];\n\nexport interface CalloutCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Fully drawn. */\n\tin: ReadonlyArray<{ frame: number }>;\n}\n\nconst DIRECTION: Record<Exclude<CalloutSide, \"auto\">, Vec2> = {\n\ttop: [0, -1],\n\tbottom: [0, 1],\n\tleft: [-1, 0],\n\tright: [1, 0],\n\t\"top-left\": [-1, -1],\n\t\"top-right\": [1, -1],\n\t\"bottom-left\": [-1, 1],\n\t\"bottom-right\": [1, 1],\n};\n\nexport const callout = /* @__PURE__ */ defineComponent({\n\tname: \"Callout\",\n\tprops: () => ({\n\t\ttarget: targetSchema(),\n\t\ttext: z.string().min(1),\n\t\t/** A bolder first line above `text`. */\n\t\ttitle: z.string().optional(),\n\t\tside: z.enum(CALLOUT_SIDES).default(\"auto\"),\n\t\t/** Leader length (px). */\n\t\tdistance: z.number().positive().optional(),\n\t\t/** A bend: out on the diagonal, then level into the label. */\n\t\telbow: z.boolean().default(true),\n\t\tdot: z.boolean().default(true),\n\t\tcolor: z.string().optional(),\n\t\t/** Frames to follow a moving target. Default 6 seconds. */\n\t\tlength: z.number().int().positive().optional(),\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 ?? 1920 * s;\n\t\tconst H = ctx.requested.height ?? 1080 * s;\n\t\tconst fs = props.fontSize ?? 26 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.body;\n\t\tconst color = props.color ?? theme.color.accent;\n\t\tconst reach = props.distance ?? 120 * s;\n\t\tconst margin = 24 * s;\n\t\tconst thick = Math.max(2, 2.5 * s);\n\t\tconst dotR = 7 * s;\n\n\t\tconst resolved = resolveTarget(props.target, {\n\t\t\tmeasure: host.measure,\n\t\t\tfps,\n\t\t});\n\t\tconst moving = resolved.kind === \"dynamic\";\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst intro = Math.round(base * 1.8);\n\t\tconst length = moving ? (props.length ?? fps * 6) : intro + 1;\n\n\t\t// ── Label size ────────────────────────────────────────────────────\n\t\tconst padX = fs * 0.6;\n\t\tconst padY = fs * 0.42;\n\t\tconst titleM = props.title\n\t\t\t? host.measure(props.title, { fontFamily, fontSize: fs, fontWeight: 700 })\n\t\t\t: null;\n\t\tconst textM = host.measure(props.text, {\n\t\t\tfontFamily,\n\t\t\tfontSize: fs * (props.title ? 0.85 : 1),\n\t\t});\n\t\tconst lw = Math.max(titleM?.width ?? 0, textM.width) + padX * 2;\n\t\tconst lh = (titleM?.height ?? 0) + textM.height + padY * 2;\n\n\t\t// ── Geometry per frame ────────────────────────────────────────────\n\t\tconst sideAt = (p: Vec2): Vec2 =>\n\t\t\tprops.side === \"auto\"\n\t\t\t\t? [p[0] < W / 2 ? 1 : -1, p[1] < H * 0.4 ? 1 : -1]\n\t\t\t\t: DIRECTION[props.side];\n\t\t// Side decisions ease over a few frames, so a target crossing the\n\t\t// middle swings its label across instead of snapping.\n\t\tlet dir: Vec2 | null = null;\n\t\tconst follow = 1 - Math.exp(-6 / fps);\n\t\tconst frames = Array.from({ length: length + 1 }, (_, f) => {\n\t\t\tconst anchor = targetPoint(resolved, f);\n\t\t\tconst want = sideAt(anchor);\n\t\t\tdir = dir\n\t\t\t\t? [\n\t\t\t\t\t\tdir[0] + (want[0] - dir[0]) * follow,\n\t\t\t\t\t\tdir[1] + (want[1] - dir[1]) * follow,\n\t\t\t\t\t]\n\t\t\t\t: want;\n\t\t\tconst [dx, dy] = dir;\n\t\t\tconst diagonal = props.elbow && Math.abs(dx) > 0.01;\n\t\t\tconst bend: Vec2 = diagonal\n\t\t\t\t? [anchor[0] + dx * reach * 0.55, anchor[1] + dy * reach * 0.55]\n\t\t\t\t: [anchor[0] + dx * reach, anchor[1] + dy * reach];\n\t\t\tconst end: Vec2 = diagonal\n\t\t\t\t? [bend[0] + Math.sign(dx) * reach * 0.45 * Math.abs(dx), bend[1]]\n\t\t\t\t: bend;\n\t\t\t// The label hangs off the leader's end, on its side.\n\t\t\tlet lx =\n\t\t\t\tMath.abs(dx) < 0.5 ? end[0] - lw / 2 : dx > 0 ? end[0] : end[0] - lw;\n\t\t\tlet ly =\n\t\t\t\tMath.abs(dx) < 0.5 ? (dy > 0 ? end[1] : end[1] - lh) : end[1] - lh / 2;\n\t\t\t// Stay inside the frame.\n\t\t\tlx = Math.min(W - margin - lw, Math.max(margin, lx));\n\t\t\tly = Math.min(H - margin - lh, Math.max(margin, ly));\n\t\t\tconst join: Vec2 =\n\t\t\t\tMath.abs(dx) < 0.5\n\t\t\t\t\t? [lx + lw / 2, dy > 0 ? ly : ly + lh]\n\t\t\t\t\t: [dx > 0 ? lx : lx + lw, ly + lh / 2];\n\t\t\treturn {\n\t\t\t\tanchor,\n\t\t\t\tbend: diagonal ? bend : join,\n\t\t\t\tend: join,\n\t\t\t\tlabel: [lx, ly] as Vec2,\n\t\t\t};\n\t\t});\n\n\t\t// ── Intro: dot pops, leader draws, label arrives ──────────────────\n\t\tconst draw = (f: number, from: number, to: number) =>\n\t\t\tevaluateEase(\n\t\t\t\ttheme.motion.ease.move,\n\t\t\t\tMath.min(1, Math.max(0, (f - from) / Math.max(1, to - from))),\n\t\t\t);\n\t\tconst seg1 = [Math.round(base * 0.3), Math.round(base * 1.0)] as const;\n\t\tconst seg2 = [seg1[1], Math.round(base * 1.4)] as const;\n\t\tconst labelAt = Math.round(base * 1.1);\n\n\t\tconst segment = (\n\t\t\tid: string,\n\t\t\tpick: (k: (typeof frames)[number]) => [Vec2, Vec2],\n\t\t\twindow: readonly [number, number],\n\t\t) => {\n\t\t\tconst xs: number[] = [];\n\t\t\tconst ys: number[] = [];\n\t\t\tconst ws: number[] = [];\n\t\t\tconst rs: number[] = [];\n\t\t\tframes.forEach((k, f) => {\n\t\t\t\tconst [a, b] = pick(k);\n\t\t\t\tconst len = Math.hypot(b[0] - a[0], b[1] - a[1]);\n\t\t\t\txs.push(a[0]);\n\t\t\t\tys.push(a[1] - thick / 2);\n\t\t\t\tws.push(len * draw(f, window[0], window[1]));\n\t\t\t\trs.push((Math.atan2(b[1] - a[1], b[0] - a[0]) * 180) / Math.PI);\n\t\t\t});\n\t\t\tconst node = host.box({\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: xs[0] ?? 0,\n\t\t\t\ty: ys[0] ?? 0,\n\t\t\t\twidth: 0,\n\t\t\t\theight: thick,\n\t\t\t\tbackground: color,\n\t\t\t\tborderRadius: thick / 2,\n\t\t\t\tanchorX: 0,\n\t\t\t\tanchorY: 0.5,\n\t\t\t});\n\t\t\tnode.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"x\", bakeSamples(xs, 0, { tolerance: 0.2 }))\n\t\t\t\t\t.keys(\"y\", bakeSamples(ys, 0, { tolerance: 0.2 }))\n\t\t\t\t\t.keys(\"width\", bakeSamples(ws, 0, { tolerance: 0.2 }))\n\t\t\t\t\t.keys(\"rotation\", bakeSamples(rs, 0, { tolerance: 0.05 })),\n\t\t\t);\n\t\t\treturn node;\n\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst leader1 = segment(\"leader\", (k) => [k.anchor, k.bend], seg1);\n\t\tchildren.push(leader1);\n\t\tparts.leader = leader1;\n\t\tif (props.elbow) {\n\t\t\tconst leader2 = segment(\"leader-2\", (k) => [k.bend, k.end], seg2);\n\t\t\tchildren.push(leader2);\n\t\t\tparts[\"leader-2\"] = leader2;\n\t\t}\n\t\tif (props.dot) {\n\t\t\tconst dot = host.box({\n\t\t\t\tid: ctx.partId(\"dot\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: (frames[0]?.anchor[0] ?? 0) - dotR,\n\t\t\t\ty: (frames[0]?.anchor[1] ?? 0) - dotR,\n\t\t\t\twidth: dotR * 2,\n\t\t\t\theight: dotR * 2,\n\t\t\t\tbackground: color,\n\t\t\t\tborderRadius: dotR,\n\t\t\t\tborderColor: theme.color.bg,\n\t\t\t\tborderWidth: Math.max(2, 2 * s),\n\t\t\t});\n\t\t\tconst pop = Array.from({ length: Math.min(length, intro) + 1 }, (_, f) =>\n\t\t\t\tspringAt(f / fps, { stiffness: 320, damping: 16 }),\n\t\t\t);\n\t\t\tdot.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\n\t\t\t\t\t\t\"x\",\n\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\tframes.map((k) => k.anchor[0] - dotR),\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t\t.keys(\n\t\t\t\t\t\t\"y\",\n\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\tframes.map((k) => k.anchor[1] - dotR),\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t\t.keys(\"scale\", bakeSamples(pop, 0, { tolerance: 0.005 })),\n\t\t\t);\n\t\t\tchildren.push(dot);\n\t\t\tparts.dot = dot;\n\t\t}\n\t\tconst labelKids: LayoutNode[] = [];\n\t\tif (props.title && titleM)\n\t\t\tlabelKids.push(\n\t\t\t\thost.text(props.title, {\n\t\t\t\t\tid: ctx.partId(\"title\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: padX,\n\t\t\t\t\ty: padY,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: fs,\n\t\t\t\t\tfontWeight: 700,\n\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t}),\n\t\t\t);\n\t\tlabelKids.push(\n\t\t\thost.text(props.text, {\n\t\t\t\tid: ctx.partId(\"text\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: padX,\n\t\t\t\ty: padY + (titleM?.height ?? 0),\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs * (props.title ? 0.85 : 1),\n\t\t\t\tfill: props.title ? theme.color.muted : theme.color.ink,\n\t\t\t}),\n\t\t);\n\t\tconst label = host.box({\n\t\t\tid: ctx.partId(\"label\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: frames[0]?.label[0] ?? 0,\n\t\t\ty: frames[0]?.label[1] ?? 0,\n\t\t\twidth: lw,\n\t\t\theight: lh,\n\t\t\tbackground: theme.color.raised,\n\t\t\tborderRadius: theme.radius.md,\n\t\t\tborderColor: color,\n\t\t\tborderWidth: Math.max(1.5, 1.5 * s),\n\t\t\tchildren: labelKids,\n\t\t});\n\t\tconst rise = 10 * s;\n\t\tlabel.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\n\t\t\t\t\t\"x\",\n\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\tframes.map((k) => k.label[0]),\n\t\t\t\t\t\t0,\n\t\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t\t.keys(\n\t\t\t\t\t\"y\",\n\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\tframes.map(\n\t\t\t\t\t\t\t(k, f) =>\n\t\t\t\t\t\t\t\tk.label[1] + rise * (1 - draw(f, labelAt, labelAt + base)),\n\t\t\t\t\t\t),\n\t\t\t\t\t\t0,\n\t\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[labelAt, 0],\n\t\t\t\t\t[labelAt + Math.round(base * 0.7), 1, \"power2.out\"],\n\t\t\t\t]),\n\t\t);\n\t\tchildren.push(label);\n\t\tparts.label = label;\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: moving ? length : intro,\n\t\t\topenEnded: !moving,\n\t\t\torigin: [0, 0],\n\t\t\tcues: { in: [{ frame: labelAt + base }] } as CalloutCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * A label with a leader line pinned to a point, node, text or a moving tracked\n * object; picks the open side and stays inside the frame.\n */\nexport const Callout = /* @__PURE__ */ bindComponent(callout);\n"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "captions",
|
|
3
|
+
"title": "Captions",
|
|
4
|
+
"summary": "Word-timed captions from words or a transcript (SRT, WebVTT, word JSON) in eight published styles, highlighting each word on its exact frame, optionally tucked behind the speaker.",
|
|
5
|
+
"tags": [
|
|
6
|
+
"captions",
|
|
7
|
+
"subtitles",
|
|
8
|
+
"social",
|
|
9
|
+
"talking head",
|
|
10
|
+
"transcript",
|
|
11
|
+
"karaoke"
|
|
12
|
+
],
|
|
13
|
+
"example": "Captions({ transcript: srt, style: \"karaoke\", emphasis: [\"faster\"] })",
|
|
14
|
+
"parts": [
|
|
15
|
+
"phrase-<n>",
|
|
16
|
+
"behind"
|
|
17
|
+
],
|
|
18
|
+
"cues": [
|
|
19
|
+
"words",
|
|
20
|
+
"phrases"
|
|
21
|
+
],
|
|
22
|
+
"registryDependencies": [],
|
|
23
|
+
"files": [
|
|
24
|
+
{
|
|
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"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "captions/parse.ts",
|
|
30
|
+
"content": "/**\n * Transcripts to timed words. Word-level JSON keeps its times; cue-level\n * formats (SRT, WebVTT) split each cue into words and share the cue's time\n * between them by length, so longer words are on screen longer.\n */\n\n/** A spoken word, times in seconds. */\nexport interface Word {\n\ttext: string;\n\tstart: number;\n\tend: number;\n}\n\n/** `01:02:03,456`, `02:03.456` or `3.456` → seconds. */\nexport function parseTimestamp(value: string): number {\n\tconst parts = value.trim().replace(\",\", \".\").split(\":\");\n\tlet seconds = 0;\n\tfor (const p of parts) {\n\t\tconst n = Number(p);\n\t\tif (!Number.isFinite(n))\n\t\t\tthrow new Error(`Captions: bad timestamp \"${value}\".`);\n\t\tseconds = seconds * 60 + n;\n\t}\n\treturn seconds;\n}\n\n/** Spread a cue's [start, end) over its words, weighted by length. */\nexport function splitCue(text: string, start: number, end: number): Word[] {\n\tconst tokens = text.split(/\\s+/).filter(Boolean);\n\tconst weights = tokens.map((t) => t.length + 1);\n\tconst total = weights.reduce((a, b) => a + b, 0) || 1;\n\tlet t = start;\n\treturn tokens.map((token, i) => {\n\t\tconst span = ((end - start) * (weights[i] as number)) / total;\n\t\tconst word = { text: token, start: t, end: t + span };\n\t\tt += span;\n\t\treturn word;\n\t});\n}\n\nconst ARROW = /^\\s*([\\d:.,]+)\\s*-->\\s*([\\d:.,]+)/;\n\n/** SRT or WebVTT cues → words. Markup tags (`<i>`, `<c.x>`, `{\\an8}`) are dropped. */\nfunction parseCues(source: string): Word[] {\n\tconst words: Word[] = [];\n\tconst blocks = source.replace(/\\r/g, \"\").split(/\\n\\s*\\n/);\n\tfor (const block of blocks) {\n\t\tconst lines = block.split(\"\\n\");\n\t\tconst at = lines.findIndex((l) => ARROW.test(l));\n\t\tif (at < 0) continue;\n\t\tconst m = (lines[at] as string).match(ARROW) as RegExpMatchArray;\n\t\tconst text = lines\n\t\t\t.slice(at + 1)\n\t\t\t.join(\" \")\n\t\t\t.replace(/<[^>]+>|\\{[^}]*\\}/g, \"\")\n\t\t\t.trim();\n\t\tif (text)\n\t\t\twords.push(\n\t\t\t\t...splitCue(\n\t\t\t\t\ttext,\n\t\t\t\t\tparseTimestamp(m[1] as string),\n\t\t\t\t\tparseTimestamp(m[2] as string),\n\t\t\t\t),\n\t\t\t);\n\t}\n\treturn words;\n}\n\n/** `[{ word | text, start, end }]` (seconds). */\nfunction parseWordJson(source: string): Word[] {\n\tconst data = JSON.parse(source) as unknown;\n\tconst list = Array.isArray(data)\n\t\t? data\n\t\t: ((data as { words?: unknown[] })?.words ?? []);\n\treturn (list as Array<Record<string, unknown>>).map((w, i) => {\n\t\tconst text = (w.word ?? w.text) as unknown;\n\t\tif (\n\t\t\ttypeof text !== \"string\" ||\n\t\t\ttypeof w.start !== \"number\" ||\n\t\t\ttypeof w.end !== \"number\"\n\t\t)\n\t\t\tthrow new Error(\n\t\t\t\t`Captions: word ${i} needs \\`word\\` (or \\`text\\`), \\`start\\` and \\`end\\`.`,\n\t\t\t);\n\t\treturn { text: text.trim(), start: w.start, end: w.end };\n\t});\n}\n\n/** Any supported transcript (word JSON, WebVTT or SRT), detected from its content. */\nexport function parseTranscript(source: string): Word[] {\n\tconst trimmed = source.trim();\n\tconst words =\n\t\ttrimmed.startsWith(\"[\") || trimmed.startsWith(\"{\")\n\t\t\t? parseWordJson(trimmed)\n\t\t\t: parseCues(trimmed.replace(/^WEBVTT[^\\n]*\\n/, \"\"));\n\tif (words.length === 0)\n\t\tthrow new Error(\"Captions: the transcript has no words.\");\n\treturn words.filter((w) => w.text.length > 0);\n}\n"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "captions/phrases.ts",
|
|
34
|
+
"content": "/**\n * Words to on-screen phrases: what is shown together, broken into lines.\n * A phrase never splits a word, prefers to end at punctuation or a pause,\n * and every line fits the safe area as measured (not just by characters).\n */\nimport type { Word } from \"./parse.js\";\n\nexport interface GroupOptions {\n\tmaxChars: number;\n\tmaxWords: number;\n\tmaxLines: number;\n\tbreakOnPunctuation: boolean;\n\t/** A silence this long (seconds) always starts a new phrase. */\n\tminGap: number;\n}\n\nexport interface Phrase {\n\t/** Indices into the words, per line. */\n\tlines: number[][];\n\t/** First and last word index (inclusive). */\n\tfirst: number;\n\tlast: number;\n\t/** Seconds. */\n\tstart: number;\n\tend: number;\n}\n\nconst STRONG = /[.!?…][\"')\\]]*$/;\nconst SOFT = /[,;:—–][\"')\\]]*$/;\n\n/** Group words into phrases. `fits(line)` says whether a line of text fits the width. */\nexport function buildPhrases(\n\twords: readonly Word[],\n\to: GroupOptions,\n\tfits: (line: string) => boolean = () => true,\n\t/** Seconds a phrase lingers after its last word, if nothing follows sooner. */\n\tlinger = 0.6,\n): Phrase[] {\n\tconst phrases: Phrase[] = [];\n\tlet lines: number[][] = [];\n\tconst lineText = (line: number[]) =>\n\t\tline.map((i) => words[i]?.text ?? \"\").join(\" \");\n\tconst count = () => lines.reduce((a, l) => a + l.length, 0);\n\tconst chars = () => lines.map(lineText).join(\" \").length;\n\n\tconst close = () => {\n\t\tif (lines.length === 0) return;\n\t\tconst flat = lines.flat();\n\t\tconst first = flat[0] as number;\n\t\tconst last = flat[flat.length - 1] as number;\n\t\tphrases.push({\n\t\t\tlines,\n\t\t\tfirst,\n\t\t\tlast,\n\t\t\tstart: (words[first] as Word).start,\n\t\t\tend: (words[last] as Word).end,\n\t\t});\n\t\tlines = [];\n\t};\n\n\twords.forEach((word, i) => {\n\t\tconst prev = words[i - 1];\n\t\tif (prev && lines.length > 0) {\n\t\t\tconst pause = word.start - prev.end >= o.minGap;\n\t\t\tconst strong = o.breakOnPunctuation && STRONG.test(prev.text);\n\t\t\tconst soft =\n\t\t\t\to.breakOnPunctuation &&\n\t\t\t\tSOFT.test(prev.text) &&\n\t\t\t\tchars() >= o.maxChars * 0.6;\n\t\t\tif (pause || strong || soft || count() >= o.maxWords) close();\n\t\t}\n\t\tconst line = lines[lines.length - 1];\n\t\tif (line) {\n\t\t\tconst candidate = `${lineText(line)} ${word.text}`;\n\t\t\tif (candidate.length <= o.maxChars && fits(candidate)) {\n\t\t\t\tline.push(i);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (lines.length >= o.maxLines) close();\n\t\t}\n\t\tlines.push([i]);\n\t});\n\tclose();\n\n\t// On screen until the next phrase starts, lingering a little after the last word.\n\tphrases.forEach((p, k) => {\n\t\tconst next = phrases[k + 1];\n\t\tp.end = next ? Math.min(next.start, p.end + linger) : p.end + linger;\n\t});\n\treturn phrases;\n}\n\n/** The text a phrase shows, lines joined with newlines. */\nexport function phraseText(p: Phrase, words: readonly Word[]): string {\n\treturn p.lines\n\t\t.map((l) => l.map((i) => words[i]?.text ?? \"\").join(\" \"))\n\t\t.join(\"\\n\");\n}\n"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "card-stack",
|
|
3
|
+
"title": "CardStack",
|
|
4
|
+
"summary": "Cards in a depth stack that shuffle: the front card flies off and returns to the back, or cards are dealt onto the pile.",
|
|
5
|
+
"tags": [
|
|
6
|
+
"cards",
|
|
7
|
+
"stack",
|
|
8
|
+
"deck",
|
|
9
|
+
"carousel",
|
|
10
|
+
"testimonials",
|
|
11
|
+
"features",
|
|
12
|
+
"3d"
|
|
13
|
+
],
|
|
14
|
+
"example": "CardStack({ cards: [{ title: \"Fast\" }, { title: \"Typed\" }, { title: \"Yours\" }] })",
|
|
15
|
+
"parts": [
|
|
16
|
+
"card-0",
|
|
17
|
+
"card-0-clip",
|
|
18
|
+
"tilt"
|
|
19
|
+
],
|
|
20
|
+
"cues": [
|
|
21
|
+
"changes"
|
|
22
|
+
],
|
|
23
|
+
"registryDependencies": [],
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
26
|
+
"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
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chat-thread",
|
|
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.",
|
|
5
|
+
"tags": [
|
|
6
|
+
"chat",
|
|
7
|
+
"messages",
|
|
8
|
+
"conversation",
|
|
9
|
+
"social",
|
|
10
|
+
"ui",
|
|
11
|
+
"demo"
|
|
12
|
+
],
|
|
13
|
+
"example": "ChatThread({ messages: [{ from: \"me\", text: \"Shipped?\" }, { from: \"sam\", text: \"Live!\", reaction: \"🎉\" }] })",
|
|
14
|
+
"parts": [
|
|
15
|
+
"viewport",
|
|
16
|
+
"column",
|
|
17
|
+
"bubble-<n>",
|
|
18
|
+
"typing-<n>",
|
|
19
|
+
"reaction-<n>"
|
|
20
|
+
],
|
|
21
|
+
"cues": [
|
|
22
|
+
"messages",
|
|
23
|
+
"typing"
|
|
24
|
+
],
|
|
25
|
+
"registryDependencies": [],
|
|
26
|
+
"files": [
|
|
27
|
+
{
|
|
28
|
+
"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
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|