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.7.18

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, 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.

Scene Data Format

When using createViewer() with self-hosted JSON, the scene data should match the format exported by the StorySplat editor. Key fields:

| Field | Type | Description | |-------|------|-------------| | loadedModelUrl | string | URL to the .splat, .ply, or .ksplat file | | waypoints | array | Camera path waypoints | | hotspots | array | Interactive hotspot markers | | portals | array | Scene-to-scene navigation portals | | activeSkyboxUrl | string | URL to the skybox HDR/image file | | skyboxRotation | number | Skybox rotation offset | | lights | array | Scene lighting configuration | | particleSystems | array | Particle effect systems | | customMeshes | array | Imported 3D models | | uiColor | string | Accent color for UI controls | | uiOptions | object | UI visibility toggles and button labels | | defaultCameraMode | string | Initial camera mode: 'tour', 'explore', or 'walk' |

The viewer's transform layer normalizes all field variations automatically, so JSON exported from any version of the editor will work.

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';
  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') => 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 }>;

  // 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;
}

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';

  // Lazy loading
  lazyLoad?: boolean;
  lazyLoadThumbnail?: string;
  lazyLoadThumbnailType?: 'image' | 'video' | 'gif';  // Thumbnail media type
  lazyLoadButtonText?: 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'
});

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 | | 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}`);
});

// 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})`);
});

// 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}`);
});

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;

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);
  }
}

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' or 'explore' | | 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 | | 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) |

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 |

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();
};

Browser Compatibility

StorySplat Viewer requires:

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

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>

Self-Hosting

To self-host the viewer bundle instead of using a CDN:

  1. Download the UMD bundle from npm:
npm pack storysplat-viewer
# Extract storysplat-viewer-x.x.x.tgz
# Copy dist/storysplat-viewer.umd.js to your server
  1. Reference your self-hosted bundle:
<script src="/path/to/storysplat-viewer.umd.js"></script>

CDN Alternatives

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>

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
}

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",
      "position": { "x": 2, "y": 0, "z": -3 },
      "type": "sphere",
      "activationMode": "click"
    }
  ]
}

Portal Paths for Self-Hosted Scenes

Important: By default, portals use StorySplat scene IDs and fetch from discover.storysplat.com. For fully self-hosted portals, you need to intercept the portalActivated event and implement custom navigation.

Self-Hosted Portal Example

import { createViewer } from 'storysplat-viewer';

// Your self-hosted scene folder structure:
// /scenes/
//   ├── scene_campervan/
//   │   └── scene.json
//   ├── scene_cabin/
//   │   └── scene.json
//   └── scene_garden/
//       └── scene.json

// Load initial scene
const initialScene = await fetch('/scenes/scene_campervan/scene.json').then(r => r.json());
let viewer = await createViewer(container, initialScene);

// Handle portal navigation with custom paths
viewer.on('portalActivated', async (data) => {
  console.log('Portal clicked:', data.targetSceneId);

  // Map scene IDs to your folder paths
  const scenePaths = {
    'cabin-scene-id': '/scenes/scene_cabin/scene.json',
    'garden-scene-id': '/scenes/scene_garden/scene.json',
    // Or use the scene ID directly as folder name:
    // [data.targetSceneId]: `/scenes/${data.targetSceneId}/scene.json`
  };

  const scenePath = scenePaths[data.targetSceneId];
  if (!scenePath) {
    console.error('Unknown scene:', data.targetSceneId);
    return;
  }

  // Fetch from YOUR server
  const newSceneData = await fetch(scenePath).then(r => r.json());

  // Dispose old viewer and create new one
  viewer.destroy();
  viewer = await createViewer(container, newSceneData);

  // Re-attach portal handler for the new scene
  viewer.on('portalActivated', arguments.callee);
});

Using Relative Paths

If you want to use the targetSceneId directly as a folder name:

viewer.on('portalActivated', async (data) => {
  // targetSceneId becomes the folder name
  const scenePath = `/scenes/${data.targetSceneId}/scene.json`;

  const newSceneData = await fetch(scenePath).then(r => r.json());
  viewer.destroy();
  viewer = await createViewer(container, newSceneData);
});

Then structure your folders to match:

/scenes/
  ├── campervan/        ← targetSceneId: "campervan"
  │   └── scene.json
  ├── cabin/            ← targetSceneId: "cabin"
  │   └── scene.json

Fully Self-Hosted (No StorySplat at All)

If both scenes are created locally and never uploaded to StorySplat, you have full control over the targetSceneId values. They don't need to be real StorySplat IDs—use any string identifier you want:

Step 1: Edit your scene.json files to add portals with custom IDs

// scenes/campervan/scene.json
{
  "name": "Campervan Tour",
  "portals": [
    {
      "id": "portal-1",
      "targetSceneId": "cabin",
      "targetSceneName": "Mountain Cabin",
      "position": { "x": 2, "y": 0, "z": -3 },
      "type": "sphere",
      "activationMode": "click"
    }
  ]
}
// scenes/cabin/scene.json
{
  "name": "Mountain Cabin",
  "portals": [
    {
      "id": "portal-back",
      "targetSceneId": "campervan",
      "targetSceneName": "Back to Campervan",
      "position": { "x": -1, "y": 0, "z": 2 },
      "type": "sphere",
      "activationMode": "click"
    }
  ]
}

Step 2: Complete self-hosted viewer code

import { createViewer } from 'storysplat-viewer';

const container = document.getElementById('viewer');

// Map your scene identifiers to file paths
const SCENES = {
  'campervan': '/scenes/campervan/scene.json',
  'cabin': '/scenes/cabin/scene.json',
  'garden': '/scenes/garden/scene.json'
};

let currentViewer = null;

async function loadScene(sceneId) {
  // Cleanup previous viewer
  if (currentViewer) {
    currentViewer.destroy();
  }

  // Fetch scene from YOUR server
  const sceneData = await fetch(SCENES[sceneId]).then(r => r.json());

  // Create viewer
  currentViewer = await createViewer(container, sceneData);

  // Handle portal clicks - loads target scene from your server
  currentViewer.on('portalActivated', (data) => {
    loadScene(data.targetSceneId);
  });
}

// Start with initial scene
loadScene('campervan');

Key point: The targetSceneId is just a string you define. It doesn't need to exist on StorySplat—you 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 } |

Q: Where does the viewer look for scene files?

A: It depends on how you load scenes:

  • createViewerFromSceneId() - Fetches from discover.storysplat.com/api/scene/{id}
  • createViewer(sceneData) - Uses the data object you pass in directly (no fetch)
  • Splat file URLs in scene JSON - Can be absolute URLs or relative to your page

The viewer itself has no "base path" concept - splat file paths in your scene JSON should be absolute URLs or relative to your HTML page, not to the viewer JS file.

Support

License

MIT License - see LICENSE file for details