Source: BaseBlend.js

import EventEmitter from 'src/helpers/EventEmitter'
import EventObserver from 'src/helpers/EventObserver'
import Raf from 'src/helpers/Raf'
import {clamp} from 'src/helpers/MathUtils'
import { TweenLite } from 'gsap'
import {BlendShaderFacade} from "./shaderFacades/BlendShaderFacade"
const THREE = require('three')

/**
 * @typeDef {object} BaseBlend.EVENTS__TYPE
 * @property {string} BLEND_UNIT -  emitted on blend value change. event value:number (0 to 1)
 */
const EVENTS = {
  BLEND_UNIT: 'BLEND_UNIT',
}


/**@typeDef {object} BaseBlend.FIT__TYPE
 *  @property {string} COVER  - fill frame, maintain aspect ratio
 *  @property {string} CONTAIN - fit within frame, maintain aspect ratio
 *  @property {string} STRETCH - stretch to frame, dont maintain aspect ratio
 */
const FIT = Object.freeze({
    COVER: 'cover',
    CONTAIN: 'contain',
    STRETCH: 'stretch',
})


export const createDefaultConfig = function(){
  let defaultConfig =  {
    media: null,

    fit: FIT.CONTAIN,
    pollResize:true,

    cellGrid: {
      x: 1,
      y: 2,
    },
    cellIndexLength: null, //set if spritesheet max-cell-index is less than the full cell grid
    cellUVTrim:0.985, // trim video cell texture lookups to avoid cell-bleed

    vertexShader:null, //eg ./shaders/base.vert
    fragmentShader:null, //eg ./shaders/circleFade.frag

    //internalRenderer: if false : wont create renderer, scene, camera.
    internalRenderer:true,

    //if using a radius effect in shader, auto-calculate max-radius based on hypotonuse (diagonal) of rendered viewport
    autoMaxRadius:true,
  }

  return defaultConfig
}

//better auto-complete
export const BaseBlendConst = {
  EVENTS,
  FIT,
  createDefaultConfig
}

/**
 * @class BaseBlend - shader handling for Blend
 */
  export class BaseBlend {

    /** @property {...BaseBlend.EVENTS__TYPE}**/
    get EVENTS() { return EVENTS }

    /** @property {...BaseBlend.FIT__TYPE}**/
    get FIT() { return FIT }

    //CLASS const
     /** @property {...BaseBlend.EVENTS__TYPE}**/
    static get EVENTS() { return EVENTS }

    /** @property {...BaseBlend.FIT__TYPE}**/
    static get FIT() { return FIT }


  /**
   *
   * @param {Object} config - configuration , see createDefaultConfig() for default values
   * @param {(HTMLVideoElement|Media|MediaElementTrack)} config.media - must pass one of: (src/services/MediaService/Media, src/services/MediaService/tracks/MediaElementTrack, HTMLVideoElement)
   *
   * @param {string} [config.fit=contain]  - video maintain-aspect-crop behaviour [contain | cover | stretch], see const FIT
   *
   * @param {object} [config.cellGrid={x:1, y:2}] - the x:column, y:row totals of the video spritesheet
   * @param {number} config.cellGrid.x - the column total of the video spritesheet (x axis)
   * @param {number} config.cellGrid.y - the row total of the video spritesheet (y axis)
   *
   * @param {integer} [config.cellIndexLength] - set if spritesheet max-cell-index is less than the full celltotal(w*h)
   * @param {number} [config.cellUVTrim=0.985] - trim video cell texture lookups to avoid cell-bleed (notrim=1, trim to 25% borderwidth = 0.5
   *
   * @param {string} [config.vertexShader=./shaders/base.vert] - eg ./shaders/base.vert
   * @param {string} [config.fragmentShader=./shaders/circleFade.frag] - eg ./shaders/circleFade.frag
   * @param {boolean} [config.internalRenderer=true] - if false, wont create renderer, scene, camera.
   * Intended to then use mesh/material in an externally defined scene.
   * Side effects: fit should probably then be full uv coverage (fit:FIT.STRETCH)
   * @param {boolean} [config.autoMaxRadius=true]

   */
    constructor(config) {
      Object.assign(this, EventEmitter )
      Object.assign(this, EventObserver)

      // CONFIG
      this.config = Object.assign({}, createDefaultConfig() , config)
      this.config.cellIndexLength = this.config.cellIndexLength || this.config.cellGrid.x * this.config.cellGrid.y

      this.init();
     }

  init(){
    this._initElement()
    this._initVideo()
    this._initRenderer()
    this._initTexture()
    this._initMaterial()
    this._setCoreUniforms()
    this._initMesh()

    this.blend = 0

    this._addEvents()
  }

   getMaterial(){
     return this.shader.material
   }

   getMesh(){
     return this.mesh
   }

  _initElement(){
    this.el = document.createElement('canvas')
    this.el.style.width = "100%";
    this.el.style.height = "100%";
    this.elementRect = {width:1000, height:1000}
  }


  _initVideo(){
    let media = this.config.media
    let isVideo = (media && media.nodeName && media.nodeName.toLowerCase() === "video")
    let isMedia = (media && media.getDomElement)

    if (!media || (!isVideo && !isMedia) ){
      throw("BaseBlend requires a HTMLMediaElement, (resn)Media or (resn)MediaElemenTrack")
    }

    this.video = (isVideo) ? media : media.getDomElement()
    this.media = (isMedia) ? media : null

    this._setupVideoAspect()
    this.listen(this.video, 'loadedmetadata', (e) => {this._setupVideoAspect()})

  }

  _setupVideoAspect(){
    this.videoWidth = (this.video.videoWidth || 16) / this.config.cellGrid.x
    this.videoHeight = (this.video.videoHeight || (9*2)) / this.config.cellGrid.y
    this.videoAspect = this.videoWidth/this.videoHeight;
    if(this.shader){this._resize()}
  }

  _initRenderer(){
    let w = window.innerWidth
    let h = window.innerHeight

    if (!this.config.internalRenderer) {return}
    this.renderer = new THREE.WebGLRenderer({
      canvas: this.el,
      alpha: false,
      antialias: false,
      autoClear:true,
      gammaInput:false,
      gammaOutput:false
    })
    this.renderer.setPixelRatio(window.devicePixelRatio || 1)
    this.renderer.setClearColor(0x000000, 0)

    this.camera = new THREE.OrthographicCamera(w/-2, w/2, h/-2, h/2, 0.001, 1000)
    this.camera.position.z = 1

    this.scene = new THREE.Scene()
  }

  _initTexture(){
    this._iosVideoTextureFix()
    this.texture = new THREE.VideoTexture(this.video)
    this.texture.minFilter = this.texture.magFilter = THREE.LinearFilter
    this.texture.format = THREE.RGBFormat
  }

  _iosVideoTextureFix(){
    //ios needs to refresh the src to correctly display the videoTexture, otherwise shows black
    // - unsure of underlying issue.
    let ios =  /(iOS|iPod|iPad|iPhone)/i.test(navigator.userAgent)
    if (ios){
      if (this.video._iosWebglTainted){
        if(this.media){
          //this.media.stop()
          this.media.videoTrack.load(this.media.videoTrack._src).then(()=>{this.media.replay()})
        }
        if(!this.media){ this.video.src = this.video.src }
      }
      this.video._iosWebglTainted = true
    }
  }

   _initMaterial() {
     this.shader = new BlendShaderFacade({
       vertexShader: this.config.vertexShader,
       fragmentShader: this.config.fragmentShader,
       uniforms:{}
     })
   }

  _initMesh(){
    let geometry = new THREE.PlaneGeometry(1, 1, 4, 4)
    this.mesh = new THREE.Mesh(geometry, this.shader.material)
    this.mesh.position.z = 0
    if (this.config.internalRenderer){
      this.scene.add(this.mesh)
    }
  }

  _addEvents() {
    if (this.config.internalRenderer){
      this.listen(window,'resize', this._resize)
      this._resize()
      TweenLite.delayedCall(1, this._resize, null,null, true)
      TweenLite.delayedCall(2, this._resize, null,null, true)
    }
    Raf.add(this._onFrame)
  }


  _removeEvents() {
    Raf.remove(this._onFrame)
    this.unlisten()
    this.off()
  }

  _updateCanvasRect = () =>{
    let rect = this.el.getBoundingClientRect();

    this.elementRect.width = rect.width || 100
    this.elementRect.height = rect.height || 100

 }


  _resize = () => {
    this._updateCanvasRect();
    let oW = this.elementRect.width;
    let oH = this.elementRect.height;

    this.camera.left = oW / -2
    this.camera.right = oW / 2
    this.camera.top = oH / 2
    this.camera.bottom = oH / -2
    this.camera.aspect = oW / oH
    this.camera.updateProjectionMatrix()

    //dont set css -let external style dictate
    this.renderer.setSize(oW, oH, false)

    this.calculateMeshScale()
  }



   calculateMeshScale(){
     let oW = this.elementRect.width;
     let oH = this.elementRect.height;

     //note aspect-ratio-fit logic in fragmentShader
     this.mesh.scale.x = oW
     this.mesh.scale.y = oH

     if(this.config.autoMaxRadius) this.calculateMaxRadius()
     this.calculateVideoScale()
   }

   calculateMaxRadius(){
     this.updateResolution()
     var meshAspect = this.resolutionY/this.resolutionX;
     let a = meshAspect
     let b = 1
     let hypotenuse = Math.sqrt((a*a) + (b*b))
     this.maxRadius = hypotenuse; //diagonal of resolution-aspect
   }


    calculateVideoScale(){
      this.updateResolution()
      let oW = this.resolutionX //
      let oH = this.resolutionY //

      let oA = oW/oH
      let vA = this.videoAspect

      //video uv scale for shader
      let cover  = (this.config.fit === FIT.COVER)
      let stretch = (this.config.fit === FIT.STRETCH)

        if (stretch){
          this.videoScaleX = 1
          this.videoScaleY = 1

        } else if((!cover && (oA < vA)) || (cover &&(oA > vA))){
          this.videoScaleX = 1
          this.videoScaleY = (1.0/oA)*vA

        } else{
          this.videoScaleX =oA/vA
          this.videoScaleY = 1
        }
    }


   //--ON FRAME --------------------------------------------------------------------------------------//

  _onFrame = (delta) =>{
    this.update()
  }

  update(){
    this._updateResize()
    this._updateMediaAudio()
    this.updateResolution()
    this._render()
  }

  _updateResize = () =>{
    if(!this.config.pollResize ){ return}
    let changed  = (this._wiw !== window.innerWidth || this._wih !== window.innerHeight)
    this._wiw = window.innerWidth
    this._wih = window.innerHeight
    if (changed){this._resize()}
  }

  updateResolution(){
    //NOTE: geometry x,y init dimensions are 1,1, if otherwise multiply by initial geometry dimensions
    let pixelAspect = window.devicePixelRatio || 1
    this.resolutionX = this.mesh.scale.x * pixelAspect
    this.resolutionY = this.mesh.scale.y * pixelAspect
  }

  _updateMediaAudio(){
    if(!this.media || !this.media.blendAudioBetweenIndexes){return}
    this.media.blendAudioBetweenIndexes(clamp(this.blend,0,1), this.cellIndex1, this.cellIndex2)
  }

  _render(){
    if (this.config.internalRenderer){
      this.renderer.render(this.scene, this.camera)
    }
  }


   /*------MATERIAL SETUP + INTERFACING---------------------------------------------------------------------*/

   _setCoreUniforms(){
     this.shader.videoTexture = this.texture
     this.shader.cellGridX = this.config.cellGrid.x
     this.shader.cellGridY = this.config.cellGrid.y
     this.shader.uvTrim = this.config.cellUVTrim
   }

    /** @property {number} blend - sets blend value in shader */
   set blend(val)        {
     let changed = (this.shader.blend !== val)
     this.shader.blend = val
     if(changed) {this.emit(EVENTS.BLEND_UNIT, val);}
   }
   get blend()                { return  this.shader.blend }

  /** @property {number} touchX - sets touchX value in shader. touchX,touchY are used as a radial effect center */
   set touchX(val)            {         this.shader.material.uniforms.touch.value.x = val || 0 }
   get touchX()               { return  this.shader.touchX }

  /** @property {number} touchY - sets touchX value in shader. touchX,touchY are used as a radial effect center */
   set touchY(val)            {         this.shader.touchY = val || 0 }
   get touchY()               { return  this.shader.touchY }

  /** @property {integer} cellIndex1 - sets cellIndex1 value in shader. cellIndex1 is the video-spritesheet cellIndex blended FROM*/
   set cellIndex1(val)        {         this.shader.cellIndexX = clamp(val, 0, this.config.cellIndexLength-1); }
   get cellIndex1()           { return  this.shader.cellIndexX }

  /** @property {integer} cellIndex2 - sets cellIndex2 value in shader. cellIndex2 is the video-spritesheet cellIndex blended TO*/
   set cellIndex2(val)        {         this.shader.cellIndexY = clamp(val, 0, this.config.cellIndexLength-1); }
   get cellIndex2()           { return  this.shader.cellIndexY }

    /** @property {number} maxRadius - sets maxRadius value in shader.
     * maxRadius is a viewport-unit setting for the maximum blend radius
     * @see config.autoMaxRadius - set to true, will auto-calculate/update this value to the minimum full-coverage size
     * */
   set maxRadius(val)         {         this.shader.maxRadius = val || 0 }
   get maxRadius()            { return  this.shader.maxRadius}

  /** @property {number} radiusBleed - sets radiusBleed value in shader. changes the radial effect bleed width */
   set radiusBleed(val)       {         this.shader.radiusBleed = val || 0 }
   get radiusBleed()          { return  this.shader.radiusBleed}

  /** @property {number} uvTrim - sets uvTrim value in shader.
   * @see {@link config.cellUVTrim} - set initially by this config value
   *  */
   set uvTrim(val)            {         this.shader.uvTrim = val || 0 }
   get uvTrim()               { return  this.shader.uvTrim}


  /** @property {number} resolutionX - sets resolutionX value in shader.
   * auto-calculated/updated based on canvas size
   */
   set resolutionX(val)       {         this.shader.resolutionX = val || 0 }
   get resolutionX()          { return  this.shader.resolutionX }
  /** @property {number} resolutionY -  sets resolutionY value in shader.
   * auto-calculated/updated based on canvas size
   */
   set resolutionY(val)       {         this.shader.resolutionY = val || 0 }
   get resolutionY()          { return  this.shader.resolutionY }

  /** @property {number} videoScaleX -  sets videoScaleX value in shader.
   * auto-calculated/updated based on canvas size/video-aspect in accordance to config.fit
   */
   set videoScaleX(val)       {         this.shader.videoScaleX = val || 0 }
   get videoScaleX()          { return  this.shader.videoScaleX }

  /** @property {number} videoScaleY -  sets videoScaleY value in shader.
   * auto-calculated/updated based on canvas size/video-aspect in accordance to config.fit
   */
   set videoScaleY(val)       {         this.shader.videoScaleY = val || 0 }
   get videoScaleY()          { return  this.shader.videoScaleY }



   /*----END : MATERIAL SETUP + INTERFACING---------------------------------------------------------------------*/


  _clearScene(scene){
    for (let i = scene.children.length - 1; i >= 0; i--) {
      const object = scene.children[i]
      object.geometry.dispose()
      object.material.dispose()
      scene.remove(object)
    }
  }

  /** as advertised */
  destroy() {
    this.destroyed = true;
    this._removeEvents()

    if(this.config.internalRenderer){
      this.renderer.forceContextLoss()
      this._clearScene(this.scene)
    }

    this.shader.destroy()
    this.texture.dispose()
    this.texture.image = null

    this.renderer = null
    this.camera = null
    this.scene = null
    this.mesh = null
    this.texture = null
    this.media = null
    this.video = null
    this.shader = null

    delete this.renderer
    delete this.camera
    delete this.scene
    delete this.mesh
    delete this.texture
  }
}