@leavepulse/ui 0.83.1 → 0.85.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/assets/{LpDropzone-DNbh_NFJ.css → LpDropzone-CfL3Vrx4.css} +10 -10
- package/dist/assets/{LpImageEditor-Bgu20XLs.css → LpImageEditor-y3B-aW3H.css} +6 -6
- package/dist/assets/{LpKeys-C5VMcBOW.css → LpKeys-5ijsXX-M.css} +6 -6
- package/dist/assets/{LpShortcutsDialog-C6uzPeMf.css → LpShortcutsDialog-BBdVWxZq.css} +7 -7
- package/dist/assets/LpVideoTrim-CMoUw3dJ.css +8 -0
- package/dist/chunks/{ContextMenuItems-h-apPY2d.js → ContextMenuItems-D15cGEw9.js} +1 -1
- package/dist/chunks/{DropzoneLinkField-_Op8b_ee.js → DropzoneLinkField-Dz7fIaLh.js} +1 -1
- package/dist/chunks/{FieldLeading-mOMcwNqi.js → FieldLeading-DZZLK02U.js} +1 -1
- package/dist/chunks/{FieldWrapper-DtT-AIcn.js → FieldWrapper-BDDw1F9H.js} +1 -1
- package/dist/chunks/{IconCloseButton-YmisskyZ.js → IconCloseButton-Cn_w4aN3.js} +1 -1
- package/dist/chunks/{LayoutCanvas-BrISZEBr.js → LayoutCanvas-Dr5eSfAu.js} +1 -1
- package/dist/chunks/{LayoutNode-CwMQ8ACa.js → LayoutNode-CViJnx6q.js} +1 -1
- package/dist/chunks/{LpActionBar-CVVC6YbC.js → LpActionBar-C0LK-Aqs.js} +1 -1
- package/dist/chunks/{LpAddressInput-tiuJ-bru.js → LpAddressInput-B9sSvodU.js} +7 -7
- package/dist/chunks/{LpAlert-8-x4H_Nk.js → LpAlert-Bpd7122M.js} +1 -1
- package/dist/chunks/{LpAppShell-BW2YMq-A.js → LpAppShell-DW8JmY1o.js} +4 -4
- package/dist/chunks/{LpAutocomplete-BZODIops.js → LpAutocomplete-CQID7Zc-.js} +4 -4
- package/dist/chunks/{LpAvatar-BZq4nH53.js → LpAvatar-DLkiMGKx.js} +1 -1
- package/dist/chunks/{LpBadge-Cz0pSSUu.js → LpBadge-RFp8uHFt.js} +1 -1
- package/dist/chunks/{LpBottomNav-DVdR5Mry.js → LpBottomNav-BcJVkAhh.js} +1 -1
- package/dist/chunks/{LpBreadcrumbs-B2jphAel.js → LpBreadcrumbs-DJ_DB45r.js} +1 -1
- package/dist/chunks/{LpCalendar-C0_N5LVT.js → LpCalendar-D6tfCr2G.js} +1 -1
- package/dist/chunks/{LpCheckbox-CwqLNdw4.js → LpCheckbox-DcXC2qd5.js} +2 -2
- package/dist/chunks/{LpCodeBlock-Dcss6bnl.js → LpCodeBlock-Dx0J9xlm.js} +2 -2
- package/dist/chunks/{LpCodeDisplay-CLms1zDY.js → LpCodeDisplay-BlN7U8Z-.js} +4 -4
- package/dist/chunks/{LpColorPicker-CbIY450X.js → LpColorPicker-Cjna_Bio.js} +3 -3
- package/dist/chunks/{LpConfirmDialog-RmP4z6va.js → LpConfirmDialog-bfcX5F9d.js} +2 -2
- package/dist/chunks/{LpConfirmHost-DheBgRMB.js → LpConfirmHost-DIgloymi.js} +1 -1
- package/dist/chunks/{LpContextMenu-DIYhlap8.js → LpContextMenu-CUFZIQX2.js} +2 -2
- package/dist/chunks/{LpContextSwitcher-fDA6ERl-.js → LpContextSwitcher-CMweLbv2.js} +2 -2
- package/dist/chunks/{LpCopyButton-DSqKFGEb.js → LpCopyButton-BrbvIVMG.js} +1 -1
- package/dist/chunks/{LpDataList-f9UjSx9m.js → LpDataList-DhETPjzv.js} +2 -2
- package/dist/chunks/{LpDatePicker-3kDQLVbg.js → LpDatePicker-B5gzwI3T.js} +5 -5
- package/dist/chunks/{LpDateRangePicker-DUPiTt0_.js → LpDateRangePicker-CwNnpyoL.js} +3 -3
- package/dist/chunks/{LpDisclosure-C5WZjhbQ.js → LpDisclosure-CsJ7nnFz.js} +1 -1
- package/dist/chunks/{LpDrawer-CkYvIGIa.js → LpDrawer-C1q2bl1r.js} +1 -1
- package/dist/chunks/{LpDropdownMenu-zSsK2Xra.js → LpDropdownMenu-DRABI-Bl.js} +2 -2
- package/dist/chunks/{LpEditable-CP7Y_lKg.js → LpEditable-DCPdDDBb.js} +2 -2
- package/dist/chunks/{LpEmptyState-dsc5x-ya.js → LpEmptyState-DT8U4R8L.js} +1 -1
- package/dist/chunks/{LpFieldNotice-TOTbWvgv.js → LpFieldNotice-mcmO-O-J.js} +1 -1
- package/dist/chunks/{LpFileTree-BPjnOTln.js → LpFileTree-wiXExcZI.js} +4 -4
- package/dist/chunks/{LpFileTreeNode-_-MiWmie.js → LpFileTreeNode-DfvIGmSz.js} +4 -4
- package/dist/chunks/{LpFormField-BJwD1iEF.js → LpFormField-C5GOuOY-.js} +1 -1
- package/dist/chunks/{LpHintLayer-CmfGnxYu.js → LpHintLayer-B0choo1r.js} +1 -1
- package/dist/chunks/{LpHints-C2Xvc1Fd.js → LpHints-BEetUVTl.js} +3 -3
- package/dist/chunks/{LpIcon-CoOLz1oJ.js → LpIcon-DCGhNsCU.js} +1 -1
- package/dist/chunks/{LpInfraNode-CW3qsM-U.js → LpInfraNode-BsExhdPE.js} +2 -2
- package/dist/chunks/{LpInput-BVWhkPuN.js → LpInput-CKMblRtp.js} +3 -3
- package/dist/chunks/{LpInspector-REmoX_es.js → LpInspector-CqPgVXrm.js} +4 -4
- package/dist/chunks/{LpLightbox-C3FFTYP5.js → LpLightbox-D7iJqdmH.js} +4 -4
- package/dist/chunks/{LpLogInspector-Cs8Z_fM0.js → LpLogInspector-Yj310k4n.js} +2 -2
- package/dist/chunks/{LpLogViewer-DQRGe-ZK.js → LpLogViewer-Bki-IdNC.js} +6 -6
- package/dist/chunks/{LpMapPicker-CQnQPgKm.js → LpMapPicker-CnpJhiY0.js} +1 -1
- package/dist/chunks/{LpMediaGallery-DkKqTy9P.js → LpMediaGallery-D6DyJDvL.js} +4 -4
- package/dist/chunks/{LpModal-DwejR7VP.js → LpModal-B5y9_LgA.js} +1 -1
- package/dist/chunks/{LpModalNav-D1-dSe6f.js → LpModalNav-BLvDVl2-.js} +3 -3
- package/dist/chunks/{LpNotificationBell-EKLkN0sp.js → LpNotificationBell-Bf0u3t5q.js} +4 -4
- package/dist/chunks/{LpNotificationFeed-Dc-yQ4NH.js → LpNotificationFeed-BPZM3zkS.js} +4 -4
- package/dist/chunks/{LpNumberField-DvUjEMp-.js → LpNumberField-BeYT7zzt.js} +3 -3
- package/dist/chunks/{LpOtpInput-xvDO14f4.js → LpOtpInput-DqwY-U5R.js} +1 -1
- package/dist/chunks/{LpPagination-BFZY8dFn.js → LpPagination-V1wEhu1K.js} +1 -1
- package/dist/chunks/{LpPaletteTheme-EBJv0Bwz.js → LpPaletteTheme-Bi6pD1GL.js} +2 -2
- package/dist/chunks/{LpPasswordInput-DRJR3fF1.js → LpPasswordInput-LaOXNYuV.js} +2 -2
- package/dist/chunks/{LpPhoneInput-BGOOtUoW.js → LpPhoneInput-D7Tq2iaz.js} +3 -3
- package/dist/chunks/{LpPie-Deyn8h5J.js → LpPie-Bzv8mnVR.js} +2 -2
- package/dist/chunks/{LpPipeline-9ChjeNh5.js → LpPipeline-D-uPcbMN.js} +2 -2
- package/dist/chunks/{LpQuota-CNh5eF_p.js → LpQuota-BR8ck2a9.js} +1 -1
- package/dist/chunks/{LpRadioGroup-Br2Nl9Gr.js → LpRadioGroup-DSOCL9MD.js} +2 -2
- package/dist/chunks/{LpRangeCalendar-D66Y3U_w.js → LpRangeCalendar-QepHrmba.js} +1 -1
- package/dist/chunks/{LpRangeSlider-eX-fIlOw.js → LpRangeSlider-BaAM7XIs.js} +1 -1
- package/dist/chunks/{LpRepeater-0XvnlVd_.js → LpRepeater-BKq5XPh4.js} +3 -3
- package/dist/chunks/{LpSecretField-1RCooChq.js → LpSecretField-_B2qPfk8.js} +5 -5
- package/dist/chunks/{LpSection-B9gbJJcP.js → LpSection-BGzlKIWO.js} +1 -1
- package/dist/chunks/{LpSegmented-hIjGzGTo.js → LpSegmented-CiaF9DUK.js} +2 -2
- package/dist/chunks/{LpSelect-DugubwTc.js → LpSelect-CB6Xbnfk.js} +4 -4
- package/dist/chunks/{LpSidebar-DevSoN8W.js → LpSidebar-DnqbKULj.js} +4 -4
- package/dist/chunks/{LpSidebarNav-DwQQ3KXe.js → LpSidebarNav-B3Eo_VIS.js} +4 -4
- package/dist/chunks/{LpSidebarRow-CAGgtJCS.js → LpSidebarRow-CDBnvvd8.js} +3 -3
- package/dist/chunks/{LpSlider-1fHpVxpB.js → LpSlider-DTb_Ivkm.js} +1 -1
- package/dist/chunks/{LpStat-Cm9-FaWQ.js → LpStat-BtxSto7K.js} +2 -2
- package/dist/chunks/{LpStatus-CE8FSAT1.js → LpStatus-_fIwWXQV.js} +1 -1
- package/dist/chunks/{LpStepMark-BWSlV69R.js → LpStepMark-iDZw1DYf.js} +1 -1
- package/dist/chunks/{LpStepper-1D0ZA7Nx.js → LpStepper-CjC3cp1F.js} +1 -1
- package/dist/chunks/{LpSwitch-D_kvE0oQ.js → LpSwitch-BNHyYDhX.js} +1 -1
- package/dist/chunks/{LpTabFace-CpbFKi-N.js → LpTabFace-gATEe9pQ.js} +2 -2
- package/dist/chunks/{LpTableFilter-BYHyCu38.js → LpTableFilter-B_BFoBqS.js} +2 -2
- package/dist/chunks/{LpTagsInput-Cqm38t5N.js → LpTagsInput-By7LMb47.js} +5 -5
- package/dist/chunks/{LpTextarea-DaSKBkiV.js → LpTextarea-Ck6RAfzu.js} +1 -1
- package/dist/chunks/{LpThemeStudio-BNdfi-Ds.js → LpThemeStudio-C1xKiv8C.js} +5 -5
- package/dist/chunks/{LpThemeSwitcher-CbC1nqBP.js → LpThemeSwitcher-Dk4Bk6u1.js} +3 -3
- package/dist/chunks/{LpTimeField-CCfPCWPT.js → LpTimeField-BTKJVeNt.js} +2 -2
- package/dist/chunks/{LpToaster-BhlI7h-h.js → LpToaster-C_CFWSys.js} +2 -2
- package/dist/chunks/{LpTopologyCanvas-DlKbu7E5.js → LpTopologyCanvas-paC1Dup-.js} +4 -4
- package/dist/chunks/{LpTour-CSLerA1B.js → LpTour-D_AN_SsZ.js} +1 -1
- package/dist/chunks/{LpTruncate-Ct26BSYB.js → LpTruncate-CBUKnr36.js} +1 -1
- package/dist/chunks/LpVideoCompress-BfulQ8Rs.js +245 -0
- package/dist/chunks/{LpVideoPlayer-DcWd9DDW.js → LpVideoPlayer-koYKHKkC.js} +4 -4
- package/dist/chunks/{LpWindow-YnF_knSj.js → LpWindow-Bo5qQgVY.js} +2 -2
- package/dist/chunks/{LpWorkspaceSwitcher-B-5UU6o4.js → LpWorkspaceSwitcher-D50sHs9N.js} +3 -3
- package/dist/chunks/{MenuTitle-BnZbFSwq.js → MenuTitle-DCHcfyPH.js} +1 -1
- package/dist/chunks/{OptionContent-D5e_eklA.js → OptionContent-o58kS7Zh.js} +1 -1
- package/dist/chunks/{PickerField-KpHiowNI.js → PickerField-DNo1k89A.js} +1 -1
- package/dist/chunks/{RevealToggle-CE1hAIJB.js → RevealToggle-w7-BMcgC.js} +1 -1
- package/dist/chunks/{TourCard-t2csiTDS.js → TourCard-0yYxSdd4.js} +1 -1
- package/dist/chunks/{VideoThumb-Csf9bUz3.js → VideoThumb-DL_QkAJY.js} +1 -1
- package/dist/chunks/{VideoVolume-B4_DNmNi.js → VideoVolume-D-2TmsVj.js} +2 -2
- package/dist/chunks/{icons-CdSlCrle.js → icons-V0k93Puc.js} +3 -3
- package/dist/component-names.d.ts +1 -1
- package/dist/components/ContextMenuItems.vue.js +1 -1
- package/dist/components/DropzoneLinkField.vue.js +1 -1
- package/dist/components/FieldLeading.vue.js +1 -1
- package/dist/components/FieldWrapper.vue.js +1 -1
- package/dist/components/HintSampleTag.vue.d.ts +5 -3
- package/dist/components/IconCloseButton.vue.js +1 -1
- package/dist/components/LayoutCanvas.vue.js +1 -1
- package/dist/components/LayoutNode.vue.js +1 -1
- package/dist/components/LpActionBar.vue.d.ts +5 -3
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpActivityLine.vue.d.ts +6 -6
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.d.ts +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- package/dist/components/LpAppShell.vue.d.ts +10 -14
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAutocomplete.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpBadge.vue.d.ts +4 -0
- package/dist/components/LpBadge.vue.js +1 -1
- package/dist/components/LpBottomNav.vue.d.ts +6 -4
- package/dist/components/LpBottomNav.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.d.ts +6 -6
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpButton.vue.d.ts +6 -6
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpChart.vue.js +2 -2
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.d.ts +12 -2
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpCodeDisplay.vue.d.ts +1 -1
- package/dist/components/LpCodeDisplay.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.d.ts +1 -1
- package/dist/components/LpCommandPalette.vue.js +2 -2
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpContextSwitcher.vue.d.ts +6 -4
- package/dist/components/LpContextSwitcher.vue.js +1 -1
- package/dist/components/LpCopyButton.vue.js +1 -1
- package/dist/components/LpDataList.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDock.vue.js +1 -1
- package/dist/components/LpDrawer.vue.d.ts +6 -6
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.d.ts +10 -14
- package/dist/components/LpDropzone.vue.js +4 -4
- package/dist/components/LpEditable.vue.js +1 -1
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFieldNotice.vue.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpHintExample.vue.d.ts +2 -3
- package/dist/components/LpHintLayer.vue.d.ts +7 -8
- package/dist/components/LpHintLayer.vue.js +1 -1
- package/dist/components/LpHints.vue.d.ts +6 -5
- package/dist/components/LpHints.vue.js +1 -1
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.d.ts +14 -3
- package/dist/components/LpImageEditor.vue.js +3 -3
- package/dist/components/LpInfraNode.vue.js +1 -1
- package/dist/components/LpInput.vue.d.ts +6 -6
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpInspector.vue.d.ts +8 -6
- package/dist/components/LpInspector.vue.js +1 -1
- package/dist/components/LpKeys.vue.js +2 -2
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogInspector.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.d.ts +8 -0
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.d.ts +3 -7
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.d.ts +5 -3
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.d.ts +4 -0
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNotificationFeed.vue.js +1 -1
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpOtpInput.vue.js +1 -1
- package/dist/components/LpPagination.vue.d.ts +8 -2
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPaletteFacts.vue.js +1 -1
- package/dist/components/LpPaletteHeader.vue.js +2 -2
- package/dist/components/LpPaletteTheme.vue.d.ts +12 -0
- package/dist/components/LpPaletteTheme.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpPipeline.vue.js +1 -1
- package/dist/components/LpQuota.vue.js +1 -1
- package/dist/components/LpRadioGroup.vue.js +1 -1
- package/dist/components/LpRangeCalendar.vue.js +1 -1
- package/dist/components/LpRangeSlider.vue.js +1 -1
- package/dist/components/LpRepeater.vue.d.ts +9 -7
- package/dist/components/LpRepeater.vue.js +1 -1
- package/dist/components/LpSecretField.vue.js +1 -1
- package/dist/components/LpSection.vue.js +1 -1
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +11 -1
- package/dist/components/LpShortcutsDialog.vue.js +3 -3
- package/dist/components/LpSidebar.vue.d.ts +13 -11
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpSidebarRow.vue.js +1 -1
- package/dist/components/LpSlider.vue.js +1 -1
- package/dist/components/LpSparkline.vue.d.ts +10 -1
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStatus.vue.js +1 -1
- package/dist/components/LpStepMark.vue.js +1 -1
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpSwitch.vue.js +1 -1
- package/dist/components/LpTabFace.vue.js +1 -1
- package/dist/components/LpTable.vue.js +9 -9
- package/dist/components/LpTableFilter.vue.js +1 -1
- package/dist/components/LpTableOfContents.vue.d.ts +5 -3
- package/dist/components/LpTabs.vue.js +2 -2
- package/dist/components/LpTagsInput.vue.js +1 -1
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeStudio.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.d.ts +8 -6
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.d.ts +7 -5
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/components/LpTour.vue.d.ts +13 -20
- package/dist/components/LpTour.vue.js +1 -1
- package/dist/components/LpTruncate.vue.js +1 -1
- package/dist/components/LpTypewriter.vue.d.ts +1 -1
- package/dist/components/LpUptimeBar.vue.d.ts +4 -2
- package/dist/components/LpVideoCompress.vue.d.ts +63 -0
- package/dist/components/LpVideoCompress.vue.js +4 -0
- package/dist/components/LpVideoPlayer.vue.d.ts +33 -3
- package/dist/components/LpVideoPlayer.vue.js +1 -1
- package/dist/components/LpVideoTrim.vue.d.ts +40 -3
- package/dist/components/LpVideoTrim.vue.js +6 -6
- package/dist/components/LpWindow.vue.d.ts +7 -8
- package/dist/components/LpWindow.vue.js +1 -1
- package/dist/components/LpWorkspaceSwitcher.vue.d.ts +13 -11
- package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
- package/dist/components/MenuTitle.vue.js +1 -1
- package/dist/components/OptionContent.vue.js +1 -1
- package/dist/components/PickerField.vue.js +1 -1
- package/dist/components/RevealToggle.vue.js +1 -1
- package/dist/components/TourCard.vue.js +1 -1
- package/dist/components/VideoFlash.vue.js +1 -1
- package/dist/components/VideoThumb.vue.js +1 -1
- package/dist/components/VideoVolume.vue.js +1 -1
- package/dist/components/menuWhen.js +1 -1
- package/dist/components/status.d.ts +3 -1
- package/dist/composables/usePaletteCommands.d.ts +4 -0
- package/dist/composables/videoTranscode.d.ts +55 -0
- package/dist/composables/videoTranscode.js +134 -0
- package/dist/icons-all.js +1 -1
- package/dist/index.d.ts +44 -10
- package/dist/index.js +748 -170
- package/dist/locales/uk.d.ts +165 -0
- package/package.json +2 -1
- package/src/component-names.ts +1 -0
- package/src/components/HintSampleTag.vue +7 -2
- package/src/components/LpActionBar.vue +7 -2
- package/src/components/LpActivityLine.vue +7 -2
- package/src/components/LpAppShell.vue +11 -2
- package/src/components/LpBadge.vue +6 -1
- package/src/components/LpBottomNav.vue +8 -2
- package/src/components/LpBreadcrumbs.vue +7 -2
- package/src/components/LpButton.vue +7 -2
- package/src/components/LpCodeBlock.vue +13 -2
- package/src/components/LpContextSwitcher.vue +8 -2
- package/src/components/LpDrawer.vue +7 -2
- package/src/components/LpDropzone.vue +11 -2
- package/src/components/LpHintExample.vue +2 -2
- package/src/components/LpHintLayer.vue +9 -3
- package/src/components/LpHints.vue +17 -3
- package/src/components/LpImageEditor.vue +14 -7
- package/src/components/LpInput.vue +7 -2
- package/src/components/LpInspector.vue +10 -2
- package/src/components/LpKeys.vue +2 -1
- package/src/components/LpMapPicker.vue +10 -1
- package/src/components/LpMediaGallery.vue +12 -3
- package/src/components/LpModal.vue +7 -2
- package/src/components/LpModalNav.vue +6 -1
- package/src/components/LpPagination.vue +9 -2
- package/src/components/LpPaletteTheme.vue +14 -1
- package/src/components/LpRepeater.vue +11 -2
- package/src/components/LpShortcutsDialog.vue +12 -1
- package/src/components/LpSidebar.vue +15 -12
- package/src/components/LpSparkline.vue +12 -2
- package/src/components/LpTableOfContents.vue +7 -2
- package/src/components/LpThemeSwitcher.vue +10 -2
- package/src/components/LpToaster.vue +9 -2
- package/src/components/LpTour.vue +15 -3
- package/src/components/LpUptimeBar.vue +5 -2
- package/src/components/LpVideoCompress.vue +240 -0
- package/src/components/LpVideoPlayer.vue +33 -7
- package/src/components/LpVideoTrim.vue +40 -9
- package/src/components/LpWindow.vue +8 -2
- package/src/components/LpWorkspaceSwitcher.vue +15 -12
- package/src/components/status.ts +4 -1
- package/src/composables/usePaletteCommands.ts +6 -1
- package/src/composables/videoTranscode.ts +192 -0
- package/src/index.ts +51 -10
- package/src/locales/uk.ts +746 -0
- package/src/nuxt.ts +5 -1
- package/src/theme/palette.ts +2 -1
- package/dist/assets/LpVideoTrim-BcGs3Sh0.css +0 -8
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Trim and shrink a video on the person's own machine before it is uploaded.
|
|
4
|
+
*
|
|
5
|
+
* <LpVideoCompress :file="file" @done="(out) => upload(out)" @skip="upload(file)" />
|
|
6
|
+
*
|
|
7
|
+
* - The picture plays from a local object URL, so nothing is sent while the
|
|
8
|
+
* person trims; LpVideoTrim does the trimming.
|
|
9
|
+
* - A preset picks the height and bitrate, or a target size ("fit for a chat"),
|
|
10
|
+
* and the line under it estimates the result against the original.
|
|
11
|
+
* - "Shrink" encodes with `transcodeVideo` (WebCodecs, hardware where there is
|
|
12
|
+
* some) with a progress bar and a cancel; `done` hands the new MP4 back.
|
|
13
|
+
* "Upload as is" emits `skip`: the original goes, untouched.
|
|
14
|
+
* - A browser without a video encoder only offers `skip`, and says why.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
|
|
17
|
+
|
|
18
|
+
import { useLocale } from "../composables/useLocale"
|
|
19
|
+
import { useLabels } from "../composables/useLpConfig"
|
|
20
|
+
import {
|
|
21
|
+
canTranscodeVideo,
|
|
22
|
+
clipLength,
|
|
23
|
+
estimateTranscodedBytes,
|
|
24
|
+
probeVideo,
|
|
25
|
+
transcodeVideo,
|
|
26
|
+
type TranscodeVideoOptions,
|
|
27
|
+
type VideoInfo,
|
|
28
|
+
} from "../composables/videoTranscode"
|
|
29
|
+
import { formatBytes } from "./format"
|
|
30
|
+
import LpButton from "./LpButton.vue"
|
|
31
|
+
import LpCollapse from "./LpCollapse.vue"
|
|
32
|
+
import LpProgress from "./LpProgress.vue"
|
|
33
|
+
import LpSegmented from "./LpSegmented.vue"
|
|
34
|
+
import LpVideoTrim from "./LpVideoTrim.vue"
|
|
35
|
+
import type { TimeRange } from "./videoTrim"
|
|
36
|
+
|
|
37
|
+
/** Wording of the compressor. `{size}`, `{original}`, `{percent}` and `{error}` are filled in. */
|
|
38
|
+
export type VideoCompressLabels = {
|
|
39
|
+
original: string
|
|
40
|
+
p1080: string
|
|
41
|
+
p720: string
|
|
42
|
+
p480: string
|
|
43
|
+
fit: string
|
|
44
|
+
quality: string
|
|
45
|
+
estimate: string
|
|
46
|
+
probing: string
|
|
47
|
+
shrink: string
|
|
48
|
+
skip: string
|
|
49
|
+
encoding: string
|
|
50
|
+
cancel: string
|
|
51
|
+
unsupported: string
|
|
52
|
+
failed: string
|
|
53
|
+
bigger: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface VideoCompressPreset {
|
|
57
|
+
key: string
|
|
58
|
+
label: string
|
|
59
|
+
maxHeight?: number | null
|
|
60
|
+
targetBytes?: number | null
|
|
61
|
+
videoBitrate?: number | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const props = withDefaults(
|
|
65
|
+
defineProps<{
|
|
66
|
+
file: File
|
|
67
|
+
/** The presets offered; the default set is original, 1080p, 720p, 480p and "fits 10 MB". */
|
|
68
|
+
presets?: VideoCompressPreset[]
|
|
69
|
+
/** The longest clip allowed, in seconds. */
|
|
70
|
+
maxClip?: number
|
|
71
|
+
disabled?: boolean
|
|
72
|
+
labels?: Partial<VideoCompressLabels>
|
|
73
|
+
}>(),
|
|
74
|
+
{ presets: undefined, maxClip: undefined, labels: () => ({}) },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const preset = defineModel<string>("preset", { default: "p720" })
|
|
78
|
+
|
|
79
|
+
const emit = defineEmits<{
|
|
80
|
+
(e: "done", file: File, info: { range: TimeRange; preset: string }): void
|
|
81
|
+
(e: "skip"): void
|
|
82
|
+
(e: "busy", value: boolean): void
|
|
83
|
+
}>()
|
|
84
|
+
|
|
85
|
+
const text = useLabels<VideoCompressLabels>(
|
|
86
|
+
"videoCompress",
|
|
87
|
+
{
|
|
88
|
+
original: "Original",
|
|
89
|
+
p1080: "1080p",
|
|
90
|
+
p720: "720p",
|
|
91
|
+
p480: "480p",
|
|
92
|
+
/** `{size}` is replaced by the target size. */
|
|
93
|
+
fit: "Fits {size}",
|
|
94
|
+
quality: "Quality",
|
|
95
|
+
/** `{size}` and `{original}` are replaced by sizes; the size is a ceiling, simple pictures come out smaller. */
|
|
96
|
+
estimate: "Up to about {size} instead of {original}",
|
|
97
|
+
probing: "Reading the video…",
|
|
98
|
+
shrink: "Shrink",
|
|
99
|
+
skip: "Upload as is",
|
|
100
|
+
/** `{percent}` is replaced by a number. */
|
|
101
|
+
encoding: "Encoding on this device… {percent}%",
|
|
102
|
+
cancel: "Cancel",
|
|
103
|
+
unsupported: "This browser can't encode video, so the original will be uploaded.",
|
|
104
|
+
/** `{error}` is replaced by the reason. */
|
|
105
|
+
failed: "Couldn't encode: {error}",
|
|
106
|
+
bigger: "The result wouldn't be smaller, so the original is better.",
|
|
107
|
+
},
|
|
108
|
+
() => props.labels,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const FIT_BYTES = 10 * 1024 * 1024
|
|
112
|
+
const locale = useLocale()
|
|
113
|
+
const size = (bytes: number) => formatBytes(bytes, locale.value)
|
|
114
|
+
|
|
115
|
+
const choices = computed<VideoCompressPreset[]>(() => props.presets ?? [
|
|
116
|
+
{ key: "original", label: text.value.original },
|
|
117
|
+
{ key: "p1080", label: text.value.p1080, maxHeight: 1080 },
|
|
118
|
+
{ key: "p720", label: text.value.p720, maxHeight: 720 },
|
|
119
|
+
{ key: "p480", label: text.value.p480, maxHeight: 480 },
|
|
120
|
+
{ key: "fit", label: text.value.fit.replace("{size}", size(FIT_BYTES)), maxHeight: 720, targetBytes: FIT_BYTES },
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
const url = ref("")
|
|
124
|
+
const info = shallowRef<VideoInfo | null>(null)
|
|
125
|
+
const supported = ref<boolean | null>(null)
|
|
126
|
+
const range = ref<TimeRange | null>(null)
|
|
127
|
+
const progress = ref<number | null>(null)
|
|
128
|
+
const error = ref("")
|
|
129
|
+
let controller: AbortController | null = null
|
|
130
|
+
|
|
131
|
+
function load(file: File): void {
|
|
132
|
+
if (url.value) URL.revokeObjectURL(url.value)
|
|
133
|
+
url.value = URL.createObjectURL(file)
|
|
134
|
+
info.value = null
|
|
135
|
+
range.value = null
|
|
136
|
+
error.value = ""
|
|
137
|
+
void probeVideo(file).then(
|
|
138
|
+
(found) => { if (props.file === file) info.value = found },
|
|
139
|
+
(cause: unknown) => { if (props.file === file) error.value = text.value.failed.replace("{error}", cause instanceof Error ? cause.message : String(cause)) },
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
onMounted(() => {
|
|
144
|
+
void canTranscodeVideo().then((ok) => (supported.value = ok))
|
|
145
|
+
})
|
|
146
|
+
watch(() => props.file, load, { immediate: true })
|
|
147
|
+
onBeforeUnmount(() => {
|
|
148
|
+
controller?.abort()
|
|
149
|
+
if (url.value) URL.revokeObjectURL(url.value)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const chosen = computed(() => choices.value.find((p) => p.key === preset.value) ?? choices.value[0])
|
|
153
|
+
|
|
154
|
+
const options = computed<TranscodeVideoOptions>(() => ({
|
|
155
|
+
range: range.value,
|
|
156
|
+
maxHeight: chosen.value?.maxHeight ?? null,
|
|
157
|
+
targetBytes: chosen.value?.targetBytes ?? null,
|
|
158
|
+
videoBitrate: chosen.value?.videoBitrate ?? null,
|
|
159
|
+
}))
|
|
160
|
+
|
|
161
|
+
const estimate = computed(() => (info.value ? estimateTranscodedBytes(info.value, options.value) : null))
|
|
162
|
+
|
|
163
|
+
const trimmed = computed(() => {
|
|
164
|
+
const found = info.value
|
|
165
|
+
return !!found && clipLength(found.duration, range.value) < found.duration - 0.05
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const worthIt = computed(() => estimate.value !== null && (estimate.value < props.file.size * 0.95 || trimmed.value))
|
|
169
|
+
|
|
170
|
+
const estimateLine = computed(() => {
|
|
171
|
+
if (estimate.value === null) return text.value.probing
|
|
172
|
+
if (!worthIt.value) return text.value.bigger
|
|
173
|
+
return text.value.estimate.replace("{size}", size(estimate.value)).replace("{original}", size(props.file.size))
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const busy = computed(() => progress.value !== null)
|
|
177
|
+
watch(busy, (value) => emit("busy", value))
|
|
178
|
+
|
|
179
|
+
async function shrink(): Promise<void> {
|
|
180
|
+
if (busy.value || !info.value) return
|
|
181
|
+
controller = new AbortController()
|
|
182
|
+
progress.value = 0
|
|
183
|
+
error.value = ""
|
|
184
|
+
try {
|
|
185
|
+
const out = await transcodeVideo(props.file, {
|
|
186
|
+
...options.value,
|
|
187
|
+
onProgress: (fraction) => (progress.value = fraction),
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
})
|
|
190
|
+
emit("done", out, { range: range.value ?? [0, info.value.duration], preset: preset.value })
|
|
191
|
+
} catch (cause) {
|
|
192
|
+
if (!(cause instanceof DOMException && cause.name === "AbortError")) {
|
|
193
|
+
error.value = text.value.failed.replace("{error}", cause instanceof Error ? cause.message : String(cause))
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
progress.value = null
|
|
197
|
+
controller = null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function cancel(): void {
|
|
202
|
+
controller?.abort()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const percent = computed(() => Math.round((progress.value ?? 0) * 100))
|
|
206
|
+
</script>
|
|
207
|
+
|
|
208
|
+
<template>
|
|
209
|
+
<div class="flex flex-col gap-4" data-lp-video-compress>
|
|
210
|
+
<LpVideoTrim v-model:range="range" :src="url" :max-clip="maxClip" :disabled="disabled || busy" />
|
|
211
|
+
<div v-if="supported !== false" class="flex flex-col gap-2">
|
|
212
|
+
<span class="text-xs font-medium text-muted">{{ text.quality }}</span>
|
|
213
|
+
<LpSegmented
|
|
214
|
+
v-model="preset"
|
|
215
|
+
:options="choices.map((p) => ({ value: p.key, label: p.label }))"
|
|
216
|
+
:disabled="disabled || busy"
|
|
217
|
+
size="sm"
|
|
218
|
+
class="max-w-full overflow-x-auto"
|
|
219
|
+
/>
|
|
220
|
+
<p class="text-sm text-muted" aria-live="polite">{{ estimateLine }}</p>
|
|
221
|
+
</div>
|
|
222
|
+
<p v-else class="text-sm text-muted">{{ text.unsupported }}</p>
|
|
223
|
+
<LpCollapse :open="!!error">
|
|
224
|
+
<p class="text-sm text-danger" role="alert">{{ error }}</p>
|
|
225
|
+
</LpCollapse>
|
|
226
|
+
<LpCollapse :open="busy">
|
|
227
|
+
<div class="flex flex-col gap-1.5">
|
|
228
|
+
<LpProgress :value="percent" :max="100" />
|
|
229
|
+
<span class="text-xs text-muted" aria-live="polite">{{ text.encoding.replace("{percent}", String(percent)) }}</span>
|
|
230
|
+
</div>
|
|
231
|
+
</LpCollapse>
|
|
232
|
+
<div class="flex flex-wrap justify-end gap-2">
|
|
233
|
+
<LpButton v-if="busy" variant="ghost" @click="cancel">{{ text.cancel }}</LpButton>
|
|
234
|
+
<template v-else>
|
|
235
|
+
<LpButton variant="ghost" :disabled="disabled" @click="emit('skip')">{{ text.skip }}</LpButton>
|
|
236
|
+
<LpButton v-if="supported !== false" :disabled="disabled || !info || !worthIt" @click="shrink">{{ text.shrink }}</LpButton>
|
|
237
|
+
</template>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</template>
|
|
@@ -61,6 +61,37 @@ import { formatClipTime, widestClipTime } from "./videoBackdrop"
|
|
|
61
61
|
import { FrameSampler } from "./videoFrames"
|
|
62
62
|
import { FADE, FADE_SCALE, ICON_SWAP, OVERLAY_BUTTON } from "./videoMotion"
|
|
63
63
|
|
|
64
|
+
/** Wording of the video player. `{n}` is seconds, `{time}` the playhead's time. */
|
|
65
|
+
export type VideoPlayerLabels = {
|
|
66
|
+
player: string
|
|
67
|
+
play: string
|
|
68
|
+
pause: string
|
|
69
|
+
replay: string
|
|
70
|
+
mute: string
|
|
71
|
+
unmute: string
|
|
72
|
+
volume: string
|
|
73
|
+
speed: string
|
|
74
|
+
normalSpeed: string
|
|
75
|
+
fullscreen: string
|
|
76
|
+
exitFullscreen: string
|
|
77
|
+
pip: string
|
|
78
|
+
exitPip: string
|
|
79
|
+
captions: string
|
|
80
|
+
seek: string
|
|
81
|
+
failed: string
|
|
82
|
+
unsupported: string
|
|
83
|
+
back: string
|
|
84
|
+
forward: string
|
|
85
|
+
rewind: string
|
|
86
|
+
fastForward: string
|
|
87
|
+
menuAt: string
|
|
88
|
+
copyTime: string
|
|
89
|
+
copyFrame: string
|
|
90
|
+
copiedTime: string
|
|
91
|
+
copiedFrame: string
|
|
92
|
+
copyFailed: string
|
|
93
|
+
}
|
|
94
|
+
|
|
64
95
|
const props = withDefaults(
|
|
65
96
|
defineProps<{
|
|
66
97
|
src: string
|
|
@@ -89,12 +120,7 @@ const props = withDefaults(
|
|
|
89
120
|
fit?: boolean
|
|
90
121
|
/** More items for the right-click menu. */
|
|
91
122
|
menuItems?: () => ContextMenuItemDef[]
|
|
92
|
-
labels?: Partial<
|
|
93
|
-
| "player" | "play" | "pause" | "replay" | "mute" | "unmute" | "volume" | "speed" | "normalSpeed"
|
|
94
|
-
| "fullscreen" | "exitFullscreen" | "pip" | "exitPip" | "captions" | "seek" | "failed" | "unsupported"
|
|
95
|
-
| "back" | "forward" | "rewind" | "fastForward" | "menuAt" | "copyTime" | "copyFrame" | "copiedTime" | "copiedFrame" | "copyFailed",
|
|
96
|
-
string
|
|
97
|
-
>>
|
|
123
|
+
labels?: Partial<VideoPlayerLabels>
|
|
98
124
|
}>(),
|
|
99
125
|
{
|
|
100
126
|
poster: undefined,
|
|
@@ -115,7 +141,7 @@ const emit = defineEmits<{
|
|
|
115
141
|
(e: "error", error: { cause: VideoErrorCause; message: string }): void
|
|
116
142
|
}>()
|
|
117
143
|
|
|
118
|
-
const text = useLabels(
|
|
144
|
+
const text = useLabels<VideoPlayerLabels>(
|
|
119
145
|
"videoPlayer",
|
|
120
146
|
{
|
|
121
147
|
player: "Video player — Space plays, arrows seek, F fullscreen",
|
|
@@ -67,6 +67,44 @@ import {
|
|
|
67
67
|
type VideoTrimErrorCause,
|
|
68
68
|
} from "./videoTrim"
|
|
69
69
|
|
|
70
|
+
/** Wording of the video trimmer. `{left}`, `{max}` and `{time}` are times. */
|
|
71
|
+
export type VideoTrimLabels = {
|
|
72
|
+
play: string
|
|
73
|
+
pause: string
|
|
74
|
+
stepBack: string
|
|
75
|
+
stepForward: string
|
|
76
|
+
mute: string
|
|
77
|
+
unmute: string
|
|
78
|
+
zoomIn: string
|
|
79
|
+
zoomOut: string
|
|
80
|
+
zoomFit: string
|
|
81
|
+
start: string
|
|
82
|
+
end: string
|
|
83
|
+
position: string
|
|
84
|
+
length: string
|
|
85
|
+
left: string
|
|
86
|
+
overview: string
|
|
87
|
+
timeline: string
|
|
88
|
+
region: string
|
|
89
|
+
regionHint: string
|
|
90
|
+
regionClear: string
|
|
91
|
+
failed: string
|
|
92
|
+
unsupported: string
|
|
93
|
+
invalidTime: string
|
|
94
|
+
menuAt: string
|
|
95
|
+
setStart: string
|
|
96
|
+
setEnd: string
|
|
97
|
+
goStart: string
|
|
98
|
+
goEnd: string
|
|
99
|
+
zoomHere: string
|
|
100
|
+
volume: string
|
|
101
|
+
copyTime: string
|
|
102
|
+
copyFrame: string
|
|
103
|
+
copiedTime: string
|
|
104
|
+
copiedFrame: string
|
|
105
|
+
copyFailed: string
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
const props = withDefaults(
|
|
71
109
|
defineProps<{
|
|
72
110
|
src: string
|
|
@@ -86,14 +124,7 @@ const props = withDefaults(
|
|
|
86
124
|
/** More items for the right-click menu, given the time it was opened at (under the pointer on the timeline, the playhead elsewhere). */
|
|
87
125
|
menuItems?: (at: number) => ContextMenuItemDef[]
|
|
88
126
|
disabled?: boolean
|
|
89
|
-
labels?: Partial<
|
|
90
|
-
| "play" | "pause" | "stepBack" | "stepForward" | "mute" | "unmute" | "zoomIn" | "zoomOut" | "zoomFit"
|
|
91
|
-
| "start" | "end" | "position" | "length" | "left" | "overview" | "timeline"
|
|
92
|
-
| "region" | "regionHint" | "regionClear" | "failed" | "unsupported" | "invalidTime"
|
|
93
|
-
| "menuAt" | "setStart" | "setEnd" | "goStart" | "goEnd" | "zoomHere" | "volume"
|
|
94
|
-
| "copyTime" | "copyFrame" | "copiedTime" | "copiedFrame" | "copyFailed",
|
|
95
|
-
string
|
|
96
|
-
>>
|
|
127
|
+
labels?: Partial<VideoTrimLabels>
|
|
97
128
|
}>(),
|
|
98
129
|
{ poster: undefined, range: undefined, maxClip: undefined, minClip: 0.1, region: null, frameRate: 30, labels: () => ({}) },
|
|
99
130
|
)
|
|
@@ -106,7 +137,7 @@ const emit = defineEmits<{
|
|
|
106
137
|
(e: "error", error: { cause: VideoTrimErrorCause; message: string }): void
|
|
107
138
|
}>()
|
|
108
139
|
|
|
109
|
-
const text = useLabels(
|
|
140
|
+
const text = useLabels<VideoTrimLabels>(
|
|
110
141
|
"videoTrim",
|
|
111
142
|
{
|
|
112
143
|
play: "Play",
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
export default { inheritAttrs: false }
|
|
3
|
+
|
|
4
|
+
/** Wording of the window's controls. */
|
|
5
|
+
export type WindowLabels = {
|
|
6
|
+
close: string
|
|
7
|
+
move: string
|
|
8
|
+
}
|
|
3
9
|
</script>
|
|
4
10
|
|
|
5
11
|
<script setup lang="ts">
|
|
@@ -93,7 +99,7 @@ const props = withDefaults(
|
|
|
93
99
|
guard?: CloseGuard
|
|
94
100
|
/** Frosted glass instead of a solid panel. Unset follows `provideGlass`. */
|
|
95
101
|
glass?: boolean
|
|
96
|
-
labels?: Partial<
|
|
102
|
+
labels?: Partial<WindowLabels>
|
|
97
103
|
}>(),
|
|
98
104
|
{
|
|
99
105
|
minWidth: 240,
|
|
@@ -113,7 +119,7 @@ const emit = defineEmits<{
|
|
|
113
119
|
(e: "change", value: { rect: WindowRect; dock: WindowDock | null }): void
|
|
114
120
|
}>()
|
|
115
121
|
|
|
116
|
-
const text = useLabels("window", { close: "Close", move: "Move window" }, () => props.labels)
|
|
122
|
+
const text = useLabels<WindowLabels>("window", { close: "Close", move: "Move window" }, () => props.labels)
|
|
117
123
|
const frosted = useGlass(() => props.glass)
|
|
118
124
|
const portalTo = usePortalTarget()
|
|
119
125
|
const view = useView()
|
|
@@ -18,22 +18,25 @@ import LpDropdownMenu, { type MenuItem } from "./LpDropdownMenu.vue"
|
|
|
18
18
|
import LpInput from "./LpInput.vue"
|
|
19
19
|
import LpModal from "./LpModal.vue"
|
|
20
20
|
|
|
21
|
+
/** Wording of the workspace switcher. */
|
|
22
|
+
export type WorkspaceSwitcherLabels = {
|
|
23
|
+
menu: string
|
|
24
|
+
create: string
|
|
25
|
+
duplicate: string
|
|
26
|
+
rename: string
|
|
27
|
+
remove: string
|
|
28
|
+
name: string
|
|
29
|
+
save: string
|
|
30
|
+
cancel: string
|
|
31
|
+
required: string
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
const props = defineProps<{
|
|
22
35
|
workspaces: UseWorkspaces
|
|
23
|
-
labels?: Partial<
|
|
24
|
-
menu: string
|
|
25
|
-
create: string
|
|
26
|
-
duplicate: string
|
|
27
|
-
rename: string
|
|
28
|
-
remove: string
|
|
29
|
-
name: string
|
|
30
|
-
save: string
|
|
31
|
-
cancel: string
|
|
32
|
-
required: string
|
|
33
|
-
}>
|
|
36
|
+
labels?: Partial<WorkspaceSwitcherLabels>
|
|
34
37
|
}>()
|
|
35
38
|
|
|
36
|
-
const text = useLabels(
|
|
39
|
+
const text = useLabels<WorkspaceSwitcherLabels>(
|
|
37
40
|
"workspaces",
|
|
38
41
|
{
|
|
39
42
|
menu: "Workspaces",
|
package/src/components/status.ts
CHANGED
|
@@ -35,7 +35,10 @@ export const STATUS_LOOK: Record<StatusKind, StatusLook> = {
|
|
|
35
35
|
unknown: { tone: "neutral", pulse: false },
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/** A name for each status, as LpStatus shows and announces it. */
|
|
39
|
+
export type StatusLabels = Record<StatusKind, string>
|
|
40
|
+
|
|
41
|
+
export const STATUS_LABELS: StatusLabels = {
|
|
39
42
|
online: "Online",
|
|
40
43
|
offline: "Offline",
|
|
41
44
|
degraded: "Degraded",
|
|
@@ -5,6 +5,11 @@ import type { ContextMenuItemDef } from "../components/LpContextMenu.vue"
|
|
|
5
5
|
import { provideField, useField } from "./useField"
|
|
6
6
|
import { useLabels } from "./useLpConfig"
|
|
7
7
|
|
|
8
|
+
/** Wording of the palette command a form control registers. */
|
|
9
|
+
export type PaletteControlLabels = {
|
|
10
|
+
focus: string
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
export interface PaletteRegistration {
|
|
9
14
|
commands: () => readonly Command[]
|
|
10
15
|
priority: number
|
|
@@ -185,7 +190,7 @@ export function usePaletteControl(options: {
|
|
|
185
190
|
const blocked = inject(BLOCKED, false)
|
|
186
191
|
if (options.ownsChildren) suppressPaletteDiscovery()
|
|
187
192
|
if (!instance || !scope || blocked) return
|
|
188
|
-
const text = useLabels("paletteControl", { focus: "Go to field" })
|
|
193
|
+
const text = useLabels<PaletteControlLabels>("paletteControl", { focus: "Go to field" })
|
|
189
194
|
const field = useField()
|
|
190
195
|
const settings = () => (instance.attrs.palette ?? instance.props.palette) as PaletteControlOptions | undefined
|
|
191
196
|
const label = () => (settings() || {}).label ?? options.label?.() ?? field?.value.label
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Re-encoding a video in the browser before it is uploaded: cut to a range,
|
|
3
|
+
* scale down, squeeze to a bitrate or a target size. WebCodecs does the work
|
|
4
|
+
* (hardware encoders where the device has them) through mediabunny, which is
|
|
5
|
+
* loaded only when a video is actually probed or encoded, so pages that never
|
|
6
|
+
* touch a video don't download it.
|
|
7
|
+
*/
|
|
8
|
+
import type { TimeRange } from "../components/videoTrim"
|
|
9
|
+
|
|
10
|
+
export interface VideoInfo {
|
|
11
|
+
/** Seconds. */
|
|
12
|
+
duration: number
|
|
13
|
+
/** Display size, after rotation. */
|
|
14
|
+
width: number
|
|
15
|
+
height: number
|
|
16
|
+
/** Bytes of the file as given. */
|
|
17
|
+
size: number
|
|
18
|
+
hasAudio: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TranscodeVideoOptions {
|
|
22
|
+
/** `[start, end]` in seconds; the whole video when unset. */
|
|
23
|
+
range?: TimeRange | null
|
|
24
|
+
/** Scale down to this height when the video is taller; never scales up. */
|
|
25
|
+
maxHeight?: number | null
|
|
26
|
+
/** Aim the result at this many bytes; the video bitrate is derived from it. */
|
|
27
|
+
targetBytes?: number | null
|
|
28
|
+
/** Video bitrate in bits per second; ignored when `targetBytes` is set. */
|
|
29
|
+
videoBitrate?: number | null
|
|
30
|
+
/** 0..1 as encoding goes. */
|
|
31
|
+
onProgress?: (fraction: number) => void
|
|
32
|
+
/** Aborting stops the encoders and rejects with an `AbortError`. */
|
|
33
|
+
signal?: AbortSignal
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Audio rate the estimate and the encoder share. */
|
|
37
|
+
export const TRANSCODE_AUDIO_BITRATE = 96_000
|
|
38
|
+
const MIN_VIDEO_BITRATE = 250_000
|
|
39
|
+
/* Container and key-frame overhead, kept off a target so the result lands under it. */
|
|
40
|
+
const TARGET_HEADROOM = 0.92
|
|
41
|
+
|
|
42
|
+
const loadLib = () => import("mediabunny")
|
|
43
|
+
|
|
44
|
+
/** A sensible video bitrate for a height, bits per second. */
|
|
45
|
+
export function bitrateForHeight(height: number): number {
|
|
46
|
+
if (height <= 480) return 1_200_000
|
|
47
|
+
if (height <= 720) return 2_500_000
|
|
48
|
+
if (height <= 1080) return 5_000_000
|
|
49
|
+
return 8_000_000
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The height the output will have: the source's, or `maxHeight` when the source is taller, always even. */
|
|
53
|
+
export function outputHeight(sourceHeight: number, maxHeight?: number | null): number {
|
|
54
|
+
const height = maxHeight && sourceHeight > maxHeight ? maxHeight : sourceHeight
|
|
55
|
+
return Math.max(2, Math.round(height / 2) * 2)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Length of the clip that will be encoded, seconds. */
|
|
59
|
+
export function clipLength(duration: number, range?: TimeRange | null): number {
|
|
60
|
+
if (!range) return duration
|
|
61
|
+
const start = Math.max(0, Math.min(range[0], range[1]))
|
|
62
|
+
const end = Math.min(duration, Math.max(range[0], range[1]))
|
|
63
|
+
return Math.max(0, end - start)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Planned = Pick<VideoInfo, "duration" | "height" | "hasAudio"> & Partial<Pick<VideoInfo, "size">>
|
|
67
|
+
|
|
68
|
+
/** The source's own video bitrate, from its size and length; null when unknown. */
|
|
69
|
+
function sourceBitrate(info: Planned): number | null {
|
|
70
|
+
if (!info.size || info.duration <= 0) return null
|
|
71
|
+
const audio = info.hasAudio ? TRANSCODE_AUDIO_BITRATE : 0
|
|
72
|
+
return Math.max(MIN_VIDEO_BITRATE, Math.floor((info.size * 8) / info.duration - audio))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The video bitrate an encode will use, bits per second. Never above the
|
|
77
|
+
* source's own: re-encoding adds no detail, only bytes.
|
|
78
|
+
*/
|
|
79
|
+
export function plannedVideoBitrate(info: Planned, options: TranscodeVideoOptions = {}): number {
|
|
80
|
+
const length = clipLength(info.duration, options.range)
|
|
81
|
+
const ceiling = sourceBitrate(info) ?? Infinity
|
|
82
|
+
if (options.targetBytes && length > 0) {
|
|
83
|
+
const audio = info.hasAudio ? TRANSCODE_AUDIO_BITRATE : 0
|
|
84
|
+
return Math.min(ceiling, Math.max(MIN_VIDEO_BITRATE, Math.floor((options.targetBytes * 8 * TARGET_HEADROOM) / length - audio)))
|
|
85
|
+
}
|
|
86
|
+
return Math.min(ceiling, options.videoBitrate ?? bitrateForHeight(outputHeight(info.height, options.maxHeight)))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Roughly how many bytes an encode with these options will produce. */
|
|
90
|
+
export function estimateTranscodedBytes(info: Planned, options: TranscodeVideoOptions = {}): number {
|
|
91
|
+
const length = clipLength(info.duration, options.range)
|
|
92
|
+
const audio = info.hasAudio ? TRANSCODE_AUDIO_BITRATE : 0
|
|
93
|
+
return Math.round(((plannedVideoBitrate(info, options) + audio) * length) / 8)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether this browser can encode video at all (WebCodecs with an H.264, VP9 or AV1 encoder). */
|
|
97
|
+
export async function canTranscodeVideo(): Promise<boolean> {
|
|
98
|
+
if (typeof globalThis.VideoEncoder === "undefined" || typeof globalThis.VideoDecoder === "undefined") return false
|
|
99
|
+
try {
|
|
100
|
+
const lib = await loadLib()
|
|
101
|
+
return (await lib.getFirstEncodableVideoCodec(["avc", "vp9", "av1"])) !== null
|
|
102
|
+
} catch {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Length, size and whether there is sound, read from the file's headers without decoding it. */
|
|
108
|
+
export async function probeVideo(file: Blob): Promise<VideoInfo> {
|
|
109
|
+
const lib = await loadLib()
|
|
110
|
+
const input = new lib.Input({ source: new lib.BlobSource(file), formats: lib.ALL_FORMATS })
|
|
111
|
+
try {
|
|
112
|
+
const video = await input.getPrimaryVideoTrack()
|
|
113
|
+
if (!video) throw new Error("The file has no video track")
|
|
114
|
+
const [duration, width, height, audio] = await Promise.all([
|
|
115
|
+
input.computeDuration(),
|
|
116
|
+
video.getDisplayWidth(),
|
|
117
|
+
video.getDisplayHeight(),
|
|
118
|
+
input.getPrimaryAudioTrack(),
|
|
119
|
+
])
|
|
120
|
+
return { duration, width, height, size: file.size, hasAudio: audio !== null }
|
|
121
|
+
} finally {
|
|
122
|
+
input.dispose()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function abortError(): DOMException {
|
|
127
|
+
return new DOMException("The video encode was aborted", "AbortError")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The file name with an `.mp4` extension, which is what comes out. */
|
|
131
|
+
export function transcodedName(name: string): string {
|
|
132
|
+
const dot = name.lastIndexOf(".")
|
|
133
|
+
return `${dot > 0 ? name.slice(0, dot) : name || "video"}.mp4`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* An MP4 with the range cut out, scaled down to `maxHeight` and encoded at the
|
|
138
|
+
* planned bitrate. Sound is kept and re-encoded (AAC, or Opus where the
|
|
139
|
+
* browser has no AAC encoder); a browser that can encode neither the picture
|
|
140
|
+
* nor the sound rejects instead of returning a silent or broken file.
|
|
141
|
+
*/
|
|
142
|
+
export async function transcodeVideo(file: File, options: TranscodeVideoOptions = {}): Promise<File> {
|
|
143
|
+
if (options.signal?.aborted) throw abortError()
|
|
144
|
+
const lib = await loadLib()
|
|
145
|
+
const info = await probeVideo(file)
|
|
146
|
+
const height = outputHeight(info.height, options.maxHeight)
|
|
147
|
+
const width = Math.max(2, Math.round((info.width * height) / info.height / 2) * 2)
|
|
148
|
+
const bitrate = plannedVideoBitrate(info, options)
|
|
149
|
+
const videoCodec = await lib.getFirstEncodableVideoCodec(["avc", "vp9", "av1"], { width, height, quality: new lib.Quality({ bitrate }) })
|
|
150
|
+
if (!videoCodec) throw new Error("This browser can't encode video of this size")
|
|
151
|
+
const audioCodec = info.hasAudio ? await lib.getFirstEncodableAudioCodec(["aac", "opus"], { quality: new lib.Quality({ bitrate: TRANSCODE_AUDIO_BITRATE }) }) : null
|
|
152
|
+
if (info.hasAudio && !audioCodec) throw new Error("This browser can't encode this video's sound")
|
|
153
|
+
|
|
154
|
+
const input = new lib.Input({ source: new lib.BlobSource(file), formats: lib.ALL_FORMATS })
|
|
155
|
+
const target = new lib.BufferTarget()
|
|
156
|
+
const output = new lib.Output({ format: new lib.Mp4OutputFormat({ fastStart: "in-memory" }), target })
|
|
157
|
+
try {
|
|
158
|
+
const range = options.range ?? null
|
|
159
|
+
const conversion = await lib.Conversion.init({
|
|
160
|
+
input,
|
|
161
|
+
output,
|
|
162
|
+
trim: range ? { start: Math.min(range[0], range[1]), end: Math.max(range[0], range[1]) } : undefined,
|
|
163
|
+
video: {
|
|
164
|
+
height: height === info.height ? undefined : height,
|
|
165
|
+
codec: videoCodec,
|
|
166
|
+
quality: new lib.Quality({ bitrate }),
|
|
167
|
+
forceTranscode: true,
|
|
168
|
+
},
|
|
169
|
+
audio: audioCodec ? { codec: audioCodec, quality: new lib.Quality({ bitrate: TRANSCODE_AUDIO_BITRATE }) } : { discard: true },
|
|
170
|
+
})
|
|
171
|
+
if (!conversion.isValid) {
|
|
172
|
+
const reasons = conversion.discardedTracks.map((t) => t.reason).join(", ")
|
|
173
|
+
throw new Error(`This video can't be re-encoded here (${reasons || "unknown reason"})`)
|
|
174
|
+
}
|
|
175
|
+
const onAbort = () => void conversion.cancel()
|
|
176
|
+
options.signal?.addEventListener("abort", onAbort, { once: true })
|
|
177
|
+
conversion.onProgress = (progress) => options.onProgress?.(progress)
|
|
178
|
+
try {
|
|
179
|
+
await conversion.execute()
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (options.signal?.aborted) throw abortError()
|
|
182
|
+
throw error
|
|
183
|
+
} finally {
|
|
184
|
+
options.signal?.removeEventListener("abort", onAbort)
|
|
185
|
+
}
|
|
186
|
+
if (!target.buffer) throw new Error("The encoder produced no file")
|
|
187
|
+
options.onProgress?.(1)
|
|
188
|
+
return new File([target.buffer], transcodedName(file.name), { type: "video/mp4", lastModified: Date.now() })
|
|
189
|
+
} finally {
|
|
190
|
+
input.dispose()
|
|
191
|
+
}
|
|
192
|
+
}
|