diff --git a/src/assets/images/giphy_logo.png b/src/assets/images/giphy_logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..f979e8bed55a6eda4f7dba98eeb4ce6297f4f837
Binary files /dev/null and b/src/assets/images/giphy_logo.png differ
diff --git a/src/components/auto-box-collider.js b/src/components/auto-box-collider.js
deleted file mode 100644
index 220ad92ed83ba506aa63f70dc2ee24e087ce61db..0000000000000000000000000000000000000000
--- a/src/components/auto-box-collider.js
+++ /dev/null
@@ -1,49 +0,0 @@
-AFRAME.registerComponent("auto-box-collider", {
-  schema: {
-    resize: { default: false },
-    resizeLength: { default: 0.5 }
-  },
-
-  init() {
-    this.onLoaded = this.onLoaded.bind(this);
-    this.el.addEventListener("model-loaded", this.onLoaded);
-  },
-
-  remove() {
-    this.el.removeEventListener("model-loaded", this.onLoaded);
-  },
-
-  onLoaded() {
-    const rotation = this.el.object3D.rotation.clone();
-    this.el.object3D.rotation.set(0, 0, 0);
-    const { min, max } = new THREE.Box3().setFromObject(this.el.object3DMap.mesh);
-    const halfExtents = new THREE.Vector3()
-      .addVectors(min.clone().negate(), max)
-      .multiplyScalar(0.5 / this.el.object3D.scale.x);
-    this.el.setAttribute("shape", {
-      shape: "box",
-      halfExtents: halfExtents,
-      offset: new THREE.Vector3(0, halfExtents.y, 0)
-    });
-    if (this.data.resize) {
-      this.resize(min, max);
-    }
-    this.el.object3D.rotation.copy(rotation);
-    this.el.removeAttribute("auto-box-collider");
-  },
-
-  // Adjust the scale such that the object fits within a box of a specified size.
-  resize(min, max) {
-    const dX = Math.abs(max.x - min.x);
-    const dY = Math.abs(max.y - min.y);
-    const dZ = Math.abs(max.z - min.z);
-    const lengthOfLongestComponent = Math.max(dX, dY, dZ);
-    const correctiveFactor = this.data.resizeLength / lengthOfLongestComponent;
-    const scale = this.el.object3D.scale;
-    this.el.setAttribute("scale", {
-      x: scale.x * correctiveFactor,
-      y: scale.y * correctiveFactor,
-      z: scale.z * correctiveFactor
-    });
-  }
-});
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index 38be846de6e7f68fbf937be2937459a445099555..c801f76188260c695d7121ef242a00e1d5db4ea9 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -53,6 +53,7 @@ AFRAME.registerComponent("cursor-controller", {
 
   tick: (() => {
     const rayObjectRotation = new THREE.Quaternion();
+    const cameraPos = new THREE.Vector3();
 
     return function() {
       if (!this.enabled) {
@@ -97,6 +98,11 @@ AFRAME.registerComponent("cursor-controller", {
       if (this.data.drawLine) {
         this.el.setAttribute("line", { start: this.origin.clone(), end: this.data.cursor.object3D.position.clone() });
       }
+
+      // The cursor will always be oriented towards the player about its Y axis, so objects held by the cursor will rotate towards the player.
+      this.data.camera.object3D.getWorldPosition(cameraPos);
+      cameraPos.y = this.data.cursor.object3D.position.y;
+      this.data.cursor.object3D.lookAt(cameraPos);
     };
   })(),
 
diff --git a/src/components/destroy-at-extreme-distances.js b/src/components/destroy-at-extreme-distances.js
new file mode 100644
index 0000000000000000000000000000000000000000..af878249afc37c25785ecae5d8c3bf072e0d6c9f
--- /dev/null
+++ b/src/components/destroy-at-extreme-distances.js
@@ -0,0 +1,24 @@
+AFRAME.registerComponent("destroy-at-extreme-distances", {
+  schema: {
+    xMin: { default: -1000 },
+    xMax: { default: 1000 },
+    yMin: { default: -1000 },
+    yMax: { default: 1000 },
+    zMin: { default: -1000 },
+    zMax: { default: 1000 }
+  },
+
+  tick: (function() {
+    const pos = new THREE.Vector3();
+    return function() {
+      const { xMin, xMax, yMin, yMax, zMin, zMax } = this.data;
+      this.el.object3D.getWorldPosition(pos);
+      this.el.parentNode === this.el.sceneEl
+        ? pos.copy(this.el.object3D.position)
+        : this.el.object3D.getWorldPosition(pos);
+      if (pos.x < xMin || pos.x > xMax || pos.y < yMin || pos.y > yMax || pos.z < zMin || pos.z > zMax) {
+        this.el.parentNode.removeChild(this.el);
+      }
+    };
+  })()
+});
diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js
index b1195922467eb57dd6b536e688b147c164e502ea..0b9755a4a8a449b2001a4d44180df73c3b9f7545 100644
--- a/src/components/gltf-model-plus.js
+++ b/src/components/gltf-model-plus.js
@@ -181,11 +181,12 @@ function nextTick() {
   });
 }
 
-function cachedLoadGLTF(src, preferredTechnique, onProgress) {
+function cachedLoadGLTF(src, basePath, preferredTechnique, onProgress) {
   // Load the gltf model from the cache if it exists.
   if (!GLTFCache[src]) {
     GLTFCache[src] = new Promise((resolve, reject) => {
       const gltfLoader = new THREE.GLTFLoader();
+      gltfLoader.path = basePath;
       gltfLoader.preferredTechnique = preferredTechnique;
       gltfLoader.load(src, resolve, onProgress, reject);
     });
@@ -202,6 +203,7 @@ function cachedLoadGLTF(src, preferredTechnique, onProgress) {
 AFRAME.registerComponent("gltf-model-plus", {
   schema: {
     src: { type: "string" },
+    basePath: { type: "string", default: undefined },
     inflate: { default: false }
   },
 
@@ -242,7 +244,7 @@ AFRAME.registerComponent("gltf-model-plus", {
       }
 
       const gltfPath = THREE.LoaderUtils.extractUrlBase(src);
-      const model = await cachedLoadGLTF(src, this.preferredTechnique);
+      const model = await cachedLoadGLTF(src, this.data.basePath, this.preferredTechnique);
 
       // If we started loading something else already
       // TODO: there should be a way to cancel loading instead
@@ -254,11 +256,10 @@ AFRAME.registerComponent("gltf-model-plus", {
       this.model = model.scene || model.scenes[0];
       this.model.animations = model.animations;
 
-      this.el.setObject3D("mesh", this.model);
-
-      if (this.data.inflate) {
-        this.inflatedEl = inflateEntities(this.model, this.templates, gltfPath);
+      let object3DToSet = this.model;
+      if (this.data.inflate && (this.inflatedEl = inflateEntities(this.model, this.templates, gltfPath))) {
         this.el.appendChild(this.inflatedEl);
+        object3DToSet = this.inflatedEl.object3D;
         // TODO: Still don't fully understand the lifecycle here and how it differs between browsers, we should dig in more
         // Wait one tick for the appended custom elements to be connected before attaching templates
         await nextTick();
@@ -267,7 +268,7 @@ AFRAME.registerComponent("gltf-model-plus", {
           attachTemplate(this.el, name, this.templates[name]);
         }
       }
-
+      this.el.setObject3D("mesh", object3DToSet);
       this.el.emit("model-loaded", { format: "gltf", model: this.model });
     } catch (e) {
       console.error("Failed to load glTF model", e, this);
diff --git a/src/components/image-plus.js b/src/components/image-plus.js
index 744020534a6d9f5f6924ea12084572d4eb325109..5110cebbaca330f9ce53aa32d2d5fbee0963c196 100644
--- a/src/components/image-plus.js
+++ b/src/components/image-plus.js
@@ -67,45 +67,11 @@ errorImage.onload = () => {
 };
 
 AFRAME.registerComponent("image-plus", {
-  dependencies: ["geometry"],
-
   schema: {
     src: { type: "string" },
-    contentType: { type: "string" }
-  },
-
-  _fit(w, h) {
-    const ratio = (h || 1.0) / (w || 1.0);
-    const geo = this.el.geometry;
-    let width, height;
-    if (geo && geo.width) {
-      if (geo.height && ratio > 1) {
-        width = geo.width / ratio;
-      } else {
-        height = geo.height * ratio;
-      }
-    } else if (geo && geo.height) {
-      width = geo.width / ratio;
-    } else {
-      width = Math.min(1.0, 1.0 / ratio);
-      height = Math.min(1.0, ratio);
-    }
-    this.el.setAttribute("geometry", { width, height });
-    this.el.setAttribute("shape", {
-      shape: "box",
-      halfExtents: {
-        x: width / 2,
-        y: height / 2,
-        z: 0.05
-      }
-    });
-  },
+    contentType: { type: "string" },
 
-  init() {
-    const material = new THREE.MeshBasicMaterial();
-    material.side = THREE.DoubleSide;
-    material.transparent = true;
-    this.el.getObject3D("mesh").material = material;
+    depth: { default: 0.05 }
   },
 
   remove() {
@@ -216,33 +182,66 @@ AFRAME.registerComponent("image-plus", {
         return;
       }
 
+      let cacheItem;
       if (textureCache.has(url)) {
-        const cacheItem = textureCache.get(url);
+        cacheItem = textureCache.get(url);
         texture = cacheItem.texture;
         cacheItem.count++;
       } else {
+        cacheItem = { count: 1 };
         if (url === "error") {
           texture = errorTexture;
         } else if (contentType === "image/gif") {
           texture = await this.loadGIF(url);
         } else if (contentType.startsWith("image/")) {
           texture = await this.loadImage(url);
-        } else if (contentType.startsWith("video")) {
+        } else if (contentType.startsWith("video/") || contentType.startsWith("audio/")) {
           texture = await this.loadVideo(url);
+          cacheItem.audioSource = this.el.sceneEl.audioListener.context.createMediaElementSource(texture.image);
         } else {
-          throw new Error(`Unknown centent type: ${contentType}`);
+          throw new Error(`Unknown content type: ${contentType}`);
         }
 
-        textureCache.set(url, { count: 1, texture });
+        cacheItem.texture = texture;
+        textureCache.set(url, cacheItem);
+      }
+
+      if (cacheItem.audioSource) {
+        const sound = new THREE.PositionalAudio(this.el.sceneEl.audioListener);
+        sound.setNodeSource(cacheItem.audioSource);
+        this.el.setObject3D("sound", sound);
       }
     } catch (e) {
       console.error("Error loading media", this.data.src, e);
       texture = errorTexture;
     }
 
-    const material = this.el.getObject3D("mesh").material;
+    const material = new THREE.MeshBasicMaterial();
+    material.side = THREE.DoubleSide;
+    material.transparent = true;
     material.map = texture;
     material.needsUpdate = true;
-    this._fit(texture.image.videoWidth || texture.image.width, texture.image.videoHeight || texture.image.height);
+    material.map.needsUpdate = true;
+
+    const ratio =
+      (texture.image.videoHeight || texture.image.height || 1.0) /
+      (texture.image.videoWidth || texture.image.width || 1.0);
+    const width = Math.min(1.0, 1.0 / ratio);
+    const height = Math.min(1.0, ratio);
+
+    const geometry = new THREE.PlaneGeometry(width, height, 1, 1);
+    this.mesh = new THREE.Mesh(geometry, material);
+    this.el.setObject3D("mesh", this.mesh);
+    this.el.setAttribute("shape", {
+      shape: "box",
+      halfExtents: { x: width / 2, y: height / 2, z: this.data.depth }
+    });
+
+    // TODO: verify if we actually need to do this
+    if (this.el.components.body && this.el.components.body.body) {
+      this.el.components.body.syncToPhysics();
+      this.el.components.body.updateCannonScale();
+    }
+    this.el.emit("image-loaded");
   }
 });
diff --git a/src/components/media-loader.js b/src/components/media-loader.js
new file mode 100644
index 0000000000000000000000000000000000000000..adae71d23a1cd47af037d6f76495ffef31104b91
--- /dev/null
+++ b/src/components/media-loader.js
@@ -0,0 +1,93 @@
+import { getBox, getScaleCoefficient } from "../utils/auto-box-collider";
+import { resolveFarsparkUrl } from "../utils/media-utils";
+
+const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type"));
+
+AFRAME.registerComponent("media-loader", {
+  schema: {
+    src: { type: "string" },
+    resize: { default: false }
+  },
+
+  init() {
+    this.onError = this.onError.bind(this);
+  },
+
+  setShapeAndScale(resize) {
+    const mesh = this.el.getObject3D("mesh");
+    const box = getBox(this.el, mesh);
+    const scaleCoefficient = resize ? getScaleCoefficient(0.5, box) : 1;
+    this.el.object3DMap.mesh.scale.multiplyScalar(scaleCoefficient);
+    if (this.el.body && this.el.body.shapes.length > 1) {
+      this.el.removeAttribute("shape");
+    } else {
+      const center = new THREE.Vector3();
+      const { min, max } = box;
+      const halfExtents = {
+        x: Math.abs(min.x - max.x) / 2 * scaleCoefficient,
+        y: Math.abs(min.y - max.y) / 2 * scaleCoefficient,
+        z: Math.abs(min.z - max.z) / 2 * scaleCoefficient
+      };
+      center.addVectors(min, max).multiplyScalar(0.5 * scaleCoefficient);
+      mesh.position.sub(center);
+      this.el.setAttribute("shape", {
+        shape: "box",
+        halfExtents: halfExtents
+      });
+    }
+  },
+
+  onError() {
+    this.el.setAttribute("image-plus", { src: "error" });
+    clearTimeout(this.showLoaderTimeout);
+  },
+
+  // TODO: correctly handle case where src changes
+  async update() {
+    try {
+      const url = this.data.src;
+
+      this.showLoaderTimeout = setTimeout(() => {
+        const loadingObj = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial());
+        this.el.setObject3D("mesh", loadingObj);
+        this.setShapeAndScale(true);
+      }, 100);
+
+      const { raw, origin, meta } = await resolveFarsparkUrl(url);
+      console.log("resolved", url, raw, origin, meta);
+
+      const contentType = (meta && meta.expected_content_type) || (await fetchContentType(raw));
+      if (contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/")) {
+        this.el.addEventListener(
+          "image-loaded",
+          () => {
+            clearTimeout(this.showLoaderTimeout);
+          },
+          { once: true }
+        );
+        this.el.setAttribute("image-plus", { src: raw, contentType });
+        this.el.setAttribute("position-at-box-shape-border", { target: ".delete-button", dirs: ["forward", "back"] });
+      } else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) {
+        this.el.addEventListener(
+          "model-loaded",
+          () => {
+            clearTimeout(this.showLoaderTimeout);
+            this.setShapeAndScale(this.data.resize);
+          },
+          { once: true }
+        );
+        this.el.addEventListener("model-error", this.onError, { once: true });
+        this.el.setAttribute("gltf-model-plus", {
+          src: raw,
+          basePath: THREE.LoaderUtils.extractUrlBase(origin),
+          inflate: true
+        });
+      } else {
+        throw new Error(`Unsupported content type: ${contentType}`);
+      }
+    } catch (e) {
+      console.error("Error adding media", e);
+      this.onError();
+    }
+  }
+});
diff --git a/src/components/offset-relative-to.js b/src/components/offset-relative-to.js
index 23920e099a94fd50373f6804bf8c02d8cae0910f..eff56e8a45e9f201bbabbbf516de4a3625e843dc 100644
--- a/src/components/offset-relative-to.js
+++ b/src/components/offset-relative-to.js
@@ -37,10 +37,12 @@ AFRAME.registerComponent("offset-relative-to", {
         obj.parent.worldToLocal(offsetVector);
       }
       obj.position.copy(offsetVector);
+      this.el.body && this.el.body.position.copy(obj.position);
       // TODO: Hack here to deal with the fact that the rotation component mutates ordering, and we network rotation without sending ordering information
       // See https://github.com/networked-aframe/networked-aframe/issues/134
       obj.rotation.order = "YXZ";
       target.getWorldQuaternion(obj.quaternion);
+      this.el.body && this.el.body.quaternion.copy(obj.quaternion);
       if (this.data.selfDestruct) {
         if (this.data.on) {
           this.el.sceneEl.removeEventListener(this.data.on, this.updateOffset);
diff --git a/src/components/position-at-box-shape-border.js b/src/components/position-at-box-shape-border.js
index 59776bf6de0c2f12f2b46e40720a4287dee49c9b..561577b5eb1d302f0dfa44d508352e76cad11829 100644
--- a/src/components/position-at-box-shape-border.js
+++ b/src/components/position-at-box-shape-border.js
@@ -1,6 +1,8 @@
+import { getBox } from "../utils/auto-box-collider.js";
+
 const PI = Math.PI;
 const HALF_PI = PI / 2;
-const THREE_HALF_PI = 3 * PI / 2;
+const THREE_HALF_PI = 3 * HALF_PI;
 const right = new THREE.Vector3(1, 0, 0);
 const forward = new THREE.Vector3(0, 0, 1);
 const left = new THREE.Vector3(-1, 0, 0);
@@ -28,6 +30,11 @@ const dirs = {
   }
 };
 
+const inverseHalfExtents = {
+  x: "z",
+  z: "x"
+};
+
 AFRAME.registerComponent("position-at-box-shape-border", {
   schema: {
     target: { type: "string" },
@@ -36,6 +43,7 @@ AFRAME.registerComponent("position-at-box-shape-border", {
 
   init() {
     this.cam = this.el.sceneEl.camera.el.object3D;
+    this.halfExtents = new THREE.Vector3();
   },
 
   update() {
@@ -47,25 +55,39 @@ AFRAME.registerComponent("position-at-box-shape-border", {
     const targetPosition = new THREE.Vector3();
     const pointOnBoxFace = new THREE.Vector3();
     return function() {
-      if (!this.shape) {
-        this.shape = this.el.components["shape"];
-        if (!this.shape) return;
-      }
       if (!this.target) {
         this.target = this.el.querySelector(this.data.target).object3D;
         if (!this.target) return;
       }
-      const halfExtents = this.shape.data.halfExtents;
+      if (!this.el.getObject3D("mesh")) {
+        return;
+      }
+      if (!this.halfExtents || this.mesh !== this.el.getObject3D("mesh") || this.shape !== this.el.components.shape) {
+        this.mesh = this.el.getObject3D("mesh");
+        if (this.el.components.shape) {
+          this.shape = this.el.components.shape;
+          this.halfExtents.copy(this.shape.data.halfExtents);
+        } else {
+          const box = getBox(this.el, this.mesh);
+          this.halfExtents = box.min
+            .clone()
+            .negate()
+            .add(box.max)
+            .multiplyScalar(0.51 / this.el.object3D.scale.x);
+        }
+      }
       this.cam.getWorldPosition(camWorldPos);
 
       let minSquareDistance = Infinity;
       let targetDir = this.dirs[0].dir;
-      let targetHalfExtent = halfExtents[this.dirs[0].halfExtent];
+      let targetHalfExtentStr = this.dirs[0].halfExtent;
+      let targetHalfExtent = this.halfExtents[targetHalfExtentStr];
       let targetRotation = this.dirs[0].rotation;
 
       for (let i = 0; i < this.dirs.length; i++) {
         const dir = this.dirs[i].dir;
-        const halfExtent = halfExtents[this.dirs[i].halfExtent];
+        const halfExtentStr = this.dirs[i].halfExtent;
+        const halfExtent = this.halfExtents[halfExtentStr];
         pointOnBoxFace.copy(dir).multiplyScalar(halfExtent);
         this.el.object3D.localToWorld(pointOnBoxFace);
         const squareDistance = pointOnBoxFace.distanceToSquared(camWorldPos);
@@ -74,16 +96,13 @@ AFRAME.registerComponent("position-at-box-shape-border", {
           targetDir = dir;
           targetHalfExtent = halfExtent;
           targetRotation = this.dirs[i].rotation;
+          targetHalfExtentStr = halfExtentStr;
         }
       }
 
-      this.target.position.copy(
-        targetPosition
-          .copy(targetDir)
-          .multiplyScalar(targetHalfExtent)
-          .add(this.shape.data.offset)
-      );
+      this.target.position.copy(targetPosition.copy(targetDir).multiplyScalar(targetHalfExtent));
       this.target.rotation.set(0, targetRotation, 0);
+      this.target.scale.setScalar(this.halfExtents[inverseHalfExtents[targetHalfExtentStr]] * 4);
     };
   })()
 });
diff --git a/src/components/sticky-object.js b/src/components/sticky-object.js
index d415779078f6a0acb2d6975170cde39cbd80cf47..d84c82313100622864b9457161b71b842edf6309 100644
--- a/src/components/sticky-object.js
+++ b/src/components/sticky-object.js
@@ -1,6 +1,6 @@
 /* global THREE, CANNON, AFRAME */
 AFRAME.registerComponent("sticky-object", {
-  dependencies: ["body", "super-networked-interactable"],
+  dependencies: ["body"],
 
   schema: {
     autoLockOnLoad: { default: false },
@@ -17,24 +17,27 @@ AFRAME.registerComponent("sticky-object", {
   play() {
     this.el.addEventListener("grab-start", this._onGrab);
     this.el.addEventListener("grab-end", this._onRelease);
-    this.el.addEventListener("body-loaded", this._onBodyLoaded);
+
+    if (this.hasSetupBodyLoaded) return;
+    this.hasSetupBodyLoaded = true;
+
+    if (this.el.body) {
+      this._onBodyLoaded();
+    } else {
+      this.el.addEventListener("body-loaded", this._onBodyLoaded, { once: true });
+    }
   },
 
   pause() {
     this.el.removeEventListener("grab-start", this._onGrab);
     this.el.removeEventListener("grab-end", this._onRelease);
-    this.el.removeEventListener("body-loaded", this._onBodyLoaded);
   },
 
   setLocked(locked) {
-    if (!NAF.utils.isMine(this.el)) return;
+    if (this.el.components.networked && !NAF.utils.isMine(this.el)) return;
 
-    const mass = this.el.components["super-networked-interactable"].data.mass;
     this.locked = locked;
-    this.el.body.type = locked ? window.CANNON.Body.STATIC : window.CANNON.Body.DYNAMIC;
-    this.el.setAttribute("body", {
-      mass: locked ? 0 : mass
-    });
+    this.el.setAttribute("body", { type: locked ? "static" : "dynamic" });
   },
 
   _onBodyLoaded() {
@@ -45,6 +48,7 @@ AFRAME.registerComponent("sticky-object", {
 
   _onRelease() {
     if (
+      !this.el.is("grabbed") &&
       this.data.autoLockOnRelease &&
       this.el.body.velocity.lengthSquared() < this.data.autoLockSpeedLimit * this.data.autoLockSpeedLimit
     ) {
@@ -57,6 +61,7 @@ AFRAME.registerComponent("sticky-object", {
   },
 
   remove() {
+    this.el.removeEventListener("body-loaded", this._onBodyLoaded);
     if (this.stuckTo) {
       const stuckTo = this.stuckTo;
       delete this.stuckTo;
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index 3b84bb7ac3b99421024113d527aa8a1ea54da725..844dc1df1706f2a51f55afd531e41898724fcb77 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -5,7 +5,6 @@
  */
 AFRAME.registerComponent("super-networked-interactable", {
   schema: {
-    mass: { default: 1 },
     hapticsMassVelocityFactor: { default: 0.1 },
     counter: { type: "selector" }
   },
@@ -18,7 +17,7 @@ AFRAME.registerComponent("super-networked-interactable", {
     NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
       this.networkedEl = networkedEl;
       if (!NAF.utils.isMine(networkedEl)) {
-        this.el.setAttribute("body", { type: "static", mass: 0 });
+        this.el.setAttribute("body", { type: "static" });
       } else {
         this.counter.register(networkedEl);
       }
@@ -51,7 +50,7 @@ AFRAME.registerComponent("super-networked-interactable", {
     this.hand = e.detail.hand;
     if (this.networkedEl && !NAF.utils.isMine(this.networkedEl)) {
       if (NAF.utils.takeOwnership(this.networkedEl)) {
-        this.el.setAttribute("body", { type: "dynamic", mass: this.data.mass });
+        this.el.setAttribute("body", { type: "dynamic" });
         this.counter.register(this.networkedEl);
       } else {
         this.el.emit("grab-end", { hand: this.hand });
@@ -61,7 +60,7 @@ AFRAME.registerComponent("super-networked-interactable", {
   },
 
   _onOwnershipLost: function() {
-    this.el.setAttribute("body", { type: "static", mass: 0 });
+    this.el.setAttribute("body", { type: "static" });
     this.el.emit("grab-end", { hand: this.hand });
     this.hand = null;
     this.counter.deregister(this.el);
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index 08491ec0ea05f253c720b50e078c5895bd3f188f..a3d2df9137fe19999c04d66ba27e55ce90afc97e 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -1,3 +1,7 @@
+import { addMedia } from "../utils/media-utils";
+import { waitForEvent } from "../utils/async-utils";
+
+let nextGrabId = 0;
 /**
  * Spawns networked objects when grabbed.
  * @namespace network
@@ -5,108 +9,119 @@
  */
 AFRAME.registerComponent("super-spawner", {
   schema: {
-    template: { default: "" },
+    /**
+     * Source of the media asset the spawner will spawn when grabbed. This can be a gltf, video, or image, or a url that the reticiulm media API can resolve to a gltf, video, or image.
+     */
+    src: { default: "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf" },
+
+    /**
+     * Spawn the object at a custom position, rather than at the center of the spanwer.
+     */
     useCustomSpawnPosition: { default: false },
     spawnPosition: { type: "vec3" },
+
+    /**
+     * Spawn the object with a custom orientation, rather than copying that of the spawner.
+     */
     useCustomSpawnRotation: { default: false },
     spawnRotation: { type: "vec4" },
-    events: { default: ["cursor-grab", "hand_grab"] },
-    spawnCooldown: { default: 1 }
+
+    /**
+     * The events to emit for programmatically grabbing and releasing objects
+     */
+    grabEvents: { default: ["cursor-grab", "hand_grab"] },
+    releaseEvents: { default: ["cursor-release", "hand_release"] },
+
+    /**
+     * The spawner will become invisible and ungrabbable for this ammount of time after being grabbed. This can prevent rapidly spawning objects.
+     */
+    spawnCooldown: { default: 1 },
+
+    /**
+     * Center the spawned object on the hand that grabbed it after it finishes loading. By default the object will be grabbed relative to where the spawner was grabbed
+     */
+    centerSpawnedObject: { default: false }
   },
 
-  init: function() {
-    this.entities = new Map();
-    this.timeout = null;
+  init() {
+    this.heldEntities = new Map();
+    this.cooldownTimeout = null;
+    this.onGrabStart = this.onGrabStart.bind(this);
+    this.onGrabEnd = this.onGrabEnd.bind(this);
   },
 
-  play: function() {
-    this.handleGrabStart = this._handleGrabStart.bind(this);
-    this.el.addEventListener("grab-start", this.handleGrabStart);
+  play() {
+    this.el.addEventListener("grab-start", this.onGrabStart);
+    this.el.addEventListener("grab-end", this.onGrabEnd);
   },
 
-  pause: function() {
-    this.el.removeEventListener("grab-start", this.handleGrabStart);
+  pause() {
+    this.el.removeEventListener("grab-start", this.onGrabStart);
+    this.el.removeEventListener("grab-end", this.onGrabEnd);
 
-    if (this.timeout) {
-      clearTimeout(this.timeout);
-      this.timeout = null;
+    if (this.cooldownTimeout) {
+      clearTimeout(this.cooldownTimeout);
+      this.cooldownTimeout = null;
       this.el.setAttribute("visible", true);
       this.el.classList.add("interactable");
     }
   },
 
-  remove: function() {
-    for (const entity of this.entities.keys()) {
-      const data = this.entities.get(entity);
-      entity.removeEventListener("componentinitialized", data.componentinInitializedListener);
-      entity.removeEventListener("body-loaded", data.bodyLoadedListener);
-    }
-
-    this.entities.clear();
+  remove() {
+    this.heldEntities.clear();
   },
 
-  _handleGrabStart: function(e) {
-    if (this.timeout) {
+  async onGrabStart(e) {
+    if (this.cooldownTimeout) {
       return;
     }
+
+    // This tells super-hands we are handling this grab. The user is now "grabbing" the spawner
+    e.preventDefault();
+
     const hand = e.detail.hand;
-    const entity = document.createElement("a-entity");
+    const thisGrabId = nextGrabId++;
+    this.heldEntities.set(hand, thisGrabId);
+
+    const entity = addMedia(this.data.src);
+    entity.object3D.position.copy(
+      this.data.useCustomSpawnPosition ? this.data.spawnPosition : this.el.object3D.position
+    );
+    entity.object3D.rotation.copy(
+      this.data.useCustomSpawnRotation ? this.data.spawnRotation : this.el.object3D.rotation
+    );
 
-    entity.setAttribute("networked", "template:" + this.data.template);
+    this.activateCooldown();
 
-    const componentinInitializedListener = this._handleComponentInitialzed.bind(this, entity);
-    const bodyLoadedListener = this._handleBodyLoaded.bind(this, entity);
-    this.entities.set(entity, {
-      hand: hand,
-      componentInitialized: false,
-      bodyLoaded: false,
-      componentinInitializedListener: componentinInitializedListener,
-      bodyLoadedListener: bodyLoadedListener
-    });
+    await waitForEvent("body-loaded", entity);
 
-    entity.addEventListener("componentinitialized", componentinInitializedListener);
-    entity.addEventListener("body-loaded", bodyLoadedListener);
+    // If we are still holding the spawner with the hand that grabbed to create this entity, release the spawner and grab the entity
+    if (this.heldEntities.get(hand) === thisGrabId) {
+      if (this.data.centerSpawnedObject) {
+        entity.body.position.copy(hand.object3D.position);
+      }
+      for (let i = 0; i < this.data.grabEvents.length; i++) {
+        hand.emit(this.data.releaseEvents[i]);
+        hand.emit(this.data.grabEvents[i], { targetEntity: entity });
+      }
+    }
+  },
 
-    const pos = this.data.useCustomSpawnPosition ? this.data.spawnPosition : this.el.getAttribute("position");
-    entity.setAttribute("position", pos);
-    const rot = this.data.useCustomSpawnRotation ? this.data.spawnRotation : this.el.getAttribute("rotation");
-    entity.setAttribute("rotation", rot);
-    this.el.sceneEl.appendChild(entity);
+  onGrabEnd(e) {
+    this.heldEntities.delete(e.detail.hand);
+    // This tells super-hands we are handling this release
+    e.preventDefault();
+  },
 
+  activateCooldown() {
     if (this.data.spawnCooldown > 0) {
       this.el.setAttribute("visible", false);
       this.el.classList.remove("interactable");
-      this.timeout = setTimeout(() => {
+      this.cooldownTimeout = setTimeout(() => {
         this.el.setAttribute("visible", true);
         this.el.classList.add("interactable");
-        this.timeout = null;
+        this.cooldownTimeout = null;
       }, this.data.spawnCooldown * 1000);
     }
-  },
-
-  _handleComponentInitialzed: function(entity, e) {
-    if (e.detail.name === "grabbable") {
-      this.entities.get(entity).componentInitialized = true;
-      this._emitEvents.call(this, entity);
-    }
-  },
-
-  _handleBodyLoaded: function(entity) {
-    this.entities.get(entity).bodyLoaded = true;
-    this._emitEvents.call(this, entity);
-  },
-
-  _emitEvents: function(entity) {
-    const data = this.entities.get(entity);
-    if (data.componentInitialized && data.bodyLoaded) {
-      for (let i = 0; i < this.data.events.length; i++) {
-        data.hand.emit(this.data.events[i], { targetEntity: entity });
-      }
-
-      entity.removeEventListener("componentinitialized", data.componentinInitializedListener);
-      entity.removeEventListener("body-loaded", data.bodyLoadedListener);
-
-      this.entities.delete(entity);
-    }
   }
 });
diff --git a/src/hub.html b/src/hub.html
index c37b5800001ee8437b0f5097734ff40e3185ba4e..c4ae4bec6bde9b22694ba9667193b3b2fb6a0f37 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -86,7 +86,6 @@
             <img id="water-normal-map" crossorigin="anonymous" src="./assets/waternormals.jpg">
 
             <!-- Templates -->
-
             <template id="video-template">
                 <a-entity class="video" geometry="primitive: plane;" material="side: double; shader: flat;" networked-video-player></a-entity>
             </template>
@@ -153,55 +152,18 @@
                 </a-entity>
             </template>
 
-            <template id="interactable-template">
+            <template id="interactable-media">
                 <a-entity
-                    gltf-model-plus="src: #interactable-duck; inflate: true;"
                     class="interactable"
-                    super-networked-interactable="counter: #counter; mass: 1;"
-                    body="type: dynamic; shape: none; mass: 1;"
-                    auto-scale-cannon-physics-body
-                    grabbable
-                    stretchable="useWorldPosition: true; usePhysics: never"
-                    hoverable
-                    duck
-                    sticky-object="autoLockOnRelease: true;"
-                ></a-entity>
-            </template>
-
-            <template id="interactable-model">
-                <a-entity
-                    gltf-model-plus="inflate: false;"
-                    class="interactable"
-                    super-networked-interactable="counter: #media-counter; mass: 1;"
+                    super-networked-interactable="counter: #media-counter;"
                     body="type: dynamic; shape: none; mass: 1;"
                     grabbable
                     stretchable="useWorldPosition: true; usePhysics: never"
                     hoverable
+                    auto-scale-cannon-physics-body
                     sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
-                    auto-box-collider
                     position-at-box-shape-border="target:.delete-button"
-                    auto-scale-cannon-physics-body
-                >
-                    <a-entity class="delete-button" visible-while-frozen scale="0.08 0.08 0.08">
-                        <a-entity mixin="rounded-text-button" remove-object-button position="0 0 0"> </a-entity>
-                        <a-entity text=" value:Delete; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
-                    </a-entity>
-                </a-entity>
-            </template>
-
-            <template id="interactable-image">
-                <a-entity
-                    class="interactable"
-                    super-networked-interactable="counter: #media-counter; mass: 1;"
-                    body="type: dynamic; shape: none; mass: 1;"
-                    auto-scale-cannon-physics-body
-                    grabbable
-                    stretchable="useWorldPosition: true; usePhysics: never"
-                    hoverable
-                    geometry="primitive: plane"
-                    image-plus
-                    sticky-object="autoLockOnLoad: true; autoLockOnRelease: true;"
-                    position-at-box-shape-border="target:.delete-button;dirs:forward,back"
+                    destroy-at-extreme-distances
                 >
                     <a-entity class="delete-button" visible-while-frozen>
                         <a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
@@ -241,7 +203,6 @@
         </a-assets>
 
         <!-- Interactables -->
-        <a-entity id="counter" networked-counter="max: 3; ttl: 120"></a-entity>
         <a-entity id="media-counter" networked-counter="max: 10;"></a-entity>
 
         <a-entity
diff --git a/src/hub.js b/src/hub.js
index 4f4b205b97d3b393213ce59280f044a5b93e21a1..cf326c1491e276b73ac8437c985d5b1ac4035e50 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -64,7 +64,6 @@ import "./components/css-class";
 import "./components/scene-shadow";
 import "./components/avatar-replay";
 import "./components/image-plus";
-import "./components/auto-box-collider";
 import "./components/pinch-to-move";
 import "./components/look-on-mobile";
 import "./components/pitch-yaw-rotator";
@@ -73,6 +72,8 @@ import "./components/sticky-object";
 import "./components/auto-scale-cannon-physics-body";
 import "./components/position-at-box-shape-border";
 import "./components/remove-networked-object-button";
+import "./components/destroy-at-extreme-distances";
+import "./components/media-loader";
 
 import ReactDOM from "react-dom";
 import React from "react";
@@ -297,8 +298,17 @@ const onReady = async () => {
       NAF.connection.entities.completeSync(ev.detail.clientId);
     });
 
+    const offset = { x: 0, y: 0, z: -1.5 };
+    const spawnMediaInfrontOfPlayer = url => {
+      const entity = addMedia(url, true);
+      entity.setAttribute("offset-relative-to", {
+        target: "#player-camera",
+        offset
+      });
+    };
+
     scene.addEventListener("add_media", e => {
-      addMedia(e.detail);
+      spawnMediaInfrontOfPlayer(e.detail);
     });
 
     if (qsTruthy("mediaTools")) {
@@ -307,7 +317,7 @@ const onReady = async () => {
 
         const imgUrl = e.clipboardData.getData("text");
         console.log("Pasted: ", imgUrl, e);
-        addMedia(imgUrl);
+        spawnMediaInfrontOfPlayer(imgUrl);
       });
 
       document.addEventListener("dragover", e => {
@@ -318,8 +328,8 @@ const onReady = async () => {
         e.preventDefault();
         const imgUrl = e.dataTransfer.getData("url");
         if (imgUrl) {
-          console.log("Droped: ", imgUrl);
-          addMedia(imgUrl);
+          console.log("Dropped: ", imgUrl);
+          spawnMediaInfrontOfPlayer(imgUrl);
         }
       });
     }
diff --git a/src/network-schemas.js b/src/network-schemas.js
index a67b0d02381f0accc53472818dfdcac56951171a..5993934a4b0c613ca5b311d14c585fda7a44856c 100644
--- a/src/network-schemas.js
+++ b/src/network-schemas.js
@@ -82,22 +82,7 @@ function registerNetworkSchemas() {
   });
 
   NAF.schemas.add({
-    template: "#interactable-template",
-    components: [
-      {
-        component: "position",
-        requiresNetworkUpdate: vectorRequiresUpdate(0.001)
-      },
-      {
-        component: "rotation",
-        requiresNetworkUpdate: vectorRequiresUpdate(0.5)
-      },
-      "scale"
-    ]
-  });
-
-  NAF.schemas.add({
-    template: "#interactable-image",
+    template: "#interactable-media",
     components: [
       {
         component: "position",
@@ -108,14 +93,9 @@ function registerNetworkSchemas() {
         requiresNetworkUpdate: vectorRequiresUpdate(0.5)
       },
       "scale",
-      "image-plus"
+      "media-loader"
     ]
   });
-
-  NAF.schemas.add({
-    template: "#interactable-model",
-    components: ["position", "rotation", "scale", "gltf-model-plus"]
-  });
 }
 
 export default registerNetworkSchemas;
diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js
index 83b46b9dfee5569044581b44879566a13ec3c934..ff9b4da1b86f19c864bcab89b2eb997fc2eb6a1f 100644
--- a/src/react-components/info-dialog.js
+++ b/src/react-components/info-dialog.js
@@ -5,11 +5,10 @@ import PropTypes from "prop-types";
 import { FormattedMessage } from "react-intl";
 import formurlencoded from "form-urlencoded";
 import LinkDialog from "./link-dialog.js";
+import MediaToolsDialog from "./media-tools-dialog.js";
 
 // TODO i18n
 
-let lastAddMediaUrl = "";
-
 class InfoDialog extends Component {
   static dialogTypes = {
     slack: Symbol("slack"),
@@ -38,17 +37,14 @@ class InfoDialog extends Component {
     this.shareLink = `${loc.protocol}//${loc.host}${loc.pathname}`;
     this.onKeyDown = this.onKeyDown.bind(this);
     this.onContainerClicked = this.onContainerClicked.bind(this);
-    this.onAddMediaClicked = this.onAddMediaClicked.bind(this);
   }
 
   componentDidMount() {
     window.addEventListener("keydown", this.onKeyDown);
-    this.setState({ addMediaUrl: lastAddMediaUrl });
   }
 
   componentWillUnmount() {
     window.removeEventListener("keydown", this.onKeyDown);
-    lastAddMediaUrl = this.state.addMediaUrl;
   }
 
   onKeyDown(e) {
@@ -63,11 +59,6 @@ class InfoDialog extends Component {
     }
   }
 
-  onAddMediaClicked() {
-    this.props.onAddMedia(this.state.addMediaUrl);
-    this.props.onCloseDialog();
-  }
-
   shareLinkClicked = () => {
     navigator.share({
       title: document.title,
@@ -83,8 +74,7 @@ class InfoDialog extends Component {
   state = {
     mailingListEmail: "",
     mailingListPrivacy: false,
-    copyLinkButtonText: "Copy",
-    addMediaUrl: ""
+    copyLinkButtonText: "Copy"
   };
 
   signUpForMailingList = async e => {
@@ -199,28 +189,7 @@ class InfoDialog extends Component {
         break;
       case InfoDialog.dialogTypes.add_media:
         dialogTitle = "Add Media";
-        dialogBody = (
-          <div>
-            <div>Tip: You can paste media urls directly into hubs with ctrl+v</div>
-            <form onSubmit={this.onAddMediaClicked}>
-              <div className="add-media-form">
-                <input
-                  type="url"
-                  placeholder="Image, Video, or GLTF URL"
-                  className="add-media-form__link_field"
-                  value={this.state.addMediaUrl}
-                  onChange={e => this.setState({ addMediaUrl: e.target.value })}
-                  required
-                />
-                <div className="add-media-form__buttons">
-                  <button className="add-media-form__action-button">
-                    <span>Add</span>
-                  </button>
-                </div>
-              </div>
-            </form>
-          </div>
-        );
+        dialogBody = <MediaToolsDialog onAddMedia={this.props.onAddMedia} onCloseDialog={this.props.onCloseDialog} />;
         break;
       case InfoDialog.dialogTypes.updates:
         dialogTitle = "";
diff --git a/src/react-components/media-tools-dialog.js b/src/react-components/media-tools-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..e0753b2882b9462e44a49023df9171822fbccf02
--- /dev/null
+++ b/src/react-components/media-tools-dialog.js
@@ -0,0 +1,79 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import giphyLogo from "../assets/images/giphy_logo.png";
+
+const attributionHostnames = {
+  "giphy.com": giphyLogo
+};
+
+let lastAddMediaUrl = "";
+export default class MediaToolsDialog extends Component {
+  state = {
+    addMediaUrl: ""
+  };
+
+  static propTypes = {
+    onAddMedia: PropTypes.func,
+    onCloseDialog: PropTypes.func
+  };
+
+  constructor() {
+    super();
+    this.onAddMediaClicked = this.onAddMediaClicked.bind(this);
+    this.onUrlChange = this.onUrlChange.bind(this);
+  }
+
+  componentDidMount() {
+    this.setState({ addMediaUrl: lastAddMediaUrl }, () => {
+      this.onUrlChange({ target: this.input });
+    });
+  }
+
+  componentWillUnmount() {
+    lastAddMediaUrl = this.state.addMediaUrl;
+  }
+
+  onUrlChange(e) {
+    this.setState({
+      addMediaUrl: e.target.value,
+      attributionImage: e.target.validity.valid && attributionHostnames[new URL(e.target.value).hostname]
+    });
+  }
+
+  onAddMediaClicked() {
+    this.props.onAddMedia(this.state.addMediaUrl);
+    this.props.onCloseDialog();
+  }
+
+  render() {
+    return (
+      <div>
+        <div>Tip: You can paste media urls directly into hubs with ctrl+v</div>
+        <form onSubmit={this.onAddMediaClicked}>
+          <div className="add-media-form">
+            <input
+              ref={el => (this.input = el)}
+              type="url"
+              placeholder="Image, Video, or GLTF URL"
+              className="add-media-form__link_field"
+              value={this.state.addMediaUrl}
+              onChange={this.onUrlChange}
+              required
+            />
+            <div className="add-media-form__buttons">
+              <button className="add-media-form__action-button">
+                <span>Add</span>
+              </button>
+            </div>
+            {this.state.attributionImage ? (
+              <div>
+                <img src={this.state.attributionImage} />
+              </div>
+            ) : null}
+          </div>
+        </form>
+      </div>
+    );
+  }
+}
diff --git a/src/utils/async-utils.js b/src/utils/async-utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..99954f683ccbf14cb7360524a7b44b48db734c30
--- /dev/null
+++ b/src/utils/async-utils.js
@@ -0,0 +1,5 @@
+export const waitForEvent = function(eventName, eventObj) {
+  return new Promise(resolve => {
+    eventObj.addEventListener(eventName, resolve, { once: true });
+  });
+};
diff --git a/src/utils/auto-box-collider.js b/src/utils/auto-box-collider.js
new file mode 100644
index 0000000000000000000000000000000000000000..0a8b69037f59147065a141273616e30a034d6ee6
--- /dev/null
+++ b/src/utils/auto-box-collider.js
@@ -0,0 +1,21 @@
+const rotation = new THREE.Euler();
+export function getBox(entity, boxRoot) {
+  const box = new THREE.Box3();
+  rotation.copy(entity.object3D.rotation);
+  entity.object3D.rotation.set(0, 0, 0);
+  entity.object3D.updateMatrixWorld(true);
+  box.setFromObject(boxRoot);
+  entity.object3D.worldToLocal(box.min);
+  entity.object3D.worldToLocal(box.max);
+  entity.object3D.rotation.copy(rotation);
+  return box;
+}
+
+export function getScaleCoefficient(length, box) {
+  const { max, min } = box;
+  const dX = Math.abs(max.x - min.x);
+  const dY = Math.abs(max.y - min.y);
+  const dZ = Math.abs(max.z - min.z);
+  const lengthOfLongestComponent = Math.max(dX, dY, dZ);
+  return length / lengthOfLongestComponent;
+}
diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js
index 9c17813b926e5541eef96b586f8efb133e696f6c..e4f812b59dd04d2402905e97e8a6e0f29f4e85f9 100644
--- a/src/utils/media-utils.js
+++ b/src/utils/media-utils.js
@@ -1,6 +1,5 @@
 const whitelistedHosts = [/^.*\.reticulum\.io$/, /^.*hubs\.mozilla\.com$/, /^hubs\.local$/];
 const isHostWhitelisted = hostname => !!whitelistedHosts.filter(r => r.test(hostname)).length;
-
 let resolveMediaUrl = "/api/v1/media";
 if (process.env.NODE_ENV === "development") {
   resolveMediaUrl = `https://${process.env.DEV_RETICULUM_SERVER}${resolveMediaUrl}`;
@@ -9,67 +8,23 @@ if (process.env.NODE_ENV === "development") {
 export const resolveFarsparkUrl = async url => {
   const parsedUrl = new URL(url);
   if ((parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || isHostWhitelisted(parsedUrl.hostname))
-    return url;
+    return { raw: url, origin: url };
 
-  return (await fetch(resolveMediaUrl, {
+  return await fetch(resolveMediaUrl, {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({ media: { url } })
-  }).then(r => r.json())).raw;
+  }).then(r => r.json());
 };
 
-const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type"));
 let interactableId = 0;
-
-const offset = { x: 0, y: 0, z: -1.5 };
-export const spawnNetworkedImage = (src, contentType) => {
-  const scene = AFRAME.scenes[0];
-  const image = document.createElement("a-entity");
-  image.id = "interactable-image-" + interactableId++;
-  image.setAttribute("networked", { template: "#interactable-image" });
-  image.setAttribute("offset-relative-to", {
-    target: "#player-camera",
-    offset: offset,
-    selfDestruct: true
-  });
-  image.setAttribute("image-plus", { src, contentType });
-  scene.appendChild(image);
-  return image;
-};
-
-export const spawnNetworkedInteractable = src => {
+export const addMedia = (src, resize = false) => {
   const scene = AFRAME.scenes[0];
-  const model = document.createElement("a-entity");
-  model.id = "interactable-model-" + interactableId++;
-  model.setAttribute("networked", { template: "#interactable-model" });
-  model.setAttribute("offset-relative-to", {
-    on: "model-loaded",
-    target: "#player-camera",
-    offset: offset,
-    selfDestruct: true
-  });
-  model.setAttribute("gltf-model-plus", "src", src);
-  model.setAttribute("auto-box-collider", { resize: true });
-  scene.appendChild(model);
-  return model;
-};
-
-export const addMedia = async url => {
-  try {
-    const farsparkUrl = await resolveFarsparkUrl(url);
-    console.log("resolved", url, farsparkUrl);
-
-    const contentType = await fetchContentType(farsparkUrl);
 
-    if (contentType.startsWith("image/") || contentType.startsWith("video/")) {
-      spawnNetworkedImage(farsparkUrl, contentType);
-    } else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) {
-      spawnNetworkedInteractable(farsparkUrl);
-    } else {
-      throw new Error(`Unsupported content type: ${contentType}`);
-    }
-  } catch (e) {
-    console.error("Error adding media", e);
-    spawnNetworkedImage("error");
-  }
+  const entity = document.createElement("a-entity");
+  entity.id = "interactable-media-" + interactableId++;
+  entity.setAttribute("networked", { template: "#interactable-media" });
+  entity.setAttribute("media-loader", { src, resize });
+  scene.appendChild(entity);
+  return entity;
 };
diff --git a/src/vendor/GLTFLoader.js b/src/vendor/GLTFLoader.js
index 754ae5d9460e80e176a928cc53ea75ab7a6f5212..5e23d9250e1f917f1206398fa361115e4be77d28 100644
--- a/src/vendor/GLTFLoader.js
+++ b/src/vendor/GLTFLoader.js
@@ -40,7 +40,7 @@ THREE.GLTFLoader = ( function () {
 
 			loader.setResponseType( 'arraybuffer' );
 
-			var farsparkURL = await resolveFarsparkUrl(url);
+			var farsparkURL = (await resolveFarsparkUrl(url)).raw;
 
 			loader.load( farsparkURL, function ( data ) {
 
@@ -1623,7 +1623,7 @@ THREE.GLTFLoader = ( function () {
 
 		var options = this.options;
 
-		var farsparkURL = await resolveFarsparkUrl(resolveURL(bufferDef.uri, options.path));
+		var farsparkURL = (await resolveFarsparkUrl(resolveURL(bufferDef.uri, options.path))).raw;
 
 		return new Promise( function ( resolve, reject ) {
 
@@ -1823,7 +1823,7 @@ THREE.GLTFLoader = ( function () {
 
     var urlToLoad = resolveURL(sourceURI, options.path);
     if (!hasBufferView){
-      urlToLoad = await resolveFarsparkUrl(urlToLoad);
+      urlToLoad = (await resolveFarsparkUrl(urlToLoad)).raw;
     }
 
 		return Promise.resolve( sourceURI ).then( function ( sourceURI ) {
diff --git a/webpack.config.js b/webpack.config.js
index bddcd3303febc079720043ba172306e9cf41c182..a631e867c87dcbbeb4335443cb369421977813f8 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -134,7 +134,8 @@ const config = {
         loader: "worker-loader",
         options: {
           name: "assets/js/[name]-[hash].js",
-          publicPath: "/"
+          publicPath: "/",
+          inline: true
         }
       },
       {
diff --git a/yarn.lock b/yarn.lock
index 4bd30225dc8626c30c5a590523bcd70c9626f655..eb95576ea64ee647be75accadf0e993c3cdab7bc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -184,7 +184,7 @@ aframe-physics-extras@^0.1.3:
 
 "aframe-physics-system@https://github.com/mozillareality/aframe-physics-system#hubs/master":
   version "3.1.2"
-  resolved "https://github.com/mozillareality/aframe-physics-system#50f5deb1134eb0d43c0435d287eef7037818d3cc"
+  resolved "https://github.com/mozillareality/aframe-physics-system#e4767d90dd3b828f348eea93814a2ebb58c0aee3"
   dependencies:
     browserify "^14.3.0"
     budo "^10.0.3"
@@ -5495,7 +5495,7 @@ neo-async@^2.5.0:
 
 "networked-aframe@https://github.com/mozillareality/networked-aframe#mr-social-client/master":
   version "0.6.1"
-  resolved "https://github.com/mozillareality/networked-aframe#06236f794f83cfebdc4ea9f3a9e8a5804f5bdcf9"
+  resolved "https://github.com/mozillareality/networked-aframe#da92fd0068e9ef7430b3970733cec17dae910505"
   dependencies:
     buffered-interpolation "^0.2.4"
     easyrtc "1.1.0"