Repository files navigation

Splat Viewer

Standalone, client-only Gaussian Splat viewer (three.js + @sparkjsdev/spark), driven by URL params plus an optional same-origin postMessage capture-back. Everything runs in the browser — no server-side rendering, no upload of splat data.

This is a standalone project. Use it directly (npm run dev, or serve the static dist/ build), or embed it in another app — either as one hosted build behind a same-origin path (see Hosting & embedding) or installed as a git dependency exposing the SplatViewer React component (see Consuming as a dependency).

Features

Loading & files

  • SPZ / PLY splat loading — drag-drop, file picker, or ?url= param (URL must be CORS-enabled).
  • GLB / OBJ mesh loading — add polygonal meshes into the splat scene via the Scene panel's + Add Mesh (.glb / .obj) button or by dragging files onto the panel.
  • Scene save / load — a modal FileDialog reads and writes scene .json files through an optional host-provided file API (see Optional host file API below; not included in the standalone build):
    • Two modes: Save (returns directory + filename) and Open (returns an absolute file path); header reads Save scene / Open scene.
    • Editable path input with a Go button, a parent-folder (up) button, and a live directory listing.
    • Listing shows folders first (yellow folder icon) then files (file icon); files are filtered to the configured extension (default .json). Clicking a directory navigates into it.
    • Open: single-click selects (indigo highlight), double-click opens immediately, or confirm with Open (disabled until a file is selected).
    • Save: clicking an existing file reuses its base name; a Name: field (with the fixed extension suffix shown) sets the filename; Enter or Save confirms (disabled if blank).
    • Loading spinner while listing; error state with a Go to home directory fallback link; Empty folder message; current path shown in the footer (monospace, truncated).
    • Dismiss via backdrop click, Escape, or Cancel. Handles both Windows (\) and POSIX (/) separators, auto-detecting and normalizing trailing slashes.

Camera & optics (cinema presets)

  • Sensor presets (7): Super 35mm 24.89×18.66 (default), Full Frame 36.0×24.0, ARRI Alexa LF 36.70×25.54, RED Monstro VV 40.96×21.60, IMAX 65mm 70.41×52.63, Micro Four Thirds 17.30×13.0, APS-C 23.60×15.60 (mm).
  • Lens focal lengths (13 primes): 14, 18, 21, 24, 28, 35 (default), 40, 50, 75, 85, 100, 135, 200 mm.
  • Aspect-ratio presets (6): 2.39:1 Scope, 1.85:1 Flat, 16:9 (default), 4:3, 1.43:1 IMAX, 1:1 Square.
  • FOV math: horizontal FOV = 2·atan(sensorWidth / (2·focal)); the Three.js camera vertical FOV = 2·atan(tan(hFOV/2) / aspect).
  • Summary/filename helpers:getCameraSummary() (e.g. "ARRI Alexa LF · 85mm · 2.39:1 Scope · 15.2° vFOV") and getCameraFilenameSegment() (e.g. Super35mm_35mm).
  • Camera Reset — one-click restore to the exact pose the camera had when the file loaded.
  • Far clip control — manual override for the camera far plane (fixes black sky on large splats). Auto default is distance × 1000.
  • Camera scale — log slider (0.001 → 1000) that multiplies camera-track translations so a cm-scale COLMAP pairs with a metre-scale splat without resizing the splat (pure translation rescale). Reset (↺) returns to 1.0.
  • Coordinate system — dropdown selecting the capture's world-frame convention (COLMAP/OpenCV Y-down, glTF Y-up, Blender Z-up). Applied to imported/exported COLMAP tracks and to PLY splats at load (SPZ uses its embedded metadata).

Navigation

  • Fly mode (default) — WASD to move, Q/E down/up, Shift for a ~3× speed boost, mouse-drag to look. Roll is preserved so restored/scrubbed poses keep their full orientation.
  • Orbit mode — click-drag to orbit around the target, with damping. Toggle Fly ⇄ Orbit with F or the Nav buttons.
  • Frame — re-fit the camera to the splat's bounding box (SuperSplat-style ¾ view).
  • Ground grid and origin axes (X red / Y green / Z blue) toggles.

Scene helpers, meshes & lights

The floating Scene panel (MeshPanel) is a scrollable dark card with three tabs, each showing a live count: Meshes (N), Lights (N), IBL (N).

Meshes tab

  • Add via footer + Add Mesh (.glb / .obj) or drag-drop (empty state: Drop .glb / .obj or click Add).
  • Each row: visibility eye toggle, clickable name (selects the mesh), an always-on-top overlay toggle, an expand chevron, and a delete (X) button.
  • Always-on-top overlay: ON draws the mesh through/over the splat (amber icon); OFF clips it behind the splat.
  • Selecting a mesh highlights the row (indigo border) and reveals the gizmo mode/space bar.
  • Expanded row transform controls: Position X/Y/Z, Rotation° X/Y/Z (step 1), and a single uniform Scale input (clamped ≥ 0.0001, step 0.1).
  • Capture IBL from mesh — captures a 360° image-based-lighting probe from the mesh center and adds it to the IBL list.

Gizmo toolbar (shown only when something is selected)

  • Mode buttons: Translate / Rotate / Scale (T/R/S; active = indigo).
  • Space toggle: World (W) vs Local (L) (Local shown amber).

Lights tab

  • Add via footer buttons: Point / Spot / Rect (empty state: No lights yet).
  • Each row: visibility eye toggle, name + type label, expand chevron, delete (X); selectable (indigo border when selected).
  • Common expanded controls: Color picker, Intensity slider (0–100, step 0.5, value shown), and Position X/Y/Z.
  • Spot extras: Move Light vs Move Target selector (chooses which the gizmo affects), a Move light + target together lock checkbox, Target X/Y/Z, Angle° slider (1–89°, radian-converted), Penumbra slider (0–1, step 0.05).
  • Rect (area) extras: Width / Height inputs (each clamped ≥ 0.1) and Rotation° X/Y/Z (step 1).

IBL tab

  • Lists captured image-based-lighting probes; footer + Capture IBL at Camera captures a 360° probe from the current camera position (empty state: No IBLs yet. Capture from mesh or camera.).
  • Each row: visibility eye toggle, name, expand chevron, delete (X).
  • Expanded controls: Position X/Y/Z; Radius slider (0–50, step 0.5; 0 displays as = infinite/global); Ramp slider (0–1, shown only when radius > 0) for falloff; Intensity slider (0–5, step 0.05).
  • HDR color grade: negative-capable Lift (−2…2), Gain (−2…10), and Gamma (0.1…5) sliders, each paired with a numeric input.

Inputs use raw-string local state so you can type intermediate/negative values (-, 1.) before parsing; values display rounded to 3 decimals. All controls carry nodrag nopan so slider/keyboard interaction never pans or drags the underlying canvas node.

Animation & timeline (keyframe camera system)

  • Keyframes store camera position (Vector3), rotation (Quaternion), and vertical FOV (degrees) at a normalized time in [0,1].
  • Per-keyframe interpolation mode (applies to the segment starting at that keyframe): step, linear, easeInOut, smooth.
  • Full scene-state snapshot per keyframe (SceneSnapshot): splat transform, meshes (id/transform/visible), lights, IBLs, orbit target — so geometry, visibility, and lights animate alongside the camera.
  • Interpolation:
    • Position: Catmull-Rom spline (smooth / easeInOut) using 4 control points with clamped-duplicate endpoints; straight-line lerp for linear / step.
    • Rotation: quaternion SLERP between the two bracketing keyframes (no cross-key spline smoothing).
    • FOV: linear lerp between bracketing keyframes.
    • easeInOutCubic applied to local segment time in easeInOut mode; step pins local time to 0 (holds start value); linear/smooth use raw local time.
  • Path evaluation (evaluateCameraPath) clamps: first keyframe before range, last after range, single keyframe directly, null if none.
  • Scene evaluation (evaluateSceneAtFrame) mirrors camera bracketing; returns null unless a keyframe carries scene data (backward-compatible with camera-only / older saves). Generic value interpolation: numbers lerp, booleans/non-color strings step, #rrggbb colors blend per channel, arrays/objects recurse, missing keys hold prior value; entities matched by id, unmatched entities held. IBLs are captured and serialized but not applied live during playback (too expensive per-frame).
  • Keyframe CRUD:addKeyframe (replaces a keyframe within ~half-frame tolerance, re-sorts), removeKeyframe, updateKeyframe (partial merge + re-sort); cloning deep-copies position/quaternion but shares the scene snapshot by reference.
  • Serialization:serializePath / deserializePath — position as [x,y,z], quaternion as [x,y,z,w]; deserialization tolerant of legacy {x,y,z} / {x,y,z,w} object forms.
  • Helpers:frameToTime / timeToFrame (clamped), createEmptyPath (default 120 frames @ 25 fps), getPathDurationSeconds.
  • Interactive canvas timeline: device-pixel-ratio aware, ResizeObserver-driven redraw. Draws track background, auto-spaced frame ticks with labels, yellow keyframe diamonds (selected = orange with white outline), and a red playhead bar with triangle marker.
  • Playback controls: play/stop toggle, loop toggle, previous/next keyframe (jump to nearest keyframe before/after current frame and select it), add keyframe, delete selected keyframe (disabled when none selected).
  • Scrubbing: click/drag empty track to move the playhead. Keyframe dragging: drag a diamond to a new time (10px hit radius, topmost-first hit test).
  • Interpolation selector (STEP / LIN / EASE / SMOOTH) shown only when a keyframe is selected (defaults to smooth when unset).
  • Duration input (min 2, max 9999 frames), FPS dropdown (12 / 24 / 25 / 30 / 60), and live readout: currentFrame / lastFrame · Xs plus N keys.

Post-processing effects (real-time)

Both effects live in the Camera panel with an On/Off toggle and a Reset button (zeroes the coefficients / restores defaults), and reveal their parameter controls only when enabled.

  • Depth-of-field — thin-lens circle-of-confusion blur consuming a color texture and a linearized depth texture (R = Z metres, ≤ 0 = background).
    • Scatter-as-gather weighting prevents in-focus foreground bleeding onto blurred backgrounds.
    • 24-tap golden-angle Vogel-spiral disk sampler (GOLDEN = 2.39996323) with sqrt(t) radial mapping for uniform density; gather radius floored at 1px for cheap AA even in focus.
    • CoC model: D = f/N; CoC_mm = f·D·|Z−S| / (Z·(S − f/1000)) reconciled to metres; CoC_px = CoC_mm · pxPerMm, clamped to [0, maxBlurPx]; singularity guard denom = max(Z·(focusM − focalMm·0.001), 1e-4) (clamp focusM ≥ focalMm/1000 + ε).
    • Uniforms: focalMm (50), apertureMm (50/2.8), focusM (3.0 m), pxPerMm (80 = passWidth/sensorWidthMm), maxBlurPx (60), showCoC toggle, enabled toggle.
    • CoC debug overlay (showCoC): heatmap of |CoC_px|/maxBlurPx — green at the focal plane → yellow → red at heavy defocus.
  • Lens distortion — forward Brown-Conrady (OpenCV) via inverse mapping with 5 Newton iterations (sub-pixel accuracy for k1 ≲ 0.5).
    • Radial factor 1 + k1·r² + k2·r²²; tangential dx = 2·p1·u·v + p2·(r²+2u²), dy = p1·(r²+2v²) + 2·p2·u·v. Handles radial k1,k2 + tangential p1,p2 only (no k3 / fisheye).
    • Out-of-bounds source samples render opaque black so the framing edge is unambiguous.
    • Uniforms: fx/fy (1000), cx/cy (0.5), k1/k2/p1/p2 (0), enabled toggle.
    • computeFovMargin() estimates the extra FOV coverage a distortion pass needs by forward-distorting the worst image corner, returning a focal-length multiplier ≥ 1, capped at 2.0 — superseded by a measured DISTORTION_SCALE when available.

Video & depth export

Camera-path export animating the camera along keyframes via evaluateCameraPath().

  • Output modes: RGB only, Depth only, or Both (RGB + depth as two separate MP4 blobs in VideoExportResult).
  • Resolutions: 1280×720 (HD), 1920×1080 (Full HD, default), 3840×2160 (4K).
  • FPS: 12 / 24 / 25 / 30 / 60.
  • Total frame count: user-set (min 2, max 9999) with live duration readout (durationFrames / fps).
  • Quality / codec presets (all H.264):H.264 20 Mbps, H.264 HQ 50 Mbps, H.264 Max 100 Mbps (DEFAULT_BITRATE = 20 Mbps).
  • Bake DoF — optional per-frame depth-of-field pass using the viewer's live aperture/focus/sensor settings; off by default (~1× extra splat-render cost per frame).
  • COLMAP camera data toggle (cameras.txt + images.txt), on by default.
  • Dual encoder backends: primary WebCodecsVideoEncoder + mp4-muxer, with automatic fallback to MediaRecorder if WebCodecs is unavailable or throws (logged via console.warn).
    • WebCodecs: MP4, codec avc1.640028 (H.264 High L4.0), latencyMode: "quality", keyframe every 2 s (fps·2).
    • MediaRecorder fallback: captureStream(0) with manual requestFrame() per frame and real-time pacing (1000/fps ms); MIME prefers MP4/avc1 then falls through webm vp9/vp8.
  • Global depth-range scan: when depth is needed, a first pass walks all frames to find global min/max depth so every depth frame normalizes against one consistent range.
  • Post-processing baked into export: generic postProcess.apply (reused for both DoF and lens distortion) renders into srcTarget then a fullscreen quad into dstTarget; postProcess.prepareCamera adds per-frame FOV margin (restored after each frame).
  • Per-frame scene animation via applySceneAtFrame() so splat/mesh/light transforms and visibility animate, not just the camera.
  • Progress via onProgress(frame, totalFrames), scaled across scan + render (+ encode) passes; UI shows a progress bar with Rendering frame X/total and percentage.
  • Robustness: even-dimension enforcement (ensureEvenDimension — nearest lower even ≥ 2, returns 0 for non-finite/≤0); full camera-state restore in a finally block (position, quaternion, fov, aspect, renderer size) plus render-target disposal; WebGL vertical flip of read-back pixels (flipVerticallyInto); safeReadPixels unbinds any stray PIXEL_PACK_BUFFER before readRenderTargetPixels.
  • Preconditions: ≥ 2 keyframes anddurationFrames > 0 — enforced in the UI (Export disabled, warning banner "Add at least 2 keyframes before exporting") and in the backend (both encoders throw "Need at least 2 keyframes and > 0 frames to export video").

COLMAP import / export

  • Export: writes the camera path as standard COLMAP cameras.txt + images.txt + empty points3D.txt, bundled in a ZIP (JSZip) — interoperable with COLMAP, 3DGS trainers, and NerfStudio.
    • Single shared PINHOLE camera: focalLengthPx = (focalLengthMm/sensorWidthMm)·width, fx=fy=focalLengthPx, cx=width/2, cy=height/2.
    • Per-frame images.txt line IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME (world→camera R and t) with an empty POINTS2D line after each; image names frame_00000.png; one image per durationFrames.
  • Import: reads cameras.txt + images.txt (+ optional Nodos extras.txt sidecar) from a ZIP and produces a CameraPath — one keyframe per pose plus parsed intrinsics.
    • Per-keyframe FOV from intrinsics: fov = 2·atan(height/(2·fy)) degrees (fallback 60° when intrinsics missing).
    • Poses sorted by IMAGE_ID; keyframe times distributed uniformly across [0,1].
    • Parses camera models PINHOLE, SIMPLE_PINHOLE, OPENCV, RADIAL, SIMPLE_RADIAL; unrecognized models are skipped (best-effort fallback to PINHOLE defaults, distortion dropped).
    • Extracts Brown-Conrady coefficients: OPENCV → k1,k2,p1,p2; RADIAL → k1,k2; SIMPLE_RADIAL → k1.
    • Nodos extras.txt: reads DISTORTION_SCALE (column 6) into cameraParams.distortionScale as an authoritative FOV-margin multiplier overriding k1/k2 estimation.
  • World-frame conventions (both directions; inverse is the transpose):
    • y-up — identity (glTF / Three.js / Maya / Unity)
    • y-down — 180° about X (raw COLMAP / OpenCV / vanilla SfM) — default for import and export
    • z-up — −90° about X (Blender / Unreal / RealityCapture / Metashape)
    • COLMAP camera frame is spec-fixed RDF (+X right, +Y down, +Z forward); Three.js is RUB. The camera-axis change D = diag(1,−1,−1) (180° about X) is applied on the camera side in both directions.

Capture (postMessage)

  • Screenshot capture — aspect-ratio cropped, cinematic naming.
  • Optional same-origin postMessage capture-back (lets an embedding host receive captures); worldId is echoed back with captures.

Develop

npm install
npm run dev

Open with a CORS-enabled splat:

http://localhost:5173/?url=<https URL of a .ply or .spz>&name=test.ply

URL params

  • url — splat URL (.ply / .spz), must be CORS-enabled
  • name — display name / download filename
  • worldId — opaque id echoed back with captures
  • lens — focal length in mm (e.g. 75)
  • sensor — sensor width in mm as a number (e.g. 33.7)
  • gsDir — default folder for the Save Scene dialog (host integration; needs the host file API)
  • projectDir — project folder used to derive default camera.json / frame-render paths (host integration; needs the host file API)

Keyboard shortcuts

Viewer shortcuts are suppressed while typing in an input/select/textarea.

  • WASD — move (Fly mode); Q / E — down / up; Shift — speed boost; mouse-drag — look
  • F — toggle Fly ⇄ Orbit navigation
  • H — show / hide the controls panels
  • T — show / hide the timeline
  • K — add keyframe
  • Del / Backspace — delete the selected keyframe
  • Ctrl/Cmd + Z — undo (scene/keyframe edits)
  • Escape — cancel/close the file dialog
  • Enter (path input) — navigate to the typed path; Enter (save Name field) — confirm save
  • Double-click a file (open mode) — open it immediately

Capture/screenshot is button-only — there is no keyboard shortcut for it.

Build

npm run build # outputs dist/ with relative asset paths (base: "./")

The build is self-contained and path-relative, so dist/ can be served from any static host, including under an arbitrary subpath. Keep base: "./" in vite.config.ts for subpath hosting to work.

Hosting & embedding

The build is static and path-relative, so dist/ can be served from any web server, under any subpath. Two common ways to consume the viewer:

  • One hosted build, embedded by apps — deploy dist/ once and embed it (iframe or popup window) from your apps. If an embedding app runs on a different origin, reverse-proxy the viewer path (e.g. /viewer → https://your-host/viewer-build/) so the viewer appears same-origin to that app — this is what keeps blob: splat URLs, the sessionStorage state handoff, and the postMessage capture-back working (all three are same-origin-only). Rebuild once and every consumer reflects it on next load.
    • Versioning tip: serve versioned paths (/viewer/vN/) so a consumer can pin a known-good build (with a shared build, a regression otherwise hits every consumer at once).
  • As a package — install the repo as a git dependency and render the exported React component; see Consuming as a dependency below.

This is a static client-only build — there is no viewer server/API. "Hosted" just means the static files live at one URL.

Optional host file API

Disk-based scene save/open (the FileDialog), sidecar splat restore, and camera.json export call three endpoints that an embedding host may provide — the standalone build has no server, so these UI paths are inert unless the host implements them (same-origin):

EndpointReturns
GET /api/list-directory?path=<dir>{ success: true, entries: [{ name, type: "file" | "directory" }] }
GET /api/read-file?path=<absolute path>raw file bytes
POST /api/write-file?path=<absolute path> (body = file content){ success: true }
POST /api/save-generation (multipart form: file, directoryPath, customFilename, createDirectory)any 2xx

Everything else (drag-drop loading, ?url= loading, browser downloads, video/COLMAP export) works without them. Video/COLMAP exports always download in the browser; when /api/save-generation exists they are additionally mirrored to the host (best-effort — failures only log).

Host integration (popup embedding)

When another app opens the viewer as a popup/iframe (same-origin — reverse-proxy if needed):

  • Open the viewer with ?worldId=<opaque id> (plus url/name/lens/sensor as needed). ?url= accepts any CORS-enabled URL, including same-origin blob: URLs.
  • State handoff: before opening, the host may write a serialized viewer state to sessionStorage["splat-viewer-state-<worldId>"]; the viewer restores it on load. While running, the viewer auto-posts state changes back to window.opener as { type: "splat-viewer-state", worldId, state } (same-origin targeted).
  • Capture-back: screenshots post to window.opener as { type: "worldlabs-capture", worldId, image, depthImage, filename, width, height } (PNG data URLs). With no opener, captures download directly instead.

Architecture invariant

src/ must use only relative imports + npm packages — no @/ or next/ imports. This keeps the source portable so it builds under this Vite root and transpiles cleanly inside a consumer's bundler (e.g. Next's transpilePackages). FileDialog.tsx and src/lib/cinemaCameraPresets.ts are deliberately self-contained (no shared/aliased imports) for this reason.

Consuming as a dependency

The package entry is the TS/TSX source (exportssrc/SplatViewer.tsx), and react / react-dom / three / @sparkjsdev/spark are peerDependencies so the host supplies a single copy (a duplicate React breaks hooks; a duplicate three breaks instanceof).

// consumer package.json"dependencies": { "splat-viewer": "github:gitcapoom/capoom-splat-viewer#main" }

The package is intentionally not published to npm ("private": true guards against accidental publish) — consume it via the git URL above.

importSplatViewerfrom"splat-viewer";

The consumer's bundler must transpile the package source (in Next: transpilePackages: ["splat-viewer"]). Because the component uses Tailwind utility classes, a Tailwind v4 host must also scan the package (@source ".../node_modules/splat-viewer/src";). Pick up updates with npm update splat-viewer, or pin a commit via #<sha>.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Splat Viewer

Standalone, client-only Gaussian Splat viewer (three.js + @sparkjsdev/spark), driven by URL params plus an optional same-origin postMessage capture-back. Everything runs in the browser — no server-side rendering, no upload of splat data.

This is a standalone project. Use it directly (npm run dev, or serve the static dist/ build), or embed it in another app — either as one hosted build behind a same-origin path (see Hosting & embedding) or installed as a git dependency exposing the SplatViewer React component (see Consuming as a dependency).

Features

Loading & files

  • SPZ / PLY splat loading — drag-drop, file picker, or ?url= param (URL must be CORS-enabled).
  • GLB / OBJ mesh loading — add polygonal meshes into the splat scene via the Scene panel's + Add Mesh (.glb / .obj) button or by dragging files onto the panel.
  • Scene save / load — a modal FileDialog reads and writes scene .json files through an optional host-provided file API (see Optional host file API below; not included in the standalone build):
    • Two modes: Save (returns directory + filename) and Open (returns an absolute file path); header reads Save scene / Open scene.
    • Editable path input with a Go button, a parent-folder (up) button, and a live directory listing.
    • Listing shows folders first (yellow folder icon) then files (file icon); files are filtered to the configured extension (default .json). Clicking a directory navigates into it.
    • Open: single-click selects (indigo highlight), double-click opens immediately, or confirm with Open (disabled until a file is selected).
    • Save: clicking an existing file reuses its base name; a Name: field (with the fixed extension suffix shown) sets the filename; Enter or Save confirms (disabled if blank).
    • Loading spinner while listing; error state with a Go to home directory fallback link; Empty folder message; current path shown in the footer (monospace, truncated).
    • Dismiss via backdrop click, Escape, or Cancel. Handles both Windows (\) and POSIX (/) separators, auto-detecting and normalizing trailing slashes.

Camera & optics (cinema presets)

  • Sensor presets (7): Super 35mm 24.89×18.66 (default), Full Frame 36.0×24.0, ARRI Alexa LF 36.70×25.54, RED Monstro VV 40.96×21.60, IMAX 65mm 70.41×52.63, Micro Four Thirds 17.30×13.0, APS-C 23.60×15.60 (mm).
  • Lens focal lengths (13 primes): 14, 18, 21, 24, 28, 35 (default), 40, 50, 75, 85, 100, 135, 200 mm.
  • Aspect-ratio presets (6): 2.39:1 Scope, 1.85:1 Flat, 16:9 (default), 4:3, 1.43:1 IMAX, 1:1 Square.
  • FOV math: horizontal FOV = 2·atan(sensorWidth / (2·focal)); the Three.js camera vertical FOV = 2·atan(tan(hFOV/2) / aspect).
  • Summary/filename helpers:getCameraSummary() (e.g. "ARRI Alexa LF · 85mm · 2.39:1 Scope · 15.2° vFOV") and getCameraFilenameSegment() (e.g. Super35mm_35mm).
  • Camera Reset — one-click restore to the exact pose the camera had when the file loaded.
  • Far clip control — manual override for the camera far plane (fixes black sky on large splats). Auto default is distance × 1000.
  • Camera scale — log slider (0.001 → 1000) that multiplies camera-track translations so a cm-scale COLMAP pairs with a metre-scale splat without resizing the splat (pure translation rescale). Reset (↺) returns to 1.0.
  • Coordinate system — dropdown selecting the capture's world-frame convention (COLMAP/OpenCV Y-down, glTF Y-up, Blender Z-up). Applied to imported/exported COLMAP tracks and to PLY splats at load (SPZ uses its embedded metadata).

Navigation

  • Fly mode (default) — WASD to move, Q/E down/up, Shift for a ~3× speed boost, mouse-drag to look. Roll is preserved so restored/scrubbed poses keep their full orientation.
  • Orbit mode — click-drag to orbit around the target, with damping. Toggle Fly ⇄ Orbit with F or the Nav buttons.
  • Frame — re-fit the camera to the splat's bounding box (SuperSplat-style ¾ view).
  • Ground grid and origin axes (X red / Y green / Z blue) toggles.

Scene helpers, meshes & lights

The floating Scene panel (MeshPanel) is a scrollable dark card with three tabs, each showing a live count: Meshes (N), Lights (N), IBL (N).

Meshes tab

  • Add via footer + Add Mesh (.glb / .obj) or drag-drop (empty state: Drop .glb / .obj or click Add).
  • Each row: visibility eye toggle, clickable name (selects the mesh), an always-on-top overlay toggle, an expand chevron, and a delete (X) button.
  • Always-on-top overlay: ON draws the mesh through/over the splat (amber icon); OFF clips it behind the splat.
  • Selecting a mesh highlights the row (indigo border) and reveals the gizmo mode/space bar.
  • Expanded row transform controls: Position X/Y/Z, Rotation° X/Y/Z (step 1), and a single uniform Scale input (clamped ≥ 0.0001, step 0.1).
  • Capture IBL from mesh — captures a 360° image-based-lighting probe from the mesh center and adds it to the IBL list.

Gizmo toolbar (shown only when something is selected)

  • Mode buttons: Translate / Rotate / Scale (T/R/S; active = indigo).
  • Space toggle: World (W) vs Local (L) (Local shown amber).

Lights tab

  • Add via footer buttons: Point / Spot / Rect (empty state: No lights yet).
  • Each row: visibility eye toggle, name + type label, expand chevron, delete (X); selectable (indigo border when selected).
  • Common expanded controls: Color picker, Intensity slider (0–100, step 0.5, value shown), and Position X/Y/Z.
  • Spot extras: Move Light vs Move Target selector (chooses which the gizmo affects), a Move light + target together lock checkbox, Target X/Y/Z, Angle° slider (1–89°, radian-converted), Penumbra slider (0–1, step 0.05).
  • Rect (area) extras: Width / Height inputs (each clamped ≥ 0.1) and Rotation° X/Y/Z (step 1).

IBL tab

  • Lists captured image-based-lighting probes; footer + Capture IBL at Camera captures a 360° probe from the current camera position (empty state: No IBLs yet. Capture from mesh or camera.).
  • Each row: visibility eye toggle, name, expand chevron, delete (X).
  • Expanded controls: Position X/Y/Z; Radius slider (0–50, step 0.5; 0 displays as = infinite/global); Ramp slider (0–1, shown only when radius > 0) for falloff; Intensity slider (0–5, step 0.05).
  • HDR color grade: negative-capable Lift (−2…2), Gain (−2…10), and Gamma (0.1…5) sliders, each paired with a numeric input.

Inputs use raw-string local state so you can type intermediate/negative values (-, 1.) before parsing; values display rounded to 3 decimals. All controls carry nodrag nopan so slider/keyboard interaction never pans or drags the underlying canvas node.

Animation & timeline (keyframe camera system)

  • Keyframes store camera position (Vector3), rotation (Quaternion), and vertical FOV (degrees) at a normalized time in [0,1].
  • Per-keyframe interpolation mode (applies to the segment starting at that keyframe): step, linear, easeInOut, smooth.
  • Full scene-state snapshot per keyframe (SceneSnapshot): splat transform, meshes (id/transform/visible), lights, IBLs, orbit target — so geometry, visibility, and lights animate alongside the camera.
  • Interpolation:
    • Position: Catmull-Rom spline (smooth / easeInOut) using 4 control points with clamped-duplicate endpoints; straight-line lerp for linear / step.
    • Rotation: quaternion SLERP between the two bracketing keyframes (no cross-key spline smoothing).
    • FOV: linear lerp between bracketing keyframes.
    • easeInOutCubic applied to local segment time in easeInOut mode; step pins local time to 0 (holds start value); linear/smooth use raw local time.
  • Path evaluation (evaluateCameraPath) clamps: first keyframe before range, last after range, single keyframe directly, null if none.
  • Scene evaluation (evaluateSceneAtFrame) mirrors camera bracketing; returns null unless a keyframe carries scene data (backward-compatible with camera-only / older saves). Generic value interpolation: numbers lerp, booleans/non-color strings step, #rrggbb colors blend per channel, arrays/objects recurse, missing keys hold prior value; entities matched by id, unmatched entities held. IBLs are captured and serialized but not applied live during playback (too expensive per-frame).
  • Keyframe CRUD:addKeyframe (replaces a keyframe within ~half-frame tolerance, re-sorts), removeKeyframe, updateKeyframe (partial merge + re-sort); cloning deep-copies position/quaternion but shares the scene snapshot by reference.
  • Serialization:serializePath / deserializePath — position as [x,y,z], quaternion as [x,y,z,w]; deserialization tolerant of legacy {x,y,z} / {x,y,z,w} object forms.
  • Helpers:frameToTime / timeToFrame (clamped), createEmptyPath (default 120 frames @ 25 fps), getPathDurationSeconds.
  • Interactive canvas timeline: device-pixel-ratio aware, ResizeObserver-driven redraw. Draws track background, auto-spaced frame ticks with labels, yellow keyframe diamonds (selected = orange with white outline), and a red playhead bar with triangle marker.
  • Playback controls: play/stop toggle, loop toggle, previous/next keyframe (jump to nearest keyframe before/after current frame and select it), add keyframe, delete selected keyframe (disabled when none selected).
  • Scrubbing: click/drag empty track to move the playhead. Keyframe dragging: drag a diamond to a new time (10px hit radius, topmost-first hit test).
  • Interpolation selector (STEP / LIN / EASE / SMOOTH) shown only when a keyframe is selected (defaults to smooth when unset).
  • Duration input (min 2, max 9999 frames), FPS dropdown (12 / 24 / 25 / 30 / 60), and live readout: currentFrame / lastFrame · Xs plus N keys.

Post-processing effects (real-time)

Both effects live in the Camera panel with an On/Off toggle and a Reset button (zeroes the coefficients / restores defaults), and reveal their parameter controls only when enabled.

  • Depth-of-field — thin-lens circle-of-confusion blur consuming a color texture and a linearized depth texture (R = Z metres, ≤ 0 = background).
    • Scatter-as-gather weighting prevents in-focus foreground bleeding onto blurred backgrounds.
    • 24-tap golden-angle Vogel-spiral disk sampler (GOLDEN = 2.39996323) with sqrt(t) radial mapping for uniform density; gather radius floored at 1px for cheap AA even in focus.
    • CoC model: D = f/N; CoC_mm = f·D·|Z−S| / (Z·(S − f/1000)) reconciled to metres; CoC_px = CoC_mm · pxPerMm, clamped to [0, maxBlurPx]; singularity guard denom = max(Z·(focusM − focalMm·0.001), 1e-4) (clamp focusM ≥ focalMm/1000 + ε).
    • Uniforms: focalMm (50), apertureMm (50/2.8), focusM (3.0 m), pxPerMm (80 = passWidth/sensorWidthMm), maxBlurPx (60), showCoC toggle, enabled toggle.
    • CoC debug overlay (showCoC): heatmap of |CoC_px|/maxBlurPx — green at the focal plane → yellow → red at heavy defocus.
  • Lens distortion — forward Brown-Conrady (OpenCV) via inverse mapping with 5 Newton iterations (sub-pixel accuracy for k1 ≲ 0.5).
    • Radial factor 1 + k1·r² + k2·r²²; tangential dx = 2·p1·u·v + p2·(r²+2u²), dy = p1·(r²+2v²) + 2·p2·u·v. Handles radial k1,k2 + tangential p1,p2 only (no k3 / fisheye).
    • Out-of-bounds source samples render opaque black so the framing edge is unambiguous.
    • Uniforms: fx/fy (1000), cx/cy (0.5), k1/k2/p1/p2 (0), enabled toggle.
    • computeFovMargin() estimates the extra FOV coverage a distortion pass needs by forward-distorting the worst image corner, returning a focal-length multiplier ≥ 1, capped at 2.0 — superseded by a measured DISTORTION_SCALE when available.

Video & depth export

Camera-path export animating the camera along keyframes via evaluateCameraPath().

  • Output modes: RGB only, Depth only, or Both (RGB + depth as two separate MP4 blobs in VideoExportResult).
  • Resolutions: 1280×720 (HD), 1920×1080 (Full HD, default), 3840×2160 (4K).
  • FPS: 12 / 24 / 25 / 30 / 60.
  • Total frame count: user-set (min 2, max 9999) with live duration readout (durationFrames / fps).
  • Quality / codec presets (all H.264):H.264 20 Mbps, H.264 HQ 50 Mbps, H.264 Max 100 Mbps (DEFAULT_BITRATE = 20 Mbps).
  • Bake DoF — optional per-frame depth-of-field pass using the viewer's live aperture/focus/sensor settings; off by default (~1× extra splat-render cost per frame).
  • COLMAP camera data toggle (cameras.txt + images.txt), on by default.
  • Dual encoder backends: primary WebCodecsVideoEncoder + mp4-muxer, with automatic fallback to MediaRecorder if WebCodecs is unavailable or throws (logged via console.warn).
    • WebCodecs: MP4, codec avc1.640028 (H.264 High L4.0), latencyMode: "quality", keyframe every 2 s (fps·2).
    • MediaRecorder fallback: captureStream(0) with manual requestFrame() per frame and real-time pacing (1000/fps ms); MIME prefers MP4/avc1 then falls through webm vp9/vp8.
  • Global depth-range scan: when depth is needed, a first pass walks all frames to find global min/max depth so every depth frame normalizes against one consistent range.
  • Post-processing baked into export: generic postProcess.apply (reused for both DoF and lens distortion) renders into srcTarget then a fullscreen quad into dstTarget; postProcess.prepareCamera adds per-frame FOV margin (restored after each frame).
  • Per-frame scene animation via applySceneAtFrame() so splat/mesh/light transforms and visibility animate, not just the camera.
  • Progress via onProgress(frame, totalFrames), scaled across scan + render (+ encode) passes; UI shows a progress bar with Rendering frame X/total and percentage.
  • Robustness: even-dimension enforcement (ensureEvenDimension — nearest lower even ≥ 2, returns 0 for non-finite/≤0); full camera-state restore in a finally block (position, quaternion, fov, aspect, renderer size) plus render-target disposal; WebGL vertical flip of read-back pixels (flipVerticallyInto); safeReadPixels unbinds any stray PIXEL_PACK_BUFFER before readRenderTargetPixels.
  • Preconditions: ≥ 2 keyframes anddurationFrames > 0 — enforced in the UI (Export disabled, warning banner "Add at least 2 keyframes before exporting") and in the backend (both encoders throw "Need at least 2 keyframes and > 0 frames to export video").

COLMAP import / export

  • Export: writes the camera path as standard COLMAP cameras.txt + images.txt + empty points3D.txt, bundled in a ZIP (JSZip) — interoperable with COLMAP, 3DGS trainers, and NerfStudio.
    • Single shared PINHOLE camera: focalLengthPx = (focalLengthMm/sensorWidthMm)·width, fx=fy=focalLengthPx, cx=width/2, cy=height/2.
    • Per-frame images.txt line IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME (world→camera R and t) with an empty POINTS2D line after each; image names frame_00000.png; one image per durationFrames.
  • Import: reads cameras.txt + images.txt (+ optional Nodos extras.txt sidecar) from a ZIP and produces a CameraPath — one keyframe per pose plus parsed intrinsics.
    • Per-keyframe FOV from intrinsics: fov = 2·atan(height/(2·fy)) degrees (fallback 60° when intrinsics missing).
    • Poses sorted by IMAGE_ID; keyframe times distributed uniformly across [0,1].
    • Parses camera models PINHOLE, SIMPLE_PINHOLE, OPENCV, RADIAL, SIMPLE_RADIAL; unrecognized models are skipped (best-effort fallback to PINHOLE defaults, distortion dropped).
    • Extracts Brown-Conrady coefficients: OPENCV → k1,k2,p1,p2; RADIAL → k1,k2; SIMPLE_RADIAL → k1.
    • Nodos extras.txt: reads DISTORTION_SCALE (column 6) into cameraParams.distortionScale as an authoritative FOV-margin multiplier overriding k1/k2 estimation.
  • World-frame conventions (both directions; inverse is the transpose):
    • y-up — identity (glTF / Three.js / Maya / Unity)
    • y-down — 180° about X (raw COLMAP / OpenCV / vanilla SfM) — default for import and export
    • z-up — −90° about X (Blender / Unreal / RealityCapture / Metashape)
    • COLMAP camera frame is spec-fixed RDF (+X right, +Y down, +Z forward); Three.js is RUB. The camera-axis change D = diag(1,−1,−1) (180° about X) is applied on the camera side in both directions.

Capture (postMessage)

  • Screenshot capture — aspect-ratio cropped, cinematic naming.
  • Optional same-origin postMessage capture-back (lets an embedding host receive captures); worldId is echoed back with captures.

Develop

npm install
npm run dev

Open with a CORS-enabled splat:

http://localhost:5173/?url=<https URL of a .ply or .spz>&name=test.ply

URL params

  • url — splat URL (.ply / .spz), must be CORS-enabled
  • name — display name / download filename
  • worldId — opaque id echoed back with captures
  • lens — focal length in mm (e.g. 75)
  • sensor — sensor width in mm as a number (e.g. 33.7)
  • gsDir — default folder for the Save Scene dialog (host integration; needs the host file API)
  • projectDir — project folder used to derive default camera.json / frame-render paths (host integration; needs the host file API)

Keyboard shortcuts

Viewer shortcuts are suppressed while typing in an input/select/textarea.

  • WASD — move (Fly mode); Q / E — down / up; Shift — speed boost; mouse-drag — look
  • F — toggle Fly ⇄ Orbit navigation
  • H — show / hide the controls panels
  • T — show / hide the timeline
  • K — add keyframe
  • Del / Backspace — delete the selected keyframe
  • Ctrl/Cmd + Z — undo (scene/keyframe edits)
  • Escape — cancel/close the file dialog
  • Enter (path input) — navigate to the typed path; Enter (save Name field) — confirm save
  • Double-click a file (open mode) — open it immediately

Capture/screenshot is button-only — there is no keyboard shortcut for it.

Build

npm run build # outputs dist/ with relative asset paths (base: "./")

The build is self-contained and path-relative, so dist/ can be served from any static host, including under an arbitrary subpath. Keep base: "./" in vite.config.ts for subpath hosting to work.

Hosting & embedding

The build is static and path-relative, so dist/ can be served from any web server, under any subpath. Two common ways to consume the viewer:

  • One hosted build, embedded by apps — deploy dist/ once and embed it (iframe or popup window) from your apps. If an embedding app runs on a different origin, reverse-proxy the viewer path (e.g. /viewer → https://your-host/viewer-build/) so the viewer appears same-origin to that app — this is what keeps blob: splat URLs, the sessionStorage state handoff, and the postMessage capture-back working (all three are same-origin-only). Rebuild once and every consumer reflects it on next load.
    • Versioning tip: serve versioned paths (/viewer/vN/) so a consumer can pin a known-good build (with a shared build, a regression otherwise hits every consumer at once).
  • As a package — install the repo as a git dependency and render the exported React component; see Consuming as a dependency below.

This is a static client-only build — there is no viewer server/API. "Hosted" just means the static files live at one URL.

Optional host file API

Disk-based scene save/open (the FileDialog), sidecar splat restore, and camera.json export call three endpoints that an embedding host may provide — the standalone build has no server, so these UI paths are inert unless the host implements them (same-origin):

EndpointReturns
GET /api/list-directory?path=<dir>{ success: true, entries: [{ name, type: "file" | "directory" }] }
GET /api/read-file?path=<absolute path>raw file bytes
POST /api/write-file?path=<absolute path> (body = file content){ success: true }
POST /api/save-generation (multipart form: file, directoryPath, customFilename, createDirectory)any 2xx

Everything else (drag-drop loading, ?url= loading, browser downloads, video/COLMAP export) works without them. Video/COLMAP exports always download in the browser; when /api/save-generation exists they are additionally mirrored to the host (best-effort — failures only log).

Host integration (popup embedding)

When another app opens the viewer as a popup/iframe (same-origin — reverse-proxy if needed):

  • Open the viewer with ?worldId=<opaque id> (plus url/name/lens/sensor as needed). ?url= accepts any CORS-enabled URL, including same-origin blob: URLs.
  • State handoff: before opening, the host may write a serialized viewer state to sessionStorage["splat-viewer-state-<worldId>"]; the viewer restores it on load. While running, the viewer auto-posts state changes back to window.opener as { type: "splat-viewer-state", worldId, state } (same-origin targeted).
  • Capture-back: screenshots post to window.opener as { type: "worldlabs-capture", worldId, image, depthImage, filename, width, height } (PNG data URLs). With no opener, captures download directly instead.

Architecture invariant

src/ must use only relative imports + npm packages — no @/ or next/ imports. This keeps the source portable so it builds under this Vite root and transpiles cleanly inside a consumer's bundler (e.g. Next's transpilePackages). FileDialog.tsx and src/lib/cinemaCameraPresets.ts are deliberately self-contained (no shared/aliased imports) for this reason.

Consuming as a dependency

The package entry is the TS/TSX source (exportssrc/SplatViewer.tsx), and react / react-dom / three / @sparkjsdev/spark are peerDependencies so the host supplies a single copy (a duplicate React breaks hooks; a duplicate three breaks instanceof).

// consumer package.json"dependencies": { "splat-viewer": "github:gitcapoom/capoom-splat-viewer#main" }

The package is intentionally not published to npm ("private": true guards against accidental publish) — consume it via the git URL above.

importSplatViewerfrom"splat-viewer";

The consumer's bundler must transpile the package source (in Next: transpilePackages: ["splat-viewer"]). Because the component uses Tailwind utility classes, a Tailwind v4 host must also scan the package (@source ".../node_modules/splat-viewer/src";). Pick up updates with npm update splat-viewer, or pin a commit via #<sha>.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Splat Viewer

Standalone, client-only Gaussian Splat viewer (three.js + @sparkjsdev/spark), driven by URL params plus an optional same-origin postMessage capture-back. Everything runs in the browser — no server-side rendering, no upload of splat data.

This is a standalone project. Use it directly (npm run dev, or serve the static dist/ build), or embed it in another app — either as one hosted build behind a same-origin path (see Hosting & embedding) or installed as a git dependency exposing the SplatViewer React component (see Consuming as a dependency).

Features

Loading & files

  • SPZ / PLY splat loading — drag-drop, file picker, or ?url= param (URL must be CORS-enabled).
  • GLB / OBJ mesh loading — add polygonal meshes into the splat scene via the Scene panel's + Add Mesh (.glb / .obj) button or by dragging files onto the panel.
  • Scene save / load — a modal FileDialog reads and writes scene .json files through an optional host-provided file API (see Optional host file API below; not included in the standalone build):
    • Two modes: Save (returns directory + filename) and Open (returns an absolute file path); header reads Save scene / Open scene.
    • Editable path input with a Go button, a parent-folder (up) button, and a live directory listing.
    • Listing shows folders first (yellow folder icon) then files (file icon); files are filtered to the configured extension (default .json). Clicking a directory navigates into it.
    • Open: single-click selects (indigo highlight), double-click opens immediately, or confirm with Open (disabled until a file is selected).
    • Save: clicking an existing file reuses its base name; a Name: field (with the fixed extension suffix shown) sets the filename; Enter or Save confirms (disabled if blank).
    • Loading spinner while listing; error state with a Go to home directory fallback link; Empty folder message; current path shown in the footer (monospace, truncated).
    • Dismiss via backdrop click, Escape, or Cancel. Handles both Windows (\) and POSIX (/) separators, auto-detecting and normalizing trailing slashes.

Camera & optics (cinema presets)

  • Sensor presets (7): Super 35mm 24.89×18.66 (default), Full Frame 36.0×24.0, ARRI Alexa LF 36.70×25.54, RED Monstro VV 40.96×21.60, IMAX 65mm 70.41×52.63, Micro Four Thirds 17.30×13.0, APS-C 23.60×15.60 (mm).
  • Lens focal lengths (13 primes): 14, 18, 21, 24, 28, 35 (default), 40, 50, 75, 85, 100, 135, 200 mm.
  • Aspect-ratio presets (6): 2.39:1 Scope, 1.85:1 Flat, 16:9 (default), 4:3, 1.43:1 IMAX, 1:1 Square.
  • FOV math: horizontal FOV = 2·atan(sensorWidth / (2·focal)); the Three.js camera vertical FOV = 2·atan(tan(hFOV/2) / aspect).
  • Summary/filename helpers:getCameraSummary() (e.g. "ARRI Alexa LF · 85mm · 2.39:1 Scope · 15.2° vFOV") and getCameraFilenameSegment() (e.g. Super35mm_35mm).
  • Camera Reset — one-click restore to the exact pose the camera had when the file loaded.
  • Far clip control — manual override for the camera far plane (fixes black sky on large splats). Auto default is distance × 1000.
  • Camera scale — log slider (0.001 → 1000) that multiplies camera-track translations so a cm-scale COLMAP pairs with a metre-scale splat without resizing the splat (pure translation rescale). Reset (↺) returns to 1.0.
  • Coordinate system — dropdown selecting the capture's world-frame convention (COLMAP/OpenCV Y-down, glTF Y-up, Blender Z-up). Applied to imported/exported COLMAP tracks and to PLY splats at load (SPZ uses its embedded metadata).

Navigation

  • Fly mode (default) — WASD to move, Q/E down/up, Shift for a ~3× speed boost, mouse-drag to look. Roll is preserved so restored/scrubbed poses keep their full orientation.
  • Orbit mode — click-drag to orbit around the target, with damping. Toggle Fly ⇄ Orbit with F or the Nav buttons.
  • Frame — re-fit the camera to the splat's bounding box (SuperSplat-style ¾ view).
  • Ground grid and origin axes (X red / Y green / Z blue) toggles.

Scene helpers, meshes & lights

The floating Scene panel (MeshPanel) is a scrollable dark card with three tabs, each showing a live count: Meshes (N), Lights (N), IBL (N).

Meshes tab

  • Add via footer + Add Mesh (.glb / .obj) or drag-drop (empty state: Drop .glb / .obj or click Add).
  • Each row: visibility eye toggle, clickable name (selects the mesh), an always-on-top overlay toggle, an expand chevron, and a delete (X) button.
  • Always-on-top overlay: ON draws the mesh through/over the splat (amber icon); OFF clips it behind the splat.
  • Selecting a mesh highlights the row (indigo border) and reveals the gizmo mode/space bar.
  • Expanded row transform controls: Position X/Y/Z, Rotation° X/Y/Z (step 1), and a single uniform Scale input (clamped ≥ 0.0001, step 0.1).
  • Capture IBL from mesh — captures a 360° image-based-lighting probe from the mesh center and adds it to the IBL list.

Gizmo toolbar (shown only when something is selected)

  • Mode buttons: Translate / Rotate / Scale (T/R/S; active = indigo).
  • Space toggle: World (W) vs Local (L) (Local shown amber).

Lights tab

  • Add via footer buttons: Point / Spot / Rect (empty state: No lights yet).
  • Each row: visibility eye toggle, name + type label, expand chevron, delete (X); selectable (indigo border when selected).
  • Common expanded controls: Color picker, Intensity slider (0–100, step 0.5, value shown), and Position X/Y/Z.
  • Spot extras: Move Light vs Move Target selector (chooses which the gizmo affects), a Move light + target together lock checkbox, Target X/Y/Z, Angle° slider (1–89°, radian-converted), Penumbra slider (0–1, step 0.05).
  • Rect (area) extras: Width / Height inputs (each clamped ≥ 0.1) and Rotation° X/Y/Z (step 1).

IBL tab

  • Lists captured image-based-lighting probes; footer + Capture IBL at Camera captures a 360° probe from the current camera position (empty state: No IBLs yet. Capture from mesh or camera.).
  • Each row: visibility eye toggle, name, expand chevron, delete (X).
  • Expanded controls: Position X/Y/Z; Radius slider (0–50, step 0.5; 0 displays as = infinite/global); Ramp slider (0–1, shown only when radius > 0) for falloff; Intensity slider (0–5, step 0.05).
  • HDR color grade: negative-capable Lift (−2…2), Gain (−2…10), and Gamma (0.1…5) sliders, each paired with a numeric input.

Inputs use raw-string local state so you can type intermediate/negative values (-, 1.) before parsing; values display rounded to 3 decimals. All controls carry nodrag nopan so slider/keyboard interaction never pans or drags the underlying canvas node.

Animation & timeline (keyframe camera system)

  • Keyframes store camera position (Vector3), rotation (Quaternion), and vertical FOV (degrees) at a normalized time in [0,1].
  • Per-keyframe interpolation mode (applies to the segment starting at that keyframe): step, linear, easeInOut, smooth.
  • Full scene-state snapshot per keyframe (SceneSnapshot): splat transform, meshes (id/transform/visible), lights, IBLs, orbit target — so geometry, visibility, and lights animate alongside the camera.
  • Interpolation:
    • Position: Catmull-Rom spline (smooth / easeInOut) using 4 control points with clamped-duplicate endpoints; straight-line lerp for linear / step.
    • Rotation: quaternion SLERP between the two bracketing keyframes (no cross-key spline smoothing).
    • FOV: linear lerp between bracketing keyframes.
    • easeInOutCubic applied to local segment time in easeInOut mode; step pins local time to 0 (holds start value); linear/smooth use raw local time.
  • Path evaluation (evaluateCameraPath) clamps: first keyframe before range, last after range, single keyframe directly, null if none.
  • Scene evaluation (evaluateSceneAtFrame) mirrors camera bracketing; returns null unless a keyframe carries scene data (backward-compatible with camera-only / older saves). Generic value interpolation: numbers lerp, booleans/non-color strings step, #rrggbb colors blend per channel, arrays/objects recurse, missing keys hold prior value; entities matched by id, unmatched entities held. IBLs are captured and serialized but not applied live during playback (too expensive per-frame).
  • Keyframe CRUD:addKeyframe (replaces a keyframe within ~half-frame tolerance, re-sorts), removeKeyframe, updateKeyframe (partial merge + re-sort); cloning deep-copies position/quaternion but shares the scene snapshot by reference.
  • Serialization:serializePath / deserializePath — position as [x,y,z], quaternion as [x,y,z,w]; deserialization tolerant of legacy {x,y,z} / {x,y,z,w} object forms.
  • Helpers:frameToTime / timeToFrame (clamped), createEmptyPath (default 120 frames @ 25 fps), getPathDurationSeconds.
  • Interactive canvas timeline: device-pixel-ratio aware, ResizeObserver-driven redraw. Draws track background, auto-spaced frame ticks with labels, yellow keyframe diamonds (selected = orange with white outline), and a red playhead bar with triangle marker.
  • Playback controls: play/stop toggle, loop toggle, previous/next keyframe (jump to nearest keyframe before/after current frame and select it), add keyframe, delete selected keyframe (disabled when none selected).
  • Scrubbing: click/drag empty track to move the playhead. Keyframe dragging: drag a diamond to a new time (10px hit radius, topmost-first hit test).
  • Interpolation selector (STEP / LIN / EASE / SMOOTH) shown only when a keyframe is selected (defaults to smooth when unset).
  • Duration input (min 2, max 9999 frames), FPS dropdown (12 / 24 / 25 / 30 / 60), and live readout: currentFrame / lastFrame · Xs plus N keys.

Post-processing effects (real-time)

Both effects live in the Camera panel with an On/Off toggle and a Reset button (zeroes the coefficients / restores defaults), and reveal their parameter controls only when enabled.

  • Depth-of-field — thin-lens circle-of-confusion blur consuming a color texture and a linearized depth texture (R = Z metres, ≤ 0 = background).
    • Scatter-as-gather weighting prevents in-focus foreground bleeding onto blurred backgrounds.
    • 24-tap golden-angle Vogel-spiral disk sampler (GOLDEN = 2.39996323) with sqrt(t) radial mapping for uniform density; gather radius floored at 1px for cheap AA even in focus.
    • CoC model: D = f/N; CoC_mm = f·D·|Z−S| / (Z·(S − f/1000)) reconciled to metres; CoC_px = CoC_mm · pxPerMm, clamped to [0, maxBlurPx]; singularity guard denom = max(Z·(focusM − focalMm·0.001), 1e-4) (clamp focusM ≥ focalMm/1000 + ε).
    • Uniforms: focalMm (50), apertureMm (50/2.8), focusM (3.0 m), pxPerMm (80 = passWidth/sensorWidthMm), maxBlurPx (60), showCoC toggle, enabled toggle.
    • CoC debug overlay (showCoC): heatmap of |CoC_px|/maxBlurPx — green at the focal plane → yellow → red at heavy defocus.
  • Lens distortion — forward Brown-Conrady (OpenCV) via inverse mapping with 5 Newton iterations (sub-pixel accuracy for k1 ≲ 0.5).
    • Radial factor 1 + k1·r² + k2·r²²; tangential dx = 2·p1·u·v + p2·(r²+2u²), dy = p1·(r²+2v²) + 2·p2·u·v. Handles radial k1,k2 + tangential p1,p2 only (no k3 / fisheye).
    • Out-of-bounds source samples render opaque black so the framing edge is unambiguous.
    • Uniforms: fx/fy (1000), cx/cy (0.5), k1/k2/p1/p2 (0), enabled toggle.
    • computeFovMargin() estimates the extra FOV coverage a distortion pass needs by forward-distorting the worst image corner, returning a focal-length multiplier ≥ 1, capped at 2.0 — superseded by a measured DISTORTION_SCALE when available.

Video & depth export

Camera-path export animating the camera along keyframes via evaluateCameraPath().

  • Output modes: RGB only, Depth only, or Both (RGB + depth as two separate MP4 blobs in VideoExportResult).
  • Resolutions: 1280×720 (HD), 1920×1080 (Full HD, default), 3840×2160 (4K).
  • FPS: 12 / 24 / 25 / 30 / 60.
  • Total frame count: user-set (min 2, max 9999) with live duration readout (durationFrames / fps).
  • Quality / codec presets (all H.264):H.264 20 Mbps, H.264 HQ 50 Mbps, H.264 Max 100 Mbps (DEFAULT_BITRATE = 20 Mbps).
  • Bake DoF — optional per-frame depth-of-field pass using the viewer's live aperture/focus/sensor settings; off by default (~1× extra splat-render cost per frame).
  • COLMAP camera data toggle (cameras.txt + images.txt), on by default.
  • Dual encoder backends: primary WebCodecsVideoEncoder + mp4-muxer, with automatic fallback to MediaRecorder if WebCodecs is unavailable or throws (logged via console.warn).
    • WebCodecs: MP4, codec avc1.640028 (H.264 High L4.0), latencyMode: "quality", keyframe every 2 s (fps·2).
    • MediaRecorder fallback: captureStream(0) with manual requestFrame() per frame and real-time pacing (1000/fps ms); MIME prefers MP4/avc1 then falls through webm vp9/vp8.
  • Global depth-range scan: when depth is needed, a first pass walks all frames to find global min/max depth so every depth frame normalizes against one consistent range.
  • Post-processing baked into export: generic postProcess.apply (reused for both DoF and lens distortion) renders into srcTarget then a fullscreen quad into dstTarget; postProcess.prepareCamera adds per-frame FOV margin (restored after each frame).
  • Per-frame scene animation via applySceneAtFrame() so splat/mesh/light transforms and visibility animate, not just the camera.
  • Progress via onProgress(frame, totalFrames), scaled across scan + render (+ encode) passes; UI shows a progress bar with Rendering frame X/total and percentage.
  • Robustness: even-dimension enforcement (ensureEvenDimension — nearest lower even ≥ 2, returns 0 for non-finite/≤0); full camera-state restore in a finally block (position, quaternion, fov, aspect, renderer size) plus render-target disposal; WebGL vertical flip of read-back pixels (flipVerticallyInto); safeReadPixels unbinds any stray PIXEL_PACK_BUFFER before readRenderTargetPixels.
  • Preconditions: ≥ 2 keyframes anddurationFrames > 0 — enforced in the UI (Export disabled, warning banner "Add at least 2 keyframes before exporting") and in the backend (both encoders throw "Need at least 2 keyframes and > 0 frames to export video").

COLMAP import / export

  • Export: writes the camera path as standard COLMAP cameras.txt + images.txt + empty points3D.txt, bundled in a ZIP (JSZip) — interoperable with COLMAP, 3DGS trainers, and NerfStudio.
    • Single shared PINHOLE camera: focalLengthPx = (focalLengthMm/sensorWidthMm)·width, fx=fy=focalLengthPx, cx=width/2, cy=height/2.
    • Per-frame images.txt line IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME (world→camera R and t) with an empty POINTS2D line after each; image names frame_00000.png; one image per durationFrames.
  • Import: reads cameras.txt + images.txt (+ optional Nodos extras.txt sidecar) from a ZIP and produces a CameraPath — one keyframe per pose plus parsed intrinsics.
    • Per-keyframe FOV from intrinsics: fov = 2·atan(height/(2·fy)) degrees (fallback 60° when intrinsics missing).
    • Poses sorted by IMAGE_ID; keyframe times distributed uniformly across [0,1].
    • Parses camera models PINHOLE, SIMPLE_PINHOLE, OPENCV, RADIAL, SIMPLE_RADIAL; unrecognized models are skipped (best-effort fallback to PINHOLE defaults, distortion dropped).
    • Extracts Brown-Conrady coefficients: OPENCV → k1,k2,p1,p2; RADIAL → k1,k2; SIMPLE_RADIAL → k1.
    • Nodos extras.txt: reads DISTORTION_SCALE (column 6) into cameraParams.distortionScale as an authoritative FOV-margin multiplier overriding k1/k2 estimation.
  • World-frame conventions (both directions; inverse is the transpose):
    • y-up — identity (glTF / Three.js / Maya / Unity)
    • y-down — 180° about X (raw COLMAP / OpenCV / vanilla SfM) — default for import and export
    • z-up — −90° about X (Blender / Unreal / RealityCapture / Metashape)
    • COLMAP camera frame is spec-fixed RDF (+X right, +Y down, +Z forward); Three.js is RUB. The camera-axis change D = diag(1,−1,−1) (180° about X) is applied on the camera side in both directions.

Capture (postMessage)

  • Screenshot capture — aspect-ratio cropped, cinematic naming.
  • Optional same-origin postMessage capture-back (lets an embedding host receive captures); worldId is echoed back with captures.

Develop

npm install
npm run dev

Open with a CORS-enabled splat:

http://localhost:5173/?url=<https URL of a .ply or .spz>&name=test.ply

URL params

  • url — splat URL (.ply / .spz), must be CORS-enabled
  • name — display name / download filename
  • worldId — opaque id echoed back with captures
  • lens — focal length in mm (e.g. 75)
  • sensor — sensor width in mm as a number (e.g. 33.7)
  • gsDir — default folder for the Save Scene dialog (host integration; needs the host file API)
  • projectDir — project folder used to derive default camera.json / frame-render paths (host integration; needs the host file API)

Keyboard shortcuts

Viewer shortcuts are suppressed while typing in an input/select/textarea.

  • WASD — move (Fly mode); Q / E — down / up; Shift — speed boost; mouse-drag — look
  • F — toggle Fly ⇄ Orbit navigation
  • H — show / hide the controls panels
  • T — show / hide the timeline
  • K — add keyframe
  • Del / Backspace — delete the selected keyframe
  • Ctrl/Cmd + Z — undo (scene/keyframe edits)
  • Escape — cancel/close the file dialog
  • Enter (path input) — navigate to the typed path; Enter (save Name field) — confirm save
  • Double-click a file (open mode) — open it immediately

Capture/screenshot is button-only — there is no keyboard shortcut for it.

Build

npm run build # outputs dist/ with relative asset paths (base: "./")

The build is self-contained and path-relative, so dist/ can be served from any static host, including under an arbitrary subpath. Keep base: "./" in vite.config.ts for subpath hosting to work.

Hosting & embedding

The build is static and path-relative, so dist/ can be served from any web server, under any subpath. Two common ways to consume the viewer:

  • One hosted build, embedded by apps — deploy dist/ once and embed it (iframe or popup window) from your apps. If an embedding app runs on a different origin, reverse-proxy the viewer path (e.g. /viewer → https://your-host/viewer-build/) so the viewer appears same-origin to that app — this is what keeps blob: splat URLs, the sessionStorage state handoff, and the postMessage capture-back working (all three are same-origin-only). Rebuild once and every consumer reflects it on next load.
    • Versioning tip: serve versioned paths (/viewer/vN/) so a consumer can pin a known-good build (with a shared build, a regression otherwise hits every consumer at once).
  • As a package — install the repo as a git dependency and render the exported React component; see Consuming as a dependency below.

This is a static client-only build — there is no viewer server/API. "Hosted" just means the static files live at one URL.

Optional host file API

Disk-based scene save/open (the FileDialog), sidecar splat restore, and camera.json export call three endpoints that an embedding host may provide — the standalone build has no server, so these UI paths are inert unless the host implements them (same-origin):

EndpointReturns
GET /api/list-directory?path=<dir>{ success: true, entries: [{ name, type: "file" | "directory" }] }
GET /api/read-file?path=<absolute path>raw file bytes
POST /api/write-file?path=<absolute path> (body = file content){ success: true }
POST /api/save-generation (multipart form: file, directoryPath, customFilename, createDirectory)any 2xx

Everything else (drag-drop loading, ?url= loading, browser downloads, video/COLMAP export) works without them. Video/COLMAP exports always download in the browser; when /api/save-generation exists they are additionally mirrored to the host (best-effort — failures only log).

Host integration (popup embedding)

When another app opens the viewer as a popup/iframe (same-origin — reverse-proxy if needed):

  • Open the viewer with ?worldId=<opaque id> (plus url/name/lens/sensor as needed). ?url= accepts any CORS-enabled URL, including same-origin blob: URLs.
  • State handoff: before opening, the host may write a serialized viewer state to sessionStorage["splat-viewer-state-<worldId>"]; the viewer restores it on load. While running, the viewer auto-posts state changes back to window.opener as { type: "splat-viewer-state", worldId, state } (same-origin targeted).
  • Capture-back: screenshots post to window.opener as { type: "worldlabs-capture", worldId, image, depthImage, filename, width, height } (PNG data URLs). With no opener, captures download directly instead.

Architecture invariant

src/ must use only relative imports + npm packages — no @/ or next/ imports. This keeps the source portable so it builds under this Vite root and transpiles cleanly inside a consumer's bundler (e.g. Next's transpilePackages). FileDialog.tsx and src/lib/cinemaCameraPresets.ts are deliberately self-contained (no shared/aliased imports) for this reason.

Consuming as a dependency

The package entry is the TS/TSX source (exportssrc/SplatViewer.tsx), and react / react-dom / three / @sparkjsdev/spark are peerDependencies so the host supplies a single copy (a duplicate React breaks hooks; a duplicate three breaks instanceof).

// consumer package.json"dependencies": { "splat-viewer": "github:gitcapoom/capoom-splat-viewer#main" }

The package is intentionally not published to npm ("private": true guards against accidental publish) — consume it via the git URL above.

importSplatViewerfrom"splat-viewer";

The consumer's bundler must transpile the package source (in Next: transpilePackages: ["splat-viewer"]). Because the component uses Tailwind utility classes, a Tailwind v4 host must also scan the package (@source ".../node_modules/splat-viewer/src";). Pick up updates with npm update splat-viewer, or pin a commit via #<sha>.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Splat Viewer

Standalone, client-only Gaussian Splat viewer (three.js + @sparkjsdev/spark), driven by URL params plus an optional same-origin postMessage capture-back. Everything runs in the browser — no server-side rendering, no upload of splat data.

This is a standalone project. Use it directly (npm run dev, or serve the static dist/ build), or embed it in another app — either as one hosted build behind a same-origin path (see Hosting & embedding) or installed as a git dependency exposing the SplatViewer React component (see Consuming as a dependency).

Features

Loading & files

  • SPZ / PLY splat loading — drag-drop, file picker, or ?url= param (URL must be CORS-enabled).
  • GLB / OBJ mesh loading — add polygonal meshes into the splat scene via the Scene panel's + Add Mesh (.glb / .obj) button or by dragging files onto the panel.
  • Scene save / load — a modal FileDialog reads and writes scene .json files through an optional host-provided file API (see Optional host file API below; not included in the standalone build):
    • Two modes: Save (returns directory + filename) and Open (returns an absolute file path); header reads Save scene / Open scene.
    • Editable path input with a Go button, a parent-folder (up) button, and a live directory listing.
    • Listing shows folders first (yellow folder icon) then files (file icon); files are filtered to the configured extension (default .json). Clicking a directory navigates into it.
    • Open: single-click selects (indigo highlight), double-click opens immediately, or confirm with Open (disabled until a file is selected).
    • Save: clicking an existing file reuses its base name; a Name: field (with the fixed extension suffix shown) sets the filename; Enter or Save confirms (disabled if blank).
    • Loading spinner while listing; error state with a Go to home directory fallback link; Empty folder message; current path shown in the footer (monospace, truncated).
    • Dismiss via backdrop click, Escape, or Cancel. Handles both Windows (\) and POSIX (/) separators, auto-detecting and normalizing trailing slashes.

Camera & optics (cinema presets)

  • Sensor presets (7): Super 35mm 24.89×18.66 (default), Full Frame 36.0×24.0, ARRI Alexa LF 36.70×25.54, RED Monstro VV 40.96×21.60, IMAX 65mm 70.41×52.63, Micro Four Thirds 17.30×13.0, APS-C 23.60×15.60 (mm).
  • Lens focal lengths (13 primes): 14, 18, 21, 24, 28, 35 (default), 40, 50, 75, 85, 100, 135, 200 mm.
  • Aspect-ratio presets (6): 2.39:1 Scope, 1.85:1 Flat, 16:9 (default), 4:3, 1.43:1 IMAX, 1:1 Square.
  • FOV math: horizontal FOV = 2·atan(sensorWidth / (2·focal)); the Three.js camera vertical FOV = 2·atan(tan(hFOV/2) / aspect).
  • Summary/filename helpers:getCameraSummary() (e.g. "ARRI Alexa LF · 85mm · 2.39:1 Scope · 15.2° vFOV") and getCameraFilenameSegment() (e.g. Super35mm_35mm).
  • Camera Reset — one-click restore to the exact pose the camera had when the file loaded.
  • Far clip control — manual override for the camera far plane (fixes black sky on large splats). Auto default is distance × 1000.
  • Camera scale — log slider (0.001 → 1000) that multiplies camera-track translations so a cm-scale COLMAP pairs with a metre-scale splat without resizing the splat (pure translation rescale). Reset (↺) returns to 1.0.
  • Coordinate system — dropdown selecting the capture's world-frame convention (COLMAP/OpenCV Y-down, glTF Y-up, Blender Z-up). Applied to imported/exported COLMAP tracks and to PLY splats at load (SPZ uses its embedded metadata).

Navigation

  • Fly mode (default) — WASD to move, Q/E down/up, Shift for a ~3× speed boost, mouse-drag to look. Roll is preserved so restored/scrubbed poses keep their full orientation.
  • Orbit mode — click-drag to orbit around the target, with damping. Toggle Fly ⇄ Orbit with F or the Nav buttons.
  • Frame — re-fit the camera to the splat's bounding box (SuperSplat-style ¾ view).
  • Ground grid and origin axes (X red / Y green / Z blue) toggles.

Scene helpers, meshes & lights

The floating Scene panel (MeshPanel) is a scrollable dark card with three tabs, each showing a live count: Meshes (N), Lights (N), IBL (N).

Meshes tab

  • Add via footer + Add Mesh (.glb / .obj) or drag-drop (empty state: Drop .glb / .obj or click Add).
  • Each row: visibility eye toggle, clickable name (selects the mesh), an always-on-top overlay toggle, an expand chevron, and a delete (X) button.
  • Always-on-top overlay: ON draws the mesh through/over the splat (amber icon); OFF clips it behind the splat.
  • Selecting a mesh highlights the row (indigo border) and reveals the gizmo mode/space bar.
  • Expanded row transform controls: Position X/Y/Z, Rotation° X/Y/Z (step 1), and a single uniform Scale input (clamped ≥ 0.0001, step 0.1).
  • Capture IBL from mesh — captures a 360° image-based-lighting probe from the mesh center and adds it to the IBL list.

Gizmo toolbar (shown only when something is selected)

  • Mode buttons: Translate / Rotate / Scale (T/R/S; active = indigo).
  • Space toggle: World (W) vs Local (L) (Local shown amber).

Lights tab

  • Add via footer buttons: Point / Spot / Rect (empty state: No lights yet).
  • Each row: visibility eye toggle, name + type label, expand chevron, delete (X); selectable (indigo border when selected).
  • Common expanded controls: Color picker, Intensity slider (0–100, step 0.5, value shown), and Position X/Y/Z.
  • Spot extras: Move Light vs Move Target selector (chooses which the gizmo affects), a Move light + target together lock checkbox, Target X/Y/Z, Angle° slider (1–89°, radian-converted), Penumbra slider (0–1, step 0.05).
  • Rect (area) extras: Width / Height inputs (each clamped ≥ 0.1) and Rotation° X/Y/Z (step 1).

IBL tab

  • Lists captured image-based-lighting probes; footer + Capture IBL at Camera captures a 360° probe from the current camera position (empty state: No IBLs yet. Capture from mesh or camera.).
  • Each row: visibility eye toggle, name, expand chevron, delete (X).
  • Expanded controls: Position X/Y/Z; Radius slider (0–50, step 0.5; 0 displays as = infinite/global); Ramp slider (0–1, shown only when radius > 0) for falloff; Intensity slider (0–5, step 0.05).
  • HDR color grade: negative-capable Lift (−2…2), Gain (−2…10), and Gamma (0.1…5) sliders, each paired with a numeric input.

Inputs use raw-string local state so you can type intermediate/negative values (-, 1.) before parsing; values display rounded to 3 decimals. All controls carry nodrag nopan so slider/keyboard interaction never pans or drags the underlying canvas node.

Animation & timeline (keyframe camera system)

  • Keyframes store camera position (Vector3), rotation (Quaternion), and vertical FOV (degrees) at a normalized time in [0,1].
  • Per-keyframe interpolation mode (applies to the segment starting at that keyframe): step, linear, easeInOut, smooth.
  • Full scene-state snapshot per keyframe (SceneSnapshot): splat transform, meshes (id/transform/visible), lights, IBLs, orbit target — so geometry, visibility, and lights animate alongside the camera.
  • Interpolation:
    • Position: Catmull-Rom spline (smooth / easeInOut) using 4 control points with clamped-duplicate endpoints; straight-line lerp for linear / step.
    • Rotation: quaternion SLERP between the two bracketing keyframes (no cross-key spline smoothing).
    • FOV: linear lerp between bracketing keyframes.
    • easeInOutCubic applied to local segment time in easeInOut mode; step pins local time to 0 (holds start value); linear/smooth use raw local time.
  • Path evaluation (evaluateCameraPath) clamps: first keyframe before range, last after range, single keyframe directly, null if none.
  • Scene evaluation (evaluateSceneAtFrame) mirrors camera bracketing; returns null unless a keyframe carries scene data (backward-compatible with camera-only / older saves). Generic value interpolation: numbers lerp, booleans/non-color strings step, #rrggbb colors blend per channel, arrays/objects recurse, missing keys hold prior value; entities matched by id, unmatched entities held. IBLs are captured and serialized but not applied live during playback (too expensive per-frame).
  • Keyframe CRUD:addKeyframe (replaces a keyframe within ~half-frame tolerance, re-sorts), removeKeyframe, updateKeyframe (partial merge + re-sort); cloning deep-copies position/quaternion but shares the scene snapshot by reference.
  • Serialization:serializePath / deserializePath — position as [x,y,z], quaternion as [x,y,z,w]; deserialization tolerant of legacy {x,y,z} / {x,y,z,w} object forms.
  • Helpers:frameToTime / timeToFrame (clamped), createEmptyPath (default 120 frames @ 25 fps), getPathDurationSeconds.
  • Interactive canvas timeline: device-pixel-ratio aware, ResizeObserver-driven redraw. Draws track background, auto-spaced frame ticks with labels, yellow keyframe diamonds (selected = orange with white outline), and a red playhead bar with triangle marker.
  • Playback controls: play/stop toggle, loop toggle, previous/next keyframe (jump to nearest keyframe before/after current frame and select it), add keyframe, delete selected keyframe (disabled when none selected).
  • Scrubbing: click/drag empty track to move the playhead. Keyframe dragging: drag a diamond to a new time (10px hit radius, topmost-first hit test).
  • Interpolation selector (STEP / LIN / EASE / SMOOTH) shown only when a keyframe is selected (defaults to smooth when unset).
  • Duration input (min 2, max 9999 frames), FPS dropdown (12 / 24 / 25 / 30 / 60), and live readout: currentFrame / lastFrame · Xs plus N keys.

Post-processing effects (real-time)

Both effects live in the Camera panel with an On/Off toggle and a Reset button (zeroes the coefficients / restores defaults), and reveal their parameter controls only when enabled.

  • Depth-of-field — thin-lens circle-of-confusion blur consuming a color texture and a linearized depth texture (R = Z metres, ≤ 0 = background).
    • Scatter-as-gather weighting prevents in-focus foreground bleeding onto blurred backgrounds.
    • 24-tap golden-angle Vogel-spiral disk sampler (GOLDEN = 2.39996323) with sqrt(t) radial mapping for uniform density; gather radius floored at 1px for cheap AA even in focus.
    • CoC model: D = f/N; CoC_mm = f·D·|Z−S| / (Z·(S − f/1000)) reconciled to metres; CoC_px = CoC_mm · pxPerMm, clamped to [0, maxBlurPx]; singularity guard denom = max(Z·(focusM − focalMm·0.001), 1e-4) (clamp focusM ≥ focalMm/1000 + ε).
    • Uniforms: focalMm (50), apertureMm (50/2.8), focusM (3.0 m), pxPerMm (80 = passWidth/sensorWidthMm), maxBlurPx (60), showCoC toggle, enabled toggle.
    • CoC debug overlay (showCoC): heatmap of |CoC_px|/maxBlurPx — green at the focal plane → yellow → red at heavy defocus.
  • Lens distortion — forward Brown-Conrady (OpenCV) via inverse mapping with 5 Newton iterations (sub-pixel accuracy for k1 ≲ 0.5).
    • Radial factor 1 + k1·r² + k2·r²²; tangential dx = 2·p1·u·v + p2·(r²+2u²), dy = p1·(r²+2v²) + 2·p2·u·v. Handles radial k1,k2 + tangential p1,p2 only (no k3 / fisheye).
    • Out-of-bounds source samples render opaque black so the framing edge is unambiguous.
    • Uniforms: fx/fy (1000), cx/cy (0.5), k1/k2/p1/p2 (0), enabled toggle.
    • computeFovMargin() estimates the extra FOV coverage a distortion pass needs by forward-distorting the worst image corner, returning a focal-length multiplier ≥ 1, capped at 2.0 — superseded by a measured DISTORTION_SCALE when available.

Video & depth export

Camera-path export animating the camera along keyframes via evaluateCameraPath().

  • Output modes: RGB only, Depth only, or Both (RGB + depth as two separate MP4 blobs in VideoExportResult).
  • Resolutions: 1280×720 (HD), 1920×1080 (Full HD, default), 3840×2160 (4K).
  • FPS: 12 / 24 / 25 / 30 / 60.
  • Total frame count: user-set (min 2, max 9999) with live duration readout (durationFrames / fps).
  • Quality / codec presets (all H.264):H.264 20 Mbps, H.264 HQ 50 Mbps, H.264 Max 100 Mbps (DEFAULT_BITRATE = 20 Mbps).
  • Bake DoF — optional per-frame depth-of-field pass using the viewer's live aperture/focus/sensor settings; off by default (~1× extra splat-render cost per frame).
  • COLMAP camera data toggle (cameras.txt + images.txt), on by default.
  • Dual encoder backends: primary WebCodecsVideoEncoder + mp4-muxer, with automatic fallback to MediaRecorder if WebCodecs is unavailable or throws (logged via console.warn).
    • WebCodecs: MP4, codec avc1.640028 (H.264 High L4.0), latencyMode: "quality", keyframe every 2 s (fps·2).
    • MediaRecorder fallback: captureStream(0) with manual requestFrame() per frame and real-time pacing (1000/fps ms); MIME prefers MP4/avc1 then falls through webm vp9/vp8.
  • Global depth-range scan: when depth is needed, a first pass walks all frames to find global min/max depth so every depth frame normalizes against one consistent range.
  • Post-processing baked into export: generic postProcess.apply (reused for both DoF and lens distortion) renders into srcTarget then a fullscreen quad into dstTarget; postProcess.prepareCamera adds per-frame FOV margin (restored after each frame).
  • Per-frame scene animation via applySceneAtFrame() so splat/mesh/light transforms and visibility animate, not just the camera.
  • Progress via onProgress(frame, totalFrames), scaled across scan + render (+ encode) passes; UI shows a progress bar with Rendering frame X/total and percentage.
  • Robustness: even-dimension enforcement (ensureEvenDimension — nearest lower even ≥ 2, returns 0 for non-finite/≤0); full camera-state restore in a finally block (position, quaternion, fov, aspect, renderer size) plus render-target disposal; WebGL vertical flip of read-back pixels (flipVerticallyInto); safeReadPixels unbinds any stray PIXEL_PACK_BUFFER before readRenderTargetPixels.
  • Preconditions: ≥ 2 keyframes anddurationFrames > 0 — enforced in the UI (Export disabled, warning banner "Add at least 2 keyframes before exporting") and in the backend (both encoders throw "Need at least 2 keyframes and > 0 frames to export video").

COLMAP import / export

  • Export: writes the camera path as standard COLMAP cameras.txt + images.txt + empty points3D.txt, bundled in a ZIP (JSZip) — interoperable with COLMAP, 3DGS trainers, and NerfStudio.
    • Single shared PINHOLE camera: focalLengthPx = (focalLengthMm/sensorWidthMm)·width, fx=fy=focalLengthPx, cx=width/2, cy=height/2.
    • Per-frame images.txt line IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME (world→camera R and t) with an empty POINTS2D line after each; image names frame_00000.png; one image per durationFrames.
  • Import: reads cameras.txt + images.txt (+ optional Nodos extras.txt sidecar) from a ZIP and produces a CameraPath — one keyframe per pose plus parsed intrinsics.
    • Per-keyframe FOV from intrinsics: fov = 2·atan(height/(2·fy)) degrees (fallback 60° when intrinsics missing).
    • Poses sorted by IMAGE_ID; keyframe times distributed uniformly across [0,1].
    • Parses camera models PINHOLE, SIMPLE_PINHOLE, OPENCV, RADIAL, SIMPLE_RADIAL; unrecognized models are skipped (best-effort fallback to PINHOLE defaults, distortion dropped).
    • Extracts Brown-Conrady coefficients: OPENCV → k1,k2,p1,p2; RADIAL → k1,k2; SIMPLE_RADIAL → k1.
    • Nodos extras.txt: reads DISTORTION_SCALE (column 6) into cameraParams.distortionScale as an authoritative FOV-margin multiplier overriding k1/k2 estimation.
  • World-frame conventions (both directions; inverse is the transpose):
    • y-up — identity (glTF / Three.js / Maya / Unity)
    • y-down — 180° about X (raw COLMAP / OpenCV / vanilla SfM) — default for import and export
    • z-up — −90° about X (Blender / Unreal / RealityCapture / Metashape)
    • COLMAP camera frame is spec-fixed RDF (+X right, +Y down, +Z forward); Three.js is RUB. The camera-axis change D = diag(1,−1,−1) (180° about X) is applied on the camera side in both directions.

Capture (postMessage)

  • Screenshot capture — aspect-ratio cropped, cinematic naming.
  • Optional same-origin postMessage capture-back (lets an embedding host receive captures); worldId is echoed back with captures.

Develop

npm install
npm run dev

Open with a CORS-enabled splat:

http://localhost:5173/?url=<https URL of a .ply or .spz>&name=test.ply

URL params

  • url — splat URL (.ply / .spz), must be CORS-enabled
  • name — display name / download filename
  • worldId — opaque id echoed back with captures
  • lens — focal length in mm (e.g. 75)
  • sensor — sensor width in mm as a number (e.g. 33.7)
  • gsDir — default folder for the Save Scene dialog (host integration; needs the host file API)
  • projectDir — project folder used to derive default camera.json / frame-render paths (host integration; needs the host file API)

Keyboard shortcuts

Viewer shortcuts are suppressed while typing in an input/select/textarea.

  • WASD — move (Fly mode); Q / E — down / up; Shift — speed boost; mouse-drag — look
  • F — toggle Fly ⇄ Orbit navigation
  • H — show / hide the controls panels
  • T — show / hide the timeline
  • K — add keyframe
  • Del / Backspace — delete the selected keyframe
  • Ctrl/Cmd + Z — undo (scene/keyframe edits)
  • Escape — cancel/close the file dialog
  • Enter (path input) — navigate to the typed path; Enter (save Name field) — confirm save
  • Double-click a file (open mode) — open it immediately

Capture/screenshot is button-only — there is no keyboard shortcut for it.

Build

npm run build # outputs dist/ with relative asset paths (base: "./")

The build is self-contained and path-relative, so dist/ can be served from any static host, including under an arbitrary subpath. Keep base: "./" in vite.config.ts for subpath hosting to work.

Hosting & embedding

The build is static and path-relative, so dist/ can be served from any web server, under any subpath. Two common ways to consume the viewer:

  • One hosted build, embedded by apps — deploy dist/ once and embed it (iframe or popup window) from your apps. If an embedding app runs on a different origin, reverse-proxy the viewer path (e.g. /viewer → https://your-host/viewer-build/) so the viewer appears same-origin to that app — this is what keeps blob: splat URLs, the sessionStorage state handoff, and the postMessage capture-back working (all three are same-origin-only). Rebuild once and every consumer reflects it on next load.
    • Versioning tip: serve versioned paths (/viewer/vN/) so a consumer can pin a known-good build (with a shared build, a regression otherwise hits every consumer at once).
  • As a package — install the repo as a git dependency and render the exported React component; see Consuming as a dependency below.

This is a static client-only build — there is no viewer server/API. "Hosted" just means the static files live at one URL.

Optional host file API

Disk-based scene save/open (the FileDialog), sidecar splat restore, and camera.json export call three endpoints that an embedding host may provide — the standalone build has no server, so these UI paths are inert unless the host implements them (same-origin):

EndpointReturns
GET /api/list-directory?path=<dir>{ success: true, entries: [{ name, type: "file" | "directory" }] }
GET /api/read-file?path=<absolute path>raw file bytes
POST /api/write-file?path=<absolute path> (body = file content){ success: true }
POST /api/save-generation (multipart form: file, directoryPath, customFilename, createDirectory)any 2xx

Everything else (drag-drop loading, ?url= loading, browser downloads, video/COLMAP export) works without them. Video/COLMAP exports always download in the browser; when /api/save-generation exists they are additionally mirrored to the host (best-effort — failures only log).

Host integration (popup embedding)

When another app opens the viewer as a popup/iframe (same-origin — reverse-proxy if needed):

  • Open the viewer with ?worldId=<opaque id> (plus url/name/lens/sensor as needed). ?url= accepts any CORS-enabled URL, including same-origin blob: URLs.
  • State handoff: before opening, the host may write a serialized viewer state to sessionStorage["splat-viewer-state-<worldId>"]; the viewer restores it on load. While running, the viewer auto-posts state changes back to window.opener as { type: "splat-viewer-state", worldId, state } (same-origin targeted).
  • Capture-back: screenshots post to window.opener as { type: "worldlabs-capture", worldId, image, depthImage, filename, width, height } (PNG data URLs). With no opener, captures download directly instead.

Architecture invariant

src/ must use only relative imports + npm packages — no @/ or next/ imports. This keeps the source portable so it builds under this Vite root and transpiles cleanly inside a consumer's bundler (e.g. Next's transpilePackages). FileDialog.tsx and src/lib/cinemaCameraPresets.ts are deliberately self-contained (no shared/aliased imports) for this reason.

Consuming as a dependency

The package entry is the TS/TSX source (exportssrc/SplatViewer.tsx), and react / react-dom / three / @sparkjsdev/spark are peerDependencies so the host supplies a single copy (a duplicate React breaks hooks; a duplicate three breaks instanceof).

// consumer package.json"dependencies": { "splat-viewer": "github:gitcapoom/capoom-splat-viewer#main" }

The package is intentionally not published to npm ("private": true guards against accidental publish) — consume it via the git URL above.

importSplatViewerfrom"splat-viewer";

The consumer's bundler must transpile the package source (in Next: transpilePackages: ["splat-viewer"]). Because the component uses Tailwind utility classes, a Tailwind v4 host must also scan the package (@source ".../node_modules/splat-viewer/src";). Pick up updates with npm update splat-viewer, or pin a commit via #<sha>.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Splat Viewer

Standalone, client-only Gaussian Splat viewer (three.js + @sparkjsdev/spark), driven by URL params plus an optional same-origin postMessage capture-back. Everything runs in the browser — no server-side rendering, no upload of splat data.

This is a standalone project. Use it directly (npm run dev, or serve the static dist/ build), or embed it in another app — either as one hosted build behind a same-origin path (see Hosting & embedding) or installed as a git dependency exposing the SplatViewer React component (see Consuming as a dependency).

Features

Loading & files

  • SPZ / PLY splat loading — drag-drop, file picker, or ?url= param (URL must be CORS-enabled).
  • GLB / OBJ mesh loading — add polygonal meshes into the splat scene via the Scene panel's + Add Mesh (.glb / .obj) button or by dragging files onto the panel.
  • Scene save / load — a modal FileDialog reads and writes scene .json files through an optional host-provided file API (see Optional host file API below; not included in the standalone build):
    • Two modes: Save (returns directory + filename) and Open (returns an absolute file path); header reads Save scene / Open scene.
    • Editable path input with a Go button, a parent-folder (up) button, and a live directory listing.
    • Listing shows folders first (yellow folder icon) then files (file icon); files are filtered to the configured extension (default .json). Clicking a directory navigates into it.
    • Open: single-click selects (indigo highlight), double-click opens immediately, or confirm with Open (disabled until a file is selected).
    • Save: clicking an existing file reuses its base name; a Name: field (with the fixed extension suffix shown) sets the filename; Enter or Save confirms (disabled if blank).
    • Loading spinner while listing; error state with a Go to home directory fallback link; Empty folder message; current path shown in the footer (monospace, truncated).
    • Dismiss via backdrop click, Escape, or Cancel. Handles both Windows (\) and POSIX (/) separators, auto-detecting and normalizing trailing slashes.

Camera & optics (cinema presets)

  • Sensor presets (7): Super 35mm 24.89×18.66 (default), Full Frame 36.0×24.0, ARRI Alexa LF 36.70×25.54, RED Monstro VV 40.96×21.60, IMAX 65mm 70.41×52.63, Micro Four Thirds 17.30×13.0, APS-C 23.60×15.60 (mm).
  • Lens focal lengths (13 primes): 14, 18, 21, 24, 28, 35 (default), 40, 50, 75, 85, 100, 135, 200 mm.
  • Aspect-ratio presets (6): 2.39:1 Scope, 1.85:1 Flat, 16:9 (default), 4:3, 1.43:1 IMAX, 1:1 Square.
  • FOV math: horizontal FOV = 2·atan(sensorWidth / (2·focal)); the Three.js camera vertical FOV = 2·atan(tan(hFOV/2) / aspect).
  • Summary/filename helpers:getCameraSummary() (e.g. "ARRI Alexa LF · 85mm · 2.39:1 Scope · 15.2° vFOV") and getCameraFilenameSegment() (e.g. Super35mm_35mm).
  • Camera Reset — one-click restore to the exact pose the camera had when the file loaded.
  • Far clip control — manual override for the camera far plane (fixes black sky on large splats). Auto default is distance × 1000.
  • Camera scale — log slider (0.001 → 1000) that multiplies camera-track translations so a cm-scale COLMAP pairs with a metre-scale splat without resizing the splat (pure translation rescale). Reset (↺) returns to 1.0.
  • Coordinate system — dropdown selecting the capture's world-frame convention (COLMAP/OpenCV Y-down, glTF Y-up, Blender Z-up). Applied to imported/exported COLMAP tracks and to PLY splats at load (SPZ uses its embedded metadata).

Navigation

  • Fly mode (default) — WASD to move, Q/E down/up, Shift for a ~3× speed boost, mouse-drag to look. Roll is preserved so restored/scrubbed poses keep their full orientation.
  • Orbit mode — click-drag to orbit around the target, with damping. Toggle Fly ⇄ Orbit with F or the Nav buttons.
  • Frame — re-fit the camera to the splat's bounding box (SuperSplat-style ¾ view).
  • Ground grid and origin axes (X red / Y green / Z blue) toggles.

Scene helpers, meshes & lights

The floating Scene panel (MeshPanel) is a scrollable dark card with three tabs, each showing a live count: Meshes (N), Lights (N), IBL (N).

Meshes tab

  • Add via footer + Add Mesh (.glb / .obj) or drag-drop (empty state: Drop .glb / .obj or click Add).
  • Each row: visibility eye toggle, clickable name (selects the mesh), an always-on-top overlay toggle, an expand chevron, and a delete (X) button.
  • Always-on-top overlay: ON draws the mesh through/over the splat (amber icon); OFF clips it behind the splat.
  • Selecting a mesh highlights the row (indigo border) and reveals the gizmo mode/space bar.
  • Expanded row transform controls: Position X/Y/Z, Rotation° X/Y/Z (step 1), and a single uniform Scale input (clamped ≥ 0.0001, step 0.1).
  • Capture IBL from mesh — captures a 360° image-based-lighting probe from the mesh center and adds it to the IBL list.

Gizmo toolbar (shown only when something is selected)

  • Mode buttons: Translate / Rotate / Scale (T/R/S; active = indigo).
  • Space toggle: World (W) vs Local (L) (Local shown amber).

Lights tab

  • Add via footer buttons: Point / Spot / Rect (empty state: No lights yet).
  • Each row: visibility eye toggle, name + type label, expand chevron, delete (X); selectable (indigo border when selected).
  • Common expanded controls: Color picker, Intensity slider (0–100, step 0.5, value shown), and Position X/Y/Z.
  • Spot extras: Move Light vs Move Target selector (chooses which the gizmo affects), a Move light + target together lock checkbox, Target X/Y/Z, Angle° slider (1–89°, radian-converted), Penumbra slider (0–1, step 0.05).
  • Rect (area) extras: Width / Height inputs (each clamped ≥ 0.1) and Rotation° X/Y/Z (step 1).

IBL tab

  • Lists captured image-based-lighting probes; footer + Capture IBL at Camera captures a 360° probe from the current camera position (empty state: No IBLs yet. Capture from mesh or camera.).
  • Each row: visibility eye toggle, name, expand chevron, delete (X).
  • Expanded controls: Position X/Y/Z; Radius slider (0–50, step 0.5; 0 displays as = infinite/global); Ramp slider (0–1, shown only when radius > 0) for falloff; Intensity slider (0–5, step 0.05).
  • HDR color grade: negative-capable Lift (−2…2), Gain (−2…10), and Gamma (0.1…5) sliders, each paired with a numeric input.

Inputs use raw-string local state so you can type intermediate/negative values (-, 1.) before parsing; values display rounded to 3 decimals. All controls carry nodrag nopan so slider/keyboard interaction never pans or drags the underlying canvas node.

Animation & timeline (keyframe camera system)

  • Keyframes store camera position (Vector3), rotation (Quaternion), and vertical FOV (degrees) at a normalized time in [0,1].
  • Per-keyframe interpolation mode (applies to the segment starting at that keyframe): step, linear, easeInOut, smooth.
  • Full scene-state snapshot per keyframe (SceneSnapshot): splat transform, meshes (id/transform/visible), lights, IBLs, orbit target — so geometry, visibility, and lights animate alongside the camera.
  • Interpolation:
    • Position: Catmull-Rom spline (smooth / easeInOut) using 4 control points with clamped-duplicate endpoints; straight-line lerp for linear / step.
    • Rotation: quaternion SLERP between the two bracketing keyframes (no cross-key spline smoothing).
    • FOV: linear lerp between bracketing keyframes.
    • easeInOutCubic applied to local segment time in easeInOut mode; step pins local time to 0 (holds start value); linear/smooth use raw local time.
  • Path evaluation (evaluateCameraPath) clamps: first keyframe before range, last after range, single keyframe directly, null if none.
  • Scene evaluation (evaluateSceneAtFrame) mirrors camera bracketing; returns null unless a keyframe carries scene data (backward-compatible with camera-only / older saves). Generic value interpolation: numbers lerp, booleans/non-color strings step, #rrggbb colors blend per channel, arrays/objects recurse, missing keys hold prior value; entities matched by id, unmatched entities held. IBLs are captured and serialized but not applied live during playback (too expensive per-frame).
  • Keyframe CRUD:addKeyframe (replaces a keyframe within ~half-frame tolerance, re-sorts), removeKeyframe, updateKeyframe (partial merge + re-sort); cloning deep-copies position/quaternion but shares the scene snapshot by reference.
  • Serialization:serializePath / deserializePath — position as [x,y,z], quaternion as [x,y,z,w]; deserialization tolerant of legacy {x,y,z} / {x,y,z,w} object forms.
  • Helpers:frameToTime / timeToFrame (clamped), createEmptyPath (default 120 frames @ 25 fps), getPathDurationSeconds.
  • Interactive canvas timeline: device-pixel-ratio aware, ResizeObserver-driven redraw. Draws track background, auto-spaced frame ticks with labels, yellow keyframe diamonds (selected = orange with white outline), and a red playhead bar with triangle marker.
  • Playback controls: play/stop toggle, loop toggle, previous/next keyframe (jump to nearest keyframe before/after current frame and select it), add keyframe, delete selected keyframe (disabled when none selected).
  • Scrubbing: click/drag empty track to move the playhead. Keyframe dragging: drag a diamond to a new time (10px hit radius, topmost-first hit test).
  • Interpolation selector (STEP / LIN / EASE / SMOOTH) shown only when a keyframe is selected (defaults to smooth when unset).
  • Duration input (min 2, max 9999 frames), FPS dropdown (12 / 24 / 25 / 30 / 60), and live readout: currentFrame / lastFrame · Xs plus N keys.

Post-processing effects (real-time)

Both effects live in the Camera panel with an On/Off toggle and a Reset button (zeroes the coefficients / restores defaults), and reveal their parameter controls only when enabled.

  • Depth-of-field — thin-lens circle-of-confusion blur consuming a color texture and a linearized depth texture (R = Z metres, ≤ 0 = background).
    • Scatter-as-gather weighting prevents in-focus foreground bleeding onto blurred backgrounds.
    • 24-tap golden-angle Vogel-spiral disk sampler (GOLDEN = 2.39996323) with sqrt(t) radial mapping for uniform density; gather radius floored at 1px for cheap AA even in focus.
    • CoC model: D = f/N; CoC_mm = f·D·|Z−S| / (Z·(S − f/1000)) reconciled to metres; CoC_px = CoC_mm · pxPerMm, clamped to [0, maxBlurPx]; singularity guard denom = max(Z·(focusM − focalMm·0.001), 1e-4) (clamp focusM ≥ focalMm/1000 + ε).
    • Uniforms: focalMm (50), apertureMm (50/2.8), focusM (3.0 m), pxPerMm (80 = passWidth/sensorWidthMm), maxBlurPx (60), showCoC toggle, enabled toggle.
    • CoC debug overlay (showCoC): heatmap of |CoC_px|/maxBlurPx — green at the focal plane → yellow → red at heavy defocus.
  • Lens distortion — forward Brown-Conrady (OpenCV) via inverse mapping with 5 Newton iterations (sub-pixel accuracy for k1 ≲ 0.5).
    • Radial factor 1 + k1·r² + k2·r²²; tangential dx = 2·p1·u·v + p2·(r²+2u²), dy = p1·(r²+2v²) + 2·p2·u·v. Handles radial k1,k2 + tangential p1,p2 only (no k3 / fisheye).
    • Out-of-bounds source samples render opaque black so the framing edge is unambiguous.
    • Uniforms: fx/fy (1000), cx/cy (0.5), k1/k2/p1/p2 (0), enabled toggle.
    • computeFovMargin() estimates the extra FOV coverage a distortion pass needs by forward-distorting the worst image corner, returning a focal-length multiplier ≥ 1, capped at 2.0 — superseded by a measured DISTORTION_SCALE when available.

Video & depth export

Camera-path export animating the camera along keyframes via evaluateCameraPath().

  • Output modes: RGB only, Depth only, or Both (RGB + depth as two separate MP4 blobs in VideoExportResult).
  • Resolutions: 1280×720 (HD), 1920×1080 (Full HD, default), 3840×2160 (4K).
  • FPS: 12 / 24 / 25 / 30 / 60.
  • Total frame count: user-set (min 2, max 9999) with live duration readout (durationFrames / fps).
  • Quality / codec presets (all H.264):H.264 20 Mbps, H.264 HQ 50 Mbps, H.264 Max 100 Mbps (DEFAULT_BITRATE = 20 Mbps).
  • Bake DoF — optional per-frame depth-of-field pass using the viewer's live aperture/focus/sensor settings; off by default (~1× extra splat-render cost per frame).
  • COLMAP camera data toggle (cameras.txt + images.txt), on by default.
  • Dual encoder backends: primary WebCodecsVideoEncoder + mp4-muxer, with automatic fallback to MediaRecorder if WebCodecs is unavailable or throws (logged via console.warn).
    • WebCodecs: MP4, codec avc1.640028 (H.264 High L4.0), latencyMode: "quality", keyframe every 2 s (fps·2).
    • MediaRecorder fallback: captureStream(0) with manual requestFrame() per frame and real-time pacing (1000/fps ms); MIME prefers MP4/avc1 then falls through webm vp9/vp8.
  • Global depth-range scan: when depth is needed, a first pass walks all frames to find global min/max depth so every depth frame normalizes against one consistent range.
  • Post-processing baked into export: generic postProcess.apply (reused for both DoF and lens distortion) renders into srcTarget then a fullscreen quad into dstTarget; postProcess.prepareCamera adds per-frame FOV margin (restored after each frame).
  • Per-frame scene animation via applySceneAtFrame() so splat/mesh/light transforms and visibility animate, not just the camera.
  • Progress via onProgress(frame, totalFrames), scaled across scan + render (+ encode) passes; UI shows a progress bar with Rendering frame X/total and percentage.
  • Robustness: even-dimension enforcement (ensureEvenDimension — nearest lower even ≥ 2, returns 0 for non-finite/≤0); full camera-state restore in a finally block (position, quaternion, fov, aspect, renderer size) plus render-target disposal; WebGL vertical flip of read-back pixels (flipVerticallyInto); safeReadPixels unbinds any stray PIXEL_PACK_BUFFER before readRenderTargetPixels.
  • Preconditions: ≥ 2 keyframes anddurationFrames > 0 — enforced in the UI (Export disabled, warning banner "Add at least 2 keyframes before exporting") and in the backend (both encoders throw "Need at least 2 keyframes and > 0 frames to export video").

COLMAP import / export

  • Export: writes the camera path as standard COLMAP cameras.txt + images.txt + empty points3D.txt, bundled in a ZIP (JSZip) — interoperable with COLMAP, 3DGS trainers, and NerfStudio.
    • Single shared PINHOLE camera: focalLengthPx = (focalLengthMm/sensorWidthMm)·width, fx=fy=focalLengthPx, cx=width/2, cy=height/2.
    • Per-frame images.txt line IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME (world→camera R and t) with an empty POINTS2D line after each; image names frame_00000.png; one image per durationFrames.
  • Import: reads cameras.txt + images.txt (+ optional Nodos extras.txt sidecar) from a ZIP and produces a CameraPath — one keyframe per pose plus parsed intrinsics.
    • Per-keyframe FOV from intrinsics: fov = 2·atan(height/(2·fy)) degrees (fallback 60° when intrinsics missing).
    • Poses sorted by IMAGE_ID; keyframe times distributed uniformly across [0,1].
    • Parses camera models PINHOLE, SIMPLE_PINHOLE, OPENCV, RADIAL, SIMPLE_RADIAL; unrecognized models are skipped (best-effort fallback to PINHOLE defaults, distortion dropped).
    • Extracts Brown-Conrady coefficients: OPENCV → k1,k2,p1,p2; RADIAL → k1,k2; SIMPLE_RADIAL → k1.
    • Nodos extras.txt: reads DISTORTION_SCALE (column 6) into cameraParams.distortionScale as an authoritative FOV-margin multiplier overriding k1/k2 estimation.
  • World-frame conventions (both directions; inverse is the transpose):
    • y-up — identity (glTF / Three.js / Maya / Unity)
    • y-down — 180° about X (raw COLMAP / OpenCV / vanilla SfM) — default for import and export
    • z-up — −90° about X (Blender / Unreal / RealityCapture / Metashape)
    • COLMAP camera frame is spec-fixed RDF (+X right, +Y down, +Z forward); Three.js is RUB. The camera-axis change D = diag(1,−1,−1) (180° about X) is applied on the camera side in both directions.

Capture (postMessage)

  • Screenshot capture — aspect-ratio cropped, cinematic naming.
  • Optional same-origin postMessage capture-back (lets an embedding host receive captures); worldId is echoed back with captures.

Develop

npm install
npm run dev

Open with a CORS-enabled splat:

http://localhost:5173/?url=<https URL of a .ply or .spz>&name=test.ply

URL params

  • url — splat URL (.ply / .spz), must be CORS-enabled
  • name — display name / download filename
  • worldId — opaque id echoed back with captures
  • lens — focal length in mm (e.g. 75)
  • sensor — sensor width in mm as a number (e.g. 33.7)
  • gsDir — default folder for the Save Scene dialog (host integration; needs the host file API)
  • projectDir — project folder used to derive default camera.json / frame-render paths (host integration; needs the host file API)

Keyboard shortcuts

Viewer shortcuts are suppressed while typing in an input/select/textarea.

  • WASD — move (Fly mode); Q / E — down / up; Shift — speed boost; mouse-drag — look
  • F — toggle Fly ⇄ Orbit navigation
  • H — show / hide the controls panels
  • T — show / hide the timeline
  • K — add keyframe
  • Del / Backspace — delete the selected keyframe
  • Ctrl/Cmd + Z — undo (scene/keyframe edits)
  • Escape — cancel/close the file dialog
  • Enter (path input) — navigate to the typed path; Enter (save Name field) — confirm save
  • Double-click a file (open mode) — open it immediately

Capture/screenshot is button-only — there is no keyboard shortcut for it.

Build

npm run build # outputs dist/ with relative asset paths (base: "./")

The build is self-contained and path-relative, so dist/ can be served from any static host, including under an arbitrary subpath. Keep base: "./" in vite.config.ts for subpath hosting to work.

Hosting & embedding

The build is static and path-relative, so dist/ can be served from any web server, under any subpath. Two common ways to consume the viewer:

  • One hosted build, embedded by apps — deploy dist/ once and embed it (iframe or popup window) from your apps. If an embedding app runs on a different origin, reverse-proxy the viewer path (e.g. /viewer → https://your-host/viewer-build/) so the viewer appears same-origin to that app — this is what keeps blob: splat URLs, the sessionStorage state handoff, and the postMessage capture-back working (all three are same-origin-only). Rebuild once and every consumer reflects it on next load.
    • Versioning tip: serve versioned paths (/viewer/vN/) so a consumer can pin a known-good build (with a shared build, a regression otherwise hits every consumer at once).
  • As a package — install the repo as a git dependency and render the exported React component; see Consuming as a dependency below.

This is a static client-only build — there is no viewer server/API. "Hosted" just means the static files live at one URL.

Optional host file API

Disk-based scene save/open (the FileDialog), sidecar splat restore, and camera.json export call three endpoints that an embedding host may provide — the standalone build has no server, so these UI paths are inert unless the host implements them (same-origin):

EndpointReturns
GET /api/list-directory?path=<dir>{ success: true, entries: [{ name, type: "file" | "directory" }] }
GET /api/read-file?path=<absolute path>raw file bytes
POST /api/write-file?path=<absolute path> (body = file content){ success: true }
POST /api/save-generation (multipart form: file, directoryPath, customFilename, createDirectory)any 2xx

Everything else (drag-drop loading, ?url= loading, browser downloads, video/COLMAP export) works without them. Video/COLMAP exports always download in the browser; when /api/save-generation exists they are additionally mirrored to the host (best-effort — failures only log).

Host integration (popup embedding)

When another app opens the viewer as a popup/iframe (same-origin — reverse-proxy if needed):

  • Open the viewer with ?worldId=<opaque id> (plus url/name/lens/sensor as needed). ?url= accepts any CORS-enabled URL, including same-origin blob: URLs.
  • State handoff: before opening, the host may write a serialized viewer state to sessionStorage["splat-viewer-state-<worldId>"]; the viewer restores it on load. While running, the viewer auto-posts state changes back to window.opener as { type: "splat-viewer-state", worldId, state } (same-origin targeted).
  • Capture-back: screenshots post to window.opener as { type: "worldlabs-capture", worldId, image, depthImage, filename, width, height } (PNG data URLs). With no opener, captures download directly instead.

Architecture invariant

src/ must use only relative imports + npm packages — no @/ or next/ imports. This keeps the source portable so it builds under this Vite root and transpiles cleanly inside a consumer's bundler (e.g. Next's transpilePackages). FileDialog.tsx and src/lib/cinemaCameraPresets.ts are deliberately self-contained (no shared/aliased imports) for this reason.

Consuming as a dependency

The package entry is the TS/TSX source (exportssrc/SplatViewer.tsx), and react / react-dom / three / @sparkjsdev/spark are peerDependencies so the host supplies a single copy (a duplicate React breaks hooks; a duplicate three breaks instanceof).

// consumer package.json"dependencies": { "splat-viewer": "github:gitcapoom/capoom-splat-viewer#main" }

The package is intentionally not published to npm ("private": true guards against accidental publish) — consume it via the git URL above.

importSplatViewerfrom"splat-viewer";

The consumer's bundler must transpile the package source (in Next: transpilePackages: ["splat-viewer"]). Because the component uses Tailwind utility classes, a Tailwind v4 host must also scan the package (@source ".../node_modules/splat-viewer/src";). Pick up updates with npm update splat-viewer, or pin a commit via #<sha>.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Splat Viewer

Standalone, client-only Gaussian Splat viewer (three.js + @sparkjsdev/spark), driven by URL params plus an optional same-origin postMessage capture-back. Everything runs in the browser — no server-side rendering, no upload of splat data.

This is a standalone project. Use it directly (npm run dev, or serve the static dist/ build), or embed it in another app — either as one hosted build behind a same-origin path (see Hosting & embedding) or installed as a git dependency exposing the SplatViewer React component (see Consuming as a dependency).

Features

Loading & files

  • SPZ / PLY splat loading — drag-drop, file picker, or ?url= param (URL must be CORS-enabled).
  • GLB / OBJ mesh loading — add polygonal meshes into the splat scene via the Scene panel's + Add Mesh (.glb / .obj) button or by dragging files onto the panel.
  • Scene save / load — a modal FileDialog reads and writes scene .json files through an optional host-provided file API (see Optional host file API below; not included in the standalone build):
    • Two modes: Save (returns directory + filename) and Open (returns an absolute file path); header reads Save scene / Open scene.
    • Editable path input with a Go button, a parent-folder (up) button, and a live directory listing.
    • Listing shows folders first (yellow folder icon) then files (file icon); files are filtered to the configured extension (default .json). Clicking a directory navigates into it.
    • Open: single-click selects (indigo highlight), double-click opens immediately, or confirm with Open (disabled until a file is selected).
    • Save: clicking an existing file reuses its base name; a Name: field (with the fixed extension suffix shown) sets the filename; Enter or Save confirms (disabled if blank).
    • Loading spinner while listing; error state with a Go to home directory fallback link; Empty folder message; current path shown in the footer (monospace, truncated).
    • Dismiss via backdrop click, Escape, or Cancel. Handles both Windows (\) and POSIX (/) separators, auto-detecting and normalizing trailing slashes.

Camera & optics (cinema presets)

  • Sensor presets (7): Super 35mm 24.89×18.66 (default), Full Frame 36.0×24.0, ARRI Alexa LF 36.70×25.54, RED Monstro VV 40.96×21.60, IMAX 65mm 70.41×52.63, Micro Four Thirds 17.30×13.0, APS-C 23.60×15.60 (mm).
  • Lens focal lengths (13 primes): 14, 18, 21, 24, 28, 35 (default), 40, 50, 75, 85, 100, 135, 200 mm.
  • Aspect-ratio presets (6): 2.39:1 Scope, 1.85:1 Flat, 16:9 (default), 4:3, 1.43:1 IMAX, 1:1 Square.
  • FOV math: horizontal FOV = 2·atan(sensorWidth / (2·focal)); the Three.js camera vertical FOV = 2·atan(tan(hFOV/2) / aspect).
  • Summary/filename helpers:getCameraSummary() (e.g. "ARRI Alexa LF · 85mm · 2.39:1 Scope · 15.2° vFOV") and getCameraFilenameSegment() (e.g. Super35mm_35mm).
  • Camera Reset — one-click restore to the exact pose the camera had when the file loaded.
  • Far clip control — manual override for the camera far plane (fixes black sky on large splats). Auto default is distance × 1000.
  • Camera scale — log slider (0.001 → 1000) that multiplies camera-track translations so a cm-scale COLMAP pairs with a metre-scale splat without resizing the splat (pure translation rescale). Reset (↺) returns to 1.0.
  • Coordinate system — dropdown selecting the capture's world-frame convention (COLMAP/OpenCV Y-down, glTF Y-up, Blender Z-up). Applied to imported/exported COLMAP tracks and to PLY splats at load (SPZ uses its embedded metadata).

Navigation

  • Fly mode (default) — WASD to move, Q/E down/up, Shift for a ~3× speed boost, mouse-drag to look. Roll is preserved so restored/scrubbed poses keep their full orientation.
  • Orbit mode — click-drag to orbit around the target, with damping. Toggle Fly ⇄ Orbit with F or the Nav buttons.
  • Frame — re-fit the camera to the splat's bounding box (SuperSplat-style ¾ view).
  • Ground grid and origin axes (X red / Y green / Z blue) toggles.

Scene helpers, meshes & lights

The floating Scene panel (MeshPanel) is a scrollable dark card with three tabs, each showing a live count: Meshes (N), Lights (N), IBL (N).

Meshes tab

  • Add via footer + Add Mesh (.glb / .obj) or drag-drop (empty state: Drop .glb / .obj or click Add).
  • Each row: visibility eye toggle, clickable name (selects the mesh), an always-on-top overlay toggle, an expand chevron, and a delete (X) button.
  • Always-on-top overlay: ON draws the mesh through/over the splat (amber icon); OFF clips it behind the splat.
  • Selecting a mesh highlights the row (indigo border) and reveals the gizmo mode/space bar.
  • Expanded row transform controls: Position X/Y/Z, Rotation° X/Y/Z (step 1), and a single uniform Scale input (clamped ≥ 0.0001, step 0.1).
  • Capture IBL from mesh — captures a 360° image-based-lighting probe from the mesh center and adds it to the IBL list.

Gizmo toolbar (shown only when something is selected)

  • Mode buttons: Translate / Rotate / Scale (T/R/S; active = indigo).
  • Space toggle: World (W) vs Local (L) (Local shown amber).

Lights tab

  • Add via footer buttons: Point / Spot / Rect (empty state: No lights yet).
  • Each row: visibility eye toggle, name + type label, expand chevron, delete (X); selectable (indigo border when selected).
  • Common expanded controls: Color picker, Intensity slider (0–100, step 0.5, value shown), and Position X/Y/Z.
  • Spot extras: Move Light vs Move Target selector (chooses which the gizmo affects), a Move light + target together lock checkbox, Target X/Y/Z, Angle° slider (1–89°, radian-converted), Penumbra slider (0–1, step 0.05).
  • Rect (area) extras: Width / Height inputs (each clamped ≥ 0.1) and Rotation° X/Y/Z (step 1).

IBL tab

  • Lists captured image-based-lighting probes; footer + Capture IBL at Camera captures a 360° probe from the current camera position (empty state: No IBLs yet. Capture from mesh or camera.).
  • Each row: visibility eye toggle, name, expand chevron, delete (X).
  • Expanded controls: Position X/Y/Z; Radius slider (0–50, step 0.5; 0 displays as = infinite/global); Ramp slider (0–1, shown only when radius > 0) for falloff; Intensity slider (0–5, step 0.05).
  • HDR color grade: negative-capable Lift (−2…2), Gain (−2…10), and Gamma (0.1…5) sliders, each paired with a numeric input.

Inputs use raw-string local state so you can type intermediate/negative values (-, 1.) before parsing; values display rounded to 3 decimals. All controls carry nodrag nopan so slider/keyboard interaction never pans or drags the underlying canvas node.

Animation & timeline (keyframe camera system)

  • Keyframes store camera position (Vector3), rotation (Quaternion), and vertical FOV (degrees) at a normalized time in [0,1].
  • Per-keyframe interpolation mode (applies to the segment starting at that keyframe): step, linear, easeInOut, smooth.
  • Full scene-state snapshot per keyframe (SceneSnapshot): splat transform, meshes (id/transform/visible), lights, IBLs, orbit target — so geometry, visibility, and lights animate alongside the camera.
  • Interpolation:
    • Position: Catmull-Rom spline (smooth / easeInOut) using 4 control points with clamped-duplicate endpoints; straight-line lerp for linear / step.
    • Rotation: quaternion SLERP between the two bracketing keyframes (no cross-key spline smoothing).
    • FOV: linear lerp between bracketing keyframes.
    • easeInOutCubic applied to local segment time in easeInOut mode; step pins local time to 0 (holds start value); linear/smooth use raw local time.
  • Path evaluation (evaluateCameraPath) clamps: first keyframe before range, last after range, single keyframe directly, null if none.
  • Scene evaluation (evaluateSceneAtFrame) mirrors camera bracketing; returns null unless a keyframe carries scene data (backward-compatible with camera-only / older saves). Generic value interpolation: numbers lerp, booleans/non-color strings step, #rrggbb colors blend per channel, arrays/objects recurse, missing keys hold prior value; entities matched by id, unmatched entities held. IBLs are captured and serialized but not applied live during playback (too expensive per-frame).
  • Keyframe CRUD:addKeyframe (replaces a keyframe within ~half-frame tolerance, re-sorts), removeKeyframe, updateKeyframe (partial merge + re-sort); cloning deep-copies position/quaternion but shares the scene snapshot by reference.
  • Serialization:serializePath / deserializePath — position as [x,y,z], quaternion as [x,y,z,w]; deserialization tolerant of legacy {x,y,z} / {x,y,z,w} object forms.
  • Helpers:frameToTime / timeToFrame (clamped), createEmptyPath (default 120 frames @ 25 fps), getPathDurationSeconds.
  • Interactive canvas timeline: device-pixel-ratio aware, ResizeObserver-driven redraw. Draws track background, auto-spaced frame ticks with labels, yellow keyframe diamonds (selected = orange with white outline), and a red playhead bar with triangle marker.
  • Playback controls: play/stop toggle, loop toggle, previous/next keyframe (jump to nearest keyframe before/after current frame and select it), add keyframe, delete selected keyframe (disabled when none selected).
  • Scrubbing: click/drag empty track to move the playhead. Keyframe dragging: drag a diamond to a new time (10px hit radius, topmost-first hit test).
  • Interpolation selector (STEP / LIN / EASE / SMOOTH) shown only when a keyframe is selected (defaults to smooth when unset).
  • Duration input (min 2, max 9999 frames), FPS dropdown (12 / 24 / 25 / 30 / 60), and live readout: currentFrame / lastFrame · Xs plus N keys.

Post-processing effects (real-time)

Both effects live in the Camera panel with an On/Off toggle and a Reset button (zeroes the coefficients / restores defaults), and reveal their parameter controls only when enabled.

  • Depth-of-field — thin-lens circle-of-confusion blur consuming a color texture and a linearized depth texture (R = Z metres, ≤ 0 = background).
    • Scatter-as-gather weighting prevents in-focus foreground bleeding onto blurred backgrounds.
    • 24-tap golden-angle Vogel-spiral disk sampler (GOLDEN = 2.39996323) with sqrt(t) radial mapping for uniform density; gather radius floored at 1px for cheap AA even in focus.
    • CoC model: D = f/N; CoC_mm = f·D·|Z−S| / (Z·(S − f/1000)) reconciled to metres; CoC_px = CoC_mm · pxPerMm, clamped to [0, maxBlurPx]; singularity guard denom = max(Z·(focusM − focalMm·0.001), 1e-4) (clamp focusM ≥ focalMm/1000 + ε).
    • Uniforms: focalMm (50), apertureMm (50/2.8), focusM (3.0 m), pxPerMm (80 = passWidth/sensorWidthMm), maxBlurPx (60), showCoC toggle, enabled toggle.
    • CoC debug overlay (showCoC): heatmap of |CoC_px|/maxBlurPx — green at the focal plane → yellow → red at heavy defocus.
  • Lens distortion — forward Brown-Conrady (OpenCV) via inverse mapping with 5 Newton iterations (sub-pixel accuracy for k1 ≲ 0.5).
    • Radial factor 1 + k1·r² + k2·r²²; tangential dx = 2·p1·u·v + p2·(r²+2u²), dy = p1·(r²+2v²) + 2·p2·u·v. Handles radial k1,k2 + tangential p1,p2 only (no k3 / fisheye).
    • Out-of-bounds source samples render opaque black so the framing edge is unambiguous.
    • Uniforms: fx/fy (1000), cx/cy (0.5), k1/k2/p1/p2 (0), enabled toggle.
    • computeFovMargin() estimates the extra FOV coverage a distortion pass needs by forward-distorting the worst image corner, returning a focal-length multiplier ≥ 1, capped at 2.0 — superseded by a measured DISTORTION_SCALE when available.

Video & depth export

Camera-path export animating the camera along keyframes via evaluateCameraPath().

  • Output modes: RGB only, Depth only, or Both (RGB + depth as two separate MP4 blobs in VideoExportResult).
  • Resolutions: 1280×720 (HD), 1920×1080 (Full HD, default), 3840×2160 (4K).
  • FPS: 12 / 24 / 25 / 30 / 60.
  • Total frame count: user-set (min 2, max 9999) with live duration readout (durationFrames / fps).
  • Quality / codec presets (all H.264):H.264 20 Mbps, H.264 HQ 50 Mbps, H.264 Max 100 Mbps (DEFAULT_BITRATE = 20 Mbps).
  • Bake DoF — optional per-frame depth-of-field pass using the viewer's live aperture/focus/sensor settings; off by default (~1× extra splat-render cost per frame).
  • COLMAP camera data toggle (cameras.txt + images.txt), on by default.
  • Dual encoder backends: primary WebCodecsVideoEncoder + mp4-muxer, with automatic fallback to MediaRecorder if WebCodecs is unavailable or throws (logged via console.warn).
    • WebCodecs: MP4, codec avc1.640028 (H.264 High L4.0), latencyMode: "quality", keyframe every 2 s (fps·2).
    • MediaRecorder fallback: captureStream(0) with manual requestFrame() per frame and real-time pacing (1000/fps ms); MIME prefers MP4/avc1 then falls through webm vp9/vp8.
  • Global depth-range scan: when depth is needed, a first pass walks all frames to find global min/max depth so every depth frame normalizes against one consistent range.
  • Post-processing baked into export: generic postProcess.apply (reused for both DoF and lens distortion) renders into srcTarget then a fullscreen quad into dstTarget; postProcess.prepareCamera adds per-frame FOV margin (restored after each frame).
  • Per-frame scene animation via applySceneAtFrame() so splat/mesh/light transforms and visibility animate, not just the camera.
  • Progress via onProgress(frame, totalFrames), scaled across scan + render (+ encode) passes; UI shows a progress bar with Rendering frame X/total and percentage.
  • Robustness: even-dimension enforcement (ensureEvenDimension — nearest lower even ≥ 2, returns 0 for non-finite/≤0); full camera-state restore in a finally block (position, quaternion, fov, aspect, renderer size) plus render-target disposal; WebGL vertical flip of read-back pixels (flipVerticallyInto); safeReadPixels unbinds any stray PIXEL_PACK_BUFFER before readRenderTargetPixels.
  • Preconditions: ≥ 2 keyframes anddurationFrames > 0 — enforced in the UI (Export disabled, warning banner "Add at least 2 keyframes before exporting") and in the backend (both encoders throw "Need at least 2 keyframes and > 0 frames to export video").

COLMAP import / export

  • Export: writes the camera path as standard COLMAP cameras.txt + images.txt + empty points3D.txt, bundled in a ZIP (JSZip) — interoperable with COLMAP, 3DGS trainers, and NerfStudio.
    • Single shared PINHOLE camera: focalLengthPx = (focalLengthMm/sensorWidthMm)·width, fx=fy=focalLengthPx, cx=width/2, cy=height/2.
    • Per-frame images.txt line IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME (world→camera R and t) with an empty POINTS2D line after each; image names frame_00000.png; one image per durationFrames.
  • Import: reads cameras.txt + images.txt (+ optional Nodos extras.txt sidecar) from a ZIP and produces a CameraPath — one keyframe per pose plus parsed intrinsics.
    • Per-keyframe FOV from intrinsics: fov = 2·atan(height/(2·fy)) degrees (fallback 60° when intrinsics missing).
    • Poses sorted by IMAGE_ID; keyframe times distributed uniformly across [0,1].
    • Parses camera models PINHOLE, SIMPLE_PINHOLE, OPENCV, RADIAL, SIMPLE_RADIAL; unrecognized models are skipped (best-effort fallback to PINHOLE defaults, distortion dropped).
    • Extracts Brown-Conrady coefficients: OPENCV → k1,k2,p1,p2; RADIAL → k1,k2; SIMPLE_RADIAL → k1.
    • Nodos extras.txt: reads DISTORTION_SCALE (column 6) into cameraParams.distortionScale as an authoritative FOV-margin multiplier overriding k1/k2 estimation.
  • World-frame conventions (both directions; inverse is the transpose):
    • y-up — identity (glTF / Three.js / Maya / Unity)
    • y-down — 180° about X (raw COLMAP / OpenCV / vanilla SfM) — default for import and export
    • z-up — −90° about X (Blender / Unreal / RealityCapture / Metashape)
    • COLMAP camera frame is spec-fixed RDF (+X right, +Y down, +Z forward); Three.js is RUB. The camera-axis change D = diag(1,−1,−1) (180° about X) is applied on the camera side in both directions.

Capture (postMessage)

  • Screenshot capture — aspect-ratio cropped, cinematic naming.
  • Optional same-origin postMessage capture-back (lets an embedding host receive captures); worldId is echoed back with captures.

Develop

npm install
npm run dev

Open with a CORS-enabled splat:

http://localhost:5173/?url=<https URL of a .ply or .spz>&name=test.ply

URL params

  • url — splat URL (.ply / .spz), must be CORS-enabled
  • name — display name / download filename
  • worldId — opaque id echoed back with captures
  • lens — focal length in mm (e.g. 75)
  • sensor — sensor width in mm as a number (e.g. 33.7)
  • gsDir — default folder for the Save Scene dialog (host integration; needs the host file API)
  • projectDir — project folder used to derive default camera.json / frame-render paths (host integration; needs the host file API)

Keyboard shortcuts

Viewer shortcuts are suppressed while typing in an input/select/textarea.

  • WASD — move (Fly mode); Q / E — down / up; Shift — speed boost; mouse-drag — look
  • F — toggle Fly ⇄ Orbit navigation
  • H — show / hide the controls panels
  • T — show / hide the timeline
  • K — add keyframe
  • Del / Backspace — delete the selected keyframe
  • Ctrl/Cmd + Z — undo (scene/keyframe edits)
  • Escape — cancel/close the file dialog
  • Enter (path input) — navigate to the typed path; Enter (save Name field) — confirm save
  • Double-click a file (open mode) — open it immediately

Capture/screenshot is button-only — there is no keyboard shortcut for it.

Build

npm run build # outputs dist/ with relative asset paths (base: "./")

The build is self-contained and path-relative, so dist/ can be served from any static host, including under an arbitrary subpath. Keep base: "./" in vite.config.ts for subpath hosting to work.

Hosting & embedding

The build is static and path-relative, so dist/ can be served from any web server, under any subpath. Two common ways to consume the viewer:

  • One hosted build, embedded by apps — deploy dist/ once and embed it (iframe or popup window) from your apps. If an embedding app runs on a different origin, reverse-proxy the viewer path (e.g. /viewer → https://your-host/viewer-build/) so the viewer appears same-origin to that app — this is what keeps blob: splat URLs, the sessionStorage state handoff, and the postMessage capture-back working (all three are same-origin-only). Rebuild once and every consumer reflects it on next load.
    • Versioning tip: serve versioned paths (/viewer/vN/) so a consumer can pin a known-good build (with a shared build, a regression otherwise hits every consumer at once).
  • As a package — install the repo as a git dependency and render the exported React component; see Consuming as a dependency below.

This is a static client-only build — there is no viewer server/API. "Hosted" just means the static files live at one URL.

Optional host file API

Disk-based scene save/open (the FileDialog), sidecar splat restore, and camera.json export call three endpoints that an embedding host may provide — the standalone build has no server, so these UI paths are inert unless the host implements them (same-origin):

EndpointReturns
GET /api/list-directory?path=<dir>{ success: true, entries: [{ name, type: "file" | "directory" }] }
GET /api/read-file?path=<absolute path>raw file bytes
POST /api/write-file?path=<absolute path> (body = file content){ success: true }
POST /api/save-generation (multipart form: file, directoryPath, customFilename, createDirectory)any 2xx

Everything else (drag-drop loading, ?url= loading, browser downloads, video/COLMAP export) works without them. Video/COLMAP exports always download in the browser; when /api/save-generation exists they are additionally mirrored to the host (best-effort — failures only log).

Host integration (popup embedding)

When another app opens the viewer as a popup/iframe (same-origin — reverse-proxy if needed):

  • Open the viewer with ?worldId=<opaque id> (plus url/name/lens/sensor as needed). ?url= accepts any CORS-enabled URL, including same-origin blob: URLs.
  • State handoff: before opening, the host may write a serialized viewer state to sessionStorage["splat-viewer-state-<worldId>"]; the viewer restores it on load. While running, the viewer auto-posts state changes back to window.opener as { type: "splat-viewer-state", worldId, state } (same-origin targeted).
  • Capture-back: screenshots post to window.opener as { type: "worldlabs-capture", worldId, image, depthImage, filename, width, height } (PNG data URLs). With no opener, captures download directly instead.

Architecture invariant

src/ must use only relative imports + npm packages — no @/ or next/ imports. This keeps the source portable so it builds under this Vite root and transpiles cleanly inside a consumer's bundler (e.g. Next's transpilePackages). FileDialog.tsx and src/lib/cinemaCameraPresets.ts are deliberately self-contained (no shared/aliased imports) for this reason.

Consuming as a dependency

The package entry is the TS/TSX source (exportssrc/SplatViewer.tsx), and react / react-dom / three / @sparkjsdev/spark are peerDependencies so the host supplies a single copy (a duplicate React breaks hooks; a duplicate three breaks instanceof).

// consumer package.json"dependencies": { "splat-viewer": "github:gitcapoom/capoom-splat-viewer#main" }

The package is intentionally not published to npm ("private": true guards against accidental publish) — consume it via the git URL above.

importSplatViewerfrom"splat-viewer";

The consumer's bundler must transpile the package source (in Next: transpilePackages: ["splat-viewer"]). Because the component uses Tailwind utility classes, a Tailwind v4 host must also scan the package (@source ".../node_modules/splat-viewer/src";). Pick up updates with npm update splat-viewer, or pin a commit via #<sha>.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Splat Viewer

Standalone, client-only Gaussian Splat viewer (three.js + @sparkjsdev/spark), driven by URL params plus an optional same-origin postMessage capture-back. Everything runs in the browser — no server-side rendering, no upload of splat data.

This is a standalone project. Use it directly (npm run dev, or serve the static dist/ build), or embed it in another app — either as one hosted build behind a same-origin path (see Hosting & embedding) or installed as a git dependency exposing the SplatViewer React component (see Consuming as a dependency).

Features

Loading & files

  • SPZ / PLY splat loading — drag-drop, file picker, or ?url= param (URL must be CORS-enabled).
  • GLB / OBJ mesh loading — add polygonal meshes into the splat scene via the Scene panel's + Add Mesh (.glb / .obj) button or by dragging files onto the panel.
  • Scene save / load — a modal FileDialog reads and writes scene .json files through an optional host-provided file API (see Optional host file API below; not included in the standalone build):
    • Two modes: Save (returns directory + filename) and Open (returns an absolute file path); header reads Save scene / Open scene.
    • Editable path input with a Go button, a parent-folder (up) button, and a live directory listing.
    • Listing shows folders first (yellow folder icon) then files (file icon); files are filtered to the configured extension (default .json). Clicking a directory navigates into it.
    • Open: single-click selects (indigo highlight), double-click opens immediately, or confirm with Open (disabled until a file is selected).
    • Save: clicking an existing file reuses its base name; a Name: field (with the fixed extension suffix shown) sets the filename; Enter or Save confirms (disabled if blank).
    • Loading spinner while listing; error state with a Go to home directory fallback link; Empty folder message; current path shown in the footer (monospace, truncated).
    • Dismiss via backdrop click, Escape, or Cancel. Handles both Windows (\) and POSIX (/) separators, auto-detecting and normalizing trailing slashes.

Camera & optics (cinema presets)

  • Sensor presets (7): Super 35mm 24.89×18.66 (default), Full Frame 36.0×24.0, ARRI Alexa LF 36.70×25.54, RED Monstro VV 40.96×21.60, IMAX 65mm 70.41×52.63, Micro Four Thirds 17.30×13.0, APS-C 23.60×15.60 (mm).
  • Lens focal lengths (13 primes): 14, 18, 21, 24, 28, 35 (default), 40, 50, 75, 85, 100, 135, 200 mm.
  • Aspect-ratio presets (6): 2.39:1 Scope, 1.85:1 Flat, 16:9 (default), 4:3, 1.43:1 IMAX, 1:1 Square.
  • FOV math: horizontal FOV = 2·atan(sensorWidth / (2·focal)); the Three.js camera vertical FOV = 2·atan(tan(hFOV/2) / aspect).
  • Summary/filename helpers:getCameraSummary() (e.g. "ARRI Alexa LF · 85mm · 2.39:1 Scope · 15.2° vFOV") and getCameraFilenameSegment() (e.g. Super35mm_35mm).
  • Camera Reset — one-click restore to the exact pose the camera had when the file loaded.
  • Far clip control — manual override for the camera far plane (fixes black sky on large splats). Auto default is distance × 1000.
  • Camera scale — log slider (0.001 → 1000) that multiplies camera-track translations so a cm-scale COLMAP pairs with a metre-scale splat without resizing the splat (pure translation rescale). Reset (↺) returns to 1.0.
  • Coordinate system — dropdown selecting the capture's world-frame convention (COLMAP/OpenCV Y-down, glTF Y-up, Blender Z-up). Applied to imported/exported COLMAP tracks and to PLY splats at load (SPZ uses its embedded metadata).

Navigation

  • Fly mode (default) — WASD to move, Q/E down/up, Shift for a ~3× speed boost, mouse-drag to look. Roll is preserved so restored/scrubbed poses keep their full orientation.
  • Orbit mode — click-drag to orbit around the target, with damping. Toggle Fly ⇄ Orbit with F or the Nav buttons.
  • Frame — re-fit the camera to the splat's bounding box (SuperSplat-style ¾ view).
  • Ground grid and origin axes (X red / Y green / Z blue) toggles.

Scene helpers, meshes & lights

The floating Scene panel (MeshPanel) is a scrollable dark card with three tabs, each showing a live count: Meshes (N), Lights (N), IBL (N).

Meshes tab

  • Add via footer + Add Mesh (.glb / .obj) or drag-drop (empty state: Drop .glb / .obj or click Add).
  • Each row: visibility eye toggle, clickable name (selects the mesh), an always-on-top overlay toggle, an expand chevron, and a delete (X) button.
  • Always-on-top overlay: ON draws the mesh through/over the splat (amber icon); OFF clips it behind the splat.
  • Selecting a mesh highlights the row (indigo border) and reveals the gizmo mode/space bar.
  • Expanded row transform controls: Position X/Y/Z, Rotation° X/Y/Z (step 1), and a single uniform Scale input (clamped ≥ 0.0001, step 0.1).
  • Capture IBL from mesh — captures a 360° image-based-lighting probe from the mesh center and adds it to the IBL list.

Gizmo toolbar (shown only when something is selected)

  • Mode buttons: Translate / Rotate / Scale (T/R/S; active = indigo).
  • Space toggle: World (W) vs Local (L) (Local shown amber).

Lights tab

  • Add via footer buttons: Point / Spot / Rect (empty state: No lights yet).
  • Each row: visibility eye toggle, name + type label, expand chevron, delete (X); selectable (indigo border when selected).
  • Common expanded controls: Color picker, Intensity slider (0–100, step 0.5, value shown), and Position X/Y/Z.
  • Spot extras: Move Light vs Move Target selector (chooses which the gizmo affects), a Move light + target together lock checkbox, Target X/Y/Z, Angle° slider (1–89°, radian-converted), Penumbra slider (0–1, step 0.05).
  • Rect (area) extras: Width / Height inputs (each clamped ≥ 0.1) and Rotation° X/Y/Z (step 1).

IBL tab

  • Lists captured image-based-lighting probes; footer + Capture IBL at Camera captures a 360° probe from the current camera position (empty state: No IBLs yet. Capture from mesh or camera.).
  • Each row: visibility eye toggle, name, expand chevron, delete (X).
  • Expanded controls: Position X/Y/Z; Radius slider (0–50, step 0.5; 0 displays as = infinite/global); Ramp slider (0–1, shown only when radius > 0) for falloff; Intensity slider (0–5, step 0.05).
  • HDR color grade: negative-capable Lift (−2…2), Gain (−2…10), and Gamma (0.1…5) sliders, each paired with a numeric input.

Inputs use raw-string local state so you can type intermediate/negative values (-, 1.) before parsing; values display rounded to 3 decimals. All controls carry nodrag nopan so slider/keyboard interaction never pans or drags the underlying canvas node.

Animation & timeline (keyframe camera system)

  • Keyframes store camera position (Vector3), rotation (Quaternion), and vertical FOV (degrees) at a normalized time in [0,1].
  • Per-keyframe interpolation mode (applies to the segment starting at that keyframe): step, linear, easeInOut, smooth.
  • Full scene-state snapshot per keyframe (SceneSnapshot): splat transform, meshes (id/transform/visible), lights, IBLs, orbit target — so geometry, visibility, and lights animate alongside the camera.
  • Interpolation:
    • Position: Catmull-Rom spline (smooth / easeInOut) using 4 control points with clamped-duplicate endpoints; straight-line lerp for linear / step.
    • Rotation: quaternion SLERP between the two bracketing keyframes (no cross-key spline smoothing).
    • FOV: linear lerp between bracketing keyframes.
    • easeInOutCubic applied to local segment time in easeInOut mode; step pins local time to 0 (holds start value); linear/smooth use raw local time.
  • Path evaluation (evaluateCameraPath) clamps: first keyframe before range, last after range, single keyframe directly, null if none.
  • Scene evaluation (evaluateSceneAtFrame) mirrors camera bracketing; returns null unless a keyframe carries scene data (backward-compatible with camera-only / older saves). Generic value interpolation: numbers lerp, booleans/non-color strings step, #rrggbb colors blend per channel, arrays/objects recurse, missing keys hold prior value; entities matched by id, unmatched entities held. IBLs are captured and serialized but not applied live during playback (too expensive per-frame).
  • Keyframe CRUD:addKeyframe (replaces a keyframe within ~half-frame tolerance, re-sorts), removeKeyframe, updateKeyframe (partial merge + re-sort); cloning deep-copies position/quaternion but shares the scene snapshot by reference.
  • Serialization:serializePath / deserializePath — position as [x,y,z], quaternion as [x,y,z,w]; deserialization tolerant of legacy {x,y,z} / {x,y,z,w} object forms.
  • Helpers:frameToTime / timeToFrame (clamped), createEmptyPath (default 120 frames @ 25 fps), getPathDurationSeconds.
  • Interactive canvas timeline: device-pixel-ratio aware, ResizeObserver-driven redraw. Draws track background, auto-spaced frame ticks with labels, yellow keyframe diamonds (selected = orange with white outline), and a red playhead bar with triangle marker.
  • Playback controls: play/stop toggle, loop toggle, previous/next keyframe (jump to nearest keyframe before/after current frame and select it), add keyframe, delete selected keyframe (disabled when none selected).
  • Scrubbing: click/drag empty track to move the playhead. Keyframe dragging: drag a diamond to a new time (10px hit radius, topmost-first hit test).
  • Interpolation selector (STEP / LIN / EASE / SMOOTH) shown only when a keyframe is selected (defaults to smooth when unset).
  • Duration input (min 2, max 9999 frames), FPS dropdown (12 / 24 / 25 / 30 / 60), and live readout: currentFrame / lastFrame · Xs plus N keys.

Post-processing effects (real-time)

Both effects live in the Camera panel with an On/Off toggle and a Reset button (zeroes the coefficients / restores defaults), and reveal their parameter controls only when enabled.

  • Depth-of-field — thin-lens circle-of-confusion blur consuming a color texture and a linearized depth texture (R = Z metres, ≤ 0 = background).
    • Scatter-as-gather weighting prevents in-focus foreground bleeding onto blurred backgrounds.
    • 24-tap golden-angle Vogel-spiral disk sampler (GOLDEN = 2.39996323) with sqrt(t) radial mapping for uniform density; gather radius floored at 1px for cheap AA even in focus.
    • CoC model: D = f/N; CoC_mm = f·D·|Z−S| / (Z·(S − f/1000)) reconciled to metres; CoC_px = CoC_mm · pxPerMm, clamped to [0, maxBlurPx]; singularity guard denom = max(Z·(focusM − focalMm·0.001), 1e-4) (clamp focusM ≥ focalMm/1000 + ε).
    • Uniforms: focalMm (50), apertureMm (50/2.8), focusM (3.0 m), pxPerMm (80 = passWidth/sensorWidthMm), maxBlurPx (60), showCoC toggle, enabled toggle.
    • CoC debug overlay (showCoC): heatmap of |CoC_px|/maxBlurPx — green at the focal plane → yellow → red at heavy defocus.
  • Lens distortion — forward Brown-Conrady (OpenCV) via inverse mapping with 5 Newton iterations (sub-pixel accuracy for k1 ≲ 0.5).
    • Radial factor 1 + k1·r² + k2·r²²; tangential dx = 2·p1·u·v + p2·(r²+2u²), dy = p1·(r²+2v²) + 2·p2·u·v. Handles radial k1,k2 + tangential p1,p2 only (no k3 / fisheye).
    • Out-of-bounds source samples render opaque black so the framing edge is unambiguous.
    • Uniforms: fx/fy (1000), cx/cy (0.5), k1/k2/p1/p2 (0), enabled toggle.
    • computeFovMargin() estimates the extra FOV coverage a distortion pass needs by forward-distorting the worst image corner, returning a focal-length multiplier ≥ 1, capped at 2.0 — superseded by a measured DISTORTION_SCALE when available.

Video & depth export

Camera-path export animating the camera along keyframes via evaluateCameraPath().

  • Output modes: RGB only, Depth only, or Both (RGB + depth as two separate MP4 blobs in VideoExportResult).
  • Resolutions: 1280×720 (HD), 1920×1080 (Full HD, default), 3840×2160 (4K).
  • FPS: 12 / 24 / 25 / 30 / 60.
  • Total frame count: user-set (min 2, max 9999) with live duration readout (durationFrames / fps).
  • Quality / codec presets (all H.264):H.264 20 Mbps, H.264 HQ 50 Mbps, H.264 Max 100 Mbps (DEFAULT_BITRATE = 20 Mbps).
  • Bake DoF — optional per-frame depth-of-field pass using the viewer's live aperture/focus/sensor settings; off by default (~1× extra splat-render cost per frame).
  • COLMAP camera data toggle (cameras.txt + images.txt), on by default.
  • Dual encoder backends: primary WebCodecsVideoEncoder + mp4-muxer, with automatic fallback to MediaRecorder if WebCodecs is unavailable or throws (logged via console.warn).
    • WebCodecs: MP4, codec avc1.640028 (H.264 High L4.0), latencyMode: "quality", keyframe every 2 s (fps·2).
    • MediaRecorder fallback: captureStream(0) with manual requestFrame() per frame and real-time pacing (1000/fps ms); MIME prefers MP4/avc1 then falls through webm vp9/vp8.
  • Global depth-range scan: when depth is needed, a first pass walks all frames to find global min/max depth so every depth frame normalizes against one consistent range.
  • Post-processing baked into export: generic postProcess.apply (reused for both DoF and lens distortion) renders into srcTarget then a fullscreen quad into dstTarget; postProcess.prepareCamera adds per-frame FOV margin (restored after each frame).
  • Per-frame scene animation via applySceneAtFrame() so splat/mesh/light transforms and visibility animate, not just the camera.
  • Progress via onProgress(frame, totalFrames), scaled across scan + render (+ encode) passes; UI shows a progress bar with Rendering frame X/total and percentage.
  • Robustness: even-dimension enforcement (ensureEvenDimension — nearest lower even ≥ 2, returns 0 for non-finite/≤0); full camera-state restore in a finally block (position, quaternion, fov, aspect, renderer size) plus render-target disposal; WebGL vertical flip of read-back pixels (flipVerticallyInto); safeReadPixels unbinds any stray PIXEL_PACK_BUFFER before readRenderTargetPixels.
  • Preconditions: ≥ 2 keyframes anddurationFrames > 0 — enforced in the UI (Export disabled, warning banner "Add at least 2 keyframes before exporting") and in the backend (both encoders throw "Need at least 2 keyframes and > 0 frames to export video").

COLMAP import / export

  • Export: writes the camera path as standard COLMAP cameras.txt + images.txt + empty points3D.txt, bundled in a ZIP (JSZip) — interoperable with COLMAP, 3DGS trainers, and NerfStudio.
    • Single shared PINHOLE camera: focalLengthPx = (focalLengthMm/sensorWidthMm)·width, fx=fy=focalLengthPx, cx=width/2, cy=height/2.
    • Per-frame images.txt line IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME (world→camera R and t) with an empty POINTS2D line after each; image names frame_00000.png; one image per durationFrames.
  • Import: reads cameras.txt + images.txt (+ optional Nodos extras.txt sidecar) from a ZIP and produces a CameraPath — one keyframe per pose plus parsed intrinsics.
    • Per-keyframe FOV from intrinsics: fov = 2·atan(height/(2·fy)) degrees (fallback 60° when intrinsics missing).
    • Poses sorted by IMAGE_ID; keyframe times distributed uniformly across [0,1].
    • Parses camera models PINHOLE, SIMPLE_PINHOLE, OPENCV, RADIAL, SIMPLE_RADIAL; unrecognized models are skipped (best-effort fallback to PINHOLE defaults, distortion dropped).
    • Extracts Brown-Conrady coefficients: OPENCV → k1,k2,p1,p2; RADIAL → k1,k2; SIMPLE_RADIAL → k1.
    • Nodos extras.txt: reads DISTORTION_SCALE (column 6) into cameraParams.distortionScale as an authoritative FOV-margin multiplier overriding k1/k2 estimation.
  • World-frame conventions (both directions; inverse is the transpose):
    • y-up — identity (glTF / Three.js / Maya / Unity)
    • y-down — 180° about X (raw COLMAP / OpenCV / vanilla SfM) — default for import and export
    • z-up — −90° about X (Blender / Unreal / RealityCapture / Metashape)
    • COLMAP camera frame is spec-fixed RDF (+X right, +Y down, +Z forward); Three.js is RUB. The camera-axis change D = diag(1,−1,−1) (180° about X) is applied on the camera side in both directions.

Capture (postMessage)

  • Screenshot capture — aspect-ratio cropped, cinematic naming.
  • Optional same-origin postMessage capture-back (lets an embedding host receive captures); worldId is echoed back with captures.

Develop

npm install
npm run dev

Open with a CORS-enabled splat:

http://localhost:5173/?url=<https URL of a .ply or .spz>&name=test.ply

URL params

  • url — splat URL (.ply / .spz), must be CORS-enabled
  • name — display name / download filename
  • worldId — opaque id echoed back with captures
  • lens — focal length in mm (e.g. 75)
  • sensor — sensor width in mm as a number (e.g. 33.7)
  • gsDir — default folder for the Save Scene dialog (host integration; needs the host file API)
  • projectDir — project folder used to derive default camera.json / frame-render paths (host integration; needs the host file API)

Keyboard shortcuts

Viewer shortcuts are suppressed while typing in an input/select/textarea.

  • WASD — move (Fly mode); Q / E — down / up; Shift — speed boost; mouse-drag — look
  • F — toggle Fly ⇄ Orbit navigation
  • H — show / hide the controls panels
  • T — show / hide the timeline
  • K — add keyframe
  • Del / Backspace — delete the selected keyframe
  • Ctrl/Cmd + Z — undo (scene/keyframe edits)
  • Escape — cancel/close the file dialog
  • Enter (path input) — navigate to the typed path; Enter (save Name field) — confirm save
  • Double-click a file (open mode) — open it immediately

Capture/screenshot is button-only — there is no keyboard shortcut for it.

Build

npm run build # outputs dist/ with relative asset paths (base: "./")

The build is self-contained and path-relative, so dist/ can be served from any static host, including under an arbitrary subpath. Keep base: "./" in vite.config.ts for subpath hosting to work.

Hosting & embedding

The build is static and path-relative, so dist/ can be served from any web server, under any subpath. Two common ways to consume the viewer:

  • One hosted build, embedded by apps — deploy dist/ once and embed it (iframe or popup window) from your apps. If an embedding app runs on a different origin, reverse-proxy the viewer path (e.g. /viewer → https://your-host/viewer-build/) so the viewer appears same-origin to that app — this is what keeps blob: splat URLs, the sessionStorage state handoff, and the postMessage capture-back working (all three are same-origin-only). Rebuild once and every consumer reflects it on next load.
    • Versioning tip: serve versioned paths (/viewer/vN/) so a consumer can pin a known-good build (with a shared build, a regression otherwise hits every consumer at once).
  • As a package — install the repo as a git dependency and render the exported React component; see Consuming as a dependency below.

This is a static client-only build — there is no viewer server/API. "Hosted" just means the static files live at one URL.

Optional host file API

Disk-based scene save/open (the FileDialog), sidecar splat restore, and camera.json export call three endpoints that an embedding host may provide — the standalone build has no server, so these UI paths are inert unless the host implements them (same-origin):

EndpointReturns
GET /api/list-directory?path=<dir>{ success: true, entries: [{ name, type: "file" | "directory" }] }
GET /api/read-file?path=<absolute path>raw file bytes
POST /api/write-file?path=<absolute path> (body = file content){ success: true }
POST /api/save-generation (multipart form: file, directoryPath, customFilename, createDirectory)any 2xx

Everything else (drag-drop loading, ?url= loading, browser downloads, video/COLMAP export) works without them. Video/COLMAP exports always download in the browser; when /api/save-generation exists they are additionally mirrored to the host (best-effort — failures only log).

Host integration (popup embedding)

When another app opens the viewer as a popup/iframe (same-origin — reverse-proxy if needed):

  • Open the viewer with ?worldId=<opaque id> (plus url/name/lens/sensor as needed). ?url= accepts any CORS-enabled URL, including same-origin blob: URLs.
  • State handoff: before opening, the host may write a serialized viewer state to sessionStorage["splat-viewer-state-<worldId>"]; the viewer restores it on load. While running, the viewer auto-posts state changes back to window.opener as { type: "splat-viewer-state", worldId, state } (same-origin targeted).
  • Capture-back: screenshots post to window.opener as { type: "worldlabs-capture", worldId, image, depthImage, filename, width, height } (PNG data URLs). With no opener, captures download directly instead.

Architecture invariant

src/ must use only relative imports + npm packages — no @/ or next/ imports. This keeps the source portable so it builds under this Vite root and transpiles cleanly inside a consumer's bundler (e.g. Next's transpilePackages). FileDialog.tsx and src/lib/cinemaCameraPresets.ts are deliberately self-contained (no shared/aliased imports) for this reason.

Consuming as a dependency

The package entry is the TS/TSX source (exportssrc/SplatViewer.tsx), and react / react-dom / three / @sparkjsdev/spark are peerDependencies so the host supplies a single copy (a duplicate React breaks hooks; a duplicate three breaks instanceof).

// consumer package.json"dependencies": { "splat-viewer": "github:gitcapoom/capoom-splat-viewer#main" }

The package is intentionally not published to npm ("private": true guards against accidental publish) — consume it via the git URL above.

importSplatViewerfrom"splat-viewer";

The consumer's bundler must transpile the package source (in Next: transpilePackages: ["splat-viewer"]). Because the component uses Tailwind utility classes, a Tailwind v4 host must also scan the package (@source ".../node_modules/splat-viewer/src";). Pick up updates with npm update splat-viewer, or pin a commit via #<sha>.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Splat Viewer

Standalone, client-only Gaussian Splat viewer (three.js + @sparkjsdev/spark), driven by URL params plus an optional same-origin postMessage capture-back. Everything runs in the browser — no server-side rendering, no upload of splat data.

This is a standalone project. Use it directly (npm run dev, or serve the static dist/ build), or embed it in another app — either as one hosted build behind a same-origin path (see Hosting & embedding) or installed as a git dependency exposing the SplatViewer React component (see Consuming as a dependency).

Features

Loading & files

  • SPZ / PLY splat loading — drag-drop, file picker, or ?url= param (URL must be CORS-enabled).
  • GLB / OBJ mesh loading — add polygonal meshes into the splat scene via the Scene panel's + Add Mesh (.glb / .obj) button or by dragging files onto the panel.
  • Scene save / load — a modal FileDialog reads and writes scene .json files through an optional host-provided file API (see Optional host file API below; not included in the standalone build):
    • Two modes: Save (returns directory + filename) and Open (returns an absolute file path); header reads Save scene / Open scene.
    • Editable path input with a Go button, a parent-folder (up) button, and a live directory listing.
    • Listing shows folders first (yellow folder icon) then files (file icon); files are filtered to the configured extension (default .json). Clicking a directory navigates into it.
    • Open: single-click selects (indigo highlight), double-click opens immediately, or confirm with Open (disabled until a file is selected).
    • Save: clicking an existing file reuses its base name; a Name: field (with the fixed extension suffix shown) sets the filename; Enter or Save confirms (disabled if blank).
    • Loading spinner while listing; error state with a Go to home directory fallback link; Empty folder message; current path shown in the footer (monospace, truncated).
    • Dismiss via backdrop click, Escape, or Cancel. Handles both Windows (\) and POSIX (/) separators, auto-detecting and normalizing trailing slashes.

Camera & optics (cinema presets)

  • Sensor presets (7): Super 35mm 24.89×18.66 (default), Full Frame 36.0×24.0, ARRI Alexa LF 36.70×25.54, RED Monstro VV 40.96×21.60, IMAX 65mm 70.41×52.63, Micro Four Thirds 17.30×13.0, APS-C 23.60×15.60 (mm).
  • Lens focal lengths (13 primes): 14, 18, 21, 24, 28, 35 (default), 40, 50, 75, 85, 100, 135, 200 mm.
  • Aspect-ratio presets (6): 2.39:1 Scope, 1.85:1 Flat, 16:9 (default), 4:3, 1.43:1 IMAX, 1:1 Square.
  • FOV math: horizontal FOV = 2·atan(sensorWidth / (2·focal)); the Three.js camera vertical FOV = 2·atan(tan(hFOV/2) / aspect).
  • Summary/filename helpers:getCameraSummary() (e.g. "ARRI Alexa LF · 85mm · 2.39:1 Scope · 15.2° vFOV") and getCameraFilenameSegment() (e.g. Super35mm_35mm).
  • Camera Reset — one-click restore to the exact pose the camera had when the file loaded.
  • Far clip control — manual override for the camera far plane (fixes black sky on large splats). Auto default is distance × 1000.
  • Camera scale — log slider (0.001 → 1000) that multiplies camera-track translations so a cm-scale COLMAP pairs with a metre-scale splat without resizing the splat (pure translation rescale). Reset (↺) returns to 1.0.
  • Coordinate system — dropdown selecting the capture's world-frame convention (COLMAP/OpenCV Y-down, glTF Y-up, Blender Z-up). Applied to imported/exported COLMAP tracks and to PLY splats at load (SPZ uses its embedded metadata).

Navigation

  • Fly mode (default) — WASD to move, Q/E down/up, Shift for a ~3× speed boost, mouse-drag to look. Roll is preserved so restored/scrubbed poses keep their full orientation.
  • Orbit mode — click-drag to orbit around the target, with damping. Toggle Fly ⇄ Orbit with F or the Nav buttons.
  • Frame — re-fit the camera to the splat's bounding box (SuperSplat-style ¾ view).
  • Ground grid and origin axes (X red / Y green / Z blue) toggles.

Scene helpers, meshes & lights

The floating Scene panel (MeshPanel) is a scrollable dark card with three tabs, each showing a live count: Meshes (N), Lights (N), IBL (N).

Meshes tab

  • Add via footer + Add Mesh (.glb / .obj) or drag-drop (empty state: Drop .glb / .obj or click Add).
  • Each row: visibility eye toggle, clickable name (selects the mesh), an always-on-top overlay toggle, an expand chevron, and a delete (X) button.
  • Always-on-top overlay: ON draws the mesh through/over the splat (amber icon); OFF clips it behind the splat.
  • Selecting a mesh highlights the row (indigo border) and reveals the gizmo mode/space bar.
  • Expanded row transform controls: Position X/Y/Z, Rotation° X/Y/Z (step 1), and a single uniform Scale input (clamped ≥ 0.0001, step 0.1).
  • Capture IBL from mesh — captures a 360° image-based-lighting probe from the mesh center and adds it to the IBL list.

Gizmo toolbar (shown only when something is selected)

  • Mode buttons: Translate / Rotate / Scale (T/R/S; active = indigo).
  • Space toggle: World (W) vs Local (L) (Local shown amber).

Lights tab

  • Add via footer buttons: Point / Spot / Rect (empty state: No lights yet).
  • Each row: visibility eye toggle, name + type label, expand chevron, delete (X); selectable (indigo border when selected).
  • Common expanded controls: Color picker, Intensity slider (0–100, step 0.5, value shown), and Position X/Y/Z.
  • Spot extras: Move Light vs Move Target selector (chooses which the gizmo affects), a Move light + target together lock checkbox, Target X/Y/Z, Angle° slider (1–89°, radian-converted), Penumbra slider (0–1, step 0.05).
  • Rect (area) extras: Width / Height inputs (each clamped ≥ 0.1) and Rotation° X/Y/Z (step 1).

IBL tab

  • Lists captured image-based-lighting probes; footer + Capture IBL at Camera captures a 360° probe from the current camera position (empty state: No IBLs yet. Capture from mesh or camera.).
  • Each row: visibility eye toggle, name, expand chevron, delete (X).
  • Expanded controls: Position X/Y/Z; Radius slider (0–50, step 0.5; 0 displays as = infinite/global); Ramp slider (0–1, shown only when radius > 0) for falloff; Intensity slider (0–5, step 0.05).
  • HDR color grade: negative-capable Lift (−2…2), Gain (−2…10), and Gamma (0.1…5) sliders, each paired with a numeric input.

Inputs use raw-string local state so you can type intermediate/negative values (-, 1.) before parsing; values display rounded to 3 decimals. All controls carry nodrag nopan so slider/keyboard interaction never pans or drags the underlying canvas node.

Animation & timeline (keyframe camera system)

  • Keyframes store camera position (Vector3), rotation (Quaternion), and vertical FOV (degrees) at a normalized time in [0,1].
  • Per-keyframe interpolation mode (applies to the segment starting at that keyframe): step, linear, easeInOut, smooth.
  • Full scene-state snapshot per keyframe (SceneSnapshot): splat transform, meshes (id/transform/visible), lights, IBLs, orbit target — so geometry, visibility, and lights animate alongside the camera.
  • Interpolation:
    • Position: Catmull-Rom spline (smooth / easeInOut) using 4 control points with clamped-duplicate endpoints; straight-line lerp for linear / step.
    • Rotation: quaternion SLERP between the two bracketing keyframes (no cross-key spline smoothing).
    • FOV: linear lerp between bracketing keyframes.
    • easeInOutCubic applied to local segment time in easeInOut mode; step pins local time to 0 (holds start value); linear/smooth use raw local time.
  • Path evaluation (evaluateCameraPath) clamps: first keyframe before range, last after range, single keyframe directly, null if none.
  • Scene evaluation (evaluateSceneAtFrame) mirrors camera bracketing; returns null unless a keyframe carries scene data (backward-compatible with camera-only / older saves). Generic value interpolation: numbers lerp, booleans/non-color strings step, #rrggbb colors blend per channel, arrays/objects recurse, missing keys hold prior value; entities matched by id, unmatched entities held. IBLs are captured and serialized but not applied live during playback (too expensive per-frame).
  • Keyframe CRUD:addKeyframe (replaces a keyframe within ~half-frame tolerance, re-sorts), removeKeyframe, updateKeyframe (partial merge + re-sort); cloning deep-copies position/quaternion but shares the scene snapshot by reference.
  • Serialization:serializePath / deserializePath — position as [x,y,z], quaternion as [x,y,z,w]; deserialization tolerant of legacy {x,y,z} / {x,y,z,w} object forms.
  • Helpers:frameToTime / timeToFrame (clamped), createEmptyPath (default 120 frames @ 25 fps), getPathDurationSeconds.
  • Interactive canvas timeline: device-pixel-ratio aware, ResizeObserver-driven redraw. Draws track background, auto-spaced frame ticks with labels, yellow keyframe diamonds (selected = orange with white outline), and a red playhead bar with triangle marker.
  • Playback controls: play/stop toggle, loop toggle, previous/next keyframe (jump to nearest keyframe before/after current frame and select it), add keyframe, delete selected keyframe (disabled when none selected).
  • Scrubbing: click/drag empty track to move the playhead. Keyframe dragging: drag a diamond to a new time (10px hit radius, topmost-first hit test).
  • Interpolation selector (STEP / LIN / EASE / SMOOTH) shown only when a keyframe is selected (defaults to smooth when unset).
  • Duration input (min 2, max 9999 frames), FPS dropdown (12 / 24 / 25 / 30 / 60), and live readout: currentFrame / lastFrame · Xs plus N keys.

Post-processing effects (real-time)

Both effects live in the Camera panel with an On/Off toggle and a Reset button (zeroes the coefficients / restores defaults), and reveal their parameter controls only when enabled.

  • Depth-of-field — thin-lens circle-of-confusion blur consuming a color texture and a linearized depth texture (R = Z metres, ≤ 0 = background).
    • Scatter-as-gather weighting prevents in-focus foreground bleeding onto blurred backgrounds.
    • 24-tap golden-angle Vogel-spiral disk sampler (GOLDEN = 2.39996323) with sqrt(t) radial mapping for uniform density; gather radius floored at 1px for cheap AA even in focus.
    • CoC model: D = f/N; CoC_mm = f·D·|Z−S| / (Z·(S − f/1000)) reconciled to metres; CoC_px = CoC_mm · pxPerMm, clamped to [0, maxBlurPx]; singularity guard denom = max(Z·(focusM − focalMm·0.001), 1e-4) (clamp focusM ≥ focalMm/1000 + ε).
    • Uniforms: focalMm (50), apertureMm (50/2.8), focusM (3.0 m), pxPerMm (80 = passWidth/sensorWidthMm), maxBlurPx (60), showCoC toggle, enabled toggle.
    • CoC debug overlay (showCoC): heatmap of |CoC_px|/maxBlurPx — green at the focal plane → yellow → red at heavy defocus.
  • Lens distortion — forward Brown-Conrady (OpenCV) via inverse mapping with 5 Newton iterations (sub-pixel accuracy for k1 ≲ 0.5).
    • Radial factor 1 + k1·r² + k2·r²²; tangential dx = 2·p1·u·v + p2·(r²+2u²), dy = p1·(r²+2v²) + 2·p2·u·v. Handles radial k1,k2 + tangential p1,p2 only (no k3 / fisheye).
    • Out-of-bounds source samples render opaque black so the framing edge is unambiguous.
    • Uniforms: fx/fy (1000), cx/cy (0.5), k1/k2/p1/p2 (0), enabled toggle.
    • computeFovMargin() estimates the extra FOV coverage a distortion pass needs by forward-distorting the worst image corner, returning a focal-length multiplier ≥ 1, capped at 2.0 — superseded by a measured DISTORTION_SCALE when available.

Video & depth export

Camera-path export animating the camera along keyframes via evaluateCameraPath().

  • Output modes: RGB only, Depth only, or Both (RGB + depth as two separate MP4 blobs in VideoExportResult).
  • Resolutions: 1280×720 (HD), 1920×1080 (Full HD, default), 3840×2160 (4K).
  • FPS: 12 / 24 / 25 / 30 / 60.
  • Total frame count: user-set (min 2, max 9999) with live duration readout (durationFrames / fps).
  • Quality / codec presets (all H.264):H.264 20 Mbps, H.264 HQ 50 Mbps, H.264 Max 100 Mbps (DEFAULT_BITRATE = 20 Mbps).
  • Bake DoF — optional per-frame depth-of-field pass using the viewer's live aperture/focus/sensor settings; off by default (~1× extra splat-render cost per frame).
  • COLMAP camera data toggle (cameras.txt + images.txt), on by default.
  • Dual encoder backends: primary WebCodecsVideoEncoder + mp4-muxer, with automatic fallback to MediaRecorder if WebCodecs is unavailable or throws (logged via console.warn).
    • WebCodecs: MP4, codec avc1.640028 (H.264 High L4.0), latencyMode: "quality", keyframe every 2 s (fps·2).
    • MediaRecorder fallback: captureStream(0) with manual requestFrame() per frame and real-time pacing (1000/fps ms); MIME prefers MP4/avc1 then falls through webm vp9/vp8.
  • Global depth-range scan: when depth is needed, a first pass walks all frames to find global min/max depth so every depth frame normalizes against one consistent range.
  • Post-processing baked into export: generic postProcess.apply (reused for both DoF and lens distortion) renders into srcTarget then a fullscreen quad into dstTarget; postProcess.prepareCamera adds per-frame FOV margin (restored after each frame).
  • Per-frame scene animation via applySceneAtFrame() so splat/mesh/light transforms and visibility animate, not just the camera.
  • Progress via onProgress(frame, totalFrames), scaled across scan + render (+ encode) passes; UI shows a progress bar with Rendering frame X/total and percentage.
  • Robustness: even-dimension enforcement (ensureEvenDimension — nearest lower even ≥ 2, returns 0 for non-finite/≤0); full camera-state restore in a finally block (position, quaternion, fov, aspect, renderer size) plus render-target disposal; WebGL vertical flip of read-back pixels (flipVerticallyInto); safeReadPixels unbinds any stray PIXEL_PACK_BUFFER before readRenderTargetPixels.
  • Preconditions: ≥ 2 keyframes anddurationFrames > 0 — enforced in the UI (Export disabled, warning banner "Add at least 2 keyframes before exporting") and in the backend (both encoders throw "Need at least 2 keyframes and > 0 frames to export video").

COLMAP import / export

  • Export: writes the camera path as standard COLMAP cameras.txt + images.txt + empty points3D.txt, bundled in a ZIP (JSZip) — interoperable with COLMAP, 3DGS trainers, and NerfStudio.
    • Single shared PINHOLE camera: focalLengthPx = (focalLengthMm/sensorWidthMm)·width, fx=fy=focalLengthPx, cx=width/2, cy=height/2.
    • Per-frame images.txt line IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME (world→camera R and t) with an empty POINTS2D line after each; image names frame_00000.png; one image per durationFrames.
  • Import: reads cameras.txt + images.txt (+ optional Nodos extras.txt sidecar) from a ZIP and produces a CameraPath — one keyframe per pose plus parsed intrinsics.
    • Per-keyframe FOV from intrinsics: fov = 2·atan(height/(2·fy)) degrees (fallback 60° when intrinsics missing).
    • Poses sorted by IMAGE_ID; keyframe times distributed uniformly across [0,1].
    • Parses camera models PINHOLE, SIMPLE_PINHOLE, OPENCV, RADIAL, SIMPLE_RADIAL; unrecognized models are skipped (best-effort fallback to PINHOLE defaults, distortion dropped).
    • Extracts Brown-Conrady coefficients: OPENCV → k1,k2,p1,p2; RADIAL → k1,k2; SIMPLE_RADIAL → k1.
    • Nodos extras.txt: reads DISTORTION_SCALE (column 6) into cameraParams.distortionScale as an authoritative FOV-margin multiplier overriding k1/k2 estimation.
  • World-frame conventions (both directions; inverse is the transpose):
    • y-up — identity (glTF / Three.js / Maya / Unity)
    • y-down — 180° about X (raw COLMAP / OpenCV / vanilla SfM) — default for import and export
    • z-up — −90° about X (Blender / Unreal / RealityCapture / Metashape)
    • COLMAP camera frame is spec-fixed RDF (+X right, +Y down, +Z forward); Three.js is RUB. The camera-axis change D = diag(1,−1,−1) (180° about X) is applied on the camera side in both directions.

Capture (postMessage)

  • Screenshot capture — aspect-ratio cropped, cinematic naming.
  • Optional same-origin postMessage capture-back (lets an embedding host receive captures); worldId is echoed back with captures.

Develop

npm install
npm run dev

Open with a CORS-enabled splat:

http://localhost:5173/?url=<https URL of a .ply or .spz>&name=test.ply

URL params

  • url — splat URL (.ply / .spz), must be CORS-enabled
  • name — display name / download filename
  • worldId — opaque id echoed back with captures
  • lens — focal length in mm (e.g. 75)
  • sensor — sensor width in mm as a number (e.g. 33.7)
  • gsDir — default folder for the Save Scene dialog (host integration; needs the host file API)
  • projectDir — project folder used to derive default camera.json / frame-render paths (host integration; needs the host file API)

Keyboard shortcuts

Viewer shortcuts are suppressed while typing in an input/select/textarea.

  • WASD — move (Fly mode); Q / E — down / up; Shift — speed boost; mouse-drag — look
  • F — toggle Fly ⇄ Orbit navigation
  • H — show / hide the controls panels
  • T — show / hide the timeline
  • K — add keyframe
  • Del / Backspace — delete the selected keyframe
  • Ctrl/Cmd + Z — undo (scene/keyframe edits)
  • Escape — cancel/close the file dialog
  • Enter (path input) — navigate to the typed path; Enter (save Name field) — confirm save
  • Double-click a file (open mode) — open it immediately

Capture/screenshot is button-only — there is no keyboard shortcut for it.

Build

npm run build # outputs dist/ with relative asset paths (base: "./")

The build is self-contained and path-relative, so dist/ can be served from any static host, including under an arbitrary subpath. Keep base: "./" in vite.config.ts for subpath hosting to work.

Hosting & embedding

The build is static and path-relative, so dist/ can be served from any web server, under any subpath. Two common ways to consume the viewer:

  • One hosted build, embedded by apps — deploy dist/ once and embed it (iframe or popup window) from your apps. If an embedding app runs on a different origin, reverse-proxy the viewer path (e.g. /viewer → https://your-host/viewer-build/) so the viewer appears same-origin to that app — this is what keeps blob: splat URLs, the sessionStorage state handoff, and the postMessage capture-back working (all three are same-origin-only). Rebuild once and every consumer reflects it on next load.
    • Versioning tip: serve versioned paths (/viewer/vN/) so a consumer can pin a known-good build (with a shared build, a regression otherwise hits every consumer at once).
  • As a package — install the repo as a git dependency and render the exported React component; see Consuming as a dependency below.

This is a static client-only build — there is no viewer server/API. "Hosted" just means the static files live at one URL.

Optional host file API

Disk-based scene save/open (the FileDialog), sidecar splat restore, and camera.json export call three endpoints that an embedding host may provide — the standalone build has no server, so these UI paths are inert unless the host implements them (same-origin):

EndpointReturns
GET /api/list-directory?path=<dir>{ success: true, entries: [{ name, type: "file" | "directory" }] }
GET /api/read-file?path=<absolute path>raw file bytes
POST /api/write-file?path=<absolute path> (body = file content){ success: true }
POST /api/save-generation (multipart form: file, directoryPath, customFilename, createDirectory)any 2xx

Everything else (drag-drop loading, ?url= loading, browser downloads, video/COLMAP export) works without them. Video/COLMAP exports always download in the browser; when /api/save-generation exists they are additionally mirrored to the host (best-effort — failures only log).

Host integration (popup embedding)

When another app opens the viewer as a popup/iframe (same-origin — reverse-proxy if needed):

  • Open the viewer with ?worldId=<opaque id> (plus url/name/lens/sensor as needed). ?url= accepts any CORS-enabled URL, including same-origin blob: URLs.
  • State handoff: before opening, the host may write a serialized viewer state to sessionStorage["splat-viewer-state-<worldId>"]; the viewer restores it on load. While running, the viewer auto-posts state changes back to window.opener as { type: "splat-viewer-state", worldId, state } (same-origin targeted).
  • Capture-back: screenshots post to window.opener as { type: "worldlabs-capture", worldId, image, depthImage, filename, width, height } (PNG data URLs). With no opener, captures download directly instead.

Architecture invariant

src/ must use only relative imports + npm packages — no @/ or next/ imports. This keeps the source portable so it builds under this Vite root and transpiles cleanly inside a consumer's bundler (e.g. Next's transpilePackages). FileDialog.tsx and src/lib/cinemaCameraPresets.ts are deliberately self-contained (no shared/aliased imports) for this reason.

Consuming as a dependency

The package entry is the TS/TSX source (exportssrc/SplatViewer.tsx), and react / react-dom / three / @sparkjsdev/spark are peerDependencies so the host supplies a single copy (a duplicate React breaks hooks; a duplicate three breaks instanceof).

// consumer package.json"dependencies": { "splat-viewer": "github:gitcapoom/capoom-splat-viewer#main" }

The package is intentionally not published to npm ("private": true guards against accidental publish) — consume it via the git URL above.

importSplatViewerfrom"splat-viewer";

The consumer's bundler must transpile the package source (in Next: transpilePackages: ["splat-viewer"]). Because the component uses Tailwind utility classes, a Tailwind v4 host must also scan the package (@source ".../node_modules/splat-viewer/src";). Pick up updates with npm update splat-viewer, or pin a commit via #<sha>.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages