npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

storysplat-viewer

v2.9.35

Published

PlayCanvas-based 3D viewer for StorySplat scenes - HTML export & dynamic embedding

Readme

StorySplat Viewer

A powerful npm package for embedding and interacting with 3D Gaussian Splatting scenes in web applications. StorySplat Viewer provides a complete solution for displaying, navigating, and interacting with .splat, .ply, and .sog files with built-in support for waypoints, hotspots, portals, audio, particles, LOD streaming, 4DGS frame sequences, and multiple camera modes.

Table of Contents

Installation

npm install storysplat-viewer

Or using yarn:

yarn add storysplat-viewer

Quick Start

import { createViewerFromSceneId } from 'storysplat-viewer';

// Create a viewer from your StorySplat scene ID
const viewer = await createViewerFromSceneId(
  document.getElementById('viewer'),
  'YOUR_SCENE_ID'
);

// Control playback
viewer.play();
viewer.pause();

// Navigate between waypoints
viewer.nextWaypoint();
viewer.goToWaypoint(2);

// Listen for events
viewer.on('ready', () => console.log('Viewer ready!'));

Demos & Guides

The package includes interactive HTML demos in the demo/ folder:

| File | Description | |------|-------------| | demo/embedding-guide.html | Interactive guide covering all 5 ways to embed StorySplat scenes — iframe, script tag, npm package (React/Vue/Svelte), self-hosted export, and more. Includes a live playground with CSS customization controls. | | demo/api-controls-demo.html | Interactive demo of the viewer API — playback controls, waypoint navigation, events, and all configuration options. |

Open these files with a local server (e.g., npx serve .) to try them out.

Loading Scenes

There are two ways to load scenes into the viewer:

Option 1: From Scene ID (Recommended)

Best for: Most use cases. Live content that updates when you edit in the StorySplat editor. Includes automatic view and bandwidth tracking.

import { createViewerFromSceneId } from 'storysplat-viewer';

// Scene ID from your StorySplat dashboard
const viewer = await createViewerFromSceneId(
  document.getElementById('viewer'),
  'YOUR_SCENE_ID'
);

The scene is fetched from the StorySplat API and always reflects your latest changes. Views and bandwidth are automatically tracked for analytics.

Getting your Scene ID:

  1. Open your scene in the StorySplat editor
  2. Click "Upload" or "Update"
  3. Copy the Scene ID from the "Developer Export" section

Option 2: From JSON File (Self-Hosted)

Best for: Self-hosted scenes where you want full control over the scene data. No tracking.

import { createViewer } from 'storysplat-viewer';
import sceneConfig from './my-scene.json'; // Downloaded from editor

const viewer = createViewer(
  document.getElementById('viewer'),
  sceneConfig
);

Downloading scene JSON:

  1. Open your scene in the StorySplat editor
  2. Click "Export" or "Upload"
  3. In the "Developer Export" section, click "Download Scene JSON"
  4. Save the file in your project

Note: When using createViewer with self-hosted scenes, views and bandwidth are not tracked. Use createViewerFromSceneId for StorySplat-hosted scenes to get analytics.

API Reference

createViewerFromSceneId

Create a viewer by fetching scene data from the StorySplat API.

async function createViewerFromSceneId(
  container: HTMLElement,
  sceneId: string,
  options?: ViewerFromSceneIdOptions
): Promise<ViewerInstance>

Parameters:

  • container - HTML element to render the viewer into
  • sceneId - Your StorySplat scene ID
  • options - Optional viewer configuration

Options:

interface ViewerFromSceneIdOptions {
  // API configuration
  baseUrl?: string;  // Default: 'https://discover.storysplat.com'
  apiKey?: string;   // For private scenes (future feature)

  // Viewer options
  autoPlay?: boolean;
  showUI?: boolean;
  backgroundColor?: string;
  revealEffect?: 'fast' | 'medium' | 'slow' | 'none';
  revealStyle?: 'bloom' | 'radial';
  lazyLoad?: boolean;
  lazyLoadThumbnail?: string;
  lazyLoadButtonText?: string;
}

createViewer

Create a viewer from scene data object. Use this for self-hosted scenes.

function createViewer(
  container: HTMLElement,
  scene: SceneData,
  options?: ViewerOptions
): ViewerInstance

Note: createViewer does not track views or bandwidth. For StorySplat-hosted scenes, use createViewerFromSceneId instead.

fetchSceneMeta

Fetch scene metadata without creating a viewer (useful for previews).

async function fetchSceneMeta(
  sceneId: string,
  options?: { baseUrl?: string; apiKey?: string }
): Promise<{
  name: string;
  description: string;
  thumbnailUrl: string;
  userName: string;
  userSlug: string;
  views: number;
  tags: string[];
  category?: string;
  createdAt: string | null;
}>

ViewerInstance

The viewer instance returned by all create functions.

interface ViewerInstance {
  // PlayCanvas app reference
  app: any;
  canvas: HTMLCanvasElement;

  // Navigation
  goToWaypoint: (index: number) => void;
  nextWaypoint: () => void;
  prevWaypoint: () => void;
  getCurrentWaypointIndex: () => number;
  getWaypointCount: () => number;

  // Camera
  setCameraMode: (mode: 'tour' | 'explore' | 'walk') => void;
  getCameraMode: () => string;
  setExploreMode: (mode: 'orbit' | 'fly') => void;
  setPosition: (x: number, y: number, z: number) => void;
  setRotation: (x: number, y: number, z: number) => void;
  getPosition: () => { x: number; y: number; z: number };
  getRotation: () => { x: number; y: number; z: number };

  // Playback / Progress
  play: () => void;
  pause: () => void;
  stop: () => void;
  isPlaying: () => boolean;
  setProgress: (progress: number) => void;
  getProgress: () => number;

  // Splat management
  goToSplat: (url: string) => Promise<void>;
  goToOriginalSplat: () => void;
  getCurrentSplatUrl: () => string;
  isShowingOriginalSplat: () => boolean;
  getAdditionalSplats: () => Array<{ url: string; name?: string; waypointIndex: number; percentage: number }>;

  // 4DGS Frame Sequence (only when frameSequence is configured)
  isFrameSequencePlaying: () => boolean;
  setFrame: (index: number) => void;
  getCurrentFrame: () => number;
  getTotalFrames: () => number;
  setFps: (fps: number) => void;
  getFps: () => number;
  getFrameProgress: () => number;
  setFrameProgress: (progress: number) => void;

  // Audio
  muteAll: () => void;
  unmuteAll: () => void;
  isMuted: () => boolean;

  // Hotspots
  getHotspots: () => Array<{ id: string; title?: string; type: string; position: { x: number; y: number; z: number } }>;
  triggerHotspot: (id: string) => void;
  closeHotspot: () => void;

  // Portals
  navigateToScene: (sceneId: string) => Promise<void>;

  // Lifecycle
  destroy: () => void;
  resize: () => void;

  // UI customization
  setButtonLabels: (labels: Partial<ButtonLabels>) => void;

  // Events
  on: (event: ViewerEvent, callback: (...args: any[]) => void) => void;
  off: (event: ViewerEvent, callback: (...args: any[]) => void) => void;
}

Controlling the Viewer

The viewer instance provides methods to control playback, navigation, and camera programmatically. This is useful for building custom UI controls outside the viewer.

External Control Example

<div id="viewer" style="width: 100%; height: 500px;"></div>

<div id="controls">
  <button id="prev">Previous</button>
  <button id="play">Play</button>
  <button id="pause">Pause</button>
  <button id="next">Next</button>
  <span id="waypoint-info"></span>
</div>

<script type="module">
import { createViewerFromSceneId } from 'storysplat-viewer';

const viewer = await createViewerFromSceneId(
  document.getElementById('viewer'),
  'YOUR_SCENE_ID'
);

// Navigation
document.getElementById('prev').onclick = () => viewer.prevWaypoint();
document.getElementById('next').onclick = () => viewer.nextWaypoint();

// Playback
document.getElementById('play').onclick = () => viewer.play();
document.getElementById('pause').onclick = () => viewer.pause();

// Update waypoint display
viewer.on('waypointChange', ({ index }) => {
  const total = viewer.getWaypointCount();
  document.getElementById('waypoint-info').textContent =
    `Waypoint ${index + 1} of ${total}`;
});

// Jump to specific waypoint
function goToWaypoint(index) {
  viewer.goToWaypoint(index);
}
</script>

Available Control Methods

| Method | Description | |--------|-------------| | Navigation | | | viewer.goToWaypoint(index) | Jump to specific waypoint (0-indexed) | | viewer.nextWaypoint() | Go to next waypoint | | viewer.prevWaypoint() | Go to previous waypoint | | viewer.getCurrentWaypointIndex() | Get current waypoint index | | viewer.getWaypointCount() | Get total number of waypoints | | Camera | | | viewer.setCameraMode(mode) | Switch mode: 'tour', 'explore', or 'walk' | | viewer.getCameraMode() | Get current camera mode | | viewer.setExploreMode(mode) | Switch explore sub-mode: 'orbit' or 'fly' | | viewer.setPosition(x, y, z) | Set camera position | | viewer.setRotation(x, y, z) | Set camera rotation (Euler angles) | | viewer.getPosition() | Get current camera position | | viewer.getRotation() | Get current camera rotation | | Playback / Progress | | | viewer.play() | Start auto-playing through waypoints | | viewer.pause() | Pause auto-play | | viewer.stop() | Stop and reset to first waypoint | | viewer.isPlaying() | Check if currently auto-playing | | viewer.setProgress(progress) | Set scroll progress (0-1) | | viewer.getProgress() | Get current scroll progress (0-1) | | Splat Management | | | viewer.goToSplat(url) | Swap to a different splat file at runtime | | viewer.goToOriginalSplat() | Switch back to the original splat | | viewer.getCurrentSplatUrl() | Get the currently loaded splat URL | | viewer.isShowingOriginalSplat() | Check if showing the original splat | | viewer.getAdditionalSplats() | Get the list of additional splat swap points | | 4DGS Frame Sequence | | | viewer.isFrameSequencePlaying() | Check if frame sequence is playing | | viewer.setFrame(index) | Jump to specific frame | | viewer.getCurrentFrame() | Get current frame index | | viewer.getTotalFrames() | Get total frame count | | viewer.setFps(fps) | Set playback FPS | | viewer.getFps() | Get current FPS | | viewer.getFrameProgress() | Get frame progress (0-1) | | viewer.setFrameProgress(progress) | Set frame progress (0-1) | | Audio | | | viewer.muteAll() | Mute all audio | | viewer.unmuteAll() | Unmute all audio | | viewer.isMuted() | Check if audio is muted | | Hotspots | | | viewer.getHotspots() | Get all hotspot data (id, title, type, position) | | viewer.triggerHotspot(id) | Programmatically open a hotspot popup | | viewer.closeHotspot() | Close the currently open hotspot popup | | Portals | | | viewer.navigateToScene(sceneId) | Navigate to a linked scene via portal | | Lifecycle | | | viewer.resize() | Recalculate canvas size (call after container resize) | | viewer.destroy() | Clean up and remove viewer | | UI | | | viewer.setButtonLabels(labels) | Update UI text labels at runtime (see i18n) | | Events | | | viewer.on(event, callback) | Register event listener | | viewer.off(event, callback) | Remove event listener |

Configuration Options

ViewerOptions

interface ViewerOptions {
  // Template style
  template?: 'standard' | 'minimal' | 'pro';

  // Playback
  autoPlay?: boolean;

  // UI
  showUI?: boolean;
  backgroundColor?: string;

  // Loading animation
  revealEffect?: 'fast' | 'medium' | 'slow' | 'none';
  revealStyle?: 'bloom' | 'radial';  // 'bloom' = burst with overshoot, 'radial' = classic dot wave

  // Lazy loading
  lazyLoad?: boolean;
  lazyLoadThumbnail?: string;
  lazyLoadThumbnailType?: 'image' | 'video' | 'gif';  // Thumbnail media type
  lazyLoadButtonText?: string;

  // Allow parent page CSS to affect the viewer
  allowParentStyles?: boolean;  // Default: false

  // Manual analytics (alternative to createViewerFromSceneId auto-tracking)
  analytics?: {
    sceneId: string;
    ownerId: string;
    baseUrl?: string;
  };
}

Lazy Loading

Show a thumbnail with a start button before loading the full viewer:

const viewer = await createViewerFromSceneId(container, sceneId, {
  lazyLoad: true,
  lazyLoadThumbnail: '/preview.jpg',  // Optional custom thumbnail
  lazyLoadButtonText: 'Start Tour'    // Default: "Start Experience"
});

Reveal Effects

Control how the splat appears when loaded:

const viewer = createViewer(container, sceneData, {
  revealEffect: 'medium',  // 'fast' | 'medium' | 'slow' | 'none'
  revealStyle: 'bloom'     // 'bloom' (burst with overshoot) | 'radial' (classic dot wave)
});

Scene Data Format

When using createViewer() with self-hosted JSON, the scene data should match the format exported by the StorySplat editor. The viewer's transform layer normalizes all field variations automatically, so JSON exported from any version of the editor will work.

Core Fields

| Field | Type | Description | |-------|------|-------------| | loadedModelUrl | string | URL to the .splat, .ply, or .ksplat file | | sogModelUrl / sogUrl | string | SOG compressed format URL (~95% smaller) | | compressedPlyUrl | string | Compressed PLY format URL (~50% smaller) | | lodMetaUrl | string | LOD streaming meta file URL (see LOD Streaming) | | waypoints | array | Camera path waypoints | | hotspots | array | Interactive hotspot markers | | portals | array | Scene-to-scene navigation portals | | audioEmitters | array | Spatial audio sources (see Audio Emitters) | | htmlMeshes | array | HTML-in-3D texture panels (see HTML Meshes) | | particleSystems | array | Particle effect systems | | customMeshes | array | Imported 3D models (.glb/.gltf) | | collisionMeshesData | array | Primitive collision shapes | | voxelCollisionUrl | string | Voxel octree collision data URL (see Voxel Collision) | | additionalSplats | array | Splat swap points along the waypoint path |

Visual & Environment

| Field | Type | Description | |-------|------|-------------| | activeSkyboxUrl | string | URL to the skybox HDR/image file | | skyboxRotation | number | Skybox rotation offset in radians | | lights | array | Scene lighting configuration | | backgroundColor | string | Background color (hex) | | splatRelighting | object | Splat relighting config (see Splat Relighting) |

Camera & Navigation

| Field | Type | Description | |-------|------|-------------| | defaultCameraMode | string | Initial camera mode: 'tour', 'explore', or 'walk' | | orbitCameraSettings | object | Initial orbit camera position + pivot: { cameraPosition: {x,y,z}, pivotPoint: {x,y,z} } | | doubleTapMoveSpeed | number | Auto-forward speed on double-tap in explore mode (default: 1.0) | | refocusTapMode | string | Camera refocus trigger: 'single' or 'double' (default: 'single') | | headBobEnabled | boolean | Enable head bob in walk mode (default: true) | | lodSettings | object | LOD display settings (see LOD Streaming) |

UI & Appearance

| Field | Type | Description | |-------|------|-------------| | uiColor | string | Accent color for UI controls | | uiOptions | object | UI visibility toggles and button labels | | uiOptions.viewerTheme | ViewerTheme | Custom viewer theme overrides (see Theme Customization) | | uiOptions.buttonLabels | ButtonLabels | UI text overrides (see i18n) | | revealStyle | string | Reveal animation: 'bloom' or 'radial' | | swapTransitionType | string | Splat swap transition: 'scanline' or 'dissolve' (default: 'dissolve') |

Model URL Priority

The viewer loads model files in this priority order:

  1. lodMetaUrl — LOD streaming (best quality, progressive loading)
  2. sogModelUrl / sogUrl — SOG compressed (~95% compression)
  3. compressedPlyUrl — Compressed PLY (~50% compression)
  4. loadedModelUrl — Original upload
  5. splatUrl — Legacy fallback

LOD Streaming

LOD (Level-of-Detail) streaming progressively loads splat data in chunks, starting with low-detail and refining to full quality. This dramatically improves initial load times for large scenes.

How It Works

The lodMetaUrl field points to a lod-meta.json file that references chunk files. Each chunk contains texture data (means, scales, quats, spherical harmonics) at different detail levels.

LOD Settings

Configure LOD display behavior via lodSettings in scene data:

const sceneData = {
  lodMetaUrl: '/path/to/lod-meta.json',
  lodSettings: {
    preset: 'auto',  // 'auto' | 'desktop-max' | 'desktop' | 'mobile-max' | 'mobile' | 'custom'
    // Custom overrides (only used when preset is 'custom'):
    lodDistances: [10, 20, 40, 80, 160],  // 5 distance thresholds
    lodRangeMin: 0,   // Min LOD range (0-5)
    lodRangeMax: 5,   // Max LOD range (0-5)
    // Per-device overrides when preset is 'auto':
    mobilePreset: 'mobile',     // Preset for mobile devices
    desktopPreset: 'desktop',   // Preset for desktop devices
  }
};

| Preset | Description | |--------|-------------| | auto | Automatically selects based on device type | | desktop-max | Maximum quality for desktop | | desktop | Balanced quality for desktop | | mobile-max | Maximum quality for mobile | | mobile | Balanced quality for mobile (recommended for mobile) | | custom | Use custom lodDistances and lodRange values |

4DGS Frame Sequences

4DGS (4D Gaussian Splatting) enables playback of frame-by-frame splat animations — like video but in 3D.

Configuration

Add a frameSequence config to your scene data:

const sceneData = {
  loadedModelUrl: '/frames/frame_000.ply',  // First frame (also used as static fallback)
  frameSequence: {
    frameUrls: [
      '/frames/frame_000.ply',
      '/frames/frame_001.ply',
      '/frames/frame_002.ply',
      // ... up to N frames
    ],
    fps: 24,           // Playback FPS (default: 24)
    loop: true,        // Loop playback (default: true)
    preloadCount: 10,  // Frames to preload ahead (default: 10)
    autoplay: true,    // Auto-play immediately (default: false)
  }
};

Controlling Frame Playback

const viewer = createViewer(container, sceneData);

// Frame navigation
viewer.setFrame(0);                    // Jump to first frame
viewer.getCurrentFrame();              // Get current frame index
viewer.getTotalFrames();               // Get total frame count
viewer.isFrameSequencePlaying();       // Check if playing

// Playback speed
viewer.setFps(30);                     // Change FPS
viewer.getFps();                       // Get current FPS

// Progress (0-1)
viewer.setFrameProgress(0.5);          // Jump to 50% through sequence
viewer.getFrameProgress();             // Get current progress

4DGS Events

viewer.on('frameChange', ({ frame, total }) => {
  console.log(`Frame ${frame} of ${total}`);
});

viewer.on('frameComplete', () => {
  console.log('Frame sequence finished (non-looping)');
});

Viewer Theme Customization

The viewer supports deep CSS customization via the ViewerTheme interface. You can override colors, font sizes, border radii, and more — either via scene data or programmatically.

Setting a Theme via Scene Data

const sceneData = await fetch('/scene.json').then(r => r.json());

sceneData.uiOptions = {
  ...sceneData.uiOptions,
  viewerTheme: {
    buttonBg: 'rgba(20, 20, 40, 0.9)',
    buttonTextColor: '#e0e0ff',
    popupBg: 'rgba(10, 10, 30, 0.95)',
    popupTextColor: '#ffffff',
    preloaderBg: '#0a0a1e',
    joystickBaseColor: 'rgba(100, 100, 255, 0.3)',
    joystickThumbColor: 'rgba(150, 150, 255, 0.6)',
    buttonBorderRadius: '12px',
    buttonFontSize: '13px',
    fontFamily: '"Inter", sans-serif',
  }
};

const viewer = createViewer(container, sceneData);

Available Theme Properties

| Category | Properties | |----------|-----------| | Colors (20) | globalTextColor, buttonBg, buttonHoverBg, buttonTextColor, popupBg, popupTextColor, popupCloseBtnColor, popupLinkBtnColor, preloaderBg, preloaderTextColor, infoBannerBg, dropdownBg, watermarkBg, helpPanelBg, errorPopupBg, errorTitleColor, lazyLoadBg, joystickBaseColor, joystickThumbColor, portalPopupBg | | Typography (9) | fontFamily, buttonFontSize, popupTitleFontSize, popupContentFontSize, infoBannerTitleFontSize, infoBannerContentFontSize, progressFontSize, modeBtnFontSize, watermarkFontSize | | Border Radii (6) | buttonBorderRadius, popupBorderRadius, dropdownBorderRadius, helpPanelBorderRadius, errorPopupBorderRadius, lazyLoadBtnBorderRadius | | Other (3) | dropdownBlur, progressBarHeight, preloaderBarHeight |

All properties are optional — unspecified values fall back to built-in defaults.

Generating Styles Programmatically

import { generateViewerStyles } from 'storysplat-viewer';

const css = generateViewerStyles({
  buttonBg: 'rgba(0, 0, 0, 0.8)',
  buttonTextColor: '#fff',
});

// Inject into your page
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);

Splat Relighting

Scene lights can dynamically illuminate Gaussian Splats for dramatic lighting effects. Configure relighting in scene data:

const sceneData = {
  // ... other scene config ...
  splatRelighting: {
    enabled: true,
    ambientColor: '#ffffff',      // Ambient light color for unlit areas
    ambientIntensity: 0.8,        // 0-1, ambient fill strength
    allowViewerToggle: true,      // Show toggle button in viewer UI
    viewerDefaultOn: false,       // Start with relighting off for end users
    shadowsEnabled: false,        // Enable shadow casting
    shadowIntensity: 0.5,         // Shadow strength (0-1)
    shadowGroundY: 0,             // Ground plane Y position for shadows
    shadowPlaneScale: 10,         // Shadow receiving plane size
  }
};

When allowViewerToggle is true, end users see a toggle button in the viewer to switch relighting on/off.

Internationalization (i18n)

All user-facing UI text can be customized via ButtonLabels. This enables full translation or label renaming.

At Creation Time

Set labels via uiOptions.buttonLabels in your scene data:

const sceneData = await fetch('/scene.json').then(r => r.json());

// Override labels before creating the viewer
sceneData.uiOptions = {
  ...sceneData.uiOptions,
  buttonLabels: {
    tour: 'Recorrido',
    explore: 'Explorar',
    walk: 'Caminar',
    next: 'Siguiente',
    previous: 'Anterior',
    waypoints: 'Puntos',
    close: 'Cerrar',
    loading: 'Cargando...',
  }
};

const viewer = createViewer(container, sceneData);

At Runtime

Update labels at any time with setButtonLabels():

const viewer = await createViewerFromSceneId(container, sceneId);

// Switch to Spanish
viewer.setButtonLabels({
  tour: 'Recorrido',
  explore: 'Explorar',
  walk: 'Caminar',
  next: 'Siguiente',
  previous: 'Anterior',
  close: 'Cerrar',
  loading: 'Cargando...',
});

// Only update specific labels (others keep their current values)
viewer.setButtonLabels({ next: 'Suivant', previous: 'Pr\u00e9c\u00e9dent' });

Available Labels

| Key | Default | Description | |-----|---------|-------------| | tour | "Tour" | Tour mode button | | explore | "Explore" | Explore mode button | | walk | "Walk" | Walk mode button | | orbit | "Orbit" | Orbit sub-mode button | | fly | "Fly" | Fly sub-mode button | | next | "Next" | Next waypoint button | | previous | "Prev" | Previous waypoint button | | startExperience | "Start Experience" | Start experience button | | fullscreen | "Fullscreen" | Fullscreen button aria-label | | mute / unmute | "Mute" / "Unmute" | Audio toggle | | waypoints | "Waypoints" | Waypoint dropdown label | | close | "Close" | Hotspot popup close button | | yes / cancel | "Yes" / "Cancel" | Portal confirmation buttons | | switchScenes | "Switch scenes?" | Portal default prompt | | hotspotDefaultTitle | "Hotspot" | Fallback hotspot title | | openExternalLink | "Open External Link" | Hotspot link button text | | vr / ar | "VR" / "AR" | XR button labels | | exitVr / exitAr | "Exit VR" / "Exit AR" | XR exit labels | | loading | "Loading..." | Loading progress text | | loadingScene | "Loading {name}..." | Portal scene loading ({name} replaced) | | helpTitle | "Controls & Help" | Help panel title | | helpCameraModes | "Camera Modes:" | Help section header | | helpTourDesc | "Follow predefined path" | Help tour description | | helpExploreDesc | "Free movement" | Help explore description | | helpWalkDesc | "First-person walking" | Help walk description | | helpTourControls | "Controls:" | Help panel - tour controls heading | | helpTourScroll | "Scroll to navigate" | Help panel - tour scroll instruction | | helpTourDrag | "Drag to look around" | Help panel - tour drag instruction | | helpExploreControls | "Controls:" | Help panel - explore controls heading | | helpExploreLMB | "Left click + drag to orbit" | Help panel - explore LMB | | helpExploreRMB | "Right click + drag to pan" | Help panel - explore RMB | | helpExploreWASD | "WASD to move" | Help panel - explore WASD | | helpExploreShift | "Shift to speed up" | Help panel - explore shift | | helpExploreScroll | "Scroll to zoom" | Help panel - explore scroll | | helpExploreDblClick | "Double-click to auto-move" | Help panel - explore double-click | | helpWalkControls | "Controls:" | Help panel - walk controls heading | | helpWalkClick | "Click to start" | Help panel - walk click | | helpWalkWASD | "WASD to walk" | Help panel - walk WASD | | helpWalkMouse | "Mouse to look" | Help panel - walk mouse | | helpWalkShift | "Shift to run" | Help panel - walk shift | | helpWalkSpace | "Space to jump" | Help panel - walk space | | errorWebGLTitle | "Unable to Initialize 3D Graphics" | WebGL error heading | | errorWebGLMessage | "Your browser or device..." | WebGL error body | | percentageFormat | "{n}%" | Progress percentage format |

Events

// Viewer is ready
viewer.on('ready', () => {
  console.log('Viewer initialized');
});

// Scene loaded
viewer.on('loaded', () => {
  console.log('Scene fully loaded');
});

// Loading progress
viewer.on('progress', ({ loaded, total, percent }) => {
  console.log(`Loading: ${percent}%`);
});

// Waypoint changed
viewer.on('waypointChange', ({ index, waypoint }) => {
  console.log(`Now at waypoint ${index}: ${waypoint.name}`);
});

// Playback state
viewer.on('playbackStart', () => console.log('Playing'));
viewer.on('playbackStop', () => console.log('Stopped'));
viewer.on('playbackComplete', () => console.log('Tour finished'));

// Camera mode
viewer.on('modeChange', ({ mode }) => {
  console.log(`Camera mode: ${mode}`);
});

// Scroll progress
viewer.on('progressUpdate', ({ progress, index }) => {
  console.log(`Progress: ${(progress * 100).toFixed(0)}%, waypoint ${index}`);
});

// Hotspot clicked
viewer.on('hotspotClick', ({ hotspot }) => {
  console.log(`Hotspot clicked: ${hotspot.id} - ${hotspot.title}`);
});

// XR sessions
viewer.on('xrStart', ({ type }) => console.log(`Entered ${type}`));
viewer.on('xrEnd', () => console.log('Exited XR'));

// Splat swap
viewer.on('splatChange', ({ url, isOriginal }) => {
  console.log(`Splat changed: ${url} (original: ${isOriginal})`);
});

// 4DGS frame sequence
viewer.on('frameChange', ({ frame, total }) => {
  console.log(`Frame ${frame} of ${total}`);
});
viewer.on('frameComplete', () => {
  console.log('Frame sequence complete');
});

// Portal activated
viewer.on('portalActivated', ({ sceneId }) => {
  console.log(`Navigating to scene: ${sceneId}`);
});

// Errors and warnings
viewer.on('error', (error) => {
  console.error('Viewer error:', error);
});
viewer.on('warning', ({ type, message }) => {
  console.warn(`Warning [${type}]: ${message}`);
});

All Events Reference

| Event | Data | Description | |-------|------|-------------| | ready | — | Viewer initialized | | loaded | — | Scene fully loaded | | progress | { loaded, total, percent } | Loading progress | | waypointChange | { index, waypoint } | Waypoint changed | | playbackStart | — | Auto-play started | | playbackStop | — | Auto-play paused/stopped | | playbackComplete | — | Tour reached the end | | modeChange | { mode } | Camera mode changed | | progressUpdate | { progress, index } | Scroll progress updated | | hotspotClick | { hotspot } | Hotspot clicked | | xrStart | { type } | Entered VR/AR | | xrEnd | — | Exited VR/AR | | splatChange | { url, isOriginal } | Splat file swapped | | frameChange | { frame, total } | 4DGS frame changed | | frameComplete | — | 4DGS sequence finished | | portalActivated | { portalId, targetSceneId, targetSceneName } | Portal triggered | | portalNavigationStart | { targetSceneId } | Portal navigation beginning | | portalNavigationComplete | { sceneId } | Portal navigation complete | | portalNavigationError | { error, targetSceneId } | Portal navigation failed | | error | Error | Viewer error | | warning | { type, message } | Non-fatal warning |

Portals (Scene-to-Scene Navigation)

Portals allow users to navigate between multiple 3D scenes by clicking or walking near portal markers.

How Portals Work

Portals are configured in the scene JSON with a targetSceneId:

{
  "portals": [
    {
      "id": "portal-1",
      "targetSceneId": "abc123xyz",
      "targetSceneName": "Campervan Interior",
      "targetSceneThumbnail": "https://example.com/thumb.jpg",
      "position": { "x": 2, "y": 0, "z": -3 },
      "type": "sphere",
      "activationMode": "click",
      "confirmNavigation": true,
      "menuOnly": false,
      "menuPath": "Building A/Floor 1"
    }
  ]
}

Portal Fields

| Field | Type | Description | |-------|------|-------------| | id | string | Unique portal identifier | | targetSceneId | string | Scene ID to navigate to | | targetSceneName | string | Display name for the target scene | | targetSceneThumbnail | string | Thumbnail URL for portal preview | | position | {x, y, z} | 3D position of the portal marker | | type | string | Portal mesh shape ('sphere', 'cube', etc.) | | activationMode | string | 'click' or 'proximity' | | confirmNavigation | boolean | Show confirmation dialog before navigating | | menuOnly | boolean | If true, portal appears only in the scene menu, not as a 3D mesh | | menuPath | string | Folder path for menu organization (e.g., "Building A/Floor 1") |

Self-Hosted Portal Navigation

By default, portals use StorySplat scene IDs and fetch from discover.storysplat.com. For fully self-hosted portals, intercept the portalActivated event:

import { createViewer } from 'storysplat-viewer';

const SCENES = {
  'campervan': '/scenes/campervan/scene.json',
  'cabin': '/scenes/cabin/scene.json',
};

let currentViewer = null;

async function loadScene(sceneId) {
  if (currentViewer) currentViewer.destroy();

  const sceneData = await fetch(SCENES[sceneId]).then(r => r.json());
  currentViewer = await createViewer(container, sceneData);

  currentViewer.on('portalActivated', (data) => {
    loadScene(data.targetSceneId);
  });
}

loadScene('campervan');

The targetSceneId is just a string you define — it doesn't need to exist on StorySplat. Map it to your own file paths.

Portal Events

| Event | Description | Data | |-------|-------------|------| | portalActivated | Portal clicked or proximity triggered | { portalId, targetSceneId, targetSceneName } | | portalNavigationStart | Navigation beginning | { targetSceneId } | | portalNavigationComplete | New scene loaded | { sceneId } | | portalNavigationError | Navigation failed | { error, targetSceneId } |

Audio Emitters

Audio emitters are standalone spatial audio sources positioned in 3D space. They support distance-based attenuation and spatialization.

{
  "audioEmitters": [
    {
      "id": "birds",
      "name": "Bird Sounds",
      "url": "https://example.com/birds.mp3",
      "position": { "x": 5, "y": 2, "z": -3 },
      "volume": 0.8,
      "loop": true,
      "autoplay": true,
      "spatialSound": true,
      "distanceModel": "inverse",
      "maxDistance": 50,
      "refDistance": 1,
      "rolloffFactor": 1,
      "enabled": true
    }
  ]
}

| Field | Type | Default | Description | |-------|------|---------|-------------| | id | string | — | Unique identifier | | name | string | — | Display name | | url | string | — | Audio file URL | | position | {x, y, z} | {0,0,0} | 3D position | | volume | number | 1 | Volume (0-1) | | loop | boolean | false | Loop playback | | autoplay | boolean | false | Play automatically on load | | spatialSound | boolean | true | Enable 3D spatial audio | | distanceModel | string | 'inverse' | 'inverse', 'linear', or 'exponential' | | maxDistance | number | 100 | Maximum hearing distance | | refDistance | number | 1 | Distance at which volume is 100% | | rolloffFactor | number | 1 | How quickly volume decreases with distance | | enabled | boolean | true | Whether this emitter is active |

HTML Meshes

HTML Meshes render HTML content as textures on 3D planes in the scene. They support text, images, iframes, CSS styling, and interactive elements.

{
  "htmlMeshes": [
    {
      "id": "info-panel",
      "name": "Info Panel",
      "htmlContent": "<h2>Welcome</h2><p>This is a 3D info panel</p>",
      "position": { "x": 0, "y": 2, "z": -5 },
      "rotation": { "x": 0, "y": 0, "z": 0 },
      "scale": { "x": 2, "y": 1.5, "z": 1 },
      "width": 512,
      "height": 384,
      "billboard": false,
      "cssStyles": "background: rgba(0,0,0,0.8); color: white; padding: 20px; font-family: sans-serif;",
      "visible": true
    }
  ]
}

| Field | Type | Description | |-------|------|-------------| | htmlContent | string | HTML to render as texture | | position / rotation / scale | {x,y,z} | 3D transform | | width / height | number | Texture resolution in pixels | | billboard | boolean | Always face the camera | | cssStyles | string | CSS to apply to the HTML content | | visible | boolean | Whether the mesh is visible |

Voxel Collision

Voxel collision provides high-fidelity collision detection derived directly from splat geometry. Instead of placing primitive collision shapes manually, the system voxelizes the splat into an octree structure for accurate ground detection and movement blocking in walk mode.

Scene Data

{
  "voxelCollisionUrl": "https://example.com/scene.voxel.json"
}

The voxel collision system requires two co-located files:

  • .voxel.json — Metadata (bounds, resolution, leaf count)
  • .voxel.bin — Binary octree data (nodes + leaf data)

Both primitive collision meshes and voxel collision coexist — primitives are checked first, then the octree.

Mirror Planes

Mirror planes add reflective surfaces to scenes, useful for replacing broken mirrors in Gaussian Splat scans with real-time reflections.

Scene Data

{
  "mirrors": [
    {
      "id": "mirror-1",
      "position": { "x": 0, "y": 1.5, "z": -3 },
      "rotation": { "x": 0, "y": 0, "z": 0 },
      "scale": { "x": 2, "y": 2, "z": 1 },
      "resolution": 0.5,
      "intensity": 0.8,
      "tint": "#ffffff"
    }
  ]
}

| Property | Type | Description | |----------|------|-------------| | position | Vector3 | World position of the mirror | | rotation | Vector3 | Euler rotation in radians | | scale | Vector3 | Dimensions of the mirror plane | | resolution | number | Reflection quality (0.25–1.0, lower = better performance) | | intensity | number | Reflection strength (0 = invisible, 1 = full) | | tint | string | Hex color overlay applied to reflections |

Up to 3 mirrors are supported per scene.

Measurements

The measurement system allows viewers to measure real-world distances in 3D scenes using a calibrated scale.

Scene Data

{
  "measurement": {
    "enabled": true,
    "scaleRatio": 0.0254,
    "unit": "meters",
    "color": "#00ff00",
    "measurements": [
      {
        "id": "m1",
        "start": { "x": 0, "y": 0, "z": 0 },
        "end": { "x": 1, "y": 0, "z": 0 },
        "label": "Wall width"
      }
    ]
  }
}

| Property | Type | Description | |----------|------|-------------| | enabled | boolean | Whether measurement mode is available | | scaleRatio | number | Scene units to real-world units multiplier | | unit | string | Display unit: "meters", "centimeters", "feet", or "inches" | | color | string | Hex color for measurement visualization | | measurements | array | Pre-defined measurements with start/end points and labels |

Post-Processing

Post-processing applies real-time color grading adjustments in the viewer.

Scene Data

{
  "postProcessing": {
    "colorEnhance": {
      "enabled": true,
      "shadows": 0.5,
      "highlights": -0.3,
      "vibrance": 0.2,
      "midtones": 0.1,
      "dehaze": 0.15
    }
  }
}

| Property | Type | Range | Description | |----------|------|-------|-------------| | enabled | boolean | — | Master toggle for color enhancement | | shadows | number | -3 to +3 | Brighten or darken dark areas | | highlights | number | -3 to +3 | Adjust bright areas | | vibrance | number | -1 to +1 | Increase or decrease color saturation | | midtones | number | -1 to +1 | Adjust mid-range tones | | dehaze | number | -1 to +1 | Remove or add atmospheric haze |

Custom Menu Links

Add external links to the viewer's navigation menu alongside portals.

Scene Data

{
  "menuLinks": [
    {
      "id": "link-1",
      "label": "Book Now",
      "url": "https://example.com/book",
      "icon": "calendar",
      "menuPath": "Resources",
      "openInNewTab": true
    }
  ]
}

| Property | Type | Description | |----------|------|-------------| | label | string | Display text for the link | | url | string | Target URL (http/https) | | icon | string | Icon type: "link", "map", "calendar", "phone", "cart", "globe", "download" | | menuPath | string | Folder path for menu organization (e.g., "Resources/Help") | | openInNewTab | boolean | Whether to open in a new browser tab (default: true) |

Menu links share the folder system with portals, so links and portals can be organized in the same folder hierarchy.

Entity Animations

Keyframe-based animation system for any scene entity (hotspots, portals, lights, particles, meshes, mirrors).

Scene Data

{
  "entityAnimations": [
    {
      "entityId": "hotspot-1",
      "entityType": "hotspot",
      "timelineMode": "independent",
      "duration": 3,
      "playbackMode": "loop",
      "autoplay": true,
      "delay": 0,
      "tracks": [
        {
          "property": "position.y",
          "keyframes": [
            { "time": 0, "value": 1, "easing": "ease-in-out" },
            { "time": 1, "value": 2, "easing": "ease-in-out" },
            { "time": 2, "value": 1, "easing": "linear" }
          ]
        }
      ]
    }
  ]
}

Timeline Modes

| Mode | Description | |------|-------------| | independent | Time-based playback with configurable duration, delay, and loop mode | | scroll | Animation driven by camera tour scroll progress (0–100%) |

Playback Modes (independent only)

| Mode | Description | |------|-------------| | once | Play once and stop | | loop | Restart from beginning when complete | | pingpong | Reverse direction at each end |

Easing Functions

linear, ease-in, ease-out, ease-in-out

Animatable Properties

| Category | Properties | |----------|------------| | Position | position.x, position.y, position.z | | Rotation | rotation.x, rotation.y, rotation.z | | Scale | scale.x, scale.y, scale.z | | Particles | rate, lifetime, speed, scale, gravity, opacity, rotationSpeed |

React Integration

import { useEffect, useRef } from 'react';
import { createViewerFromSceneId } from 'storysplat-viewer';

function StorySplatViewer({ sceneId }) {
  const containerRef = useRef(null);
  const viewerRef = useRef(null);

  useEffect(() => {
    let mounted = true;

    async function initViewer() {
      if (!containerRef.current) return;

      try {
        const viewer = await createViewerFromSceneId(
          containerRef.current,
          sceneId
        );

        if (mounted) {
          viewerRef.current = viewer;
        } else {
          viewer.destroy();
        }
      } catch (error) {
        console.error('Failed to create viewer:', error);
      }
    }

    initViewer();

    return () => {
      mounted = false;
      viewerRef.current?.destroy();
    };
  }, [sceneId]);

  return (
    <div
      ref={containerRef}
      style={{ width: '100%', height: '500px' }}
    />
  );
}

export default StorySplatViewer;

Native App Integration

StorySplat Viewer can be embedded in native mobile apps using WebView components.

React Native

import { WebView } from 'react-native-webview';

function StorySplatViewer({ sceneId }) {
  const html = `
    <!DOCTYPE html>
    <html>
    <head>
      <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
      <style>
        * { margin: 0; padding: 0; }
        html, body, #app { width: 100%; height: 100%; }
      </style>
    </head>
    <body>
      <div id="app"></div>
      <script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
      <script>
        StorySplatViewer.createViewerFromSceneId(
          document.getElementById('app'),
          '${sceneId}'
        );
      </script>
    </body>
    </html>
  `;

  return (
    <WebView
      source={{ html }}
      style={{ flex: 1 }}
      allowsInlineMediaPlayback
      mediaPlaybackRequiresUserAction={false}
    />
  );
}

Flutter

import 'package:webview_flutter/webview_flutter.dart';

class StorySplatViewer extends StatefulWidget {
  final String sceneId;
  const StorySplatViewer({required this.sceneId});

  @override
  _StorySplatViewerState createState() => _StorySplatViewerState();
}

class _StorySplatViewerState extends State<StorySplatViewer> {
  late final WebViewController controller;

  @override
  void initState() {
    super.initState();
    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..loadHtmlString(_getHtml());
  }

  String _getHtml() {
    return '''
      <!DOCTYPE html>
      <html>
      <head>
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <style>* { margin: 0; } html, body, #app { width: 100%; height: 100%; }</style>
      </head>
      <body>
        <div id="app"></div>
        <script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>
        <script>
          StorySplatViewer.createViewerFromSceneId(
            document.getElementById('app'),
            '${widget.sceneId}'
          );
        </script>
      </body>
      </html>
    ''';
  }

  @override
  Widget build(BuildContext context) {
    return WebViewWidget(controller: controller);
  }
}

iframe Embedding

For simple web integration, use an iframe:

<iframe
  src="https://discover.storysplat.com/api/v2-html/YOUR_SCENE_ID"
  width="100%"
  height="500"
  frameborder="0"
  allow="accelerometer; gyroscope; xr-spatial-tracking"
  allowfullscreen>
</iframe>

CDN Options

The viewer is available on multiple CDNs. We recommend the bundled build for simplicity — it includes PlayCanvas, so you only need one script:

<!-- RECOMMENDED: Bundled build (includes PlayCanvas - single script!) -->
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>

<!-- Alternative: Separate scripts (if you need a specific PlayCanvas version) -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/playcanvas.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.umd.js"></script>

<!-- unpkg alternative -->
<script src="https://unpkg.com/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script>

Self-Hosting the Bundle

npm pack storysplat-viewer
# Extract storysplat-viewer-x.x.x.tgz
# Copy dist/storysplat-viewer.bundled.umd.js to your server
<script src="/path/to/storysplat-viewer.bundled.umd.js"></script>

Analytics & Tracking

When using createViewerFromSceneId, the viewer automatically tracks:

  • Views: Each time a scene is loaded
  • Bandwidth: Data transferred when loading StorySplat-hosted files

This data appears in your StorySplat admin dashboard.

What Gets Tracked

| Scenario | Views | Bandwidth | |----------|-------|-----------| | createViewerFromSceneId with StorySplat-hosted files | Yes | Yes | | createViewerFromSceneId with self-hosted splat files | Yes | No | | createViewer (self-hosted scenes) | No | No | | iframe embed | Yes | Yes |

Privacy Note

Tracking only occurs for scenes loaded via createViewerFromSceneId. If you use createViewer with your own scene data, no data is sent to StorySplat servers.

Comparison: Scene ID vs JSON File

| Approach | Best For | Pros | Cons | |----------|----------|------|------| | Scene ID | Live content, CMS | Always latest, automatic tracking, no redeploy | Needs internet, API dependency | | JSON File | Self-hosted, offline | Full control, git-tracked, no external dependencies | Manual updates, no analytics |

Standalone HTML Generation

Generate standalone HTML files programmatically:

import { generateHTML, generateHTMLFromUrl } from 'storysplat-viewer';

// From scene data object
const html = generateHTML(sceneData, {
  title: 'My Scene',
  description: 'An interactive 3D experience',
});

// From scene JSON URL
const html = await generateHTMLFromUrl(
  'https://example.com/scene.json',
  { title: 'My Scene' }
);

// Save to file (Node.js)
import fs from 'fs';
fs.writeFileSync('scene.html', html);

GenerateHTMLOptions

interface GenerateHTMLOptions {
  cdnUrl?: string;           // Custom CDN URL for the viewer bundle (default: unpkg)
  title?: string;            // HTML page title (default: scene name)
  description?: string;      // Meta description for SEO
  faviconUrl?: string;       // Favicon URL
  customCSS?: string;        // Custom CSS to inject into the page
  minify?: boolean;          // Minify the output HTML
  lazyLoad?: boolean;        // Show thumbnail + start button before loading
  lazyLoadButtonText?: string; // Custom text for the lazy load button
}

Error Handling

The package exports error classes for specific error handling:

import {
  createViewerFromSceneId,
  SceneNotFoundError,
  SceneApiError
} from 'storysplat-viewer';

try {
  const viewer = await createViewerFromSceneId(container, sceneId);
} catch (error) {
  if (error instanceof SceneNotFoundError) {
    console.error('Scene not found or not public');
  } else if (error instanceof SceneApiError) {
    console.error(`API error (${error.statusCode}): ${error.message}`);
  } else {
    console.error('Unknown error:', error);
  }
}

Exports

// Core viewer functions
import {
  createViewer,
  createViewerFromSceneId,
  createViewerFromUrl,
  fetchSceneMeta,
  generateHTML,
  generateHTMLFromUrl,
} from 'storysplat-viewer';

// Types
import type {
  ViewerInstance,
  ViewerOptions,
  SceneData,
  ButtonLabels,
  ViewerTheme,
  SplatRelightingConfig,
  RevealPreset,
  RevealPresetConfig,
} from 'storysplat-viewer';

// Utilities
import {
  DEFAULT_BUTTON_LABELS,
  generateViewerStyles,
  REVEAL_PRESETS,
  getRevealPreset,
  GsplatRelighting,
  getGsplatRelightingClass,
  SceneNotFoundError,
  SceneApiError,
  BUILD_VERSION,
} from 'storysplat-viewer';

Troubleshooting

Container Needs Dimensions

The container element must have explicit dimensions:

#viewer {
  width: 100%;
  height: 500px; /* Must have explicit height */
}

CORS Issues

If loading splats from a different domain, ensure the server has proper CORS headers or use a proxy.

Memory Cleanup

Always destroy the viewer when unmounting:

// Vanilla JS
window.addEventListener('beforeunload', () => {
  viewer.destroy();
});

// React useEffect cleanup
return () => {
  viewer?.destroy();
};

Build Target Must Be ES2016+

If you bundle storysplat-viewer with Vite, esbuild, or another bundler, your build target must be es2016 or higher (we recommend es2020).

PlayCanvas (the 3D engine) serializes its gsplat sort worker via Function.toString() into a blob URL. If your bundler targets es2015, it will extract the ** (exponentiation) operator into a module-scoped helper function. That helper doesn't exist inside the worker's isolated scope, causing a ReferenceError at runtime (e.g., ss is not defined).

// vite.config.ts
export default {
  build: {
    target: 'es2020', // Must be es2016+ (NOT es2015)
  }
};

This only affects production builds — development servers typically don't minify and won't trigger the issue.

Browser Compatibility

StorySplat Viewer requires:

  • WebGL 2.0 support
  • Modern JavaScript (ES2016+)
  • Recommended browsers: Chrome 80+, Firefox 75+, Safari 13+, Edge 80+

Support

License

MIT License - see LICENSE file for details