@leavepulse/ui 0.80.1 → 0.82.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/dist/assets/{LpCommandPalette-C1LRFzbN.css → LpCommandPalette-DCZB4EAf.css} +19 -19
- package/dist/assets/LpVideoTrim-Ci6IIHW0.css +8 -0
- package/dist/chunks/{ContextMenuItems-DLK4q-mm.js → ContextMenuItems-kRVTuSZd.js} +1 -1
- package/dist/chunks/{DropzoneLinkField-CcJu8De3.js → DropzoneLinkField-CvMUu8HQ.js} +1 -1
- package/dist/chunks/{FieldLeading-CldGLINn.js → FieldLeading-CNN_5Dp1.js} +1 -1
- package/dist/chunks/{FieldWrapper-Cj8ZgoKp.js → FieldWrapper-BBJPEVHr.js} +1 -1
- package/dist/chunks/{IconCloseButton-CV4iHRpy.js → IconCloseButton-C59_etp2.js} +1 -1
- package/dist/chunks/{LayoutCanvas-BQbq7Ekw.js → LayoutCanvas-BIY74SUe.js} +1 -1
- package/dist/chunks/{LayoutNode-BMedRk3g.js → LayoutNode-CTlKlhb-.js} +1 -1
- package/dist/chunks/{LpActionBar-C7g52Cyo.js → LpActionBar-Cowb-28D.js} +1 -1
- package/dist/chunks/{LpAddressInput-1uakhQk5.js → LpAddressInput-nTJlUQ6K.js} +7 -7
- package/dist/chunks/{LpAlert-DWFeR82L.js → LpAlert-DhZdjekN.js} +1 -1
- package/dist/chunks/{LpAppShell-InIK01Nn.js → LpAppShell-CK9hTgQ1.js} +5 -5
- package/dist/chunks/{LpAutocomplete-DY3LvpB9.js → LpAutocomplete--PaZkQoU.js} +4 -4
- package/dist/chunks/{LpAvatar-CEZmMcGh.js → LpAvatar-DVCld_L2.js} +1 -1
- package/dist/chunks/{LpBadge-C_VqLHtg.js → LpBadge-DXvpxk6z.js} +1 -1
- package/dist/chunks/{LpBottomNav-4I89sPDZ.js → LpBottomNav-COQiBNUN.js} +1 -1
- package/dist/chunks/{LpBreadcrumbs-1BBzrfxG.js → LpBreadcrumbs-Dt1O5G4O.js} +1 -1
- package/dist/chunks/{LpCalendar-D2dcXjUK.js → LpCalendar-C8Xlukd2.js} +1 -1
- package/dist/chunks/{LpCheckbox-CN-MEnbX.js → LpCheckbox-Dpybd3oV.js} +2 -2
- package/dist/chunks/{LpCodeBlock-Cj7zk4QE.js → LpCodeBlock-C1Hu5RyT.js} +2 -2
- package/dist/chunks/{LpCodeDisplay-COzosm77.js → LpCodeDisplay-BVruT3-A.js} +4 -4
- package/dist/chunks/{LpColorPicker-BezW6DX1.js → LpColorPicker-BK-NRaGK.js} +3 -3
- package/dist/chunks/{LpConfirmDialog-D9GkDMYr.js → LpConfirmDialog-2CQFKtqU.js} +2 -2
- package/dist/chunks/{LpConfirmHost-BH7kf0dm.js → LpConfirmHost-CTkSytVl.js} +1 -1
- package/dist/chunks/{LpContextMenu-bn5bc9t7.js → LpContextMenu-DcHPdW-t.js} +2 -2
- package/dist/chunks/{LpContextSwitcher-CGr6ZAuq.js → LpContextSwitcher-C_nnDbeI.js} +2 -2
- package/dist/chunks/{LpCopyButton-BSr6YaFR.js → LpCopyButton-De7_elaO.js} +1 -1
- package/dist/chunks/{LpDataList-Cv7ZUlyy.js → LpDataList-BjZPfQjO.js} +2 -2
- package/dist/chunks/{LpDatePicker-CR7vsb5M.js → LpDatePicker-wOrHd0fg.js} +5 -5
- package/dist/chunks/{LpDateRangePicker-B9fRO3G6.js → LpDateRangePicker-CVMvvo5t.js} +3 -3
- package/dist/chunks/{LpDisclosure-CE4mneaE.js → LpDisclosure-CtQ63LeO.js} +1 -1
- package/dist/chunks/{LpDrawer-DU_5MqBz.js → LpDrawer-TZBPzyOM.js} +1 -1
- package/dist/chunks/{LpDropdownMenu-_u6GPWhp.js → LpDropdownMenu-DL9LgkS8.js} +2 -2
- package/dist/chunks/{LpEditable-DYoMayjt.js → LpEditable-DuPE2tkl.js} +2 -2
- package/dist/chunks/{LpEmptyState-DpdHeSfz.js → LpEmptyState-W1U_tKg-.js} +1 -1
- package/dist/chunks/{LpFieldNotice-DONFCWV7.js → LpFieldNotice-BLKlYLwu.js} +1 -1
- package/dist/chunks/{LpFileTree-f5ktGqsN.js → LpFileTree-CEiRIpj1.js} +4 -4
- package/dist/chunks/{LpFileTreeNode-nbp-JwV9.js → LpFileTreeNode-C0292AU2.js} +4 -4
- package/dist/chunks/{LpFormField-Bz50_LZW.js → LpFormField-Bhgi0idH.js} +1 -1
- package/dist/chunks/{LpHintLayer-B6bA5Cb_.js → LpHintLayer-D8ZKsvsT.js} +1 -1
- package/dist/chunks/{LpHints-DyJ5hVcr.js → LpHints-DbIV-wk4.js} +3 -3
- package/dist/chunks/{LpIcon-BCQLuHSP.js → LpIcon-CytpcLmS.js} +1 -1
- package/dist/chunks/{LpInfraNode-BS1RXYAs.js → LpInfraNode-8cGoY87Q.js} +2 -2
- package/dist/chunks/{LpInput-BjbXdjcW.js → LpInput-P2C12X7s.js} +3 -3
- package/dist/chunks/{LpInspector-BIyJyM6r.js → LpInspector-DjfMa4tD.js} +4 -4
- package/dist/chunks/{LpLightbox-CSAJuUaJ.js → LpLightbox-BEQtCz0m.js} +2 -2
- package/dist/chunks/{LpLogInspector-OJEz1vod.js → LpLogInspector-CyxK1d-V.js} +2 -2
- package/dist/chunks/{LpLogViewer-CRLwQpk0.js → LpLogViewer-CZrc_HG7.js} +6 -6
- package/dist/chunks/{LpMapPicker-DW2EnrCJ.js → LpMapPicker-CbTfFcEu.js} +1 -1
- package/dist/chunks/{LpMediaGallery-BNEYctDP.js → LpMediaGallery-C_SwNR4y.js} +3 -3
- package/dist/chunks/{LpModal-DdTqdf4x.js → LpModal-B6Yt0G7E.js} +1 -1
- package/dist/chunks/{LpModalNav-BL7Dp0om.js → LpModalNav-CMYWEiUs.js} +3 -3
- package/dist/chunks/{LpNotificationBell-d0F6MWxD.js → LpNotificationBell-BJKPx0X1.js} +4 -4
- package/dist/chunks/{LpNotificationFeed-BIe5lapb.js → LpNotificationFeed-orWISVez.js} +4 -4
- package/dist/chunks/{LpNumberField-D5Dx-hzD.js → LpNumberField-C5MeLsoE.js} +3 -3
- package/dist/chunks/{LpOtpInput-BVl74Lfl.js → LpOtpInput-6SCQ64uK.js} +1 -1
- package/dist/chunks/{LpPageScrollbar-D2O0xhJw.js → LpPageScrollbar-C9sTeU-2.js} +3 -8
- package/dist/chunks/{LpPagination-D0wb-Uru.js → LpPagination-CkwbdFa9.js} +1 -1
- package/dist/chunks/{LpPaletteTheme-DdilCBXA.js → LpPaletteTheme-RWgbWFOz.js} +2 -2
- package/dist/chunks/{LpPasswordInput-DUtqWMkA.js → LpPasswordInput-DJBRo5lt.js} +2 -2
- package/dist/chunks/{LpPhoneInput-CalRjkqc.js → LpPhoneInput-DcmcJwos.js} +3 -3
- package/dist/chunks/{LpPie-BVAEmVBT.js → LpPie-DmMlhgo2.js} +2 -2
- package/dist/chunks/{LpPipeline-Dk7_sRP1.js → LpPipeline-BXcp2shV.js} +2 -2
- package/dist/chunks/{LpQuota-DKx0IZYA.js → LpQuota-CtEiqRg4.js} +1 -1
- package/dist/chunks/{LpRadioGroup-E3SP6j9h.js → LpRadioGroup-Ubkk_eSq.js} +2 -2
- package/dist/chunks/{LpRangeCalendar-BFJFaen4.js → LpRangeCalendar-DCswVYbU.js} +1 -1
- package/dist/chunks/{LpRangeSlider-b6ipywRB.js → LpRangeSlider-DM1qy5Tp.js} +1 -1
- package/dist/chunks/{LpRepeater-BLrw62Bh.js → LpRepeater-BMo2Eabn.js} +2 -2
- package/dist/chunks/{LpSecretField-BV8BNjKc.js → LpSecretField-SSRGVqRc.js} +5 -5
- package/dist/chunks/{LpSection-CghNNxIv.js → LpSection-C3bTxD7t.js} +1 -1
- package/dist/chunks/{LpSegmented-DjvrHL_G.js → LpSegmented-C1DF6m6A.js} +2 -2
- package/dist/chunks/{LpSelect-DYPimzAZ.js → LpSelect-DvDr1VPn.js} +4 -4
- package/dist/chunks/{LpSidebar-D3S8rYFf.js → LpSidebar-Bql881r6.js} +4 -4
- package/dist/chunks/{LpSidebarNav-Dlse8vxT.js → LpSidebarNav-CKx2KJvr.js} +3 -3
- package/dist/chunks/{LpSidebarRow-Cqd_6tGG.js → LpSidebarRow-BMOL9kOG.js} +3 -3
- package/dist/chunks/{LpSlider-B9XIC75O.js → LpSlider-DsSMe4b5.js} +1 -1
- package/dist/chunks/{LpStat-kyLRmX-m.js → LpStat-CQYYDMS6.js} +2 -2
- package/dist/chunks/{LpStatus-szB2dWzM.js → LpStatus-g5nSJfU3.js} +1 -1
- package/dist/chunks/{LpStepMark-DP3CDSm9.js → LpStepMark-seVNOuNJ.js} +1 -1
- package/dist/chunks/{LpStepper-BS3iaJIz.js → LpStepper-D6qEXihZ.js} +1 -1
- package/dist/chunks/{LpSwitch-DRFy8RNr.js → LpSwitch-DM5CVm31.js} +1 -1
- package/dist/chunks/{LpTabFace-C9-OperL.js → LpTabFace-ZWwelqRU.js} +2 -2
- package/dist/chunks/{LpTableFilter-CEIwLupk.js → LpTableFilter-CWU9Dmk-.js} +2 -2
- package/dist/chunks/{LpTagsInput-ClDeF_aU.js → LpTagsInput-C2qVF2ax.js} +5 -5
- package/dist/chunks/{LpTextarea-DEY6pUBy.js → LpTextarea-C3EoV_xv.js} +1 -1
- package/dist/chunks/{LpThemeStudio-WY72Z1Db.js → LpThemeStudio-BIBahaIs.js} +5 -5
- package/dist/chunks/{LpThemeSwitcher-9uCH9c-K.js → LpThemeSwitcher-D4MqFLGS.js} +3 -3
- package/dist/chunks/{LpTimeField-BmgxCzQO.js → LpTimeField-DPOkbvyP.js} +2 -2
- package/dist/chunks/{LpToaster-DLKO-efn.js → LpToaster-Dj1VKxFv.js} +2 -2
- package/dist/chunks/{LpTopologyCanvas-B-PhPa7k.js → LpTopologyCanvas-Bspzwm7x.js} +4 -4
- package/dist/chunks/{LpTour-ODlBcat2.js → LpTour-CwpVdVN-.js} +1 -1
- package/dist/chunks/{LpTruncate--nhT0iUo.js → LpTruncate-JSaIW25q.js} +1 -1
- package/dist/chunks/{LpWindow-DJxE8Zl-.js → LpWindow-RVcP436Z.js} +2 -2
- package/dist/chunks/{LpWorkspaceSwitcher-Bpf41Bw5.js → LpWorkspaceSwitcher-D0WOz1Fg.js} +3 -3
- package/dist/chunks/{MenuTitle-CMjvLsvC.js → MenuTitle-D8Umab23.js} +1 -1
- package/dist/chunks/{OptionContent-DY8NaG8s.js → OptionContent-DCnX_KRK.js} +1 -1
- package/dist/chunks/{PickerField-CgfkaBgg.js → PickerField-ChjY27bm.js} +1 -1
- package/dist/chunks/{RevealToggle-CPMXxaKv.js → RevealToggle-MaL7PEjr.js} +1 -1
- package/dist/chunks/{TourCard-D4b3lMHi.js → TourCard-oXZSpHKb.js} +1 -1
- package/dist/chunks/{icons-C1vppR4f.js → icons-BOdqDUii.js} +1 -1
- 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/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.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- 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.js +1 -1
- package/dist/components/LpBottomNav.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- 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.js +1 -1
- package/dist/components/LpCodeDisplay.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +6 -5
- 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.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.js +1 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.js +2 -2
- 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/LpHintLayer.vue.js +1 -1
- package/dist/components/LpHints.vue.js +1 -1
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +1 -1
- package/dist/components/LpInfraNode.vue.js +1 -1
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpInspector.vue.js +1 -1
- 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.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- 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/LpPageScrollbar.vue.js +1 -1
- 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.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.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.js +1 -1
- 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/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/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.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/components/LpTour.vue.js +1 -1
- package/dist/components/LpTruncate.vue.js +1 -1
- package/dist/components/LpVideoTrim.vue.d.ts +68 -0
- package/dist/components/LpVideoTrim.vue.js +955 -0
- package/dist/components/LpWindow.vue.js +1 -1
- 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/chartWindow.js +4 -8
- package/dist/components/menuWhen.js +1 -1
- package/dist/components/videoFrames.d.ts +37 -0
- package/dist/components/videoFrames.js +134 -0
- package/dist/components/videoTrim.d.ts +75 -0
- package/dist/components/videoTrim.js +127 -0
- package/dist/composables/overlayHistory.js +16 -2
- package/dist/composables/span.d.ts +10 -0
- package/dist/composables/span.js +15 -0
- package/dist/icons-all.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +96 -89
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/LpCommandPalette.vue +5 -0
- package/src/components/LpPageScrollbar.vue +13 -11
- package/src/components/LpVideoTrim.vue +924 -0
- package/src/components/chartWindow.ts +4 -8
- package/src/components/videoFrames.ts +171 -0
- package/src/components/videoTrim.ts +198 -0
- package/src/composables/overlayHistory.ts +29 -2
- package/src/composables/span.ts +21 -0
- package/src/index.ts +3 -0
- package/src/kit-icons.ts +1 -1
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export default { inheritAttrs: false }
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
/*
|
|
7
|
+
* Pick a stretch of a video, and optionally a rectangle on its picture, for a
|
|
8
|
+
* server to cut (ffmpeg) or blur. Nothing is cut here: the component reports
|
|
9
|
+
* `[start, end]` in seconds and a region in fractions of the frame.
|
|
10
|
+
*
|
|
11
|
+
* <LpVideoTrim v-model:range="range" :src="url" :max-clip="60" @change="save" />
|
|
12
|
+
*
|
|
13
|
+
* - The video plays from its address the way a <video> does: by range
|
|
14
|
+
* requests, never downloaded whole, so an hour or a gigabyte costs what is
|
|
15
|
+
* watched. A same-origin address keeps the session cookie (no `crossorigin`
|
|
16
|
+
* is set: it would drop it), and the timeline's stills need no CORS either.
|
|
17
|
+
* - The timeline zooms (wheel, pinch, the buttons) down to a few seconds
|
|
18
|
+
* across, so an hour-long recording still trims to the frame; when zoomed, a
|
|
19
|
+
* thin overview under it shows and moves the visible part.
|
|
20
|
+
* - Keys, wherever focus is inside: Space plays, ←/→ step a frame (Shift: a
|
|
21
|
+
* second), I and O set the start and end at the playhead. On a focused
|
|
22
|
+
* handle the arrows move that handle.
|
|
23
|
+
* - `update:range` follows every move; `change` fires once a move is done (a
|
|
24
|
+
* handle let go, a key, a typed time), for the save.
|
|
25
|
+
* - `maxClip` and `minClip` bound the length: a dragged handle stops at the
|
|
26
|
+
* limit, while I, O and a typed time move the other end along.
|
|
27
|
+
*/
|
|
28
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
|
|
29
|
+
import { startPointerDrag } from "../composables/pointerDrag"
|
|
30
|
+
import { isHTMLElement } from "../composables/realm"
|
|
31
|
+
import { useLabels } from "../composables/useLpConfig"
|
|
32
|
+
import { useMergedAttrs } from "../composables/useMergedClass"
|
|
33
|
+
import { useResizeObserver } from "../composables/useResizeObserver"
|
|
34
|
+
import { usePrefersReducedMotion } from "../composables/useReducedMotion"
|
|
35
|
+
import LpButton from "./LpButton.vue"
|
|
36
|
+
import LpIcon from "./LpIcon.vue"
|
|
37
|
+
import LpInput from "./LpInput.vue"
|
|
38
|
+
import { formatClipTime } from "./videoBackdrop"
|
|
39
|
+
import { FrameSampler, stillSpan } from "./videoFrames"
|
|
40
|
+
import {
|
|
41
|
+
adjustRegion,
|
|
42
|
+
containRect,
|
|
43
|
+
dragEnd,
|
|
44
|
+
mediaErrorCause,
|
|
45
|
+
normalizeRange,
|
|
46
|
+
parseClipTime,
|
|
47
|
+
regionBetween,
|
|
48
|
+
revealTime,
|
|
49
|
+
setEnd,
|
|
50
|
+
shiftView,
|
|
51
|
+
tickStep,
|
|
52
|
+
ticks,
|
|
53
|
+
zoomView,
|
|
54
|
+
type FrameRegion,
|
|
55
|
+
type RegionHandle,
|
|
56
|
+
type TimeRange,
|
|
57
|
+
type TimeView,
|
|
58
|
+
type VideoTrimErrorCause,
|
|
59
|
+
} from "./videoTrim"
|
|
60
|
+
|
|
61
|
+
const props = withDefaults(
|
|
62
|
+
defineProps<{
|
|
63
|
+
src: string
|
|
64
|
+
poster?: string
|
|
65
|
+
/** `[start, end]` in seconds; the whole video (or the first `maxClip`) when unset. */
|
|
66
|
+
range?: TimeRange | null
|
|
67
|
+
/** The longest clip allowed, in seconds (a chat's upload limit). */
|
|
68
|
+
maxClip?: number
|
|
69
|
+
/** The shortest clip allowed, in seconds. */
|
|
70
|
+
minClip?: number
|
|
71
|
+
/** Lets a rectangle be drawn on the picture (`v-model:region`), for a part to blur. */
|
|
72
|
+
regionSelect?: boolean
|
|
73
|
+
/** The marked rectangle, fractions of the frame (0..1); null when none. */
|
|
74
|
+
region?: FrameRegion | null
|
|
75
|
+
/** Frames per second, for the size of a frame step. */
|
|
76
|
+
frameRate?: number
|
|
77
|
+
disabled?: boolean
|
|
78
|
+
labels?: Partial<Record<
|
|
79
|
+
| "play" | "pause" | "stepBack" | "stepForward" | "mute" | "unmute" | "zoomIn" | "zoomOut" | "zoomFit"
|
|
80
|
+
| "start" | "end" | "position" | "length" | "left" | "overview" | "timeline"
|
|
81
|
+
| "region" | "regionHint" | "regionClear" | "failed" | "unsupported" | "invalidTime",
|
|
82
|
+
string
|
|
83
|
+
>>
|
|
84
|
+
}>(),
|
|
85
|
+
{ poster: undefined, range: undefined, maxClip: undefined, minClip: 0.1, region: null, frameRate: 30, labels: () => ({}) },
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const emit = defineEmits<{
|
|
89
|
+
(e: "update:range", value: TimeRange): void
|
|
90
|
+
/** The range after a finished move: a handle let go, a key, a typed time. */
|
|
91
|
+
(e: "change", value: TimeRange): void
|
|
92
|
+
(e: "update:region", value: FrameRegion | null): void
|
|
93
|
+
(e: "error", error: { cause: VideoTrimErrorCause; message: string }): void
|
|
94
|
+
}>()
|
|
95
|
+
|
|
96
|
+
const text = useLabels(
|
|
97
|
+
"videoTrim",
|
|
98
|
+
{
|
|
99
|
+
play: "Play",
|
|
100
|
+
pause: "Pause",
|
|
101
|
+
stepBack: "Previous frame",
|
|
102
|
+
stepForward: "Next frame",
|
|
103
|
+
mute: "Mute",
|
|
104
|
+
unmute: "Unmute",
|
|
105
|
+
zoomIn: "Zoom in",
|
|
106
|
+
zoomOut: "Zoom out",
|
|
107
|
+
zoomFit: "Show whole video",
|
|
108
|
+
start: "Start",
|
|
109
|
+
end: "End",
|
|
110
|
+
position: "Position",
|
|
111
|
+
length: "Length",
|
|
112
|
+
/** `{left}` and `{max}` are replaced by times. */
|
|
113
|
+
left: "{left} left of {max}",
|
|
114
|
+
overview: "Visible part of the timeline",
|
|
115
|
+
timeline: "Timeline — Space plays, arrows step, I and O set start and end",
|
|
116
|
+
region: "Marked area — arrows move it, Shift+arrows resize, Delete removes",
|
|
117
|
+
regionHint: "Drag on the picture to mark an area",
|
|
118
|
+
regionClear: "Remove area",
|
|
119
|
+
failed: "This video couldn't be loaded.",
|
|
120
|
+
unsupported: "This browser can't play this video's format.",
|
|
121
|
+
invalidTime: "Type a time like 1:05.5",
|
|
122
|
+
},
|
|
123
|
+
() => props.labels,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const { class: rootClass, attrs: rest } = useMergedAttrs("flex flex-col gap-3 select-none")
|
|
127
|
+
const reduced = usePrefersReducedMotion()
|
|
128
|
+
|
|
129
|
+
const videoEl = ref<HTMLVideoElement | null>(null)
|
|
130
|
+
const samplerEl = ref<HTMLVideoElement | null>(null)
|
|
131
|
+
const stageEl = ref<HTMLElement | null>(null)
|
|
132
|
+
const trackEl = ref<HTMLElement | null>(null)
|
|
133
|
+
|
|
134
|
+
const duration = ref(0)
|
|
135
|
+
const failure = ref<VideoTrimErrorCause | null>(null)
|
|
136
|
+
const time = ref(0)
|
|
137
|
+
const playing = ref(false)
|
|
138
|
+
const muted = ref(false)
|
|
139
|
+
const videoSize = ref({ width: 0, height: 0 })
|
|
140
|
+
const stageSize = ref({ width: 0, height: 0 })
|
|
141
|
+
const trackWidth = ref(0)
|
|
142
|
+
const ready = computed(() => duration.value > 0 && !failure.value)
|
|
143
|
+
const off = computed(() => props.disabled || !ready.value)
|
|
144
|
+
|
|
145
|
+
const limits = computed(() => ({
|
|
146
|
+
duration: duration.value,
|
|
147
|
+
minClip: Math.max(0, props.minClip ?? 0),
|
|
148
|
+
maxClip: props.maxClip && props.maxClip > 0 ? props.maxClip : Infinity,
|
|
149
|
+
}))
|
|
150
|
+
|
|
151
|
+
/* ---------- range ---------- */
|
|
152
|
+
|
|
153
|
+
const local = ref<TimeRange>([0, 0])
|
|
154
|
+
/** Which handle a pointer holds, so the prop echoing back mid-drag does not fight it. */
|
|
155
|
+
const dragging = ref<0 | 1 | null>(null)
|
|
156
|
+
|
|
157
|
+
const same = (a: TimeRange | null | undefined, b: TimeRange) => !!a && a[0] === b[0] && a[1] === b[1]
|
|
158
|
+
|
|
159
|
+
watch(
|
|
160
|
+
[() => props.range, limits],
|
|
161
|
+
() => {
|
|
162
|
+
if (dragging.value !== null || !duration.value) return
|
|
163
|
+
const next = normalizeRange(props.range, limits.value)
|
|
164
|
+
local.value = next
|
|
165
|
+
// An unset or impossible range is replaced by the one the editor shows, so the model never disagrees with it.
|
|
166
|
+
if (!same(props.range, next)) emit("update:range", next)
|
|
167
|
+
},
|
|
168
|
+
{ deep: true },
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
function put(next: TimeRange, commit: boolean) {
|
|
172
|
+
const changed = !same(local.value, next)
|
|
173
|
+
local.value = next
|
|
174
|
+
if (changed) emit("update:range", next)
|
|
175
|
+
if (commit) emit("change", next)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const clipLength = computed(() => local.value[1] - local.value[0])
|
|
179
|
+
|
|
180
|
+
/* ---------- media ---------- */
|
|
181
|
+
|
|
182
|
+
let seekTarget: number | null = null
|
|
183
|
+
let seekBusy = false
|
|
184
|
+
/*
|
|
185
|
+
* One seek at a time, the latest wins: a drag asks for a position each pointer
|
|
186
|
+
* move, and WebKitGTK's pipeline stalls on a seek started inside another.
|
|
187
|
+
*/
|
|
188
|
+
function seek(t: number) {
|
|
189
|
+
const video = videoEl.value
|
|
190
|
+
const target = Math.min(Math.max(0, t), duration.value || t)
|
|
191
|
+
time.value = target
|
|
192
|
+
if (!video) return
|
|
193
|
+
if (seekBusy) {
|
|
194
|
+
seekTarget = target
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
seekBusy = true
|
|
198
|
+
video.currentTime = target
|
|
199
|
+
}
|
|
200
|
+
function onSeeked() {
|
|
201
|
+
seekBusy = false
|
|
202
|
+
if (seekTarget !== null) {
|
|
203
|
+
const next = seekTarget
|
|
204
|
+
seekTarget = null
|
|
205
|
+
seek(next)
|
|
206
|
+
} else if (videoEl.value && !playing.value) time.value = videoEl.value.currentTime
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let probingDuration = false
|
|
210
|
+
function onMetadata() {
|
|
211
|
+
const video = videoEl.value
|
|
212
|
+
if (!video) return
|
|
213
|
+
videoSize.value = { width: video.videoWidth, height: video.videoHeight }
|
|
214
|
+
if (Number.isFinite(video.duration)) {
|
|
215
|
+
duration.value = video.duration
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
// A MediaRecorder WebM carries no duration until its end has been seen: seek far past it once to learn it.
|
|
219
|
+
probingDuration = true
|
|
220
|
+
video.currentTime = 1e101
|
|
221
|
+
}
|
|
222
|
+
function onDurationChange() {
|
|
223
|
+
const video = videoEl.value
|
|
224
|
+
if (!video || !Number.isFinite(video.duration) || !video.duration) return
|
|
225
|
+
duration.value = video.duration
|
|
226
|
+
if (probingDuration) {
|
|
227
|
+
probingDuration = false
|
|
228
|
+
video.currentTime = 0
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function onError() {
|
|
232
|
+
const video = videoEl.value
|
|
233
|
+
if (!video?.error) return
|
|
234
|
+
const src = props.src
|
|
235
|
+
let cause = mediaErrorCause(video.error)
|
|
236
|
+
// Chromium answers a 404, or a login page served in place of an expired session's video, with "format not supported" too.
|
|
237
|
+
if (cause === "unsupported") cause = await probeSource(src)
|
|
238
|
+
if (src !== props.src) return
|
|
239
|
+
failure.value = cause
|
|
240
|
+
emit("error", { cause, message: cause === "unsupported" ? text.value.unsupported : text.value.failed })
|
|
241
|
+
}
|
|
242
|
+
/* One byte of the address: whether it answers with media at all. Unknown (another origin without CORS) stays "unsupported". */
|
|
243
|
+
async function probeSource(src: string): Promise<VideoTrimErrorCause> {
|
|
244
|
+
try {
|
|
245
|
+
const res = await fetch(src, { headers: { Range: "bytes=0-0" }, credentials: "same-origin" })
|
|
246
|
+
void res.body?.cancel()
|
|
247
|
+
const type = res.headers.get("content-type") ?? ""
|
|
248
|
+
return res.ok && !/^text\/|json|xml/.test(type) ? "unsupported" : "load"
|
|
249
|
+
} catch {
|
|
250
|
+
return "unsupported"
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Playback that began inside the range ends at its end, so Play previews the clip. */
|
|
255
|
+
let stopAtEnd = false
|
|
256
|
+
let frame = 0
|
|
257
|
+
function tick() {
|
|
258
|
+
frame = 0
|
|
259
|
+
const video = videoEl.value
|
|
260
|
+
if (!video || !playing.value) return
|
|
261
|
+
time.value = video.currentTime
|
|
262
|
+
if (stopAtEnd && video.currentTime >= local.value[1]) {
|
|
263
|
+
video.pause()
|
|
264
|
+
seek(local.value[1])
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value, 0.05)
|
|
268
|
+
frame = requestAnimationFrame(tick)
|
|
269
|
+
}
|
|
270
|
+
function onPlay() {
|
|
271
|
+
playing.value = true
|
|
272
|
+
if (!frame) frame = requestAnimationFrame(tick)
|
|
273
|
+
}
|
|
274
|
+
function onPause() {
|
|
275
|
+
playing.value = false
|
|
276
|
+
if (frame) cancelAnimationFrame(frame)
|
|
277
|
+
frame = 0
|
|
278
|
+
if (videoEl.value && !seekBusy) time.value = videoEl.value.currentTime
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function togglePlay() {
|
|
282
|
+
const video = videoEl.value
|
|
283
|
+
if (!video || off.value) return
|
|
284
|
+
if (!video.paused) {
|
|
285
|
+
video.pause()
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
const [a, b] = local.value
|
|
289
|
+
let from = time.value
|
|
290
|
+
if (from >= b - 1e-3 && from <= b + 1e-3) from = a
|
|
291
|
+
stopAtEnd = from >= a - 1e-3 && from < b
|
|
292
|
+
if (from !== time.value) seek(from)
|
|
293
|
+
void video.play().catch(() => {})
|
|
294
|
+
}
|
|
295
|
+
function pause() {
|
|
296
|
+
videoEl.value?.pause()
|
|
297
|
+
}
|
|
298
|
+
function step(seconds: number) {
|
|
299
|
+
if (off.value) return
|
|
300
|
+
pause()
|
|
301
|
+
seek(time.value + seconds)
|
|
302
|
+
if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
|
|
303
|
+
}
|
|
304
|
+
function toggleMute() {
|
|
305
|
+
muted.value = !muted.value
|
|
306
|
+
if (videoEl.value) videoEl.value.muted = muted.value
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const frameStep = computed(() => 1 / Math.max(1, props.frameRate))
|
|
310
|
+
|
|
311
|
+
/* ---------- timeline view ---------- */
|
|
312
|
+
|
|
313
|
+
/* The narrowest view: a couple of seconds across still leaves frames a few pixels apart. */
|
|
314
|
+
const MIN_SPAN = 2
|
|
315
|
+
const view = ref<TimeView>({ start: 0, end: 0 })
|
|
316
|
+
const span = computed(() => Math.max(1e-6, view.value.end - view.value.start))
|
|
317
|
+
const zoomed = computed(() => duration.value > 0 && span.value < duration.value - 1e-6)
|
|
318
|
+
const canZoomIn = computed(() => span.value > Math.min(MIN_SPAN, duration.value) + 1e-6)
|
|
319
|
+
|
|
320
|
+
watch(duration, (d) => {
|
|
321
|
+
view.value = { start: 0, end: d }
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const xOf = (t: number) => ((t - view.value.start) / span.value) * trackWidth.value
|
|
325
|
+
const pct = (t: number) => `${((t - view.value.start) / span.value) * 100}%`
|
|
326
|
+
function timeAt(clientX: number): number {
|
|
327
|
+
const box = trackEl.value?.getBoundingClientRect()
|
|
328
|
+
if (!box || !box.width) return 0
|
|
329
|
+
return view.value.start + ((clientX - box.left) / box.width) * span.value
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function zoomBy(factor: number, anchor = time.value) {
|
|
333
|
+
if (!duration.value) return
|
|
334
|
+
view.value = zoomView(view.value, anchor, factor, duration.value, MIN_SPAN)
|
|
335
|
+
}
|
|
336
|
+
function fit() {
|
|
337
|
+
view.value = { start: 0, end: duration.value }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function onWheel(event: WheelEvent) {
|
|
341
|
+
if (!ready.value) return
|
|
342
|
+
const dx = event.shiftKey ? event.deltaY : event.deltaX
|
|
343
|
+
// A sideways scroll (or Shift+wheel) pans; an upright one, and a pinch on a touchpad, zooms.
|
|
344
|
+
if (Math.abs(dx) > Math.abs(event.deltaY) || event.shiftKey) {
|
|
345
|
+
if (!zoomed.value) return
|
|
346
|
+
event.preventDefault()
|
|
347
|
+
view.value = shiftView(view.value, (dx / Math.max(1, trackWidth.value)) * span.value, duration.value)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
event.preventDefault()
|
|
351
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1
|
|
352
|
+
zoomBy(Math.exp((-event.deltaY * unit) / (event.ctrlKey ? 100 : 300)), timeAt(event.clientX))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const marks = computed(() => (ready.value ? ticks(view.value, tickStep(span.value, trackWidth.value)) : []))
|
|
356
|
+
const markDigits = computed(() => tickStep(span.value, trackWidth.value) < 1)
|
|
357
|
+
|
|
358
|
+
/* ---------- timeline pointers ---------- */
|
|
359
|
+
|
|
360
|
+
const pointers = new Map<number, number>()
|
|
361
|
+
let pinch: { distance: number; anchor: number } | null = null
|
|
362
|
+
let edgeFrame = 0
|
|
363
|
+
let lastX = 0
|
|
364
|
+
let scrubbing = false
|
|
365
|
+
|
|
366
|
+
function onHandleDown(end: 0 | 1, event: PointerEvent) {
|
|
367
|
+
if (off.value || event.button !== 0) return
|
|
368
|
+
event.stopPropagation()
|
|
369
|
+
;(event.currentTarget as HTMLElement).focus({ preventScroll: true })
|
|
370
|
+
pause()
|
|
371
|
+
dragging.value = end
|
|
372
|
+
lastX = event.clientX
|
|
373
|
+
startPointerDrag(event, {
|
|
374
|
+
cursor: "ew-resize",
|
|
375
|
+
move: (e) => {
|
|
376
|
+
lastX = e.clientX
|
|
377
|
+
moveHandle()
|
|
378
|
+
edgeScroll()
|
|
379
|
+
},
|
|
380
|
+
end: () => {
|
|
381
|
+
dragging.value = null
|
|
382
|
+
if (edgeFrame) cancelAnimationFrame(edgeFrame)
|
|
383
|
+
edgeFrame = 0
|
|
384
|
+
emit("change", local.value)
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
function moveHandle() {
|
|
389
|
+
const end = dragging.value
|
|
390
|
+
if (end === null) return
|
|
391
|
+
const next = dragEnd(local.value, end, timeAt(lastX), limits.value)
|
|
392
|
+
put(next, false)
|
|
393
|
+
// The picture shows the frame being cut at.
|
|
394
|
+
seek(next[end])
|
|
395
|
+
}
|
|
396
|
+
/* Held past either edge of a zoomed timeline, the view travels, faster the further out. */
|
|
397
|
+
function edgeScroll() {
|
|
398
|
+
if (edgeFrame || !zoomed.value) return
|
|
399
|
+
const box = trackEl.value?.getBoundingClientRect()
|
|
400
|
+
if (!box) return
|
|
401
|
+
const over = lastX < box.left ? lastX - box.left : lastX > box.right ? lastX - box.right : 0
|
|
402
|
+
if (!over || (dragging.value === null && !scrubbing)) return
|
|
403
|
+
edgeFrame = requestAnimationFrame(() => {
|
|
404
|
+
edgeFrame = 0
|
|
405
|
+
view.value = shiftView(view.value, (Math.max(-60, Math.min(60, over)) / 6 / Math.max(1, box.width)) * span.value, duration.value)
|
|
406
|
+
if (dragging.value !== null) moveHandle()
|
|
407
|
+
else seek(timeAt(lastX))
|
|
408
|
+
edgeScroll()
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function onTrackDown(event: PointerEvent) {
|
|
413
|
+
if (off.value || (event.pointerType === "mouse" && event.button !== 0)) return
|
|
414
|
+
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
|
415
|
+
pointers.set(event.pointerId, event.clientX)
|
|
416
|
+
if (pointers.size === 2) {
|
|
417
|
+
scrubbing = false
|
|
418
|
+
const [a, b] = [...pointers.values()] as [number, number]
|
|
419
|
+
pinch = { distance: Math.max(8, Math.abs(a - b)), anchor: timeAt((a + b) / 2) }
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
if (pointers.size > 2) return
|
|
423
|
+
event.preventDefault()
|
|
424
|
+
trackEl.value?.querySelector<HTMLElement>("[data-lp-playhead]")?.focus({ preventScroll: true })
|
|
425
|
+
pause()
|
|
426
|
+
scrubbing = true
|
|
427
|
+
lastX = event.clientX
|
|
428
|
+
seek(timeAt(event.clientX))
|
|
429
|
+
}
|
|
430
|
+
function onTrackMove(event: PointerEvent) {
|
|
431
|
+
if (!pointers.has(event.pointerId)) return
|
|
432
|
+
pointers.set(event.pointerId, event.clientX)
|
|
433
|
+
if (pinch && pointers.size === 2) {
|
|
434
|
+
const [a, b] = [...pointers.values()] as [number, number]
|
|
435
|
+
const distance = Math.max(8, Math.abs(a - b))
|
|
436
|
+
zoomBy(distance / pinch.distance, pinch.anchor)
|
|
437
|
+
pinch.distance = distance
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (!scrubbing) return
|
|
441
|
+
lastX = event.clientX
|
|
442
|
+
seek(timeAt(event.clientX))
|
|
443
|
+
edgeScroll()
|
|
444
|
+
}
|
|
445
|
+
function onTrackUp(event: PointerEvent) {
|
|
446
|
+
pointers.delete(event.pointerId)
|
|
447
|
+
if (pointers.size < 2) pinch = null
|
|
448
|
+
if (!pointers.size) scrubbing = false
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* The overview: press or drag to centre the view there. */
|
|
452
|
+
function onOverviewDown(event: PointerEvent) {
|
|
453
|
+
if (off.value || event.button !== 0) return
|
|
454
|
+
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|
455
|
+
const centreAt = (clientX: number) => {
|
|
456
|
+
const centre = ((clientX - box.left) / Math.max(1, box.width)) * duration.value
|
|
457
|
+
view.value = shiftView(view.value, centre - (view.value.start + view.value.end) / 2, duration.value)
|
|
458
|
+
}
|
|
459
|
+
centreAt(event.clientX)
|
|
460
|
+
startPointerDrag(event, { move: (e) => centreAt(e.clientX), end: () => {} })
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ---------- stills ---------- */
|
|
464
|
+
|
|
465
|
+
const STILL_HEIGHT = 56
|
|
466
|
+
const stillWidth = computed(() => {
|
|
467
|
+
const { width, height } = videoSize.value
|
|
468
|
+
return Math.round(Math.min(160, Math.max(40, width && height ? (STILL_HEIGHT * width) / height : STILL_HEIGHT * (16 / 9))))
|
|
469
|
+
})
|
|
470
|
+
const stillSeconds = computed(() => stillSpan(span.value / Math.max(1, trackWidth.value), stillWidth.value))
|
|
471
|
+
const stills = computed(() => {
|
|
472
|
+
if (!ready.value || !trackWidth.value) return []
|
|
473
|
+
const step = stillSeconds.value
|
|
474
|
+
const first = Math.floor(view.value.start / step)
|
|
475
|
+
const last = Math.min(Math.ceil(view.value.end / step), Math.ceil(duration.value / step))
|
|
476
|
+
const out: { key: string; time: number; left: string; width: string }[] = []
|
|
477
|
+
for (let k = first; k < last && out.length < 200; k++) {
|
|
478
|
+
out.push({ key: `${step}:${k}`, time: Math.min(duration.value, k * step + step / 2), left: pct(k * step), width: `${(step / span.value) * 100}%` })
|
|
479
|
+
}
|
|
480
|
+
return out
|
|
481
|
+
})
|
|
482
|
+
const frames = shallowRef(new Map<string, HTMLCanvasElement>())
|
|
483
|
+
let sampler: FrameSampler | null = null
|
|
484
|
+
let stillTimer: ReturnType<typeof setTimeout> | undefined
|
|
485
|
+
|
|
486
|
+
watch(stills, (list) => {
|
|
487
|
+
clearTimeout(stillTimer)
|
|
488
|
+
// Wait for a zoom or a pan to come to rest before asking for stills of a view that may be gone in a moment.
|
|
489
|
+
stillTimer = setTimeout(() => {
|
|
490
|
+
if (!sampler) return
|
|
491
|
+
const centre = (view.value.start + view.value.end) / 2
|
|
492
|
+
sampler.want([...list].sort((a, b) => Math.abs(a.time - centre) - Math.abs(b.time - centre)))
|
|
493
|
+
}, 120)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
function mountFrame(key: string, el: Element | null) {
|
|
497
|
+
if (!isHTMLElement(el)) return
|
|
498
|
+
const canvas = frames.value.get(key) ?? sampler?.cached(key)
|
|
499
|
+
if (canvas && canvas.parentElement !== el) el.replaceChildren(canvas)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* ---------- region ---------- */
|
|
503
|
+
|
|
504
|
+
const picture = computed(() => containRect(stageSize.value.width, stageSize.value.height, videoSize.value.width, videoSize.value.height))
|
|
505
|
+
const draft = ref<FrameRegion | null>(null)
|
|
506
|
+
const shownRegion = computed(() => draft.value ?? props.region ?? null)
|
|
507
|
+
|
|
508
|
+
function fractionAt(event: PointerEvent) {
|
|
509
|
+
const box = stageEl.value?.getBoundingClientRect()
|
|
510
|
+
const p = picture.value
|
|
511
|
+
if (!box || !p.width || !p.height) return { x: 0, y: 0 }
|
|
512
|
+
return { x: (event.clientX - box.left - p.x) / p.width, y: (event.clientY - box.top - p.y) / p.height }
|
|
513
|
+
}
|
|
514
|
+
function onRegionDown(handle: RegionHandle | "draw", event: PointerEvent) {
|
|
515
|
+
if (props.disabled || !props.regionSelect || (event.pointerType === "mouse" && event.button !== 0)) return
|
|
516
|
+
event.stopPropagation()
|
|
517
|
+
pause()
|
|
518
|
+
const start = fractionAt(event)
|
|
519
|
+
const from = props.region ?? null
|
|
520
|
+
startPointerDrag(event, {
|
|
521
|
+
move: (e) => {
|
|
522
|
+
const at = fractionAt(e)
|
|
523
|
+
draft.value = handle === "draw" || !from ? regionBetween(start.x, start.y, at.x, at.y) : adjustRegion(from, handle, at.x - start.x, at.y - start.y)
|
|
524
|
+
},
|
|
525
|
+
end: (completed) => {
|
|
526
|
+
const next = draft.value
|
|
527
|
+
draft.value = null
|
|
528
|
+
// A press without a drag is not a region: a stray click must not leave a sliver to blur.
|
|
529
|
+
if (!completed || !next || (handle === "draw" && (next.w < 0.01 || next.h < 0.01))) return
|
|
530
|
+
emit("update:region", next)
|
|
531
|
+
},
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
function onRegionKey(event: KeyboardEvent) {
|
|
535
|
+
const r = props.region
|
|
536
|
+
if (!r || props.disabled) return
|
|
537
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
538
|
+
event.preventDefault()
|
|
539
|
+
emit("update:region", null)
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
const d = event.altKey ? 0.002 : 0.01
|
|
543
|
+
const moves: Record<string, [number, number]> = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, -d], ArrowDown: [0, d] }
|
|
544
|
+
const move = moves[event.key]
|
|
545
|
+
if (!move) return
|
|
546
|
+
event.preventDefault()
|
|
547
|
+
event.stopPropagation()
|
|
548
|
+
emit("update:region", adjustRegion(r, event.shiftKey ? "se" : "move", move[0], move[1]))
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* ---------- keys ---------- */
|
|
552
|
+
|
|
553
|
+
function onKeydown(event: KeyboardEvent) {
|
|
554
|
+
const target = event.target as HTMLElement
|
|
555
|
+
if (event.defaultPrevented || event.ctrlKey || event.metaKey || target.closest("input, textarea, select, [contenteditable='true']")) return
|
|
556
|
+
const handle = target.dataset.lpHandle
|
|
557
|
+
const end = handle === "0" ? 0 : handle === "1" ? 1 : null
|
|
558
|
+
const big = event.shiftKey ? 1 : frameStep.value
|
|
559
|
+
const delta: Record<string, number> = { ArrowLeft: -big, ArrowRight: big, ArrowDown: -big, ArrowUp: big, PageDown: -10, PageUp: 10 }
|
|
560
|
+
if (event.key === " " && target.tagName !== "BUTTON") {
|
|
561
|
+
event.preventDefault()
|
|
562
|
+
togglePlay()
|
|
563
|
+
} else if (event.key in delta || event.key === "Home" || event.key === "End") {
|
|
564
|
+
if (off.value) return
|
|
565
|
+
event.preventDefault()
|
|
566
|
+
if (end !== null) {
|
|
567
|
+
const from = local.value[end]
|
|
568
|
+
const to = event.key === "Home" ? 0 : event.key === "End" ? duration.value : from + delta[event.key]!
|
|
569
|
+
const next = dragEnd(local.value, end, to, limits.value)
|
|
570
|
+
put(next, true)
|
|
571
|
+
pause()
|
|
572
|
+
seek(next[end])
|
|
573
|
+
} else if (event.key === "Home" || event.key === "End") {
|
|
574
|
+
pause()
|
|
575
|
+
seek(event.key === "Home" ? 0 : duration.value)
|
|
576
|
+
} else step(delta[event.key]!)
|
|
577
|
+
if (zoomed.value) view.value = revealTime(view.value, time.value, duration.value)
|
|
578
|
+
} else if ((event.key === "i" || event.key === "I" || event.key === "o" || event.key === "O") && !event.altKey) {
|
|
579
|
+
if (off.value) return
|
|
580
|
+
event.preventDefault()
|
|
581
|
+
put(setEnd(local.value, event.key.toLowerCase() === "i" ? 0 : 1, time.value, limits.value), true)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* ---------- typed times ---------- */
|
|
586
|
+
|
|
587
|
+
const drafts = ref<[string | null, string | null]>([null, null])
|
|
588
|
+
const invalid = ref<[boolean, boolean]>([false, false])
|
|
589
|
+
const fieldText = (end: 0 | 1) => drafts.value[end] ?? formatClipTime(local.value[end], true)
|
|
590
|
+
function onFieldInput(end: 0 | 1, value: string) {
|
|
591
|
+
drafts.value[end] = value
|
|
592
|
+
invalid.value[end] = false
|
|
593
|
+
}
|
|
594
|
+
function commitField(end: 0 | 1) {
|
|
595
|
+
const typed = drafts.value[end]
|
|
596
|
+
if (typed === null) return
|
|
597
|
+
const t = parseClipTime(typed)
|
|
598
|
+
if (t === null) {
|
|
599
|
+
invalid.value[end] = true
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
drafts.value[end] = null
|
|
603
|
+
put(setEnd(local.value, end, t, limits.value), true)
|
|
604
|
+
seek(local.value[end])
|
|
605
|
+
if (zoomed.value) view.value = revealTime(view.value, local.value[end], duration.value)
|
|
606
|
+
}
|
|
607
|
+
function revertField(end: 0 | 1) {
|
|
608
|
+
drafts.value[end] = null
|
|
609
|
+
invalid.value[end] = false
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/* ---------- mount ---------- */
|
|
613
|
+
|
|
614
|
+
useResizeObserver(trackEl, (_, el) => (trackWidth.value = (el as HTMLElement).clientWidth), { immediate: true })
|
|
615
|
+
useResizeObserver(stageEl, (_, el) => (stageSize.value = { width: (el as HTMLElement).clientWidth, height: (el as HTMLElement).clientHeight }), { immediate: true })
|
|
616
|
+
|
|
617
|
+
function startSampler() {
|
|
618
|
+
sampler?.dispose()
|
|
619
|
+
sampler = samplerEl.value
|
|
620
|
+
? new FrameSampler(samplerEl.value, () => ({ width: stillWidth.value, height: STILL_HEIGHT }), (key, canvas) => {
|
|
621
|
+
const next = new Map(frames.value)
|
|
622
|
+
next.set(key, canvas)
|
|
623
|
+
if (next.size > 240) next.delete(next.keys().next().value as string)
|
|
624
|
+
frames.value = next
|
|
625
|
+
})
|
|
626
|
+
: null
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
watch(
|
|
630
|
+
() => props.src,
|
|
631
|
+
() => {
|
|
632
|
+
duration.value = 0
|
|
633
|
+
failure.value = null
|
|
634
|
+
time.value = 0
|
|
635
|
+
frames.value = new Map()
|
|
636
|
+
seekBusy = false
|
|
637
|
+
seekTarget = null
|
|
638
|
+
void nextTick(startSampler)
|
|
639
|
+
},
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
onMounted(startSampler)
|
|
643
|
+
onBeforeUnmount(() => {
|
|
644
|
+
sampler?.dispose()
|
|
645
|
+
clearTimeout(stillTimer)
|
|
646
|
+
if (frame) cancelAnimationFrame(frame)
|
|
647
|
+
if (edgeFrame) cancelAnimationFrame(edgeFrame)
|
|
648
|
+
// Stop both streams from fetching on: an element that is merely detached keeps its connection.
|
|
649
|
+
for (const el of [videoEl.value, samplerEl.value]) {
|
|
650
|
+
if (!el) continue
|
|
651
|
+
el.pause()
|
|
652
|
+
el.removeAttribute("src")
|
|
653
|
+
el.load()
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
defineExpose({
|
|
658
|
+
play: () => (videoEl.value?.paused ? togglePlay() : undefined),
|
|
659
|
+
pause,
|
|
660
|
+
seek,
|
|
661
|
+
/** The playhead, in seconds. */
|
|
662
|
+
currentTime: () => time.value,
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
const handleText = (t: number) => formatClipTime(t, true)
|
|
666
|
+
const leftText = computed(() =>
|
|
667
|
+
Number.isFinite(limits.value.maxClip)
|
|
668
|
+
? text.value.left
|
|
669
|
+
.replace("{left}", formatClipTime(Math.max(0, limits.value.maxClip - clipLength.value), true))
|
|
670
|
+
.replace("{max}", formatClipTime(limits.value.maxClip, true))
|
|
671
|
+
: "",
|
|
672
|
+
)
|
|
673
|
+
</script>
|
|
674
|
+
|
|
675
|
+
<template>
|
|
676
|
+
<div :class="rootClass" v-bind="rest" :data-disabled="disabled || undefined" @keydown="onKeydown">
|
|
677
|
+
<!-- The picture. Its own controls are off: the region is drawn on it, and the keys are the editor's. -->
|
|
678
|
+
<div
|
|
679
|
+
ref="stageEl"
|
|
680
|
+
class="relative aspect-video w-full overflow-hidden rounded-card bg-black"
|
|
681
|
+
>
|
|
682
|
+
<video
|
|
683
|
+
ref="videoEl"
|
|
684
|
+
class="absolute inset-0 size-full object-contain"
|
|
685
|
+
:src="src"
|
|
686
|
+
:poster="poster"
|
|
687
|
+
preload="metadata"
|
|
688
|
+
playsinline
|
|
689
|
+
:muted="muted"
|
|
690
|
+
@loadedmetadata="onMetadata"
|
|
691
|
+
@durationchange="onDurationChange"
|
|
692
|
+
@seeked="onSeeked"
|
|
693
|
+
@play="onPlay"
|
|
694
|
+
@pause="onPause"
|
|
695
|
+
@error="onError"
|
|
696
|
+
@click="!regionSelect && togglePlay()"
|
|
697
|
+
/>
|
|
698
|
+
<!-- Stills come from a second element: seeking this one would move the picture under the person. -->
|
|
699
|
+
<video
|
|
700
|
+
ref="samplerEl"
|
|
701
|
+
class="pointer-events-none absolute size-px opacity-0"
|
|
702
|
+
:src="src"
|
|
703
|
+
preload="metadata"
|
|
704
|
+
muted
|
|
705
|
+
playsinline
|
|
706
|
+
aria-hidden="true"
|
|
707
|
+
tabindex="-1"
|
|
708
|
+
/>
|
|
709
|
+
<div
|
|
710
|
+
v-if="regionSelect && ready"
|
|
711
|
+
class="absolute cursor-crosshair touch-none"
|
|
712
|
+
:style="{ left: `${picture.x}px`, top: `${picture.y}px`, width: `${picture.width}px`, height: `${picture.height}px` }"
|
|
713
|
+
data-lp-region-layer
|
|
714
|
+
@pointerdown="onRegionDown('draw', $event)"
|
|
715
|
+
>
|
|
716
|
+
<div
|
|
717
|
+
v-if="shownRegion"
|
|
718
|
+
class="absolute cursor-move rounded-inner border-2 border-dashed border-white bg-brand/25 shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
719
|
+
:style="{ left: `${shownRegion.x * 100}%`, top: `${shownRegion.y * 100}%`, width: `${shownRegion.w * 100}%`, height: `${shownRegion.h * 100}%` }"
|
|
720
|
+
tabindex="0"
|
|
721
|
+
role="group"
|
|
722
|
+
:aria-label="text.region"
|
|
723
|
+
data-lp-region
|
|
724
|
+
@pointerdown="onRegionDown('move', $event)"
|
|
725
|
+
@keydown="onRegionKey"
|
|
726
|
+
>
|
|
727
|
+
<span
|
|
728
|
+
v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
|
|
729
|
+
:key="corner"
|
|
730
|
+
class="absolute grid size-6 place-items-center pointer-coarse:size-11"
|
|
731
|
+
:class="[
|
|
732
|
+
corner[0] === 'n' ? 'top-0 -translate-y-1/2' : 'bottom-0 translate-y-1/2',
|
|
733
|
+
corner[1] === 'w' ? 'left-0 -translate-x-1/2' : 'right-0 translate-x-1/2',
|
|
734
|
+
corner === 'nw' || corner === 'se' ? 'cursor-nwse-resize' : 'cursor-nesw-resize',
|
|
735
|
+
]"
|
|
736
|
+
:data-lp-corner="corner"
|
|
737
|
+
@pointerdown="onRegionDown(corner, $event)"
|
|
738
|
+
>
|
|
739
|
+
<span class="size-3 rounded-pill border-2 border-white bg-brand shadow" />
|
|
740
|
+
</span>
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
<div
|
|
744
|
+
v-if="failure"
|
|
745
|
+
role="alert"
|
|
746
|
+
class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-white/80"
|
|
747
|
+
>
|
|
748
|
+
<LpIcon name="lucide:video-off" :size="32" />
|
|
749
|
+
<p>{{ failure === "unsupported" ? text.unsupported : text.failed }}</p>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
|
|
753
|
+
<!-- Transport and zoom. -->
|
|
754
|
+
<div class="flex flex-wrap items-center gap-1">
|
|
755
|
+
<LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="playing ? text.pause : text.play" :title="playing ? text.pause : text.play" @click="togglePlay">
|
|
756
|
+
<LpIcon :name="playing ? 'lucide:pause' : 'lucide:play'" :size="16" />
|
|
757
|
+
</LpButton>
|
|
758
|
+
<LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepBack" :title="text.stepBack" @click="step(-frameStep)">
|
|
759
|
+
<LpIcon name="lucide:step-back" :size="16" />
|
|
760
|
+
</LpButton>
|
|
761
|
+
<LpButton size="sm" variant="ghost" square :disabled="off" :aria-label="text.stepForward" :title="text.stepForward" @click="step(frameStep)">
|
|
762
|
+
<LpIcon name="lucide:step-forward" :size="16" />
|
|
763
|
+
</LpButton>
|
|
764
|
+
<span class="px-1.5 font-mono text-xs text-muted tabular-nums" data-lp-clock>
|
|
765
|
+
<span class="text-ink">{{ formatClipTime(time, true) }}</span> / {{ formatClipTime(duration, true) }}
|
|
766
|
+
</span>
|
|
767
|
+
<LpButton size="sm" variant="ghost" square :disabled="!ready" :aria-label="muted ? text.unmute : text.mute" :title="muted ? text.unmute : text.mute" :aria-pressed="muted" @click="toggleMute">
|
|
768
|
+
<LpIcon :name="muted ? 'lucide:volume-x' : 'lucide:volume-2'" :size="16" />
|
|
769
|
+
</LpButton>
|
|
770
|
+
<span class="flex-1" />
|
|
771
|
+
<LpButton size="sm" variant="ghost" square :disabled="off || !zoomed" :aria-label="text.zoomOut" :title="text.zoomOut" @click="zoomBy(0.5)">
|
|
772
|
+
<LpIcon name="lucide:zoom-out" :size="16" />
|
|
773
|
+
</LpButton>
|
|
774
|
+
<LpButton size="sm" variant="ghost" square :disabled="off || !canZoomIn" :aria-label="text.zoomIn" :title="text.zoomIn" @click="zoomBy(2)">
|
|
775
|
+
<LpIcon name="lucide:zoom-in" :size="16" />
|
|
776
|
+
</LpButton>
|
|
777
|
+
<LpButton size="sm" variant="ghost" square :disabled="off || !zoomed" :aria-label="text.zoomFit" :title="text.zoomFit" @click="fit">
|
|
778
|
+
<LpIcon name="lucide:maximize-2" :size="16" />
|
|
779
|
+
</LpButton>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
782
|
+
<!-- Ruler, stills, range and playhead. -->
|
|
783
|
+
<div class="relative" @wheel="onWheel">
|
|
784
|
+
<div class="relative h-4 overflow-hidden text-[10px] text-muted tabular-nums" aria-hidden="true">
|
|
785
|
+
<span
|
|
786
|
+
v-for="m in marks"
|
|
787
|
+
:key="m"
|
|
788
|
+
class="absolute top-0 whitespace-nowrap"
|
|
789
|
+
:class="xOf(m) < 16 ? '' : xOf(m) > trackWidth - 16 ? '-translate-x-full' : '-translate-x-1/2'"
|
|
790
|
+
:style="{ left: pct(m) }"
|
|
791
|
+
>{{ formatClipTime(m, markDigits) }}</span>
|
|
792
|
+
</div>
|
|
793
|
+
<div
|
|
794
|
+
ref="trackEl"
|
|
795
|
+
class="relative h-14 touch-none rounded-control bg-surface-soft"
|
|
796
|
+
:class="off ? 'opacity-60' : 'cursor-text'"
|
|
797
|
+
:aria-label="text.timeline"
|
|
798
|
+
role="group"
|
|
799
|
+
@pointerdown="onTrackDown"
|
|
800
|
+
@pointermove="onTrackMove"
|
|
801
|
+
@pointerup="onTrackUp"
|
|
802
|
+
@pointercancel="onTrackUp"
|
|
803
|
+
>
|
|
804
|
+
<div class="absolute inset-0 overflow-hidden rounded-control">
|
|
805
|
+
<div
|
|
806
|
+
v-for="s in stills"
|
|
807
|
+
:key="s.key"
|
|
808
|
+
:ref="(el) => mountFrame(s.key, el as Element | null)"
|
|
809
|
+
class="lp-trim-still absolute inset-y-0 overflow-hidden border-r border-black/20 [&>canvas]:size-full [&>canvas]:object-cover"
|
|
810
|
+
:class="{ 'lp-trim-still-in': !reduced }"
|
|
811
|
+
:style="{ left: s.left, width: s.width }"
|
|
812
|
+
:data-lp-still="frames.has(s.key) ? 'ready' : 'pending'"
|
|
813
|
+
/>
|
|
814
|
+
<!-- What falls outside the clip is dimmed, not hidden: it is still where the clip could move. -->
|
|
815
|
+
<div class="absolute inset-y-0 left-0 bg-black/55" :style="{ width: `${Math.max(0, xOf(local[0]))}px` }" />
|
|
816
|
+
<div class="absolute inset-y-0 right-0 bg-black/55" :style="{ left: `${Math.min(trackWidth, Math.max(0, xOf(local[1])))}px` }" />
|
|
817
|
+
</div>
|
|
818
|
+
<div
|
|
819
|
+
v-if="ready"
|
|
820
|
+
class="pointer-events-none absolute inset-y-0 border-y-2 border-brand"
|
|
821
|
+
:style="{ left: pct(local[0]), width: `${(clipLength / span) * 100}%` }"
|
|
822
|
+
data-lp-range
|
|
823
|
+
/>
|
|
824
|
+
<div
|
|
825
|
+
v-for="end in ([0, 1] as const)"
|
|
826
|
+
v-show="ready"
|
|
827
|
+
:key="end"
|
|
828
|
+
class="group/handle absolute inset-y-0 z-10 flex w-6 touch-none items-stretch outline-none pointer-coarse:w-11"
|
|
829
|
+
:class="[end === 0 ? 'justify-start' : '-translate-x-full justify-end', off ? '' : 'cursor-ew-resize']"
|
|
830
|
+
:style="{ left: pct(local[end]) }"
|
|
831
|
+
role="slider"
|
|
832
|
+
:tabindex="off ? -1 : 0"
|
|
833
|
+
:aria-label="end === 0 ? text.start : text.end"
|
|
834
|
+
:aria-valuemin="0"
|
|
835
|
+
:aria-valuemax="Math.round(duration * 1000) / 1000"
|
|
836
|
+
:aria-valuenow="local[end]"
|
|
837
|
+
:aria-valuetext="handleText(local[end])"
|
|
838
|
+
:aria-disabled="off || undefined"
|
|
839
|
+
:data-lp-handle="end"
|
|
840
|
+
@pointerdown="onHandleDown(end, $event)"
|
|
841
|
+
>
|
|
842
|
+
<span
|
|
843
|
+
class="flex w-3 items-center justify-center bg-brand transition-[width] duration-(--duration-fast) group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring motion-reduce:transition-none"
|
|
844
|
+
:class="end === 0 ? 'rounded-l-control' : 'rounded-r-control'"
|
|
845
|
+
>
|
|
846
|
+
<span class="h-5 w-0.5 rounded-pill bg-white/85" />
|
|
847
|
+
</span>
|
|
848
|
+
</div>
|
|
849
|
+
<div
|
|
850
|
+
v-show="ready && time >= view.start && time <= view.end"
|
|
851
|
+
class="absolute -top-4 bottom-0 z-20 w-0 outline-none"
|
|
852
|
+
:style="{ left: pct(time) }"
|
|
853
|
+
role="slider"
|
|
854
|
+
:tabindex="off ? -1 : 0"
|
|
855
|
+
:aria-label="text.position"
|
|
856
|
+
:aria-valuemin="0"
|
|
857
|
+
:aria-valuemax="Math.round(duration * 1000) / 1000"
|
|
858
|
+
:aria-valuenow="Math.round(time * 1000) / 1000"
|
|
859
|
+
:aria-valuetext="handleText(time)"
|
|
860
|
+
data-lp-playhead
|
|
861
|
+
>
|
|
862
|
+
<span class="pointer-events-none absolute inset-y-0 -left-px w-0.5 bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.45)]" />
|
|
863
|
+
<span class="pointer-events-none absolute -top-0.5 -left-1.5 size-3 rounded-pill border-2 border-white bg-ink shadow [[data-lp-playhead]:focus-visible_&]:ring-2 [[data-lp-playhead]:focus-visible_&]:ring-ring" />
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
<!-- The whole video, for finding the way back while zoomed in. -->
|
|
867
|
+
<div
|
|
868
|
+
v-if="zoomed"
|
|
869
|
+
class="relative mt-1.5 h-2 cursor-pointer touch-none rounded-pill bg-surface-soft"
|
|
870
|
+
:aria-label="text.overview"
|
|
871
|
+
role="presentation"
|
|
872
|
+
data-lp-overview
|
|
873
|
+
@pointerdown="onOverviewDown"
|
|
874
|
+
>
|
|
875
|
+
<span class="absolute inset-y-0 rounded-pill bg-brand/45" :style="{ left: `${(local[0] / duration) * 100}%`, width: `${(clipLength / duration) * 100}%` }" />
|
|
876
|
+
<span class="absolute -inset-y-0.5 rounded-pill border-2 border-ink/70" :style="{ left: `${(view.start / duration) * 100}%`, width: `${(span / duration) * 100}%` }" />
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
|
|
880
|
+
<!-- Exact times, typed. -->
|
|
881
|
+
<div class="flex flex-wrap items-end gap-x-3 gap-y-2 text-xs">
|
|
882
|
+
<label v-for="end in ([0, 1] as const)" :key="end" class="flex flex-col gap-1">
|
|
883
|
+
<span class="text-muted">{{ end === 0 ? text.start : text.end }}</span>
|
|
884
|
+
<LpInput
|
|
885
|
+
size="sm"
|
|
886
|
+
class="w-28 font-mono tabular-nums"
|
|
887
|
+
:model-value="fieldText(end)"
|
|
888
|
+
:disabled="off"
|
|
889
|
+
:invalid="invalid[end]"
|
|
890
|
+
:title="invalid[end] ? text.invalidTime : undefined"
|
|
891
|
+
inputmode="decimal"
|
|
892
|
+
spellcheck="false"
|
|
893
|
+
autocomplete="off"
|
|
894
|
+
:data-lp-time="end"
|
|
895
|
+
@update:model-value="onFieldInput(end, $event)"
|
|
896
|
+
@blur="commitField(end)"
|
|
897
|
+
@keydown.enter.prevent="commitField(end)"
|
|
898
|
+
@keydown.esc="revertField(end)"
|
|
899
|
+
/>
|
|
900
|
+
</label>
|
|
901
|
+
<div class="flex flex-col gap-1 pb-1.5">
|
|
902
|
+
<span class="text-muted">{{ text.length }}</span>
|
|
903
|
+
<span class="font-mono text-sm text-ink tabular-nums" data-lp-length>{{ formatClipTime(clipLength, true) }}</span>
|
|
904
|
+
</div>
|
|
905
|
+
<span v-if="leftText" class="pb-2 text-muted" data-lp-left>{{ leftText }}</span>
|
|
906
|
+
<span class="flex-1" />
|
|
907
|
+
<template v-if="regionSelect">
|
|
908
|
+
<LpButton v-if="region" size="sm" variant="outline" :disabled="disabled" @click="emit('update:region', null)">
|
|
909
|
+
<LpIcon name="lucide:x" :size="14" />{{ text.regionClear }}
|
|
910
|
+
</LpButton>
|
|
911
|
+
<span v-else class="pb-2 text-muted">{{ text.regionHint }}</span>
|
|
912
|
+
</template>
|
|
913
|
+
</div>
|
|
914
|
+
</div>
|
|
915
|
+
</template>
|
|
916
|
+
|
|
917
|
+
<style scoped>
|
|
918
|
+
.lp-trim-still-in > :deep(canvas) {
|
|
919
|
+
animation: lp-trim-still var(--duration-medium) var(--ease-emphasized);
|
|
920
|
+
}
|
|
921
|
+
@keyframes lp-trim-still {
|
|
922
|
+
from { opacity: 0; }
|
|
923
|
+
}
|
|
924
|
+
</style>
|