threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
771 lines (671 loc) • 30.3 kB
text/typescript
import {Object3D, Vector3} from 'three'
import {Easing} from '@repalash/popmotion'
import {AViewerPluginEventMap, AViewerPluginSync, ThreeViewer} from '../../viewer'
import {Box3B} from '../../three'
import {onChange, onChange3, serialize, timeout} from 'ts-browser-helpers'
import {generateUiConfig, uiButton, uiDropdown, uiInput, UiObjectConfig, uiSlider, uiToggle} from 'uiconfig.js'
import {EasingFunctions, EasingFunctionType} from '../../utils'
import {CameraView, createCameraPath, ICamera, ICameraView, IGeometry, IMaterial, IObject3D, ITexture} from '../../core'
import {AnimationResult, PopmotionPlugin} from './PopmotionPlugin'
import {InteractionPromptPlugin} from '../interaction/InteractionPromptPlugin'
import {getFittingDistance} from '../../three/utils/camera'
export interface CameraViewPluginOptions{duration?: number, ease?: EasingFunctionType, interpolateMode?: 'spherical'|'linear'}
export interface CameraViewPluginEventMap extends AViewerPluginEventMap{
viewChange: {view: CameraView}
startViewChange: {view: CameraView}
viewAdd: {view: CameraView}
viewDelete: {view: CameraView}
viewUpdate: {view: CameraView}
update: {key?: string}
}
/**
* Camera View Plugin
*
* Provides API to save, interact and animate and loop between with multiple camera states/views using the {@link PopmotionPlugin}.
*
*/
export class CameraViewPlugin extends AViewerPluginSync<CameraViewPluginEventMap> {
static readonly PluginType = 'CameraViews'
enabled = true
// get dirty() { // todo: issue with recorder convergeMode?
// return this._animating
// }
constructor(options: CameraViewPluginOptions = {}) {
super()
this.addCurrentView = this.addCurrentView.bind(this)
this.resetToFirstView = this.resetToFirstView.bind(this)
this.animateAllViews = this.animateAllViews.bind(this)
// this.recordAllViews = this.recordAllViews.bind(this)
// this._wheel = this._wheel.bind(this)
// this._pointerMove = this._pointerMove.bind(this)
this._postFrame = this._postFrame.bind(this)
this.setDirty = this.setDirty.bind(this)
this.animDuration = options.duration ?? this.animDuration
this.animEase = options.ease ?? this.animEase
this.interpolateMode = options.interpolateMode ?? this.interpolateMode
}
private _cameraViews: CameraView[] = []
get cameraViews(): CameraView[] {
return this._cameraViews
}
get camViews(): CameraView[] {
return this._cameraViews
}
/**
* Loop all views indefinitely.
*/
viewLooping = false
/**
* Pauses time between view changes when animating all views or looping.
*/
viewPauseTime = 200
/**
* {@link EasingFunctions}
*/
animEase: EasingFunctionType = 'easeInOutSine' // ms
animDuration = 1000 // ms
interpolateMode: 'spherical'|'linear'|'spline' = 'spherical'
// todo spline
// @serialize() @uiDropdown('Spline Curve', ['centripetal', 'chordal', 'catmullrom'].map((label:string)=>({label})), (t: CameraViewPlugin)=>({hidden: ()=>t.interpolateMode !== 'spline', onChange: ()=>t.uiConfig?.uiRefresh?.()}))
// splineCurve: 'centripetal'|'chordal'|'catmullrom' = 'chordal'
// not used
// @onChange3('setDirty')
// @uiSlider('RotationOffset', [0.2, 0.75], 0.01)
rotationOffset = 0.25
private _animating = false
get animating(): boolean {
return this._animating
}
dependencies = [PopmotionPlugin]
// private _updaters: {u: ((timestamp: number) => void), time: number}[] = []
// private _lastFrameTime = 0 // for post frame
onAdded(viewer: ThreeViewer): void {
super.onAdded(viewer)
// todo: move to PopmotionPlugin
// todo: remove event listener
viewer.addEventListener('preFrame', (_: any)=>{
// console.log(ev.deltaTime)
// this._updaters.forEach(u=>{
// let dt = ev.deltaTime
// if (u.time + dt < 0) dt = -u.time
// u.time += dt
// if (Math.abs(dt) > 0.001)
// u.u(dt)
// })
})
viewer.addEventListener('postFrame', this._postFrame)
// window.addEventListener('wheel', this._wheel)
// window.addEventListener('pointermove', this._pointerMove)
}
onRemove(viewer: ThreeViewer): void {
viewer.removeEventListener('postFrame', this._postFrame)
// window.removeEventListener('wheel', this._wheel)
// window.removeEventListener('pointermove', this._pointerMove)
return super.onRemove(viewer)
}
public async resetToFirstView(duration = 100) {
if (this.isDisabled()) return
this._currentView = undefined
await this.animateToView(0, duration)
await timeout(2)
}
async addCurrentView() {
if (this.isDisabled()) return
const camera = this._viewer?.scene.mainCamera
if (!camera) return
const view = this.getView(camera)
this.addView(view)
view.name = 'View ' + this._cameraViews.length
return view
}
addView(view: CameraView, force = false) {
view.addEventListener('setView', this._viewSetView as any)
view.addEventListener('updateView', this._viewUpdateView as any)
view.addEventListener('deleteView', this._viewDeleteView as any)
view.addEventListener('animateView', this._viewAnimateView as any)
view.addEventListener('update', this._viewUpdated)
const incl = this._cameraViews.includes(view)
if (!incl || force) {
if (!incl) this._cameraViews.push(view)
this.setDirty({key: 'cameraViews', change: 'viewAdd'})
this.dispatchEvent({type: 'viewAdd', view})
}
}
deleteView(view: CameraView, force = false) {
const i = this._cameraViews.indexOf(view)
view.removeEventListener('setView', this._viewSetView as any)
view.removeEventListener('updateView', this._viewUpdateView as any)
view.removeEventListener('deleteView', this._viewDeleteView as any)
view.removeEventListener('animateView', this._viewAnimateView as any)
view.removeEventListener('update', this._viewUpdated)
if (i >= 0 || force) {
if (i >= 0) this._cameraViews.splice(i, 1)
this.setDirty({key: 'cameraViews', change: 'viewDelete'})
this.dispatchEvent({type: 'viewDelete', view})
}
}
getView(camera?: ICamera, worldSpace = true, view?: CameraView) {
camera = camera || this._viewer?.scene.mainCamera
if (!camera) return view ?? new CameraView()
return camera.getView(worldSpace, view)
}
setView(view: ICameraView, camera?: ICamera) {
camera = camera || this._viewer?.scene.mainCamera
if (!camera) return
camera.setView(view)
}
private _currentView: CameraView | undefined
focusNext = (wrap = true)=>{
if (this._animating) return
if (this._cameraViews.length < 2) return
let index = this._cameraViews.findIndex(v=>v === this._currentView)
if (index < 0) index = -1 // first view
index = index + 1
if (!wrap) index = Math.min(index, this._cameraViews.length - 1)
else index = index % this._cameraViews.length
this.animateToView(index)
}
focusPrevious = (wrap = true)=> {
if (this._animating) return
if (this._cameraViews.length < 2 || !this._currentView) return
let index = this._cameraViews.findIndex(v=>v === this._currentView)
if (index < 0) index = 0 // last view
index = index - 1
if (!wrap) index = Math.max(index, 0)
else index = (index + this._cameraViews.length) % this._cameraViews.length
this.animateToView(index)
}
private _popAnimations: AnimationResult[] = []
async animateToView(_view: CameraView|number|string, duration?: number, easing?: Easing|EasingFunctionType, camera?: ICamera, throwOnStop = false) {
camera = camera || this._viewer?.scene.mainCamera
if (!camera) return
// if (this._currentView === view) return // todo: also check if the camera is at the correct position and orientation, till then use resetToFirstView to reset current view
if (this._animating) {
this._popAnimations.forEach(a=>a?.stop && a.stop()) // don't call stopAllAnimations here, as it sets viewLooping to false and changes config.
this._popAnimations = []
let i = 0
while (this._animating) {
await timeout(100)
if (i++ > 20) { // 2s timeout
break
}
}
if (this._animating) {
console.warn('Unable to stop all animations, maybe because of viewLooping?')
return
}
}
const view = typeof _view === 'number' ? this._cameraViews[_view] :
typeof _view === 'string' ? this._cameraViews.find(v=>v.name === _view) :
_view
if (!view) {
this._viewer?.console.warn('Invalid view', _view)
return
}
const interactionPrompt = this._viewer?.getPlugin(InteractionPromptPlugin)
if (interactionPrompt && interactionPrompt.animationRunning) {
await interactionPrompt.stopAnimation({reset: true})
}
this._currentView = view
this._animating = true
camera.setInteractions && camera.setInteractions(false, CameraViewPlugin.PluginType) // todo: also for seekOnScroll
if (!camera.userData.autoLookAtTarget) {
console.warn('CameraViewPlugin: camera autoLookAtTarget is disabled, camera look at might not be correct during animation')
}
this.dispatchEvent({type: 'startViewChange', view})
const popmotion = this._viewer?.getPlugin(PopmotionPlugin)
if (!popmotion) throw new Error('PopmotionPlugin not found')
if (duration === undefined) duration = this.animDuration
const ease: any = (typeof easing === 'function' ? easing : EasingFunctions[easing || this.animEase]) as (x: number) => number
// const ease = (x:number)=>x
// const driver = this._driver
this._popAnimations = []
// const viewIndex = this.camViews.indexOf(view)
// let interpolateMode = this.interpolateMode
// if (viewIndex < 0) {
// if (interpolateMode === 'spline') {
// console.warn('CameraViewPlugin - Cannot animate along a spline with external camera view, fallback to spherical')
// interpolateMode = 'spherical'
// }
// }
//
// if (interpolateMode === 'spline') {
// const points = this.camViews.map(c=>c.position.clone())
// const spline = new CatmullRomCurve3(points, true, this.splineCurve)
//
// const getPosition = (t: number)=>{
// const v = new Vector3()
// const ip = 1. / points.length
// const i = viewIndex === 0 ? points.length : viewIndex
// const d = (i - 1) * ip
// spline.getPointAt(d + t * ip, v)
// return v
// }
//
// pms.push(animateAsync({
// // from: camera.position.clone(),
// // to: view.position.clone(),
// from: 0,
// to: 1,
// duration, ease, driver,
// onUpdate: (v) => camera.position = getPosition(v),
// onComplete: () => camera.position = getPosition(1), // camera.position = view.position,
// onStop: ()=> {
// throw new Error('Animation stopped')
// },
// }, popAnimations))
// // if (new Vector3().subVectors(camera.cameraObject.up, view.up).length() > 0.1)
// // pms.push(animateAsync({
// // from: camera.cameraObject.up.clone(),
// // to: view.up.clone(),
// // duration, ease, driver,
// // onUpdate: (v) => camera.cameraObject.up.copy(v),
// // onComplete: () => camera.cameraObject.up.copy(view.up),
// // }))
// // if (new Vector3().subVectors(camera.target, view.target).length() > 0.1)
// pms.push(animateAsync({
// from: camera.target.clone(),
// to: view.target.clone(),
// duration, ease, driver,
// onUpdate: (v) => {
// camera.target = v
// camera.targetUpdated()
// },
// onComplete: () => {
// camera.target = view.target
// camera.targetUpdated()
// },
// }, popAnimations))
// }
await popmotion.animateCameraAsync(camera, view, this.interpolateMode === 'spherical', {ease, duration}, this._popAnimations)
.catch((e)=>{
// console.error(e)
if (throwOnStop) throw e
})
this._viewer?.scene.mainCamera.setInteractions(true, CameraViewPlugin.PluginType)
this._animating = false
this._viewer?.setDirty()
this.dispatchEvent({type: 'viewChange', view})
await timeout(10)
}
async animateAllViews() {
if (this.isDisabled()) return
if (this.viewLooping || this._cameraViews.length < 2) return
while (this._viewQueue.length > 0) this._viewQueue.pop()
this._viewQueue.push(...this._cameraViews)
this._viewQueue.push(this._viewQueue.shift()!)
this._infiniteLooping = false
await this._animationLoop()
this._infiniteLooping = true
}
async stopAllAnimations() {
this.viewLooping = false
this._popAnimations.forEach(a => a?.stop?.())
this._popAnimations = []
while (this._animating || this._animationLooping) {
await timeout(100)
}
}
fromJSON(data: any, meta?: any): this | null {
this._cameraViews.forEach(v=>this.deleteView(v)) // deserialize pushes to the existing array
if (super.fromJSON(data, meta)) {
this._cameraViews.forEach(v=>this.addView(v, true))
this.uiConfig?.uiRefresh?.()
return this
}
return null
}
setDirty(ops?: any): any {
this.uiConfig?.uiRefresh?.(false, 'postFrame')
this.dispatchEvent({...ops, type: 'update'})
}
public async animateToObject(selected?: Object3D, distanceMultiplier = 4, duration?: number, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 5.0}) {
if (!this._viewer) return
const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot, false, true)
const center = bbox.getCenter(new Vector3())
const size = bbox.getSize(new Vector3())
const radius = size.length() / 2
await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, radius * distanceMultiplier)), center, duration, ease)
}
public async animateToFitObject(selected?: Object3D|Object3D[]|IMaterial|IMaterial[]|ITexture|ITexture[]|IGeometry|IGeometry[], distanceMultiplier = 1.5, duration = 1000, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 50.0}) {
if (!this._viewer) return
const selectedArray = (Array.isArray(selected) ? selected : selected ? [selected] : [])
.flatMap(o =>
(o as ITexture)?.isTexture ? [...(o as ITexture).appliedObjects?.values() || []] : o)
.flatMap(o =>
(o as IMaterial)?.isMaterial ? [...(o as IMaterial).appliedMeshes?.values() || []] :
(o as IGeometry)?.isBufferGeometry ? [...(o as IGeometry).appliedMeshes?.values() || []] :
o as IObject3D)
.filter(Boolean)
const obj = !selectedArray.length ? this._viewer.scene.modelRoot : selectedArray[0]
const bbox = new Box3B().expandByObject(obj, false, true)
for (let i = 1; i < selectedArray.length; i++) {
bbox.expandByObject(selectedArray[i], false, true)
}
const cameraZ = getFittingDistance(this._viewer.scene.mainCamera, bbox)
const center = bbox.getCenter(new Vector3()) // world position
const scale = bbox.getSize(new Vector3())
if (scale.lengthSq() <= 0) { // It could be a light or camera with no geometry
obj.getWorldPosition(center)
}
await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, cameraZ * distanceMultiplier)), center, duration, ease)
}
/**
*
* @param distanceFromTarget - in world units
* @param center - target (center) of the view in world coordinates
* @param duration - in milliseconds
* @param ease
*/
public async animateToTarget(distanceFromTarget: number, center: Vector3, duration?: number, ease?: Easing|EasingFunctionType) {
const view = this.getView() // world space
view.target.copy(center)
const direction = new Vector3().subVectors(view.target, view.position).normalize()
view.position.copy(direction.multiplyScalar(-distanceFromTarget).add(view.target))
await this.animateToView(view, duration, ease)
}
uiConfig: UiObjectConfig = {
type: 'folder',
label: 'Camera Views',
// expanded: true,
children: [
()=>[...this._cameraViews.map(view => view.uiConfig)],
...generateUiConfig(this) || [],
],
}
get animationLooping(): boolean {
return this._animationLooping
}
private _viewQueue: CameraView[] = []
private _animationLooping = false
private _infiniteLooping = true
private async _animationLoop() {
if (this._animationLooping) return
this._animationLooping = true
while (this.viewLooping || !this._infiniteLooping) {
if (this.isDisabled()) break
if (this._cameraViews.length < 1) break
if (this._viewQueue.length === 0) {
if (this._infiniteLooping) this._viewQueue.push(...this._cameraViews)
else break
}
await this.animateToView(this._viewQueue.shift()!)
await timeout(2 + this.viewPauseTime) // ms delay
}
this._animationLooping = false
}
protected _viewSetView = ({view, camera}: {view?: CameraView, camera?: ICamera}) => {
if (!view) {
this._viewer?.console.warn('Invalid view', view)
return
}
this.setView(view, camera)
}
protected _viewUpdateView = ({view, camera}: {view: CameraView, camera?: ICamera}) => {
if (!view) {
this._viewer?.console.warn('Invalid view', view)
return
}
const name = view.name
this.getView(camera, view.isWorldSpace ?? true, view)
view.name = name
}
protected _viewDeleteView = ({view}: {view: CameraView}) => {
if (!view) {
this._viewer?.console.warn('Invalid view', view)
return
}
this.deleteView(view)
}
protected _viewAnimateView = async({view, camera, duration, easing, throwOnStop}: {view: CameraView, camera?: ICamera, duration?: number, easing?: Easing|EasingFunctionType, throwOnStop?: boolean}) => {
if (!view) {
this._viewer?.console.warn('Invalid view', view)
return
}
return this.animateToView(view, duration || this.animDuration, easing || this.animEase, camera, throwOnStop)
}
protected _viewUpdated = async(e: {target: ICameraView, key?: string}) => {
if (!this._cameraViews.includes(e.target as any)) return
this.dispatchEvent({type: 'viewUpdate', view: e.target as CameraView})
this.setDirty({key: 'cameraViews', change: 'viewUpdate'})
}
// region deprecated
/**
* @deprecated - renamed to {@link getView} or {@link ICamera.getView}
* @param camera
* @param worldSpace
*/
getCurrentCameraView(camera?: ICamera, worldSpace = true) {
return this.getView(camera, worldSpace)
}
/**
* @deprecated - renamed to {@link setView} or {@link ICamera.setView}
* @param view
*/
setCurrentCameraView(view: CameraView) {
return this.setView(view)
}
/**
* @deprecated - use {@link animateToView} instead
* @param view
*/
async focusView(view: CameraView) {
return this.animateToView(view)
}
// endregion
private _lastAnimTime = -1
protected _postFrame() {
if (!this.enabled || !this._viewer) return
const camera = this._viewer.scene.mainCamera
if (!camera) return
if (!this._viewer.timeline.shouldRun() || !this._cameraViews.length) {
camera.setInteractions(true, CameraViewPlugin.PluginType + '-postFrame')
this._lastAnimTime = -1
return
}
camera.setInteractions(false, CameraViewPlugin.PluginType + '-postFrame')
const time = this._viewer.timeline.time
// const delta = this._viewer.timeline.delta || 0
if (time == this._lastAnimTime) return
this._lastAnimTime = time
const timeline = []
const viewDuration = this.animDuration || 1000
const pauseTime = this.viewPauseTime || 0
const views = this._cameraViews
let time1 = 0
for (let i = 0; i < views.length; i++) {
const view = views[i]
const duration = Math.max(2, view.duration * viewDuration) / 1000
timeline.push({
time: time1,
index: i,
duration: duration,
})
time1 += duration + pauseTime / 1000
}
const selectedTime = timeline
.sort((a, b) => -a.time + b.time)
.find(t => t.time <= time)
if (!selectedTime) return // todo?
const viewIndex = selectedTime.index
const start = selectedTime.time
const duration = selectedTime.duration ?? 0.5
const t = duration < 1e-6 ? 1 : (time - start) / duration
// const dt = duration < 1e-6 ? 0 : delta / duration
if (t > 1) return // todo?
// todo cache path
const {getPosition, getTarget} = createCameraPath(this.camViews)
getPosition(t, viewIndex, camera.position)
getTarget(t, viewIndex, camera.target)
camera.setDirty()
return true
}
// region to be ported to other plugins
// /**
// * For slight rotation of camera when seekOnScroll is enabled
// */
// private _pointerMove(ev: PointerEvent) {
// if (this.isDisabled()) return
// if (!this._animating && this.seekOnScroll) {
// const cam = this._viewer?.scene.mainCamera
// if (!cam) return
// const s = new Spherical()
// const p = cam.position
// const t = cam.target
// const q = new Quaternion().setFromUnitVectors(cam.cameraObject.up, new Vector3(0, 1, 0))
// const qi = q.clone().invert()
// const offset = p.clone().sub(t)
// offset.applyQuaternion(q)
// s.setFromVector3(offset)
// s.theta += this.rotationOffset * ev.movementX / this._viewer!.canvas!.clientWidth
// s.phi += this.rotationOffset * ev.movementY / this._viewer!.canvas!.clientHeight
// s.makeSafe()
// offset.setFromSpherical(s)
// offset.applyQuaternion(qi)
// p.copy(t).add(offset)
// cam.setDirty()
// }
// }
// // @uiToggle() @serialize()
// animateOnScroll = false // buggy
//
// @uiToggle() @serialize()
// seekOnScroll = false
// private _scrollAnimationState = 0
// scrollAnimationDamping = 0.1
// private _wheel(ev: any | WheelEvent) {
// if (this.isDisabled()) return
// if (this.seekOnScroll && !this._animating) {
// // if (ev.deltaY > 0) this.focusNext(false)
// // else this.focusPrevious(false)
// } else if (Math.abs(ev.deltaY) > 0.001) {
// this._scrollAnimationState = -1. * Math.sign(ev.deltaY)
// }
// }
// private _driver: Driver = (update)=>{
// return {
// start: ()=>this._updaters.push({u:update, time:0}),
// stop: ()=> this._updaters.splice(this._updaters.findIndex(u=>u.u === update), 1),
// }
// }
// private _fadeDisabled = false
// todo: same code used in PopmotionPlugin, merge somehow
// private _postFrame() {
// if (!this._viewer) return
// if (this.isDisabled() || !this._animating) {
// this._lastFrameTime = 0
// if (this._fadeDisabled) {
// this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')?.enable(CameraViewPlugin.PluginType)
// this._fadeDisabled = false
// }
// // console.log('not anim')
// return
// }
// const time = now() / 1000.0
// if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 60.0
// let delta = time - this._lastFrameTime
// this._lastFrameTime = time
// delta = delta * (this.animateOnScroll ? this._scrollAnimationState : 1)
//
// const d = this._viewer.getPluginByType<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta()
// if (d && d > 0) delta = d
// if (d === 0) return // not converged yet.
// // if d < 0: not recording, do nothing
//
// delta *= 1000
//
// // delta = 16.666
//
// // console.log(delta)
// // console.log(dt)
// //
//
// if (delta <= 0) return
//
// this._updaters.forEach(u=>{
// let dt = delta
// if (u.time + dt < 0) dt = -u.time
// u.time += dt
// if (Math.abs(dt) > 0.001)
// u.u(dt)
// })
// if (this._scrollAnimationState < 0.001) this._scrollAnimationState = 0
// else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping
//
// if (!this._fadeDisabled) {
// const ff = this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')
// if (ff) {
// ff.disable(CameraViewPlugin.PluginType)
// this._fadeDisabled = true
// }
// }
// }
// @uiButton('Record All Views')
// public async recordAllViews(onStart?: ()=>void, downloadOnEnd = true) {
// if (this.isDisabled()) return
// const recorder = this._viewer?.getPluginByType<CanvasRecorderPlugin>('CanvasRecorder')
// if (!recorder || !recorder.enabled) return
// if (this._cameraViews.length < 1) return
// if (recorder.isRecording()) {
// console.error('CanvasRecorderPlugin is already recording')
// return
// }
// let looping = false
// if (this.viewLooping) {
// looping = true
// this.viewLooping = false
// }
// await this.resetToFirstView()
// return new Promise<Blob|undefined>((resolve, reject) => {
// const listener2 = ()=>{
// recorder.removeEventListener('start', listenerStart)
// recorder.removeEventListener('stop', listener2)
// recorder.removeEventListener('error', listenerError)
// }
// const listenerStart = async() => {
// listener2()
// onStart?.()
// await this.animateAllViews()
// const blob = await recorder.stopRecording()
// if (looping) this.viewLooping = true
// if (downloadOnEnd) {
// const name = await this._viewer?.prompt('Canvas Recorder: Save file as', 'recording.mp4')
// if (name !== null && blob) await this._downloadBlob(blob, name || 'recording.mp4')
// }
// resolve(blob)
// }
// const listenerError = async() => {
// listener2()
// reject()
// }
// recorder.addEventListener('start', listenerStart)
// recorder.addEventListener('stop', listener2)
// recorder.addEventListener('error', listenerError)
// if (!recorder.startRecording()) {
// console.error('cannot start recording')
// return
// }
// })
// }
// private async _downloadBlob(blob: Blob, name: string) {
// const tr = this._viewer?.getPluginByType<FileTransferPlugin>('FileTransferPlugin')
// if (!tr) {
// this._viewer?.console.error('FileTransferPlugin required to export/download file')
// return
// }
// await tr.exportFile(blob, name)
// }
// endregion
}