storysplat-viewer
v2.7.18
Published
PlayCanvas-based 3D viewer for StorySplat scenes - HTML export & dynamic embedding
Maintainers
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
- Quick Start
- Demos & Guides
- Loading Scenes
- API Reference
- Controlling the Viewer
- Configuration Options
- Internationalization (i18n)
- Events
- React Integration
- Analytics & Tracking
- Troubleshooting
Installation
npm install storysplat-viewerOr using yarn:
yarn add storysplat-viewerQuick 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:
- Open your scene in the StorySplat editor
- Click "Upload" or "Update"
- 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:
- Open your scene in the StorySplat editor
- Click "Export" or "Upload"
- In the "Developer Export" section, click "Download Scene JSON"
- Save the file in your project
Note: When using
createViewerwith self-hosted scenes, views and bandwidth are not tracked. UsecreateViewerFromSceneIdfor 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 intosceneId- Your StorySplat scene IDoptions- 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
): ViewerInstanceNote:
createViewerdoes not track views or bandwidth. For StorySplat-hosted scenes, usecreateViewerFromSceneIdinstead.
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:
- 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- 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.jsonFully 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 fromdiscover.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
- GitHub Issues: github.com/SonnyC56/storysplat-viewer/issues
- Documentation: docs.storysplat.com
License
MIT License - see LICENSE file for details
