threepipe
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

CameraViewPlugin.ts 22KB

2 лет назад
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import {Object3D, Vector3} from 'three'
  2. import {Easing} from 'popmotion'
  3. import {AViewerPluginSync, ThreeViewer} from '../../viewer'
  4. import {Box3B} from '../../three'
  5. import {onChange, serialize, timeout} from 'ts-browser-helpers'
  6. import {generateUiConfig, uiButton, uiDropdown, uiInput, UiObjectConfig, uiSlider, uiToggle} from 'uiconfig.js'
  7. import {EasingFunctions, EasingFunctionType} from '../../utils'
  8. import {CameraView, ICamera, ICameraView} from '../../core'
  9. import {AnimationResult, PopmotionPlugin} from './PopmotionPlugin'
  10. import {InteractionPromptPlugin} from '../interaction/InteractionPromptPlugin'
  11. import {getFittingDistance} from '../../three/utils/camera'
  12. export interface CameraViewPluginOptions{duration?: number, ease?: EasingFunctionType, interpolateMode?: 'spherical'|'linear'}
  13. /**
  14. * Camera View Plugin
  15. *
  16. * Provides API to save, interact and animate and loop between with multiple camera states/views using the {@link PopmotionPlugin}.
  17. *
  18. */
  19. export class CameraViewPlugin extends AViewerPluginSync<'viewChange'|'startViewChange'|'viewAdd'|'viewDelete'> {
  20. static readonly PluginType = 'CameraViews'
  21. enabled = true
  22. // get dirty() { // todo: issue with recorder convergeMode?
  23. // return this._animating
  24. // }
  25. constructor(options: CameraViewPluginOptions = {}) {
  26. super()
  27. this.addCurrentView = this.addCurrentView.bind(this)
  28. this.resetToFirstView = this.resetToFirstView.bind(this)
  29. this.animateAllViews = this.animateAllViews.bind(this)
  30. // this.recordAllViews = this.recordAllViews.bind(this)
  31. // this._wheel = this._wheel.bind(this)
  32. // this._pointerMove = this._pointerMove.bind(this)
  33. // this._postFrame = this._postFrame.bind(this)
  34. this.animDuration = options.duration ?? this.animDuration
  35. this.animEase = options.ease ?? this.animEase
  36. this.interpolateMode = options.interpolateMode ?? this.interpolateMode
  37. }
  38. @serialize('cameraViews')
  39. private _cameraViews: CameraView[] = []
  40. get cameraViews(): CameraView[] {
  41. return this._cameraViews
  42. }
  43. get camViews(): CameraView[] {
  44. return this._cameraViews
  45. }
  46. @onChange(CameraViewPlugin.prototype._animationLoop)
  47. /**
  48. * Loop all views indefinitely.
  49. */
  50. @serialize() @uiToggle('Loop All Views') viewLooping = false
  51. /**
  52. * Pauses time between view changes when animating all views or looping.
  53. */
  54. @serialize() @uiInput('View Pause Time') viewPauseTime = 200
  55. /**
  56. * {@link EasingFunctions}
  57. */
  58. @serialize() @uiDropdown('Ease', Object.keys(EasingFunctions).map((label:string)=>({label}))) animEase: EasingFunctionType = 'easeInOutSine' // ms
  59. @serialize() @uiSlider('Duration', [10, 10000], 10) animDuration = 1000 // ms
  60. @serialize() @uiDropdown('Interpolation', ['spherical', 'linear'].map((label:string)=>({label})))
  61. interpolateMode: 'spherical'|'linear' = 'spherical'
  62. // not used
  63. @serialize()
  64. // @uiSlider('RotationOffset', [0.2, 0.75], 0.01)
  65. rotationOffset = 0.25
  66. private _animating = false
  67. get animating(): boolean {
  68. return this._animating
  69. }
  70. dependencies = [PopmotionPlugin]
  71. // private _updaters: {u: ((timestamp: number) => void), time: number}[] = []
  72. // private _lastFrameTime = 0 // for post frame
  73. onAdded(viewer: ThreeViewer): void {
  74. super.onAdded(viewer)
  75. // todo: move to PopmotionPlugin
  76. // todo: remove event listener
  77. viewer.addEventListener('preFrame', (_: any)=>{
  78. // console.log(ev.deltaTime)
  79. // this._updaters.forEach(u=>{
  80. // let dt = ev.deltaTime
  81. // if (u.time + dt < 0) dt = -u.time
  82. // u.time += dt
  83. // if (Math.abs(dt) > 0.001)
  84. // u.u(dt)
  85. // })
  86. })
  87. // viewer.addEventListener('postFrame', this._postFrame)
  88. // window.addEventListener('wheel', this._wheel)
  89. // window.addEventListener('pointermove', this._pointerMove)
  90. }
  91. onRemove(viewer: ThreeViewer): void {
  92. // viewer.removeEventListener('postFrame', this._postFrame)
  93. // window.removeEventListener('wheel', this._wheel)
  94. // window.removeEventListener('pointermove', this._pointerMove)
  95. return super.onRemove(viewer)
  96. }
  97. @uiButton('Reset To First View', {sendArgs: false})
  98. public async resetToFirstView(duration = 100) {
  99. if (this.isDisabled()) return
  100. this._currentView = undefined
  101. await this.animateToView(0, duration)
  102. await timeout(2)
  103. }
  104. @uiButton('Add Current View')
  105. async addCurrentView() {
  106. if (this.isDisabled()) return
  107. const camera = this._viewer?.scene.mainCamera
  108. if (!camera) return
  109. const view = this.getView(camera)
  110. this.addView(view)
  111. view.name = 'View ' + this._cameraViews.length
  112. return view
  113. }
  114. addView(view: CameraView) {
  115. if (!this._cameraViews.includes(view)) this._cameraViews.push(view)
  116. view.addEventListener('setView', this._viewSetView as any)
  117. view.addEventListener('updateView', this._viewUpdateView as any)
  118. view.addEventListener('deleteView', this._viewDeleteView as any)
  119. view.addEventListener('animateView', this._viewAnimateView as any)
  120. this.uiConfig.uiRefresh?.()
  121. this.dispatchEvent({type: 'viewAdd', view})
  122. }
  123. protected _viewSetView = ({view, camera}: {view?: CameraView, camera?: ICamera}) => {
  124. if (!view) {
  125. this._viewer?.console.warn('Invalid view', view)
  126. return
  127. }
  128. this.setView(view, camera)
  129. }
  130. protected _viewUpdateView = ({view, camera}: {view: CameraView, camera?: ICamera}) => {
  131. if (!view) {
  132. this._viewer?.console.warn('Invalid view', view)
  133. return
  134. }
  135. const name = view.name
  136. this.getView(camera, view.isWorldSpace ?? true, view)
  137. view.name = name
  138. }
  139. protected _viewDeleteView = ({view}: {view: CameraView}) => {
  140. if (!view) {
  141. this._viewer?.console.warn('Invalid view', view)
  142. return
  143. }
  144. this.deleteView(view)
  145. }
  146. protected _viewAnimateView = async({view, camera, duration, easing, throwOnStop}: {view: CameraView, camera?: ICamera, duration?: number, easing?: Easing|EasingFunctionType, throwOnStop?: boolean}) => {
  147. if (!view) {
  148. this._viewer?.console.warn('Invalid view', view)
  149. return
  150. }
  151. return this.animateToView(view, duration || this.animDuration, easing || this.animEase, camera, throwOnStop)
  152. }
  153. deleteView(view: CameraView) {
  154. const i = this._cameraViews.indexOf(view)
  155. if (i >= 0)
  156. this._cameraViews.splice(i, 1)
  157. view.removeEventListener('setView', this._viewSetView as any)
  158. view.removeEventListener('updateView', this._viewUpdateView as any)
  159. view.removeEventListener('deleteView', this._viewDeleteView as any)
  160. view.removeEventListener('animateView', this._viewAnimateView as any)
  161. this.uiConfig.uiRefresh?.()
  162. this.dispatchEvent({type: 'viewDelete', view})
  163. }
  164. getView(camera?: ICamera, worldSpace = true, view?: CameraView) {
  165. camera = camera || this._viewer?.scene.mainCamera
  166. if (!camera) return view ?? new CameraView()
  167. return camera.getView(worldSpace, view)
  168. }
  169. setView(view: ICameraView, camera?: ICamera) {
  170. camera = camera || this._viewer?.scene.mainCamera
  171. if (!camera) return
  172. camera.setView(view)
  173. }
  174. private _currentView: CameraView | undefined
  175. @uiButton('Focus Next', {sendArgs: false}) focusNext = (wrap = true)=>{
  176. if (this._animating) return
  177. if (this._cameraViews.length < 2) return
  178. let index = this._cameraViews.findIndex(v=>v === this._currentView)
  179. if (index < 0) index = -1 // first view
  180. index = index + 1
  181. if (!wrap) index = Math.min(index, this._cameraViews.length - 1)
  182. else index = index % this._cameraViews.length
  183. this.animateToView(index)
  184. }
  185. @uiButton('Focus Previous', {sendArgs: false}) focusPrevious = (wrap = true)=> {
  186. if (this._animating) return
  187. if (this._cameraViews.length < 2 || !this._currentView) return
  188. let index = this._cameraViews.findIndex(v=>v === this._currentView)
  189. if (index < 0) index = 0 // last view
  190. index = index - 1
  191. if (!wrap) index = Math.max(index, 0)
  192. else index = (index + this._cameraViews.length) % this._cameraViews.length
  193. this.animateToView(index)
  194. }
  195. private _popAnimations: AnimationResult[] = []
  196. async animateToView(_view: CameraView|number|string, duration?: number, easing?: Easing|EasingFunctionType, camera?: ICamera, throwOnStop = false) {
  197. camera = camera || this._viewer?.scene.mainCamera
  198. if (!camera) return
  199. // 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
  200. if (this._animating) {
  201. this._popAnimations.forEach(a=>a?.stop && a.stop()) // don't call stopAllAnimations here, as it sets viewLooping to false and changes config.
  202. this._popAnimations = []
  203. let i = 0
  204. while (this._animating) {
  205. await timeout(100)
  206. if (i++ > 20) { // 2s timeout
  207. break
  208. }
  209. }
  210. if (this._animating) {
  211. console.warn('Unable to stop all animations, maybe because of viewLooping?')
  212. return
  213. }
  214. }
  215. const view = typeof _view === 'number' ? this._cameraViews[_view] :
  216. typeof _view === 'string' ? this._cameraViews.find(v=>v.name === _view) :
  217. _view
  218. if (!view) {
  219. this._viewer?.console.warn('Invalid view', _view)
  220. return
  221. }
  222. const interactionPrompt = this._viewer?.getPlugin(InteractionPromptPlugin)
  223. if (interactionPrompt && interactionPrompt.animationRunning) {
  224. await interactionPrompt.stopAnimation({reset: true})
  225. }
  226. this._currentView = view
  227. this._animating = true
  228. this._viewer?.scene.mainCamera.setInteractions(false, CameraViewPlugin.PluginType) // todo: also for seekOnScroll
  229. this.dispatchEvent({type: 'startViewChange', view})
  230. const popmotion = this._viewer?.getPlugin(PopmotionPlugin)
  231. if (!popmotion) throw new Error('PopmotionPlugin not found')
  232. if (duration === undefined) duration = this.animDuration
  233. const ease: any = (typeof easing === 'function' ? easing : EasingFunctions[easing || this.animEase]) as (x: number) => number
  234. // const ease = (x:number)=>x
  235. // const driver = this._driver
  236. this._popAnimations = []
  237. await popmotion.animateCameraAsync(camera, view, this.interpolateMode === 'spherical', {ease, duration}, this._popAnimations)
  238. .catch((e)=>{
  239. // console.error(e)
  240. if (throwOnStop) throw e
  241. })
  242. this._viewer?.scene.mainCamera.setInteractions(true, CameraViewPlugin.PluginType)
  243. this._animating = false
  244. this._viewer?.setDirty()
  245. this.dispatchEvent({type: 'viewChange', view})
  246. await timeout(10)
  247. }
  248. @uiButton('Animate All Views')
  249. async animateAllViews() {
  250. if (this.isDisabled()) return
  251. if (this.viewLooping || this._cameraViews.length < 2) return
  252. while (this._viewQueue.length > 0) this._viewQueue.pop()
  253. this._viewQueue.push(...this._cameraViews)
  254. this._viewQueue.push(this._viewQueue.shift()!)
  255. this._infiniteLooping = false
  256. await this._animationLoop()
  257. this._infiniteLooping = true
  258. }
  259. @uiButton('Stop All Animations')
  260. async stopAllAnimations() {
  261. this.viewLooping = false
  262. this._popAnimations.forEach(a => a?.stop?.())
  263. this._popAnimations = []
  264. while (this._animating || this._animationLooping) {
  265. await timeout(100)
  266. }
  267. }
  268. fromJSON(data: any, meta?: any): this | null {
  269. this._cameraViews.forEach(v=>this.deleteView(v)) // deserialize pushes to the existing array
  270. if (super.fromJSON(data, meta)) {
  271. this._cameraViews.forEach(v=>this.addView(v))
  272. this.uiConfig.uiRefresh?.()
  273. return this
  274. }
  275. return null
  276. }
  277. public async animateToObject(selected?: Object3D, distanceMultiplier = 4, duration?: number, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 5.0}) {
  278. if (!this._viewer) return
  279. const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot, false, true)
  280. const center = bbox.getCenter(new Vector3())
  281. const size = bbox.getSize(new Vector3())
  282. const radius = size.length() / 2
  283. await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, radius * distanceMultiplier)), center, duration, ease)
  284. }
  285. public async animateToFitObject(selected?: Object3D, distanceMultiplier = 1.5, duration = 1000, ease?: Easing|EasingFunctionType, distanceBounds = {min: 0.5, max: 50.0}) {
  286. if (!this._viewer) return
  287. const bbox = new Box3B().expandByObject(selected || this._viewer.scene.modelRoot, false, true)
  288. const cameraZ = getFittingDistance(this._viewer.scene.mainCamera, bbox)
  289. const center = bbox.getCenter(new Vector3()) // world position
  290. await this.animateToTarget(Math.min(distanceBounds.max, Math.max(distanceBounds.min, cameraZ * distanceMultiplier)), center, duration, ease)
  291. }
  292. /**
  293. *
  294. * @param distanceFromTarget - in world units
  295. * @param center - target (center) of the view in world coordinates
  296. * @param duration - in milliseconds
  297. * @param ease
  298. */
  299. public async animateToTarget(distanceFromTarget: number, center: Vector3, duration?: number, ease?: Easing|EasingFunctionType) {
  300. const view = this.getView() // world space
  301. view.target.copy(center)
  302. const direction = new Vector3().subVectors(view.target, view.position).normalize()
  303. view.position.copy(direction.multiplyScalar(-distanceFromTarget).add(view.target))
  304. await this.animateToView(view, duration, ease)
  305. }
  306. uiConfig: UiObjectConfig = {
  307. type: 'folder',
  308. label: 'Camera Views',
  309. // expanded: true,
  310. children: [
  311. ()=>[...this._cameraViews.map(view => view.uiConfig)],
  312. ...generateUiConfig(this) || [],
  313. ],
  314. }
  315. get animationLooping(): boolean {
  316. return this._animationLooping
  317. }
  318. private _viewQueue: CameraView[] = []
  319. private _animationLooping = false
  320. private _infiniteLooping = true
  321. private async _animationLoop() {
  322. if (this._animationLooping) return
  323. this._animationLooping = true
  324. while (this.viewLooping || !this._infiniteLooping) {
  325. if (this.isDisabled()) break
  326. if (this._cameraViews.length < 1) break
  327. if (this._viewQueue.length === 0) {
  328. if (this._infiniteLooping) this._viewQueue.push(...this._cameraViews)
  329. else break
  330. }
  331. await this.animateToView(this._viewQueue.shift()!)
  332. await timeout(2 + this.viewPauseTime) // ms delay
  333. }
  334. this._animationLooping = false
  335. }
  336. // region deprecated
  337. /**
  338. * @deprecated - renamed to {@link getView} or {@link ICamera.getView}
  339. * @param camera
  340. * @param worldSpace
  341. */
  342. getCurrentCameraView(camera?: ICamera, worldSpace = true) {
  343. return this.getView(camera, worldSpace)
  344. }
  345. /**
  346. * @deprecated - renamed to {@link setView} or {@link ICamera.setView}
  347. * @param view
  348. */
  349. setCurrentCameraView(view: CameraView) {
  350. return this.setView(view)
  351. }
  352. /**
  353. * @deprecated - use {@link animateToView} instead
  354. * @param view
  355. */
  356. async focusView(view: CameraView) {
  357. return this.animateToView(view)
  358. }
  359. // endregion
  360. // region to be ported to other plugins
  361. // /**
  362. // * For slight rotation of camera when seekOnScroll is enabled
  363. // */
  364. // private _pointerMove(ev: PointerEvent) {
  365. // if (this.isDisabled()) return
  366. // if (!this._animating && this.seekOnScroll) {
  367. // const cam = this._viewer?.scene.mainCamera
  368. // if (!cam) return
  369. // const s = new Spherical()
  370. // const p = cam.position
  371. // const t = cam.target
  372. // const q = new Quaternion().setFromUnitVectors(cam.cameraObject.up, new Vector3(0, 1, 0))
  373. // const qi = q.clone().invert()
  374. // const offset = p.clone().sub(t)
  375. // offset.applyQuaternion(q)
  376. // s.setFromVector3(offset)
  377. // s.theta += this.rotationOffset * ev.movementX / this._viewer!.canvas!.clientWidth
  378. // s.phi += this.rotationOffset * ev.movementY / this._viewer!.canvas!.clientHeight
  379. // s.makeSafe()
  380. // offset.setFromSpherical(s)
  381. // offset.applyQuaternion(qi)
  382. // p.copy(t).add(offset)
  383. // cam.setDirty()
  384. // }
  385. // }
  386. // // @uiToggle() @serialize()
  387. // animateOnScroll = false // buggy
  388. //
  389. // @uiToggle() @serialize()
  390. // seekOnScroll = false
  391. // private _scrollAnimationState = 0
  392. // scrollAnimationDamping = 0.1
  393. // private _wheel(ev: any | WheelEvent) {
  394. // if (this.isDisabled()) return
  395. // if (this.seekOnScroll && !this._animating) {
  396. // // if (ev.deltaY > 0) this.focusNext(false)
  397. // // else this.focusPrevious(false)
  398. // } else if (Math.abs(ev.deltaY) > 0.001) {
  399. // this._scrollAnimationState = -1. * Math.sign(ev.deltaY)
  400. // }
  401. // }
  402. // private _driver: Driver = (update)=>{
  403. // return {
  404. // start: ()=>this._updaters.push({u:update, time:0}),
  405. // stop: ()=> this._updaters.splice(this._updaters.findIndex(u=>u.u === update), 1),
  406. // }
  407. // }
  408. // private _fadeDisabled = false
  409. // todo: same code used in PopmotionPlugin, merge somehow
  410. // private _postFrame() {
  411. // if (!this._viewer) return
  412. // if (this.isDisabled() || !this._animating) {
  413. // this._lastFrameTime = 0
  414. // if (this._fadeDisabled) {
  415. // this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')?.enable(CameraViewPlugin.PluginType)
  416. // this._fadeDisabled = false
  417. // }
  418. // // console.log('not anim')
  419. // return
  420. // }
  421. // const time = now() / 1000.0
  422. // if (this._lastFrameTime < 1) this._lastFrameTime = time - 1.0 / 60.0
  423. // let delta = time - this._lastFrameTime
  424. // this._lastFrameTime = time
  425. // delta = delta * (this.animateOnScroll ? this._scrollAnimationState : 1)
  426. //
  427. // const d = this._viewer.getPluginByType<ProgressivePlugin>('Progressive')?.postFrameConvergedRecordingDelta()
  428. // if (d && d > 0) delta = d
  429. // if (d === 0) return // not converged yet.
  430. // // if d < 0: not recording, do nothing
  431. //
  432. // delta *= 1000
  433. //
  434. // // delta = 16.666
  435. //
  436. // // console.log(delta)
  437. // // console.log(dt)
  438. // //
  439. //
  440. // if (delta <= 0) return
  441. //
  442. // this._updaters.forEach(u=>{
  443. // let dt = delta
  444. // if (u.time + dt < 0) dt = -u.time
  445. // u.time += dt
  446. // if (Math.abs(dt) > 0.001)
  447. // u.u(dt)
  448. // })
  449. // if (this._scrollAnimationState < 0.001) this._scrollAnimationState = 0
  450. // else this._scrollAnimationState *= 1.0 - this.scrollAnimationDamping
  451. //
  452. // if (!this._fadeDisabled) {
  453. // const ff = this._viewer.getPluginByType<FrameFadePlugin>('FrameFade')
  454. // if (ff) {
  455. // ff.disable(CameraViewPlugin.PluginType)
  456. // this._fadeDisabled = true
  457. // }
  458. // }
  459. // }
  460. // @uiButton('Record All Views')
  461. // public async recordAllViews(onStart?: ()=>void, downloadOnEnd = true) {
  462. // if (this.isDisabled()) return
  463. // const recorder = this._viewer?.getPluginByType<CanvasRecorderPlugin>('CanvasRecorder')
  464. // if (!recorder || !recorder.enabled) return
  465. // if (this._cameraViews.length < 1) return
  466. // await this.resetToFirstView()
  467. // if (recorder.isRecording()) {
  468. // console.error('CanvasRecorderPlugin is already recording')
  469. // return
  470. // }
  471. // return new Promise<Blob|undefined>((resolve, reject) => {
  472. // const listener2 = ()=>{
  473. // recorder.removeEventListener('start', listenerStart)
  474. // recorder.removeEventListener('stop', listener2)
  475. // recorder.removeEventListener('error', listenerError)
  476. // }
  477. // const listenerStart = async() => {
  478. // listener2()
  479. // onStart?.()
  480. // await this.animateAllViews()
  481. // const blob = await recorder.stopRecording()
  482. // if (downloadOnEnd) {
  483. // const name = await this._viewer?.prompt('Canvas Recorder: Save file as', 'recording.mp4')
  484. // if (name !== null && blob) await this._downloadBlob(blob, name || 'recording.mp4')
  485. // }
  486. // resolve(blob)
  487. // }
  488. // const listenerError = async() => {
  489. // listener2()
  490. // reject()
  491. // }
  492. // recorder.addEventListener('start', listenerStart)
  493. // recorder.addEventListener('stop', listener2)
  494. // recorder.addEventListener('error', listenerError)
  495. // if (!recorder.startRecording()) {
  496. // console.error('cannot start recording')
  497. // return
  498. // }
  499. // })
  500. // }
  501. // private async _downloadBlob(blob: Blob, name: string) {
  502. // const tr = this._viewer?.getPluginByType<FileTransferPlugin>('FileTransferPlugin')
  503. // if (!tr) {
  504. // this._viewer?.console.error('FileTransferPlugin required to export/download file')
  505. // return
  506. // }
  507. // await tr.exportFile(blob, name)
  508. // }
  509. // endregion
  510. }