framefields 2.5.5 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/audio/index.mjs +10 -10
- package/dist/{audio-C9euVW4K.mjs → audio-QBA2VogL.mjs} +3 -3
- package/dist/{audio-C9euVW4K.mjs.map → audio-QBA2VogL.mjs.map} +1 -1
- package/dist/cli.mjs +866 -16
- package/dist/cli.mjs.map +1 -1
- package/dist/components/index.d.mts +2 -2
- package/dist/components/index.mjs +43 -43
- package/dist/{dist-BuirkW-f.mjs → dist-CDwWQYg3.mjs} +2 -2
- package/dist/dist-CDwWQYg3.mjs.map +1 -0
- package/dist/{dist-DBdxM6xv.mjs → dist-nHCqVk-e.mjs} +3 -3
- package/dist/dist-nHCqVk-e.mjs.map +1 -0
- package/dist/effects/index.mjs +34 -34
- package/dist/{effects-B263lgUi.mjs → effects-C-6I4iYW.mjs} +2 -2
- package/dist/{effects-B263lgUi.mjs.map → effects-C-6I4iYW.mjs.map} +1 -1
- package/dist/fonts/index.d.mts +1 -1
- package/dist/fonts/index.mjs +2 -2
- package/dist/{fonts-DNx6ps2p.mjs → fonts-BieW3VWV.mjs} +7 -3
- package/dist/fonts-BieW3VWV.mjs.map +1 -0
- package/dist/{gpu-BRBIoxMC.mjs → gpu-Bb4WmYYH.mjs} +2 -2
- package/dist/{gpu-BRBIoxMC.mjs.map → gpu-Bb4WmYYH.mjs.map} +1 -1
- package/dist/index-D-Gnyffj.d.mts.map +1 -1
- package/dist/{index-N1tMc5Z9.d.mts → index-E1IrPAm4.d.mts} +629 -203
- package/dist/index-E1IrPAm4.d.mts.map +1 -0
- package/dist/{index-CalU-mpZ.d.mts → index-j5LGVVbm.d.mts} +3 -1
- package/dist/index-j5LGVVbm.d.mts.map +1 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +50 -50
- package/dist/preview/index.mjs +10 -10
- package/dist/{preview-ChR8Z8Fm.mjs → preview-D6jS38Di.mjs} +2 -2
- package/dist/{preview-ChR8Z8Fm.mjs.map → preview-D6jS38Di.mjs.map} +1 -1
- package/dist/preview-engine/{chunk-ZJLNXN7W.mjs → chunk-AYTP6OGO.mjs} +6 -6
- package/dist/preview-engine/chunk-MLAIEIUF.mjs +1974 -0
- package/dist/preview-engine/chunk-W3I7LKDV.mjs +5 -0
- package/dist/preview-engine/chunk-ZMMNN77R.mjs +3 -0
- package/dist/preview-engine/components.mjs +1 -1
- package/dist/preview-engine/fonts.mjs +1 -1
- package/dist/preview-engine/framefields.mjs +1 -1
- package/dist/preview-engine/player.mjs +1 -1
- package/dist/preview-engine/{renderers-WZEV464O.mjs → renderers-4G3UOPA7.mjs} +1 -1
- package/dist/{program-O2bWX0cs.mjs → program-Bf4yHbHC.mjs} +19 -3
- package/dist/program-Bf4yHbHC.mjs.map +1 -0
- package/dist/registry/annotate.json +1 -1
- package/dist/registry/app-frame.json +1 -1
- package/dist/registry/backdrop.json +3 -3
- package/dist/registry/before-after.json +1 -1
- package/dist/registry/callout.json +1 -1
- package/dist/registry/captions.json +7 -1
- package/dist/registry/card-stack.json +3 -3
- package/dist/registry/chat-thread.json +7 -1
- package/dist/registry/code-block.json +8 -2
- package/dist/registry/confetti.json +1 -1
- package/dist/registry/device-frame.json +2 -1
- package/dist/registry/focus-zoom.json +1 -1
- package/dist/registry/logo-reveal.json +1 -1
- package/dist/registry/lower-third.json +1 -1
- package/dist/registry/meter.json +1 -1
- package/dist/registry/mouse-pointer.json +1 -1
- package/dist/registry/selection.json +3 -3
- package/dist/registry/spotlight.json +1 -1
- package/dist/registry/terminal.json +7 -1
- package/dist/registry/text-reveal.json +2 -1
- package/dist/registry/title-card.json +1 -1
- package/dist/registry/toasts.json +1 -1
- package/dist/registry/touch-point.json +4 -4
- package/dist/registry/waveform.json +3 -3
- package/dist/registry/word-cycle.json +2 -2
- package/dist/renderer/index.mjs +2 -2
- package/dist/renderer-4FQmduPw.mjs +5 -0
- package/dist/{renderer-DW21AyEv.mjs → renderer-4v6XYAiX.mjs} +3 -3
- package/dist/{renderer-DW21AyEv.mjs.map → renderer-4v6XYAiX.mjs.map} +1 -1
- package/dist/{renderer-YFHESw3v.mjs → renderer-79mO0b9Y.mjs} +3 -3
- package/dist/{renderer-YFHESw3v.mjs.map → renderer-79mO0b9Y.mjs.map} +1 -1
- package/dist/{renderer-DtqiWq1m.mjs → renderer-AOZWHGT3.mjs} +3 -3
- package/dist/{renderer-DtqiWq1m.mjs.map → renderer-AOZWHGT3.mjs.map} +1 -1
- package/dist/{renderer-Dw3K8KT9.mjs → renderer-B1Ox12RF.mjs} +2 -2
- package/dist/{renderer-Dw3K8KT9.mjs.map → renderer-B1Ox12RF.mjs.map} +1 -1
- package/dist/{renderer-DkM6Ikdf.mjs → renderer-B6vr-B0t.mjs} +3 -3
- package/dist/{renderer-DkM6Ikdf.mjs.map → renderer-B6vr-B0t.mjs.map} +1 -1
- package/dist/renderer-BBCpRuYU.mjs +5 -0
- package/dist/renderer-BBTo_jLO.mjs +5 -0
- package/dist/{renderer-CpDaUeCN.mjs → renderer-BDCS7k5X.mjs} +3 -3
- package/dist/{renderer-CpDaUeCN.mjs.map → renderer-BDCS7k5X.mjs.map} +1 -1
- package/dist/{renderer-DO1TeUqV.mjs → renderer-BFdgbqgW.mjs} +3 -3
- package/dist/{renderer-DO1TeUqV.mjs.map → renderer-BFdgbqgW.mjs.map} +1 -1
- package/dist/{renderer-CEAmByVW.mjs → renderer-BHgYRSu8.mjs} +2 -2
- package/dist/{renderer-CEAmByVW.mjs.map → renderer-BHgYRSu8.mjs.map} +1 -1
- package/dist/{renderer-CA_CwisV.mjs → renderer-BKssznKn.mjs} +2 -2
- package/dist/{renderer-CA_CwisV.mjs.map → renderer-BKssznKn.mjs.map} +1 -1
- package/dist/{renderer-DWG6EXGj.mjs → renderer-BL9vkn6m.mjs} +3 -3
- package/dist/{renderer-DWG6EXGj.mjs.map → renderer-BL9vkn6m.mjs.map} +1 -1
- package/dist/renderer-BLg66ZgG.mjs +5 -0
- package/dist/{renderer-RbMM6Njv.mjs → renderer-BQFxoNzi.mjs} +3 -3
- package/dist/{renderer-RbMM6Njv.mjs.map → renderer-BQFxoNzi.mjs.map} +1 -1
- package/dist/{renderer-x1xlZaH_.mjs → renderer-BQdn_qGp.mjs} +3 -3
- package/dist/{renderer-x1xlZaH_.mjs.map → renderer-BQdn_qGp.mjs.map} +1 -1
- package/dist/{renderer-DkOxEQsg.mjs → renderer-BR35Xp9n.mjs} +3 -3
- package/dist/{renderer-DkOxEQsg.mjs.map → renderer-BR35Xp9n.mjs.map} +1 -1
- package/dist/renderer-BScS8AzG.mjs +5 -0
- package/dist/{renderer-C2MaJKfY.mjs → renderer-BTjveb1Y.mjs} +3 -3
- package/dist/{renderer-C2MaJKfY.mjs.map → renderer-BTjveb1Y.mjs.map} +1 -1
- package/dist/renderer-Bb1cS5mJ.mjs +5 -0
- package/dist/{renderer-Dwh0jaor.mjs → renderer-Bbkf5EIi.mjs} +3 -3
- package/dist/{renderer-Dwh0jaor.mjs.map → renderer-Bbkf5EIi.mjs.map} +1 -1
- package/dist/{renderer-DsnV8tpW.mjs → renderer-BmgyvIC3.mjs} +3 -3
- package/dist/{renderer-DsnV8tpW.mjs.map → renderer-BmgyvIC3.mjs.map} +1 -1
- package/dist/{renderer-BfQdc3RM.mjs → renderer-Bpve7M6S.mjs} +69 -69
- package/dist/{renderer-BfQdc3RM.mjs.map → renderer-Bpve7M6S.mjs.map} +1 -1
- package/dist/renderer-Bpwd9zEY.mjs +5 -0
- package/dist/{renderer-C5QYI1Wu.mjs → renderer-BsnhAgrB.mjs} +2 -2
- package/dist/{renderer-C5QYI1Wu.mjs.map → renderer-BsnhAgrB.mjs.map} +1 -1
- package/dist/{renderer-BlU3Sh_0.mjs → renderer-By5xTRqB.mjs} +3 -3
- package/dist/{renderer-BlU3Sh_0.mjs.map → renderer-By5xTRqB.mjs.map} +1 -1
- package/dist/{renderer-D8m3fuRp.mjs → renderer-BzEQXAdl.mjs} +2 -2
- package/dist/{renderer-D8m3fuRp.mjs.map → renderer-BzEQXAdl.mjs.map} +1 -1
- package/dist/renderer-Bzyd9xi5.mjs +5 -0
- package/dist/{renderer-1NoJLLDa.mjs → renderer-C64g87yU.mjs} +3 -3
- package/dist/{renderer-1NoJLLDa.mjs.map → renderer-C64g87yU.mjs.map} +1 -1
- package/dist/{renderer-Daqvhwjp.mjs → renderer-C6Wb1TZT.mjs} +3 -3
- package/dist/{renderer-Daqvhwjp.mjs.map → renderer-C6Wb1TZT.mjs.map} +1 -1
- package/dist/{renderer-BWNQQZ12.mjs → renderer-CHPLHbAs.mjs} +3 -3
- package/dist/{renderer-BWNQQZ12.mjs.map → renderer-CHPLHbAs.mjs.map} +1 -1
- package/dist/{renderer-B5ZdkcMi.mjs → renderer-CJRu7lLH.mjs} +2 -2
- package/dist/{renderer-B5ZdkcMi.mjs.map → renderer-CJRu7lLH.mjs.map} +1 -1
- package/dist/{renderer-Bvizxddu.mjs → renderer-CMWUtfwn.mjs} +2 -2
- package/dist/{renderer-Bvizxddu.mjs.map → renderer-CMWUtfwn.mjs.map} +1 -1
- package/dist/{renderer-C3tdjg8B.mjs → renderer-CRkgyk08.mjs} +3 -3
- package/dist/{renderer-C3tdjg8B.mjs.map → renderer-CRkgyk08.mjs.map} +1 -1
- package/dist/renderer-CXVssx1k.mjs +5 -0
- package/dist/{renderer-DcApNxER.mjs → renderer-CY0BT6ok.mjs} +2 -2
- package/dist/{renderer-DcApNxER.mjs.map → renderer-CY0BT6ok.mjs.map} +1 -1
- package/dist/renderer-CYFiGLls.mjs +5 -0
- package/dist/{renderer-PhjHSS97.mjs → renderer-CYLP8sD6.mjs} +2 -2
- package/dist/{renderer-PhjHSS97.mjs.map → renderer-CYLP8sD6.mjs.map} +1 -1
- package/dist/{renderer-C6n8y6Pw.mjs → renderer-CY_1u6zi.mjs} +3 -3
- package/dist/{renderer-C6n8y6Pw.mjs.map → renderer-CY_1u6zi.mjs.map} +1 -1
- package/dist/renderer-C__BvvtP.mjs +5 -0
- package/dist/renderer-Ca3YZsoT.mjs +5 -0
- package/dist/{renderer-DADTT3ym.mjs → renderer-CcSBjtRa.mjs} +2 -2
- package/dist/{renderer-CnclEQdA.mjs → renderer-CgdplRri.mjs} +2 -2
- package/dist/{renderer-CnclEQdA.mjs.map → renderer-CgdplRri.mjs.map} +1 -1
- package/dist/{renderer-oR1LvHeO.mjs → renderer-Cn9tU_s5.mjs} +2 -2
- package/dist/{renderer-oR1LvHeO.mjs.map → renderer-Cn9tU_s5.mjs.map} +1 -1
- package/dist/renderer-CnM5iFj9.mjs +5 -0
- package/dist/{renderer-DHdFVpdH.mjs → renderer-Cqp_TUl_.mjs} +2 -2
- package/dist/{renderer-DHdFVpdH.mjs.map → renderer-Cqp_TUl_.mjs.map} +1 -1
- package/dist/{renderer-C8pJFxcL.mjs → renderer-CsOLHKFx.mjs} +2 -2
- package/dist/{renderer-C8pJFxcL.mjs.map → renderer-CsOLHKFx.mjs.map} +1 -1
- package/dist/{renderer-R5Lg65F8.mjs → renderer-D01RT3Ov.mjs} +2 -2
- package/dist/renderer-D1nZrvQx.mjs +5 -0
- package/dist/renderer-D6XjVQME.mjs +5 -0
- package/dist/{renderer-QQDU1b0Z.mjs → renderer-DAONCf_3.mjs} +3 -3
- package/dist/{renderer-QQDU1b0Z.mjs.map → renderer-DAONCf_3.mjs.map} +1 -1
- package/dist/{renderer-DTPmvL1i.mjs → renderer-DAfWLGfy.mjs} +3 -3
- package/dist/{renderer-DTPmvL1i.mjs.map → renderer-DAfWLGfy.mjs.map} +1 -1
- package/dist/{renderer-BvA4sQz5.mjs → renderer-DEeKkljI.mjs} +3 -3
- package/dist/{renderer-BvA4sQz5.mjs.map → renderer-DEeKkljI.mjs.map} +1 -1
- package/dist/{renderer-YBwN9uyj.mjs → renderer-DFYXjAIT.mjs} +5 -5
- package/dist/{renderer-YBwN9uyj.mjs.map → renderer-DFYXjAIT.mjs.map} +1 -1
- package/dist/renderer-DIuMnMF0.mjs +5 -0
- package/dist/{renderer-Dz4muMyE.mjs → renderer-DJHuPNJx.mjs} +2 -2
- package/dist/{renderer-Dz4muMyE.mjs.map → renderer-DJHuPNJx.mjs.map} +1 -1
- package/dist/{renderer-H4-8sBjl.mjs → renderer-DJTXhVNh.mjs} +2 -2
- package/dist/{renderer-H4-8sBjl.mjs.map → renderer-DJTXhVNh.mjs.map} +1 -1
- package/dist/{renderer-BMiZIGkZ.mjs → renderer-DNEFYrQ5.mjs} +2 -2
- package/dist/renderer-DUvvM8EF.mjs +5 -0
- package/dist/renderer-DWogyuMO.mjs +5 -0
- package/dist/renderer-DWsj_LJR.mjs +5 -0
- package/dist/{renderer-DGOY-LBR.mjs → renderer-DYJHFHF4.mjs} +2 -2
- package/dist/{renderer-DGOY-LBR.mjs.map → renderer-DYJHFHF4.mjs.map} +1 -1
- package/dist/{renderer-CVbNiH03.mjs → renderer-DYR4gFEM.mjs} +3 -3
- package/dist/{renderer-CVbNiH03.mjs.map → renderer-DYR4gFEM.mjs.map} +1 -1
- package/dist/renderer-DYun0DJl.mjs +5 -0
- package/dist/renderer-D_6YqD-Z.mjs +5 -0
- package/dist/{renderer-B0IJirDs.mjs → renderer-D_UPFbei.mjs} +3 -3
- package/dist/{renderer-B0IJirDs.mjs.map → renderer-D_UPFbei.mjs.map} +1 -1
- package/dist/renderer-Db9LP86x.mjs +5 -0
- package/dist/{renderer-CFRTJ7cT.mjs → renderer-DgRJG_l-.mjs} +2 -2
- package/dist/{renderer-CFRTJ7cT.mjs.map → renderer-DgRJG_l-.mjs.map} +1 -1
- package/dist/renderer-DhHiC87m.mjs +5 -0
- package/dist/{renderer-BZ5uOfoX.mjs → renderer-DiD3r9tx.mjs} +2 -2
- package/dist/{renderer-BZ5uOfoX.mjs.map → renderer-DiD3r9tx.mjs.map} +1 -1
- package/dist/{renderer-B2no_9zi.mjs → renderer-Dj-y-BtT.mjs} +3 -3
- package/dist/{renderer-B2no_9zi.mjs.map → renderer-Dj-y-BtT.mjs.map} +1 -1
- package/dist/renderer-Dj5jGAkR.mjs +5 -0
- package/dist/{renderer-Bs6BaMLf.mjs → renderer-DlA7kzIs.mjs} +3 -3
- package/dist/{renderer-Bs6BaMLf.mjs.map → renderer-DlA7kzIs.mjs.map} +1 -1
- package/dist/renderer-DlPUQ7RF.mjs +5 -0
- package/dist/renderer-DpIPdgn8.mjs +5 -0
- package/dist/renderer-Dv0C9CEt.mjs +6 -0
- package/dist/{renderer-CFCR_7bq.mjs → renderer-EwSSuJXj.mjs} +3 -3
- package/dist/{renderer-CFCR_7bq.mjs.map → renderer-EwSSuJXj.mjs.map} +1 -1
- package/dist/renderer-Iw8fDL-p.mjs +5 -0
- package/dist/{renderer-tcS585Iy.mjs → renderer-JJibsJfb.mjs} +2 -2
- package/dist/{renderer-tcS585Iy.mjs.map → renderer-JJibsJfb.mjs.map} +1 -1
- package/dist/{renderer-CTy0bSsl.mjs → renderer-KppAWfmJ.mjs} +3 -3
- package/dist/{renderer-CTy0bSsl.mjs.map → renderer-KppAWfmJ.mjs.map} +1 -1
- package/dist/{renderer-CPSg6eEQ.mjs → renderer-LW8MfGjd.mjs} +2 -2
- package/dist/{renderer-CPSg6eEQ.mjs.map → renderer-LW8MfGjd.mjs.map} +1 -1
- package/dist/{renderer-Cg2xS8h8.mjs → renderer-PMjV8x65.mjs} +2 -2
- package/dist/{renderer-Cg2xS8h8.mjs.map → renderer-PMjV8x65.mjs.map} +1 -1
- package/dist/renderer-SLI69Mju.mjs +5 -0
- package/dist/{renderer-DdkPY4hO.mjs → renderer-UArFa4y9.mjs} +2 -2
- package/dist/{renderer-DdkPY4hO.mjs.map → renderer-UArFa4y9.mjs.map} +1 -1
- package/dist/{renderer-B7vXnBUo.mjs → renderer-VwKmjuc6.mjs} +3 -3
- package/dist/{renderer-B7vXnBUo.mjs.map → renderer-VwKmjuc6.mjs.map} +1 -1
- package/dist/renderer-ZVtbb3-P.mjs +5 -0
- package/dist/{renderer-Dk0okoS3.mjs → renderer-bb3puodg.mjs} +2 -2
- package/dist/{renderer-Dk0okoS3.mjs.map → renderer-bb3puodg.mjs.map} +1 -1
- package/dist/{renderer-DVGVcDvB.mjs → renderer-eyvY4Lq4.mjs} +3 -3
- package/dist/{renderer-DVGVcDvB.mjs.map → renderer-eyvY4Lq4.mjs.map} +1 -1
- package/dist/renderer-gHNCu7H5.mjs +5 -0
- package/dist/renderer-gxDNmbtU.mjs +5 -0
- package/dist/renderer-hkK4DNRX.mjs +5 -0
- package/dist/{renderer-CrKYlipx.mjs → renderer-ij448rvt.mjs} +2 -2
- package/dist/{renderer-CrKYlipx.mjs.map → renderer-ij448rvt.mjs.map} +1 -1
- package/dist/{renderer-BrKgtYq4.mjs → renderer-j7aUfyeR.mjs} +3 -3
- package/dist/{renderer-BrKgtYq4.mjs.map → renderer-j7aUfyeR.mjs.map} +1 -1
- package/dist/{renderer-8_d6YNnE.mjs → renderer-kOlfYLtu.mjs} +2 -2
- package/dist/{renderer-8_d6YNnE.mjs.map → renderer-kOlfYLtu.mjs.map} +1 -1
- package/dist/{renderer-D3FJcRA3.mjs → renderer-l1qJE4z-.mjs} +2 -2
- package/dist/{renderer-D3FJcRA3.mjs.map → renderer-l1qJE4z-.mjs.map} +1 -1
- package/dist/{renderer-BKJ8cywK.mjs → renderer-ml2LHmRb.mjs} +2 -2
- package/dist/{renderer-BKJ8cywK.mjs.map → renderer-ml2LHmRb.mjs.map} +1 -1
- package/dist/{renderer-DmTxEeMh.mjs → renderer-mu2mYo5w.mjs} +3 -3
- package/dist/{renderer-DmTxEeMh.mjs.map → renderer-mu2mYo5w.mjs.map} +1 -1
- package/dist/{renderer-Bwxm-kWL.mjs → renderer-ovkctV5a.mjs} +2 -2
- package/dist/renderer-oy6SGDRz.mjs +5 -0
- package/dist/{renderer-CqJou_2L.mjs → renderer-qQmoO5Hk.mjs} +2 -2
- package/dist/{renderer-CqJou_2L.mjs.map → renderer-qQmoO5Hk.mjs.map} +1 -1
- package/dist/renderer-rq7UIcuH.mjs +5 -0
- package/dist/{renderer-BStETpqC.mjs → renderer-rv-AKqCl.mjs} +2 -2
- package/dist/{renderer-BStETpqC.mjs.map → renderer-rv-AKqCl.mjs.map} +1 -1
- package/dist/renderer-xkEJahws.mjs +5 -0
- package/dist/{renderer-BNs31kME.mjs → renderer-yF40Pb-n.mjs} +3 -3
- package/dist/{renderer-BNs31kME.mjs.map → renderer-yF40Pb-n.mjs.map} +1 -1
- package/dist/{renderer-Bag1LFos.mjs → renderer-yFHP9Zj2.mjs} +3 -3
- package/dist/{renderer-Bag1LFos.mjs.map → renderer-yFHP9Zj2.mjs.map} +1 -1
- package/dist/{renderer-CbxfSmYE.mjs → renderer-zOK5DYYn.mjs} +3 -3
- package/dist/{renderer-CbxfSmYE.mjs.map → renderer-zOK5DYYn.mjs.map} +1 -1
- package/dist/{renderers-CP8CFWDs-EZZPEbhW.mjs → renderers-CP8CFWDs-DPLX5EJs.mjs} +2 -2
- package/dist/{renderers-CP8CFWDs-EZZPEbhW.mjs.map → renderers-CP8CFWDs-DPLX5EJs.mjs.map} +1 -1
- package/dist/signals/index.mjs +4 -4
- package/dist/{src-r7Suenhp.mjs → src-BnkrNj2W.mjs} +612 -117
- package/dist/src-BnkrNj2W.mjs.map +1 -0
- package/package.json +59 -58
- package/dist/dist-BuirkW-f.mjs.map +0 -1
- package/dist/dist-DBdxM6xv.mjs.map +0 -1
- package/dist/fonts-DNx6ps2p.mjs.map +0 -1
- package/dist/index-CalU-mpZ.d.mts.map +0 -1
- package/dist/index-N1tMc5Z9.d.mts.map +0 -1
- package/dist/preview-engine/chunk-2TCJSHFA.mjs +0 -1974
- package/dist/preview-engine/chunk-Q7XC3FI7.mjs +0 -3
- package/dist/preview-engine/chunk-ZZAZF6VC.mjs +0 -5
- package/dist/program-O2bWX0cs.mjs.map +0 -1
- package/dist/renderer-0441BN0U.mjs +0 -5
- package/dist/renderer-3qDgFc-W.mjs +0 -5
- package/dist/renderer-6AIqTN5Z.mjs +0 -5
- package/dist/renderer-B7MZ5R7u.mjs +0 -5
- package/dist/renderer-BE2rIA4B.mjs +0 -5
- package/dist/renderer-BIQ81ln4.mjs +0 -5
- package/dist/renderer-BKm3_Ob3.mjs +0 -5
- package/dist/renderer-BNdT1Wwf.mjs +0 -5
- package/dist/renderer-BQycs1q_.mjs +0 -5
- package/dist/renderer-Bj7MxXfk.mjs +0 -5
- package/dist/renderer-BjRAGYYC.mjs +0 -5
- package/dist/renderer-BqE2BQ_X.mjs +0 -5
- package/dist/renderer-Bz5BMkOE.mjs +0 -5
- package/dist/renderer-CDOSfB9H.mjs +0 -5
- package/dist/renderer-CT26yQT-.mjs +0 -5
- package/dist/renderer-CZeAbz48.mjs +0 -5
- package/dist/renderer-D50b7kip.mjs +0 -5
- package/dist/renderer-D5LmrO96.mjs +0 -5
- package/dist/renderer-DCvqZ5o_.mjs +0 -5
- package/dist/renderer-DJGJeGEn.mjs +0 -5
- package/dist/renderer-DONvNNXi.mjs +0 -5
- package/dist/renderer-DPaSo--Y.mjs +0 -6
- package/dist/renderer-DfYYokhr.mjs +0 -5
- package/dist/renderer-DlIJhdEM.mjs +0 -5
- package/dist/renderer-DqD3nMtv.mjs +0 -5
- package/dist/renderer-DrIl-uGt.mjs +0 -5
- package/dist/renderer-DyCB3Q1d.mjs +0 -5
- package/dist/renderer-DyJ-iSv6.mjs +0 -5
- package/dist/renderer-Dzs0jV0G.mjs +0 -5
- package/dist/renderer-HIKXw5-Y.mjs +0 -5
- package/dist/renderer-Jgt2g8Wp.mjs +0 -5
- package/dist/renderer-KnZWmT-M.mjs +0 -5
- package/dist/renderer-OMSXddG9.mjs +0 -5
- package/dist/renderer-P78xh6OV.mjs +0 -5
- package/dist/renderer-gaFP1Kak.mjs +0 -5
- package/dist/renderer-lQ8gH7Zd.mjs +0 -5
- package/dist/src-r7Suenhp.mjs.map +0 -1
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
{
|
|
26
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"
|
|
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\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 = theme.mode === \"light\";\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
28
|
}
|
|
29
29
|
]
|
|
30
30
|
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"files": [
|
|
33
33
|
{
|
|
34
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"
|
|
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\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 && theme.mode === \"dark\");\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: theme.weight.medium,\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: ctx.theme.weight.medium,\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: ctx.theme.weight.medium,\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
36
|
}
|
|
37
37
|
]
|
|
38
38
|
}
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"parts": [
|
|
16
16
|
"surface",
|
|
17
17
|
"background",
|
|
18
|
-
"blob
|
|
19
|
-
"blob
|
|
18
|
+
"blob-<n>",
|
|
19
|
+
"blob-<n>-x",
|
|
20
20
|
"pattern",
|
|
21
21
|
"pattern-lit",
|
|
22
22
|
"glow-matte",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"files": [
|
|
32
32
|
{
|
|
33
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"
|
|
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 * the composition `canvas`, 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\tround,\n\ttransparent,\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 ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\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\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
35
|
}
|
|
36
36
|
]
|
|
37
37
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
{
|
|
29
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"
|
|
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\tround,\n\tscaleFrames,\n\tvalueAt,\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: theme.weight.semibold,\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: theme.weight.semibold,\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\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
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
{
|
|
26
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"
|
|
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 * the composition `canvas`, 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\televate,\n\tevaluateEase,\n\tresolveTarget,\n\tspringAt,\n\tsurfaceFill,\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 ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\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, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: fs,\n\t\t\t\t\tfontWeight: theme.weight.bold,\n\t\t\t\t})\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: theme.weight.bold,\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: surfaceFill(theme.color.raised, theme),\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\televate(host, label, theme, \"low\");\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
28
|
}
|
|
29
29
|
]
|
|
30
30
|
}
|
|
@@ -19,11 +19,17 @@
|
|
|
19
19
|
"words",
|
|
20
20
|
"phrases"
|
|
21
21
|
],
|
|
22
|
+
"fonts": [
|
|
23
|
+
{
|
|
24
|
+
"font": "emoji",
|
|
25
|
+
"why": "Social captions often carry emoji."
|
|
26
|
+
}
|
|
27
|
+
],
|
|
22
28
|
"registryDependencies": [],
|
|
23
29
|
"files": [
|
|
24
30
|
{
|
|
25
31
|
"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 must read against it: every word in the\n\t\t\t\t\t// background colour when ink wouldn't, else only the emphasised\n\t\t\t\t\t// ones (accent on the accent marker), in ink.\n\t\t\t\t\tconst inkReads = (contrastRatio(ink, accent) ?? 21) >= 3;\n\t\t\t\t\tif (!inkReads || emphasisWeights.some(Boolean))\n\t\t\t\t\t\tanimators.push(\n\t\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\t\"on-marker\",\n\t\t\t\t\t\t\t\t// Switch as the marker lands under the word.\n\t\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t\t\twStart.map((f) => f + Math.ceil(glide / 2)),\n\t\t\t\t\t\t\t\t\twEnd.map((f) => f + Math.ceil(glide / 2)),\n\t\t\t\t\t\t\t\t\tinkReads ? emphasisWeights : undefined,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t{ fillColor: inkReads ? ink : theme.color.bg },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"pop\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"pop\",\n\t\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tstarts: wStart,\n\t\t\t\t\t\t\t\t\tduration: Math.max(3, Math.round(fast * 0.9)),\n\t\t\t\t\t\t\t\t\tease: \"back.out(2.2)\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{ opacity: 0, scale: 0.72, y: fs * 0.12 },\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"bold\":\n\t\t\t\tcase \"outline\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\"active\", windowExpression(\"word\", wStart, wEnd), {\n\t\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"gradient\": {\n\t\t\t\t\t// Active word: accent → accent2 across its letters.\n\t\t\t\t\tconst chars = layout.text.length;\n\t\t\t\t\tconst starts = new Array<number>(chars).fill(-1);\n\t\t\t\t\tconst ends = new Array<number>(chars).fill(-1);\n\t\t\t\t\tconst along = new Array<number>(chars).fill(0);\n\t\t\t\t\tlayout.words.forEach((w, k) => {\n\t\t\t\t\t\tfor (let c = w.from; c < w.to; c++) {\n\t\t\t\t\t\t\tstarts[c] = wStart[k] as number;\n\t\t\t\t\t\t\tends[c] = wEnd[k] as number;\n\t\t\t\t\t\t\talong[c] =\n\t\t\t\t\t\t\t\tw.to - w.from > 1 ? (c - w.from) / (w.to - w.from - 1) : 0;\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"active\",\n\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\trendererChars(layout.text, starts),\n\t\t\t\t\t\t\t\trendererChars(layout.text, ends),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t),\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"active-end\",\n\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\trendererChars(layout.text, starts),\n\t\t\t\t\t\t\t\trendererChars(layout.text, ends),\n\t\t\t\t\t\t\t\trendererChars(layout.text, along),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{ fillColor: theme.color.accent2 },\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (props.plate && (style === \"clean\" || style === \"karaoke\"))\n\t\t\t\tplates.unshift(\n\t\t\t\t\tbox(\n\t\t\t\t\t\t\"plate\",\n\t\t\t\t\t\tblockRect,\n\t\t\t\t\t\tfs * 0.35,\n\t\t\t\t\t\t\"rgba(0,0,0,0.55)\",\n\t\t\t\t\t\ttheme.radius.md,\n\t\t\t\t\t),\n\t\t\t\t);\n\n\t\t\tif (props.pulse) {\n\t\t\t\t// The phrase's slice of the voice level, indexed by its local frame.\n\t\t\t\tconst level = props.pulse.level.slice(start, end);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\"pulse\", pulseExpression(wStart, wEnd, level), {\n\t\t\t\t\t\tscale: 1 + props.pulseAmount,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (animators.length > 0) node.animators = animators as never;\n\t\t\tconst text = host.text(layout.text, node);\n\t\t\t// A heavy outline around the glyphs, never over them.\n\t\t\tif (style === \"outline\")\n\t\t\t\thost.style(text, {\n\t\t\t\t\tstroke: { size: fs * 0.08, position: \"outside\", color: \"#000000\" },\n\t\t\t\t});\n\t\t\tconst holder = host.box({\n\t\t\t\tid: ctx.partId(`phrase-${p}-box`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: safe.x,\n\t\t\t\ty,\n\t\t\t\twidth: safe.width,\n\t\t\t\theight: m.height,\n\t\t\t\tstartFrame: start,\n\t\t\t\tdurationFrames: end - start,\n\t\t\t\tchildren: [...plates, text],\n\t\t\t});\n\t\t\tholder.animate(\n\t\t\t\tphraseMotion(style, p, end - start, fs, fast, { x: safe.x, y }),\n\t\t\t);\n\t\t\tparts[`phrase-${p}`] = holder;\n\t\t\tchildren.push(holder);\n\n\t\t\tphraseCues.push({\n\t\t\t\tframe: start,\n\t\t\t\tend,\n\t\t\t\ttext: layout.text.replace(/\\n/g, \" \"),\n\t\t\t});\n\t\t\tlayout.words.forEach((w, k) => {\n\t\t\t\twordCues.push({\n\t\t\t\t\tframe: start + (wStart[k] as number),\n\t\t\t\t\tend: start + (wEnd[k] as number),\n\t\t\t\t\tindex: w.index,\n\t\t\t\t\ttext: (words[w.index] as Word).text,\n\t\t\t\t\temphasised: emphasised[w.index] ?? false,\n\t\t\t\t});\n\t\t\t});\n\t\t});\n\n\t\tif (props.behind) {\n\t\t\tconst cut = host.cutout(props.behind.video, {\n\t\t\t\tid: ctx.partId(\"behind\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: W,\n\t\t\t\theight: H,\n\t\t\t\tfit: \"cover\",\n\t\t\t\tmode: props.behind.mode,\n\t\t\t});\n\t\t\tparts.behind = cut;\n\t\t\tchildren.push(cut);\n\t\t}\n\n\t\tconst last = phraseCues[phraseCues.length - 1]?.end ?? 1;\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: last,\n\t\t\torigin: [0, 0],\n\t\t\tcues: { words: wordCues, phrases: phraseCues } as CaptionsCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** A phrase's text and each word's character range in it. */\nfunction phraseLayout(phrase: Phrase, words: readonly Word[]) {\n\tconst out: Array<{ index: number; from: number; to: number }> = [];\n\tlet text = \"\";\n\tphrase.lines.forEach((line, l) => {\n\t\tif (l > 0) text += \"\\n\";\n\t\tline.forEach((index, k) => {\n\t\t\tif (k > 0) text += \" \";\n\t\t\tconst from = text.length;\n\t\t\ttext += (words[index] as Word).text;\n\t\t\tout.push({ index, from, to: text.length });\n\t\t});\n\t});\n\treturn { text, words: out };\n}\n\n/** A marker that glides onto each word, arriving on the frame it is spoken. */\nfunction markerKeys(\n\trects: readonly Rect[],\n\tstarts: readonly number[],\n\tpad: number,\n\tglide: number,\n): LayerAnimation {\n\tconst keyed = (value: (r: Rect) => number) => {\n\t\tconst keys: KeyframeTuple[] = [[0, value(rects[0] as Rect)]];\n\t\trects.forEach((r, k) => {\n\t\t\tconst prev = rects[k - 1];\n\t\t\tif (!prev) return;\n\t\t\tconst at = starts[k] as number;\n\t\t\tconst last = (keys[keys.length - 1] as KeyframeTuple)[0];\n\t\t\t// Leave on the word's first frame and land a few frames in: the\n\t\t\t// marker never runs ahead of the voice.\n\t\t\tkeys.push([Math.max(last + 1, at), value(prev)]);\n\t\t\tkeys.push([Math.max(last + 2, at + glide), value(r), \"power3.out\"]);\n\t\t});\n\t\treturn keys;\n\t};\n\treturn LayerAnimation.create()\n\t\t.keys(\n\t\t\t\"x\",\n\t\t\tkeyed((r) => r.x - pad),\n\t\t)\n\t\t.keys(\n\t\t\t\"y\",\n\t\t\tkeyed((r) => r.y - pad * 0.5),\n\t\t)\n\t\t.keys(\n\t\t\t\"width\",\n\t\t\tkeyed((r) => r.width + pad * 2),\n\t\t)\n\t\t.keys(\n\t\t\t\"height\",\n\t\t\tkeyed((r) => r.height + pad),\n\t\t)\n\t\t.keys(\"opacity\", [\n\t\t\t[0, 0],\n\t\t\t[starts[0] ?? 0, 1, \"hold\"],\n\t\t]);\n}\n\n/** How a phrase arrives and leaves. `bold` alternates sides; the rest fade up. */\nfunction phraseMotion(\n\tstyle: CaptionStyle,\n\tindex: number,\n\tlength: number,\n\tfs: number,\n\tfast: number,\n\tat: { x: number; y: number },\n): LayerAnimation {\n\tconst anim = LayerAnimation.create();\n\tconst inF = Math.min(\n\t\tMath.max(2, Math.round(fast * 0.6)),\n\t\tMath.floor(length / 2),\n\t);\n\tconst out = Math.min(\n\t\tMath.max(2, Math.round(fast * 0.5)),\n\t\tMath.floor(length / 3),\n\t);\n\tanim.keys(\"opacity\", [\n\t\t[0, 0],\n\t\t[inF, 1, \"power2.out\"],\n\t\t[length - out, 1],\n\t\t[length, 0, \"power2.in\"],\n\t]);\n\tif (style === \"bold\") {\n\t\tconst side = index % 2 === 0 ? -1 : 1;\n\t\tanim.keys(\"x\", [\n\t\t\t[0, at.x + side * fs * 0.6],\n\t\t\t[inF + 2, at.x, \"back.out(1.8)\"],\n\t\t]);\n\t\tanim.keys(\"scale\", [\n\t\t\t[0, 0.86],\n\t\t\t[inF + 2, 1, \"back.out(1.8)\"],\n\t\t]);\n\t} else if (style !== \"pop\") {\n\t\tanim.keys(\"y\", [\n\t\t\t[0, at.y + fs * 0.14],\n\t\t\t[inF, at.y, \"power3.out\"],\n\t\t]);\n\t}\n\treturn anim;\n}\n\nfunction stripUndefined<T extends object>(o: T): Partial<T> {\n\treturn Object.fromEntries(\n\t\tObject.entries(o).filter(([, v]) => v !== undefined),\n\t) as Partial<T>;\n}\n\n/** The active word's weight is the voice level on that frame (0 elsewhere). */\nfunction pulseExpression(\n\tstarts: readonly number[],\n\tends: readonly number[],\n\tlevel: readonly number[],\n): string {\n\tconst list = (v: readonly number[]) =>\n\t\t`[${v.map((x) => Math.round(x * 1000) / 1000).join(\",\")}]`;\n\treturn `((S,E,L)=>{const s=S[wordIndex];if(s===undefined||frame<s||frame>=E[wordIndex])return 0;return L[floor(frame)]??0;})(${list(starts)},${list(ends)},${list(level)})`;\n}\n\n/**\n * Word-timed captions from words or a transcript (SRT, WebVTT, word JSON) in\n * eight published styles, highlighting each word on its exact frame, optionally\n * tucked behind the speaker.\n */\nexport const Captions = /* @__PURE__ */ bindComponent(captions);\n"
|
|
32
|
+
"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 * the composition `canvas`, 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\tSAFE_INSETS,\n\tsafeArea,\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\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 ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\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(\n\t\t\t{ width: W, height: H },\n\t\t\tprops.placement.safeArea,\n\t\t\tSAFE_INSETS.captions,\n\t\t);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst weight = props.fontWeight ?? (style === \"clean\" ? undefined : 800);\n\t\tconst measureAt = (text: string, size: number, width?: number) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: weight,\n\t\t\t\t...(width !== undefined && { width, align: \"center\" as const }),\n\t\t\t} satisfies MeasureOptions);\n\t\t// Auto size: a line of `maxChars` average glyphs spans the safe width.\n\t\tconst sample = \"nonsense words\".repeat(4).slice(0, group.maxChars);\n\t\tconst fs =\n\t\t\tprops.fontSize ??\n\t\t\tMath.min(\n\t\t\t\t(safe.width / Math.max(1, measureAt(sample, 100).width)) * 100 * 0.94,\n\t\t\t\tsafe.height / (group.maxLines * 1.6),\n\t\t\t);\n\t\tconst fits = (line: string) => measureAt(line, fs).width <= safe.width;\n\t\tconst phrases = buildPhrases(words, group, fits);\n\n\t\tconst ink = props.fill ?? theme.color.ink;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst anchor =\n\t\t\tprops.placement.anchor ?? (style === \"bold\" ? \"center\" : \"bottom\");\n\t\tconst frameOf = (seconds: number) => Math.round(seconds * fps);\n\t\tif (props.pulse && Math.abs(props.pulse.fps - fps) > 1e-6)\n\t\t\tthrow new Error(\n\t\t\t\t`Captions: \\`pulse\\` was analysed at ${props.pulse.fps} fps but the component runs at ${fps}. Analyse with { fps: ${fps} }.`,\n\t\t\t);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst wordCues: Array<CaptionsCues[\"words\"][number]> = [];\n\t\tconst phraseCues: Array<CaptionsCues[\"phrases\"][number]> = [];\n\n\t\tphrases.forEach((phrase, p) => {\n\t\t\tconst layout = phraseLayout(phrase, words);\n\t\t\tconst m = measureAt(layout.text, fs, safe.width);\n\t\t\tconst options: MeasureOptions = {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tfontWeight: weight,\n\t\t\t\twidth: safe.width,\n\t\t\t\talign: \"center\",\n\t\t\t};\n\t\t\tconst y =\n\t\t\t\tanchor === \"bottom\"\n\t\t\t\t\t? safe.y + safe.height - m.height - props.placement.offset\n\t\t\t\t\t: anchor === \"top\"\n\t\t\t\t\t\t? safe.y + props.placement.offset\n\t\t\t\t\t\t: safe.y + (safe.height - m.height) / 2;\n\t\t\tconst start = frameOf(phrase.start);\n\t\t\tconst end = Math.max(start + 1, frameOf(phrase.end));\n\t\t\t// Word windows, local to the phrase (its box starts at `start`).\n\t\t\tconst wStart = layout.words.map(\n\t\t\t\t(w) => frameOf((words[w.index] as Word).start) - start,\n\t\t\t);\n\t\t\tconst wEnd = layout.words.map((w, k) =>\n\t\t\t\tk + 1 < layout.words.length\n\t\t\t\t\t? (wStart[k + 1] as number)\n\t\t\t\t\t: Math.max(\n\t\t\t\t\t\t\t(wStart[k] as number) + 1,\n\t\t\t\t\t\t\tframeOf((words[w.index] as Word).end) - start,\n\t\t\t\t\t\t),\n\t\t\t);\n\t\t\tconst rects = layout.words.map(\n\t\t\t\t(w) => rangeRects(m, w.from, w.to, options)[0] as Rect,\n\t\t\t);\n\t\t\tconst animators: GlyphAnimator[] = [];\n\n\t\t\tconst emphasisWeights = layout.words.map((w) =>\n\t\t\t\temphasised[w.index] ? 1 : 0,\n\t\t\t);\n\t\t\tif (emphasisWeights.some(Boolean))\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"emphasis\",\n\t\t\t\t\t\tconstantExpression(\"word\", emphasisWeights),\n\t\t\t\t\t\t{ fillColor: accent },\n\t\t\t\t\t),\n\t\t\t\t);\n\n\t\t\tconst node: Partial<TextNode> = {\n\t\t\t\tid: ctx.partId(`phrase-${p}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: safe.width,\n\t\t\t\talign: \"center\",\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tfill: ink,\n\t\t\t\t...(weight !== undefined && { fontWeight: weight }),\n\t\t\t};\n\t\t\tconst plates: LayoutNode[] = [];\n\t\t\tconst box = (\n\t\t\t\tid: string,\n\t\t\t\tr: Rect,\n\t\t\t\tpad: number,\n\t\t\t\tbackground: string,\n\t\t\t\tradius: number,\n\t\t\t\textra = {},\n\t\t\t) =>\n\t\t\t\thost.box({\n\t\t\t\t\tid: ctx.partId(`phrase-${p}-${id}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: r.x - pad,\n\t\t\t\t\ty: r.y - pad * 0.5,\n\t\t\t\t\twidth: r.width + pad * 2,\n\t\t\t\t\theight: r.height + pad,\n\t\t\t\t\tbackground,\n\t\t\t\t\tborderRadius: radius,\n\t\t\t\t\t...extra,\n\t\t\t\t});\n\t\t\tconst blockRect: Rect = {\n\t\t\t\tx: Math.min(...rects.map((r) => r.x)),\n\t\t\t\ty: 0,\n\t\t\t\twidth:\n\t\t\t\t\tMath.max(...rects.map((r) => r.x + r.width)) -\n\t\t\t\t\tMath.min(...rects.map((r) => r.x)),\n\t\t\t\theight: m.height,\n\t\t\t};\n\n\t\t\tswitch (style) {\n\t\t\t\tcase \"clean\":\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"karaoke\":\n\t\t\t\t\t// Spoken words turn from muted to ink on their exact frame.\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\"spoken\", stepExpression(\"word\", wStart), {\n\t\t\t\t\t\t\tfillColor: theme.color.muted,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"highlight\":\n\t\t\t\tcase \"pill\": {\n\t\t\t\t\tconst pad = fs * (style === \"pill\" ? 0.22 : 0.16);\n\t\t\t\t\tconst glide = Math.max(2, Math.round(fast * 0.4));\n\t\t\t\t\tif (style === \"pill\")\n\t\t\t\t\t\tplates.push(\n\t\t\t\t\t\t\tbox(\n\t\t\t\t\t\t\t\t\"plate\",\n\t\t\t\t\t\t\t\tblockRect,\n\t\t\t\t\t\t\t\tfs * 0.5,\n\t\t\t\t\t\t\t\ttheme.color.surface,\n\t\t\t\t\t\t\t\ttheme.radius.pill,\n\t\t\t\t\t\t\t\t{ opacity: 0.92 },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\tconst marker = box(\n\t\t\t\t\t\t\"marker\",\n\t\t\t\t\t\trects[0] as Rect,\n\t\t\t\t\t\tpad,\n\t\t\t\t\t\taccent,\n\t\t\t\t\t\tstyle === \"pill\" ? theme.radius.pill : theme.radius.md,\n\t\t\t\t\t);\n\t\t\t\t\tmarker.animate(markerKeys(rects, wStart, pad, glide));\n\t\t\t\t\tplates.push(marker);\n\t\t\t\t\t// Text on the marker must read against it: every word in the\n\t\t\t\t\t// background colour when ink wouldn't, else only the emphasised\n\t\t\t\t\t// ones (accent on the accent marker), in ink.\n\t\t\t\t\tconst inkReads = (contrastRatio(ink, accent) ?? 21) >= 3;\n\t\t\t\t\tif (!inkReads || emphasisWeights.some(Boolean))\n\t\t\t\t\t\tanimators.push(\n\t\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\t\"on-marker\",\n\t\t\t\t\t\t\t\t// Switch as the marker lands under the word.\n\t\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t\t\twStart.map((f) => f + Math.ceil(glide / 2)),\n\t\t\t\t\t\t\t\t\twEnd.map((f) => f + Math.ceil(glide / 2)),\n\t\t\t\t\t\t\t\t\tinkReads ? emphasisWeights : undefined,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t{ fillColor: inkReads ? ink : theme.color.bg },\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"pop\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"pop\",\n\t\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tstarts: wStart,\n\t\t\t\t\t\t\t\t\tduration: Math.max(3, Math.round(fast * 0.9)),\n\t\t\t\t\t\t\t\t\tease: \"back.out(2.2)\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{ opacity: 0, scale: 0.72, y: fs * 0.12 },\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"bold\":\n\t\t\t\tcase \"outline\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\"active\", windowExpression(\"word\", wStart, wEnd), {\n\t\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"gradient\": {\n\t\t\t\t\t// Active word: accent → accent2 across its letters.\n\t\t\t\t\tconst chars = layout.text.length;\n\t\t\t\t\tconst starts = new Array<number>(chars).fill(-1);\n\t\t\t\t\tconst ends = new Array<number>(chars).fill(-1);\n\t\t\t\t\tconst along = new Array<number>(chars).fill(0);\n\t\t\t\t\tlayout.words.forEach((w, k) => {\n\t\t\t\t\t\tfor (let c = w.from; c < w.to; c++) {\n\t\t\t\t\t\t\tstarts[c] = wStart[k] as number;\n\t\t\t\t\t\t\tends[c] = wEnd[k] as number;\n\t\t\t\t\t\t\talong[c] =\n\t\t\t\t\t\t\t\tw.to - w.from > 1 ? (c - w.from) / (w.to - w.from - 1) : 0;\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"active\",\n\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\trendererChars(layout.text, starts),\n\t\t\t\t\t\t\t\trendererChars(layout.text, ends),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t),\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"active-end\",\n\t\t\t\t\t\t\twindowExpression(\n\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\trendererChars(layout.text, starts),\n\t\t\t\t\t\t\t\trendererChars(layout.text, ends),\n\t\t\t\t\t\t\t\trendererChars(layout.text, along),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t{ fillColor: theme.color.accent2 },\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (props.plate && (style === \"clean\" || style === \"karaoke\"))\n\t\t\t\tplates.unshift(\n\t\t\t\t\tbox(\n\t\t\t\t\t\t\"plate\",\n\t\t\t\t\t\tblockRect,\n\t\t\t\t\t\tfs * 0.35,\n\t\t\t\t\t\t\"rgba(0,0,0,0.55)\",\n\t\t\t\t\t\ttheme.radius.md,\n\t\t\t\t\t),\n\t\t\t\t);\n\n\t\t\tif (props.pulse) {\n\t\t\t\t// The phrase's slice of the voice level, indexed by its local frame.\n\t\t\t\tconst level = props.pulse.level.slice(start, end);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\"pulse\", pulseExpression(wStart, wEnd, level), {\n\t\t\t\t\t\tscale: 1 + props.pulseAmount,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (animators.length > 0) node.animators = animators as never;\n\t\t\tconst text = host.text(layout.text, node);\n\t\t\t// A heavy outline around the glyphs, never over them.\n\t\t\tif (style === \"outline\")\n\t\t\t\thost.style(text, {\n\t\t\t\t\tstroke: { size: fs * 0.08, position: \"outside\", color: \"#000000\" },\n\t\t\t\t});\n\t\t\tconst holder = host.box({\n\t\t\t\tid: ctx.partId(`phrase-${p}-box`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: safe.x,\n\t\t\t\ty,\n\t\t\t\twidth: safe.width,\n\t\t\t\theight: m.height,\n\t\t\t\tstartFrame: start,\n\t\t\t\tdurationFrames: end - start,\n\t\t\t\tchildren: [...plates, text],\n\t\t\t});\n\t\t\tholder.animate(\n\t\t\t\tphraseMotion(style, p, end - start, fs, fast, { x: safe.x, y }),\n\t\t\t);\n\t\t\tparts[`phrase-${p}`] = holder;\n\t\t\tchildren.push(holder);\n\n\t\t\tphraseCues.push({\n\t\t\t\tframe: start,\n\t\t\t\tend,\n\t\t\t\ttext: layout.text.replace(/\\n/g, \" \"),\n\t\t\t});\n\t\t\tlayout.words.forEach((w, k) => {\n\t\t\t\twordCues.push({\n\t\t\t\t\tframe: start + (wStart[k] as number),\n\t\t\t\t\tend: start + (wEnd[k] as number),\n\t\t\t\t\tindex: w.index,\n\t\t\t\t\ttext: (words[w.index] as Word).text,\n\t\t\t\t\temphasised: emphasised[w.index] ?? false,\n\t\t\t\t});\n\t\t\t});\n\t\t});\n\n\t\tif (props.behind) {\n\t\t\tconst cut = host.cutout(props.behind.video, {\n\t\t\t\tid: ctx.partId(\"behind\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: W,\n\t\t\t\theight: H,\n\t\t\t\tfit: \"cover\",\n\t\t\t\tmode: props.behind.mode,\n\t\t\t});\n\t\t\tparts.behind = cut;\n\t\t\tchildren.push(cut);\n\t\t}\n\n\t\tconst last = phraseCues[phraseCues.length - 1]?.end ?? 1;\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: last,\n\t\t\torigin: [0, 0],\n\t\t\tcues: { words: wordCues, phrases: phraseCues } as CaptionsCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** A phrase's text and each word's character range in it. */\nfunction phraseLayout(phrase: Phrase, words: readonly Word[]) {\n\tconst out: Array<{ index: number; from: number; to: number }> = [];\n\tlet text = \"\";\n\tphrase.lines.forEach((line, l) => {\n\t\tif (l > 0) text += \"\\n\";\n\t\tline.forEach((index, k) => {\n\t\t\tif (k > 0) text += \" \";\n\t\t\tconst from = text.length;\n\t\t\ttext += (words[index] as Word).text;\n\t\t\tout.push({ index, from, to: text.length });\n\t\t});\n\t});\n\treturn { text, words: out };\n}\n\n/** A marker that glides onto each word, arriving on the frame it is spoken. */\nfunction markerKeys(\n\trects: readonly Rect[],\n\tstarts: readonly number[],\n\tpad: number,\n\tglide: number,\n): LayerAnimation {\n\tconst keyed = (value: (r: Rect) => number) => {\n\t\tconst keys: KeyframeTuple[] = [[0, value(rects[0] as Rect)]];\n\t\trects.forEach((r, k) => {\n\t\t\tconst prev = rects[k - 1];\n\t\t\tif (!prev) return;\n\t\t\tconst at = starts[k] as number;\n\t\t\tconst last = (keys[keys.length - 1] as KeyframeTuple)[0];\n\t\t\t// Leave on the word's first frame and land a few frames in: the\n\t\t\t// marker never runs ahead of the voice.\n\t\t\tkeys.push([Math.max(last + 1, at), value(prev)]);\n\t\t\tkeys.push([Math.max(last + 2, at + glide), value(r), \"power3.out\"]);\n\t\t});\n\t\treturn keys;\n\t};\n\treturn LayerAnimation.create()\n\t\t.keys(\n\t\t\t\"x\",\n\t\t\tkeyed((r) => r.x - pad),\n\t\t)\n\t\t.keys(\n\t\t\t\"y\",\n\t\t\tkeyed((r) => r.y - pad * 0.5),\n\t\t)\n\t\t.keys(\n\t\t\t\"width\",\n\t\t\tkeyed((r) => r.width + pad * 2),\n\t\t)\n\t\t.keys(\n\t\t\t\"height\",\n\t\t\tkeyed((r) => r.height + pad),\n\t\t)\n\t\t.keys(\"opacity\", [\n\t\t\t[0, 0],\n\t\t\t[starts[0] ?? 0, 1, \"hold\"],\n\t\t]);\n}\n\n/** How a phrase arrives and leaves. `bold` alternates sides; the rest fade up. */\nfunction phraseMotion(\n\tstyle: CaptionStyle,\n\tindex: number,\n\tlength: number,\n\tfs: number,\n\tfast: number,\n\tat: { x: number; y: number },\n): LayerAnimation {\n\tconst anim = LayerAnimation.create();\n\tconst inF = Math.min(\n\t\tMath.max(2, Math.round(fast * 0.6)),\n\t\tMath.floor(length / 2),\n\t);\n\tconst out = Math.min(\n\t\tMath.max(2, Math.round(fast * 0.5)),\n\t\tMath.floor(length / 3),\n\t);\n\tanim.keys(\"opacity\", [\n\t\t[0, 0],\n\t\t[inF, 1, \"power2.out\"],\n\t\t[length - out, 1],\n\t\t[length, 0, \"power2.in\"],\n\t]);\n\tif (style === \"bold\") {\n\t\tconst side = index % 2 === 0 ? -1 : 1;\n\t\tanim.keys(\"x\", [\n\t\t\t[0, at.x + side * fs * 0.6],\n\t\t\t[inF + 2, at.x, \"back.out(1.8)\"],\n\t\t]);\n\t\tanim.keys(\"scale\", [\n\t\t\t[0, 0.86],\n\t\t\t[inF + 2, 1, \"back.out(1.8)\"],\n\t\t]);\n\t} else if (style !== \"pop\") {\n\t\tanim.keys(\"y\", [\n\t\t\t[0, at.y + fs * 0.14],\n\t\t\t[inF, at.y, \"power3.out\"],\n\t\t]);\n\t}\n\treturn anim;\n}\n\nfunction stripUndefined<T extends object>(o: T): Partial<T> {\n\treturn Object.fromEntries(\n\t\tObject.entries(o).filter(([, v]) => v !== undefined),\n\t) as Partial<T>;\n}\n\n/** The active word's weight is the voice level on that frame (0 elsewhere). */\nfunction pulseExpression(\n\tstarts: readonly number[],\n\tends: readonly number[],\n\tlevel: readonly number[],\n): string {\n\tconst list = (v: readonly number[]) =>\n\t\t`[${v.map((x) => Math.round(x * 1000) / 1000).join(\",\")}]`;\n\treturn `((S,E,L)=>{const s=S[wordIndex];if(s===undefined||frame<s||frame>=E[wordIndex])return 0;return L[floor(frame)]??0;})(${list(starts)},${list(ends)},${list(level)})`;\n}\n\n/**\n * Word-timed captions from words or a transcript (SRT, WebVTT, word JSON) in\n * eight published styles, highlighting each word on its exact frame, optionally\n * tucked behind the speaker.\n */\nexport const Captions = /* @__PURE__ */ bindComponent(captions);\n"
|
|
27
33
|
},
|
|
28
34
|
{
|
|
29
35
|
"path": "captions/parse.ts",
|