var parade; var interview_sorter; var config = { speedFactor: 0.01, // speed of the shapes distanceFactor: 20, // distance between the shapes startOffset: 0.001, // starting position shapeWidth: 100, // 8 shape of animation: width shapeHeight: 1300, // 8 shape of animation: height; playDuration: 16, // seconds. how long should audio play after dragging/pressing next. Something like audio duration / nr of images. Note that if everything is sorted interview will just continue; textColor: "orange", font: "bold 70px Arial", returnToSpectatorCamTimeout: 3000, // ms, when dragging weight slider, after release, how long should it take before reversing to spectator camera position } // polyfill window.performance = window.performance || {}; performance.now = (function() { return performance.now || performance.mozNow || performance.msNow || performance.oNow || performance.webkitNow || Date.now /*none found - fallback to browser default */ })(); const request = new Request('dataset/images_prop.json'); fetch(request) .then(response => { if (response.status === 200) { return response.json(); } else { throw new Error('Something went wrong on api server!'); } }) .then(data => { console.debug(data); // loaded data from json, initialise interface const canvas = document.getElementById("renderCanvas"); parade = new Parade(canvas, data['image'], config); // parade.scene.debugLayer.show(); }).catch(error => { console.error(error); }); class Parade { constructor(canvasEl, images, config) { // animation parameters: this.config = config; this.canvasEl = canvasEl; this.engine = new BABYLON.Engine(canvasEl, true); // create 3d engine this.isPlaying = false; this.t = 0; // playback timer // initialise image objects: this.images = []; this.imagePositions = []; // index: position, value: image-index let storedPositions = localStorage.getItem('imagePositions'); if(storedPositions) { this.imagePositions = JSON.parse(storedPositions); } // else or invalid: if(!storedPositions || this.imagePositions.length != images.length) { // fill with default: order of the JSON file for (let index = 0; index < images.length; index++) { this.imagePositions[index] = index; } } for(let i in images) { i = parseInt(i); let pos = this.imagePositions.indexOf(i); // console.log('pos', , pos); let img = new ParadeImage(images[i], pos); this.images.push(img); // for testing: // if(this.images.length > 5) // break; } this.setupSubtitles(); this.setupSorter(); this.restoreTime(); // Watch for browser/canvas resize events window.addEventListener("resize", function () { this.engine.resize(); }.bind(this)); //Register a render loop to repeatedly render the scene this.engine.runRenderLoop(this.renderLoop.bind(this)); this.createScene(); // browsers block auto-play of audio // if(this.imagesToSort.length < 1){ // this.play(); // } } setupSubtitles() { this.audioEl = document.getElementById('interview'); // audio player this.subtitleEls = { 'en': document.getElementById('sub_en'), 'nl': document.getElementById('sub_nl'), }; this.trackEls = this.audioEl.querySelectorAll('track'); // the subtitle track this.timeEl = document.getElementById('time'); // the subtitle track // set up subtitles: for(let trackEl of this.trackEls){ trackEl.addEventListener("cuechange", (event) => { let lang = event.srcElement.srclang; let cues = event.target.track.activeCues; let content = ""; for(let cue of cues) { content += "

"+cue.text+"

"; } this.subtitleEls[lang].innerHTML = content; }); } for(let track of this.audioEl.textTracks){ // by default only one (or none) is set to showing. // for some reason setting it on trackEl doesn't work, while this does the trick. track.mode = 'showing'; } this.audioEl.addEventListener('timeupdate', this.updateTimestring.bind(this)); this.audioEl.addEventListener('timeupdate', this.storeTime.bind(this)); this.updateTimestring(); } updateTimestring() { let now = this.audioEl.currentTime; let duration = this.audioEl.duration; let timestring = String(Math.floor(now/60)).padStart(2, '0') + ":" + String(Math.floor(now)%60).padStart(2, '0') + " / " + String(Math.floor(duration/60)).padStart(2, '0') + ":" + String(Math.floor(duration)%60).padStart(2, '0'); this.timeEl.innerHTML = timestring; } storeTime() { // do we use this.t or audioEl...? localStorage.setItem('playhead', String(this.audioEl.currentTime)) } restoreTime() { let time = localStorage.getItem('playhead'); this.audioEl.addEventListener('playing', (e) => { if(!this.isPlaying) this.play(); // make sure we're playing. }); // causes odd glitch // this.audioEl.addEventListener('pause', (e) => { // if(this.isPlaying) this.pause(true); // make sure we're playing. // }); this.audioEl.addEventListener('seeking',(e)=>{ this.t = parseInt(e.target.currentTime * 1000); // console.log('restore timer',this.t,e); }); if(!time) return; time = parseFloat(time); this.audioEl.currentTime = time; } setupSorter() { this.sorterTemplate = document.getElementById('annotationContentTemplate'); this.sorter = document.getElementById('annotation'); this.pauseTimeout = null; let imagesToSort = localStorage.getItem('imagesToSort'); if(imagesToSort) { this.imagesToSort = JSON.parse(imagesToSort); } // else or invalid: if(!imagesToSort || this.imagesToSort.length > this.images.length) { console.log('createimages to sort') this.imagesToSort = []; // we will keep track of images that needs weighing for (let index = 0; index < this.images.length; index++) { this.imagesToSort[index] = index; } } let nextButton = document.getElementById('nextButton'); nextButton.addEventListener('click', this.nextSorterImage.bind(this)); let resetButton = document.getElementById('resetButton'); resetButton.addEventListener('click', this.resetSortedImages.bind(this)); let resumeButton = document.getElementById('resumeButton'); resumeButton.addEventListener('click', this.play.bind(this)); // set maximum to last image index this.sorterTemplate.content.querySelector('.weight').max = this.images.length - 1; this.followCameraTimeout = null; document.body.addEventListener('mouseup', () => { // because the movement of the image can be a bit slower, we set a timeout instead // of going straight back into spectator position this.followCameraTimeout = setTimeout(this.stopFollowSortingImage.bind(this), this.config['returnToSpectatorCamTimeout']); }); this.changeSorterImage(); } resetSortedImages() { this.imagesToSort = []; // we will keep track of images that needs weighing for (let index = 0; index < this.images.length; index++) { this.imagesToSort[index] = index; } this.changeSorterImage(); // display an image to sort this.play(); // make sure playback timer gets set } changeSorterImage() { let sorterContent = this.sorter.querySelector('#annotationContent'); if(!this.imagesToSort.length) { document.body.classList.add('finished'); sorterContent.innerHTML = ""; this.audioEl.controls = true; return; } else { document.body.classList.remove('finished'); this.audioEl.controls = false; } // pick random image this.imageIdToSort = this.imagesToSort[Math.floor(Math.random()*this.imagesToSort.length)]; this.imageToSort = this.images[this.imageIdToSort]; let clone = this.sorterTemplate.content.cloneNode(true); let imgEl = clone.querySelector(".img"); let descriptionEl = clone.querySelector(".description"); let nameEl = clone.querySelector(".name"); let weightEl = clone.querySelector(".weight"); imgEl.src = this.imageToSort.getImageUrl(); nameEl.innerHTML = this.imageToSort.info['image']; descriptionEl.innerHTML = this.imageToSort.info['set']; // weightEl.value = parseInt(image.info['weight']); weightEl.value = parseInt(this.imageToSort.position); // don't use weight, but the image's actual position weightEl.addEventListener('input', this.changeSorterWeight.bind(this)); weightEl.addEventListener('mousedown', this.followSortingImage.bind(this)); sorterContent.innerHTML = ""; sorterContent.appendChild(clone); } followSortingImage() { clearTimeout(this.followCameraTimeout); // cancel any pending return to spectator cam // this.imgCamera.lockedTarget = this.imageToSort.mesh; this.imgCamera.parent = this.imageToSort.mesh; this.scene.activeCamera = this.imgCamera; } stopFollowSortingImage() { this.scene.activeCamera = this.spectatorCamera; } changeSorterWeight(ev) { this.play(); // make sure nr is never too big. let newIndex = Math.min(this.images.length -1 , parseInt(ev.target.value)); this.setImagePosition(this.imageToSort, newIndex); } setImagePosition(image, new_position) { let id = this.images.indexOf(image); let old_position = this.imagePositions.indexOf(id); // move from old to new position in the array, see https://stackoverflow.com/a/5306832 this.imagePositions.splice( new_position, 0, this.imagePositions.splice(old_position, 1)[0] ); // update positions of image objects for(let i in this.images) { i = parseInt(i); this.images[i].setPosition(this.imagePositions.indexOf(i), this.t + 2000); } // save: localStorage.setItem('imagePositions', JSON.stringify(this.imagePositions)); } nextSorterImage() { this.play(); let idx = this.imagesToSort.indexOf(this.imageIdToSort); this.imagesToSort.splice(idx, 1); // remove from images to position // store the array localStorage.setItem('imagesToSort', JSON.stringify(this.imagesToSort)); this.changeSorterImage(); } reset() { // for use in terminal only. Quick way to test localStorage.clear(); location.reload(); } play() { // play audio for n seconds: // extend timeout if it's already playing const duration = this.config.playDuration; // seconds if(this.pauseTimeout) { clearTimeout(this.pauseTimeout); } if(this.imagesToSort.length > 0){ this.pauseTimeout = setTimeout(() => { this.pause(); }, duration * 1000); } else { // don't pause anymore when everything is sorted. this.pauseTimeout = null; } this.audioEl.play(); document.body.classList.remove('paused'); this.isPlaying = true; } pause(dopause) { if(this.imagesToSort.length < 1 && (typeof dopause == 'undefined' || false)){ console.log('do not pause'); return; } // pause audio and animation document.body.classList.add('paused'); this.isPlaying = false; this.audioEl.pause(); } renderLoop() { // limit camera movement: // let camera = this.scene.cameras[0]; // if(camera.rotation['x'] < 0.1) { // camera.rotation['x'] = 0.1 // } // if(camera.rotation['x'] > .3) { // camera.rotation['x'] = .3 // } this.animate(); // update positions of the objects this.scene.render(); } animate() { let n = window.performance.now(); let dt = 0; if(this.isPlaying) { // todo: make it come to a slow halt, instead of sudden; dt = n - this.lastRenderTime; if(dt < 0) console.log('change',dt); this.t += dt; } this.lastRenderTime = n; // also increment when paused; // console.log(maxStepSize); for(let i of this.images){ i.updateMeshPosition(this.t, dt, this.config); } } createScene() { // This creates a basic Babylon Scene object this.scene = new BABYLON.Scene(this.engine); this.scene.clearColor = new BABYLON.Color3(.22,0.27,0.38); // color RGB // this.scene.clearColor = new BABYLON.Color4(0.6, 0.6, 0.,0); // transparent - RGBA this.scene.fogMode = BABYLON.Scene.FOGMODE_EXP; this.scene.fogColor = this.scene.clearColor; this.scene.fogDensity = 0.002; // let camera = new BABYLON.UniversalCamera("camera1", new BABYLON.Vector3( 0.0, 0.0, 120.86337575312979), this.scene); // camera.rotation = new BABYLON.Vector3(0, Math.PI, 0); // camera.fov = 1.1; // default: 0.8 // CAM 1: the specatator position. this.spectatorCamera = new BABYLON.UniversalCamera("SpectatorCam", new BABYLON.Vector3( -11.79906036421707, -0.7028999316894499, 32.43524104627659 ), this.scene); this.spectatorCamera.rotation = new BABYLON.Vector3(-0.16070762395712468, 2.4005702973639886, 0); this.spectatorCamera.fov = 1.5; // this.spectatorCamera.rotation = new BABYLON.Vector3(0.1, 0,0); // this.spectatorCamera.lowerRadiusLimit = 6; // this.spectatorCamera.upperRadiusLimit = 20; // This attaches the camera to the canvas this.spectatorCamera.attachControl(this.canvasEl, true); // CAM 2: follow/track the image when we're editing its weight. // Parameters: name, position, scene this.imgCamera = new BABYLON.ArcRotateCamera("FollowCam", 20, 20, 10, new BABYLON.Vector3(0, 0, 0), this.scene); this.imgCamera.position = new BABYLON.Vector3(20,10,-20); // // this.imgCamera = new BABYLON.FollowCamera("FollowCam", new BABYLON.Vector3(0, 10, -10), this.scene); // // The goal distance of camera from target // this.imgCamera.radius = 50; // // The goal height of camera above local origin (centre) of target // this.imgCamera.heightOffset = 20; // // The goal rotation of camera around local origin (centre) of target in x y plane // this.imgCamera.rotationOffset = 120; //degree // // Acceleration of camera in moving from current to goal position // this.imgCamera.cameraAcceleration = 0.1; // // The speed at which acceleration is halted // this.imgCamera.maxCameraSpeed = 100 // This attaches the camera to the canvas (disabled for now) // this.imgCamera.attachControl(this.canvasEl, true); // this.imgCamera.lockedTarget = null; // So far there's nothing to track yet this.scene.activeCamera = this.spectatorCamera; for(let i in this.images) { // create the Meshes with the images in the scene this.images[i].makeMesh(this.scene, this.config); } this.lastRenderTime = window.performance.now(); // this.scene.registerBeforeRender(this.animate.bind(this)); // we now do this in renderLoop().. for some reason works better/different? return this.scene; } } class ParadeImage { constructor(info, position) { this.info = info; this.weight = info['weight']; this.position = position; // index in list this.target_position = position; this.target_position_time = 0; } getImageUrl() { return 'dataset/small/'+ this.info['image']; } /** * * @param {int} new_position new position in parade * @param {float} time_complete time at which it should have transitioned to this position */ setPosition(new_position, time_complete) { if(this.target_position == new_position) return; // console.log('changepos', this.target_position, new_position); this.target_position = new_position; this.target_position_time = time_complete; } makeMesh(scene, config) { this.mesh = new BABYLON.TransformNode(); var img = new Image(); let self = this; // use in callback img.onload = function() { // we preload the image, so we can create the plane based on image dimensions // see https://www.html5gamedevs.com/topic/8917-textures-source-image-aspect-ratio/?do=findComment&comment=52949 var planeOptions = { // TODO: should size be dynamic? height: this.height/100, width: this.width/100, sideOrientation: BABYLON.Mesh.DOUBLESIDE // texture should be visible from front and back }; let imagePlane = BABYLON.MeshBuilder.CreatePlane("image", planeOptions, scene); var imageMat = new BABYLON.StandardMaterial("m", scene); imageMat.diffuseTexture = new BABYLON.Texture(self.getImageUrl(), scene); imageMat.roughness = 1; imageMat.emissiveColor = new BABYLON.Color3.White(); imagePlane.material = imageMat; imagePlane.parent = self.mesh; } img.src = this.getImageUrl(); // text https://www.babylonjs-playground.com/#TMHF80 // TODO: cleanup this mess of the dynamic texture: let text = this.info['image']; var temp = new BABYLON.DynamicTexture("DynamicTexture", 64, scene); var tmpctx = temp.getContext(); let maxWidth = 1700; // at some point texture starts doing messy things.. let's avoid that by squeezing text. tmpctx.font = config['font']; var DTWidth = Math.min(tmpctx.measureText(text).width, maxWidth); var planeHeight = 3; var DTHeight = 256; //or set as wished var ratio = planeHeight/DTHeight; var planeWidth = DTWidth * ratio; var dynamicTexture = new BABYLON.DynamicTexture("DynamicTexture", {width:DTWidth, height:DTHeight}, scene, false); dynamicTexture.hasAlpha = true; var ctx = dynamicTexture.getContext(); ctx.font = config['font']; ctx.fillStyle = config['textColor']; ctx.fillText(text, 0, 50, maxWidth); dynamicTexture.update(); var mat = new BABYLON.StandardMaterial("mat", scene); mat.diffuseTexture = dynamicTexture; mat.opacityTexture = dynamicTexture; // smoother transparency: https://www.html5gamedevs.com/topic/19647-quality-of-dynamictexture-text-with-transparent-background/?do=findComment&comment=111383 mat.roughness = 1; mat.emissiveColor = new BABYLON.Color3.White(); var textPlane = BABYLON.MeshBuilder.CreatePlane("text", {width:planeWidth, height:planeHeight, sideOrientation: BABYLON.Mesh.DOUBLESIDE}, scene); textPlane.material = mat; textPlane.parent = this.mesh; textPlane.position.y = -4.3; // text sits below image this.mesh.rotation.y = Math.PI; // rotate 180 deg. to avoid looking at backside from the start. } updateMeshPosition(t, dt, config){ // first see how we are with our moving positions: if(dt < 1) { // don't change positions, because we're paused } else if(Math.abs(this.position - this.target_position) > 0.00001) { if(t > this.target_position_time) { console.error('were we too slow?'); this.position = this.target_position; } let tdiff = this.target_position_time - t; if(tdiff != 0) { if(tdiff < dt) { // never place it too far... this.position = this.target_position; } else { this.position += (this.target_position - this.position) / (tdiff/dt); // console.log(this.position, this.target_position_time, t); } } } else{ // it's practically the same, but set it to avoid any rounding uglyness. this.position = this.target_position; } let p = t; //0; // if 0, disable movement for a bit. p *= config.speedFactor/1000 p -= config.startOffset/config.speedFactor; // start offset ( milliseconds) p -= config.distanceFactor * this.position / 1000; // factors determine scale of animation let target_x = Math.sin(2*p)*config.shapeWidth; let target_z = Math.sin(p)*config.shapeHeight; this.mesh.position.x = target_x; this.mesh.position.z = target_z; } }