@leavepulse/ui 0.81.0 → 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/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-BBINfN1v.js → LpAppShell-CK9hTgQ1.js} +4 -4
- 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/{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 +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.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/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/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 +95 -88
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- 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/span.ts +21 -0
- package/src/index.ts +3 -0
- package/src/kit-icons.ts +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { clamp } from "../composables/clamp"
|
|
2
|
+
import { shiftSpan, zoomSpan } from "../composables/span"
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
* The part of a long series a chart draws: which points are in the window, and
|
|
@@ -54,18 +55,13 @@ export function clampRange(range: ChartRange | null | undefined, count: number):
|
|
|
54
55
|
/** The range after a zoom by `factor` (below 1 is in), keeping `anchor` (0–1 across it) in place. */
|
|
55
56
|
export function zoomRange(range: ChartRange | null, count: number, anchor: number, factor: number): ChartRange | null {
|
|
56
57
|
const from = range ?? { start: 0, end: count - 1 }
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const at = from.start + span * clamp(anchor, 0, 1)
|
|
60
|
-
const start = at - (at - from.start) * (next / (span || 1))
|
|
61
|
-
return clampRange({ start, end: start + next }, count)
|
|
58
|
+
const at = from.start + (from.end - from.start) * clamp(anchor, 0, 1)
|
|
59
|
+
return clampRange(zoomSpan(from.start, from.end, at, factor, MIN_RANGE_POINTS - 1), count)
|
|
62
60
|
}
|
|
63
61
|
|
|
64
62
|
/** The range moved by `delta` points, keeping its width at the ends of the data. */
|
|
65
63
|
export function panRange(range: ChartRange, count: number, delta: number): ChartRange | null {
|
|
66
|
-
|
|
67
|
-
const start = Math.max(0, Math.min(count - 1 - span, range.start + delta))
|
|
68
|
-
return clampRange({ start, end: start + span }, count)
|
|
64
|
+
return clampRange(shiftSpan(range.start, range.end, delta, 0, count - 1), count)
|
|
69
65
|
}
|
|
70
66
|
|
|
71
67
|
/**
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Stills for LpVideoTrim's timeline, taken from a second, hidden <video> of the
|
|
3
|
+
* same address: seek, wait for the frame, draw it on a small canvas.
|
|
4
|
+
*
|
|
5
|
+
* - One seek at a time. A seek started while another runs is dropped by some
|
|
6
|
+
* engines and freezes WebKitGTK's GStreamer pipeline (WebKit bug 194499);
|
|
7
|
+
* a seek that never answers is given up after a while, so one bad spot does
|
|
8
|
+
* not stop the strip.
|
|
9
|
+
* - A seek lands on a key frame where the engine can (`fastSeek`, WebKit and
|
|
10
|
+
* Firefox): a thumbnail needs a nearby frame, not the exact one, and a key
|
|
11
|
+
* frame needs no decoding forward from the previous one.
|
|
12
|
+
* - Asking again replaces what is still queued: zooming or panning should not
|
|
13
|
+
* wait for stills of a stretch already scrolled away.
|
|
14
|
+
* - The canvas is only ever drawn, never read back, so a video from another
|
|
15
|
+
* origin without CORS still gives stills (a tainted canvas still shows).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SEEK_TIMEOUT_MS = 4000
|
|
19
|
+
const FRAME_TIMEOUT_MS = 250
|
|
20
|
+
const CACHE_LIMIT = 240
|
|
21
|
+
|
|
22
|
+
export interface FrameRequest {
|
|
23
|
+
key: string
|
|
24
|
+
time: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type VideoWithFrames = HTMLVideoElement & {
|
|
28
|
+
requestVideoFrameCallback?: (cb: () => void) => number
|
|
29
|
+
fastSeek?: (time: number) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class FrameSampler {
|
|
33
|
+
private queue: FrameRequest[] = []
|
|
34
|
+
private running = false
|
|
35
|
+
private disposed = false
|
|
36
|
+
private cache = new Map<string, HTMLCanvasElement>()
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly video: VideoWithFrames,
|
|
40
|
+
private readonly size: () => { width: number; height: number },
|
|
41
|
+
private readonly onFrame: (key: string, canvas: HTMLCanvasElement) => void,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
/** A frame taken earlier, if it is still kept. */
|
|
45
|
+
cached(key: string): HTMLCanvasElement | undefined {
|
|
46
|
+
const hit = this.cache.get(key)
|
|
47
|
+
if (hit) {
|
|
48
|
+
// Least recently used goes first: refresh this one's place.
|
|
49
|
+
this.cache.delete(key)
|
|
50
|
+
this.cache.set(key, hit)
|
|
51
|
+
}
|
|
52
|
+
return hit
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The frames wanted now, most wanted first; whatever was queued before is dropped. */
|
|
56
|
+
want(requests: FrameRequest[]): void {
|
|
57
|
+
this.queue = requests.filter((r) => !this.cache.has(r.key))
|
|
58
|
+
void this.pump()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
dispose(): void {
|
|
62
|
+
this.disposed = true
|
|
63
|
+
this.queue = []
|
|
64
|
+
this.cache.clear()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async pump() {
|
|
68
|
+
if (this.running) return
|
|
69
|
+
this.running = true
|
|
70
|
+
try {
|
|
71
|
+
while (!this.disposed && this.queue.length) {
|
|
72
|
+
const next = this.queue.shift()!
|
|
73
|
+
if (this.cache.has(next.key)) continue
|
|
74
|
+
const canvas = await this.take(next.time)
|
|
75
|
+
if (this.disposed) return
|
|
76
|
+
if (!canvas) continue
|
|
77
|
+
this.cache.set(next.key, canvas)
|
|
78
|
+
if (this.cache.size > CACHE_LIMIT) this.cache.delete(this.cache.keys().next().value as string)
|
|
79
|
+
this.onFrame(next.key, canvas)
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
this.running = false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async take(time: number): Promise<HTMLCanvasElement | null> {
|
|
87
|
+
const video = this.video
|
|
88
|
+
if (video.readyState < 1) {
|
|
89
|
+
const ready = await this.once("loadedmetadata", SEEK_TIMEOUT_MS)
|
|
90
|
+
if (!ready) return null
|
|
91
|
+
}
|
|
92
|
+
const target = Math.min(Math.max(0, time), Number.isFinite(video.duration) ? Math.max(0, video.duration - 0.05) : time)
|
|
93
|
+
const seeked = this.once("seeked", SEEK_TIMEOUT_MS)
|
|
94
|
+
if (video.fastSeek) video.fastSeek(target)
|
|
95
|
+
else video.currentTime = target
|
|
96
|
+
if (!(await seeked)) return null
|
|
97
|
+
await this.framePresented()
|
|
98
|
+
if (this.disposed || !video.videoWidth) return null
|
|
99
|
+
const { width, height } = this.size()
|
|
100
|
+
const ratio = Math.min(2, video.ownerDocument.defaultView?.devicePixelRatio ?? 1)
|
|
101
|
+
const canvas = video.ownerDocument.createElement("canvas")
|
|
102
|
+
canvas.width = Math.max(1, Math.round(width * ratio))
|
|
103
|
+
canvas.height = Math.max(1, Math.round(height * ratio))
|
|
104
|
+
const ctx = canvas.getContext("2d")
|
|
105
|
+
if (!ctx) return null
|
|
106
|
+
// Cover the tile, as object-fit: cover would: a strip of letterboxes reads as broken.
|
|
107
|
+
const scale = Math.max(canvas.width / video.videoWidth, canvas.height / video.videoHeight)
|
|
108
|
+
const w = video.videoWidth * scale
|
|
109
|
+
const h = video.videoHeight * scale
|
|
110
|
+
try {
|
|
111
|
+
ctx.drawImage(video, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h)
|
|
112
|
+
} catch {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
return canvas
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
* `seeked` says the position moved, not that its picture is ready to draw:
|
|
120
|
+
* Chromium can still hand the previous frame for a moment. Wait for the
|
|
121
|
+
* frame where the engine reports it, and a couple of paints where not.
|
|
122
|
+
*/
|
|
123
|
+
private framePresented(): Promise<void> {
|
|
124
|
+
const video = this.video
|
|
125
|
+
const win = video.ownerDocument.defaultView
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
let done = false
|
|
128
|
+
const finish = () => {
|
|
129
|
+
if (done) return
|
|
130
|
+
done = true
|
|
131
|
+
resolve()
|
|
132
|
+
}
|
|
133
|
+
if (video.requestVideoFrameCallback) {
|
|
134
|
+
video.requestVideoFrameCallback(finish)
|
|
135
|
+
win?.setTimeout(finish, FRAME_TIMEOUT_MS)
|
|
136
|
+
} else if (win) {
|
|
137
|
+
win.requestAnimationFrame(() => win.requestAnimationFrame(finish))
|
|
138
|
+
} else finish()
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private once(event: string, timeout: number): Promise<boolean> {
|
|
143
|
+
const video = this.video
|
|
144
|
+
const win = video.ownerDocument.defaultView
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
const finish = (ok: boolean) => {
|
|
147
|
+
video.removeEventListener(event, onEvent)
|
|
148
|
+
video.removeEventListener("error", onError)
|
|
149
|
+
if (timer) win?.clearTimeout(timer)
|
|
150
|
+
resolve(ok)
|
|
151
|
+
}
|
|
152
|
+
const onEvent = () => finish(true)
|
|
153
|
+
const onError = () => finish(false)
|
|
154
|
+
video.addEventListener(event, onEvent)
|
|
155
|
+
video.addEventListener("error", onError)
|
|
156
|
+
const timer = win?.setTimeout(() => finish(false), timeout)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Seconds one still stands for at this zoom: the shortest power of two that
|
|
163
|
+
* keeps a still at least `minPx` wide. Stills sit at fixed points in time, so
|
|
164
|
+
* panning reuses the ones already taken and a zoom step of two keeps half.
|
|
165
|
+
*/
|
|
166
|
+
export function stillSpan(secondsPerPx: number, minPx: number): number {
|
|
167
|
+
const wanted = secondsPerPx * minPx
|
|
168
|
+
let span = 1 / 8
|
|
169
|
+
while (span < wanted && span < 8192) span *= 2
|
|
170
|
+
return span
|
|
171
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The arithmetic behind LpVideoTrim, kept apart from the component so the
|
|
3
|
+
* rules a server will rely on (what range comes out, what a typed time means)
|
|
4
|
+
* are tested without a video.
|
|
5
|
+
*/
|
|
6
|
+
import { clamp } from "../composables/clamp"
|
|
7
|
+
import { shiftSpan, zoomSpan } from "../composables/span"
|
|
8
|
+
|
|
9
|
+
export type TimeRange = [number, number]
|
|
10
|
+
|
|
11
|
+
/** A rectangle on the video's frame, every value a fraction of it (0..1). */
|
|
12
|
+
export interface FrameRegion {
|
|
13
|
+
x: number
|
|
14
|
+
y: number
|
|
15
|
+
w: number
|
|
16
|
+
h: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RangeLimits {
|
|
20
|
+
duration: number
|
|
21
|
+
/** Shortest clip, seconds. */
|
|
22
|
+
minClip: number
|
|
23
|
+
/** Longest clip, seconds; Infinity for none. */
|
|
24
|
+
maxClip: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Milliseconds: finer than any frame, and what an ffmpeg -ss argument takes. */
|
|
28
|
+
const round = (t: number) => Math.round(t * 1000) / 1000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A typed time, the inverse of `formatClipTime`: "75", "75.5", "1:15",
|
|
32
|
+
* "75:00.5", and "1:02:03.25" for whoever types the hours. A comma reads as
|
|
33
|
+
* the decimal point, as a Russian keyboard's numpad types it. Anything else,
|
|
34
|
+
* a negative or a seconds field past 59 after a colon, is null.
|
|
35
|
+
*/
|
|
36
|
+
export function parseClipTime(text: string): number | null {
|
|
37
|
+
const value = text.trim().replace(",", ".")
|
|
38
|
+
if (!/^\d+(:\d{1,2}){0,2}(\.\d*)?$/.test(value)) return null
|
|
39
|
+
const parts = value.split(":")
|
|
40
|
+
let total = 0
|
|
41
|
+
for (const [i, part] of parts.entries()) {
|
|
42
|
+
const n = Number(part)
|
|
43
|
+
if (!Number.isFinite(n)) return null
|
|
44
|
+
if (i > 0 && n >= 60) return null
|
|
45
|
+
total = total * 60 + n
|
|
46
|
+
}
|
|
47
|
+
return total
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function limitsOf(l: RangeLimits) {
|
|
51
|
+
const duration = Math.max(0, l.duration)
|
|
52
|
+
const min = clamp(l.minClip, 0, duration)
|
|
53
|
+
const max = Math.max(min, l.maxClip)
|
|
54
|
+
return { duration, min, max }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A range that keeps every limit: inside the video, no shorter than minClip, no longer than maxClip. */
|
|
58
|
+
export function normalizeRange(range: TimeRange | null | undefined, limits: RangeLimits): TimeRange {
|
|
59
|
+
const { duration, min, max } = limitsOf(limits)
|
|
60
|
+
if (!range || !Number.isFinite(range[0]) || !Number.isFinite(range[1])) return [0, round(Math.min(duration, max))]
|
|
61
|
+
let [a, b] = range[0] <= range[1] ? range : [range[1], range[0]]
|
|
62
|
+
a = clamp(a, 0, Math.max(0, duration - min))
|
|
63
|
+
b = clamp(b, a + min, Math.min(duration, a + max))
|
|
64
|
+
return [round(a), round(b)]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One end dragged to `t`. The dragged end stops at a limit and the other end
|
|
69
|
+
* stays put: a handle that pushed its partner would move the clip's other
|
|
70
|
+
* edge without the person touching it.
|
|
71
|
+
*/
|
|
72
|
+
export function dragEnd(range: TimeRange, end: 0 | 1, t: number, limits: RangeLimits): TimeRange {
|
|
73
|
+
const { duration, min, max } = limitsOf(limits)
|
|
74
|
+
const [a, b] = range
|
|
75
|
+
if (end === 0) return [round(clamp(t, Math.max(0, b - max), b - min)), b]
|
|
76
|
+
return [a, round(clamp(t, a + min, Math.min(duration, a + max)))]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One end set to `t` on purpose (the I or O key, a typed time). That end goes
|
|
81
|
+
* where it was told, and the other follows when a limit asks for it.
|
|
82
|
+
*/
|
|
83
|
+
export function setEnd(range: TimeRange, end: 0 | 1, t: number, limits: RangeLimits): TimeRange {
|
|
84
|
+
const { duration, min, max } = limitsOf(limits)
|
|
85
|
+
const [a, b] = range
|
|
86
|
+
if (end === 0) {
|
|
87
|
+
const start = clamp(t, 0, duration - min)
|
|
88
|
+
return [round(start), round(clamp(b, start + min, Math.min(duration, start + max)))]
|
|
89
|
+
}
|
|
90
|
+
const stop = clamp(t, min, duration)
|
|
91
|
+
return [round(clamp(a, Math.max(0, stop - max), stop - min)), round(stop)]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TimeView {
|
|
95
|
+
start: number
|
|
96
|
+
end: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The view zoomed by `factor` (above 1 zooms in) around `anchor`, which stays where it is on screen. */
|
|
100
|
+
export function zoomView(view: TimeView, anchor: number, factor: number, duration: number, minSpan: number): TimeView {
|
|
101
|
+
const span = view.end - view.start
|
|
102
|
+
// No wider than the video: the shared zoom only floors the width, so cap the factor here.
|
|
103
|
+
const next = zoomSpan(view.start, view.end, anchor, Math.min(1 / factor, duration / (span || 1)), Math.min(minSpan, duration))
|
|
104
|
+
return shiftView(next, 0, duration)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The view moved by `delta` seconds, held inside the video. */
|
|
108
|
+
export function shiftView(view: TimeView, delta: number, duration: number): TimeView {
|
|
109
|
+
const span = Math.min(view.end - view.start, duration)
|
|
110
|
+
return shiftSpan(view.start, view.start + span, delta, 0, duration)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The view moved just enough to show `t`, with `margin` of its span to spare. */
|
|
114
|
+
export function revealTime(view: TimeView, t: number, duration: number, margin = 0.1): TimeView {
|
|
115
|
+
const span = view.end - view.start
|
|
116
|
+
const pad = span * margin
|
|
117
|
+
if (t < view.start + pad) return shiftView(view, t - pad - view.start, duration)
|
|
118
|
+
if (t > view.end - pad) return shiftView(view, t + pad - view.end, duration)
|
|
119
|
+
return view
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const TICK_STEPS = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200]
|
|
123
|
+
|
|
124
|
+
/** Seconds between ruler marks, so that marks stand at least `minPx` apart. */
|
|
125
|
+
export function tickStep(span: number, width: number, minPx = 72): number {
|
|
126
|
+
if (span <= 0 || width <= 0) return 1
|
|
127
|
+
const perPx = span / width
|
|
128
|
+
return TICK_STEPS.find((step) => step / perPx >= minPx) ?? TICK_STEPS[TICK_STEPS.length - 1]!
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The ruler's marks within the view. */
|
|
132
|
+
export function ticks(view: TimeView, step: number): number[] {
|
|
133
|
+
const out: number[] = []
|
|
134
|
+
const first = Math.ceil(view.start / step - 1e-9) * step
|
|
135
|
+
for (let t = first; t <= view.end + 1e-9 && out.length < 500; t += step) out.push(round(t))
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Where the picture sits in a box that shows it whole (object-fit: contain):
|
|
141
|
+
* the region is drawn on the picture, not on the black bars around it.
|
|
142
|
+
*/
|
|
143
|
+
export function containRect(boxWidth: number, boxHeight: number, videoWidth: number, videoHeight: number) {
|
|
144
|
+
if (!videoWidth || !videoHeight || !boxWidth || !boxHeight) return { x: 0, y: 0, width: boxWidth, height: boxHeight }
|
|
145
|
+
const scale = Math.min(boxWidth / videoWidth, boxHeight / videoHeight)
|
|
146
|
+
const width = videoWidth * scale
|
|
147
|
+
const height = videoHeight * scale
|
|
148
|
+
return { x: (boxWidth - width) / 2, y: (boxHeight - height) / 2, width, height }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fix = (n: number) => Math.round(n * 10000) / 10000
|
|
152
|
+
|
|
153
|
+
/** A region held inside the frame and no smaller than `minSize` a side. */
|
|
154
|
+
export function clampRegion(r: FrameRegion, minSize = 0.02): FrameRegion {
|
|
155
|
+
const w = clamp(r.w, minSize, 1)
|
|
156
|
+
const h = clamp(r.h, minSize, 1)
|
|
157
|
+
return { x: fix(clamp(r.x, 0, 1 - w)), y: fix(clamp(r.y, 0, 1 - h)), w: fix(w), h: fix(h) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The region spanned by two corners, in either order, cut to the frame. */
|
|
161
|
+
export function regionBetween(ax: number, ay: number, bx: number, by: number): FrameRegion {
|
|
162
|
+
const x0 = clamp(Math.min(ax, bx), 0, 1)
|
|
163
|
+
const y0 = clamp(Math.min(ay, by), 0, 1)
|
|
164
|
+
const x1 = clamp(Math.max(ax, bx), 0, 1)
|
|
165
|
+
const y1 = clamp(Math.max(ay, by), 0, 1)
|
|
166
|
+
return { x: fix(x0), y: fix(y0), w: fix(x1 - x0), h: fix(y1 - y0) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type RegionHandle = "move" | "nw" | "ne" | "sw" | "se"
|
|
170
|
+
|
|
171
|
+
/** The region after one of its handles moved by (dx, dy), fractions of the frame. */
|
|
172
|
+
export function adjustRegion(r: FrameRegion, handle: RegionHandle, dx: number, dy: number, minSize = 0.02): FrameRegion {
|
|
173
|
+
if (handle === "move") return clampRegion({ ...r, x: r.x + dx, y: r.y + dy }, minSize)
|
|
174
|
+
let x0 = r.x
|
|
175
|
+
let y0 = r.y
|
|
176
|
+
let x1 = r.x + r.w
|
|
177
|
+
let y1 = r.y + r.h
|
|
178
|
+
if (handle === "nw" || handle === "sw") x0 = clamp(x0 + dx, 0, x1 - minSize)
|
|
179
|
+
else x1 = clamp(x1 + dx, x0 + minSize, 1)
|
|
180
|
+
if (handle === "nw" || handle === "ne") y0 = clamp(y0 + dy, 0, y1 - minSize)
|
|
181
|
+
else y1 = clamp(y1 + dy, y0 + minSize, 1)
|
|
182
|
+
return { x: fix(x0), y: fix(y0), w: fix(x1 - x0), h: fix(y1 - y0) }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Why the editor has no video to show, as its `error` event reports it:
|
|
187
|
+
* - "load": the address did not answer (network, 404, an expired session);
|
|
188
|
+
* - "decode": the file arrived but is damaged;
|
|
189
|
+
* - "unsupported": a format or codec this browser cannot play (H.265 in
|
|
190
|
+
* Chromium without hardware support, ProRes anywhere).
|
|
191
|
+
*/
|
|
192
|
+
export type VideoTrimErrorCause = "load" | "decode" | "unsupported"
|
|
193
|
+
|
|
194
|
+
export function mediaErrorCause(error: MediaError | null): VideoTrimErrorCause {
|
|
195
|
+
if (error?.code === 3) return "decode"
|
|
196
|
+
if (error?.code === 4) return "unsupported"
|
|
197
|
+
return "load"
|
|
198
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A window onto a line (a chart's points, a video's seconds): zoomed around a
|
|
3
|
+
* point that stays put, or slid along without changing its width. Shared by
|
|
4
|
+
* LpChart's window and LpVideoTrim's timeline, which differ only in what they
|
|
5
|
+
* round to and how they hold the result inside their bounds.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The window zoomed by `factor` (below 1 is in) around `anchor`, a position on the line; no wider than it was asked, no narrower than `minSpan`. */
|
|
9
|
+
export function zoomSpan(start: number, end: number, anchor: number, factor: number, minSpan: number): { start: number; end: number } {
|
|
10
|
+
const span = end - start
|
|
11
|
+
const next = Math.max(minSpan, span * factor)
|
|
12
|
+
const from = anchor - (anchor - start) * (next / (span || 1))
|
|
13
|
+
return { start: from, end: from + next }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The window moved by `delta`, its width kept, stopped at `lo` and `hi`. */
|
|
17
|
+
export function shiftSpan(start: number, end: number, delta: number, lo: number, hi: number): { start: number; end: number } {
|
|
18
|
+
const span = end - start
|
|
19
|
+
const from = Math.max(lo, Math.min(hi - span, start + delta))
|
|
20
|
+
return { start: from, end: from + span }
|
|
21
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -226,6 +226,9 @@ export { default as LpRangeSlider } from "./components/LpRangeSlider.vue"
|
|
|
226
226
|
export { default as LpVideoBackdrop } from "./components/LpVideoBackdrop.vue"
|
|
227
227
|
export { formatClipTime, isNativeLoop, planLoop, seamProgress, seamReached } from "./components/videoBackdrop"
|
|
228
228
|
export type { LoopPlan } from "./components/videoBackdrop"
|
|
229
|
+
export { default as LpVideoTrim } from "./components/LpVideoTrim.vue"
|
|
230
|
+
export { dragEnd, normalizeRange as normalizeTrimRange, parseClipTime, setEnd } from "./components/videoTrim"
|
|
231
|
+
export type { FrameRegion, TimeRange, VideoTrimErrorCause } from "./components/videoTrim"
|
|
229
232
|
export { default as LpSparkline } from "./components/LpSparkline.vue"
|
|
230
233
|
export { default as LpStat } from "./components/LpStat.vue"
|
|
231
234
|
export { default as LpStepper } from "./components/LpStepper.vue"
|