Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/Features/3D-Layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ Camera clips expose their own Properties tab with:
- Near plane
- Far plane

The Transform tab becomes scene-navigation controls for the active camera clip. In FPS mode, the preview accepts WASD/QE navigation plus mouse look. Free scene navigation now belongs to camera clips rather than gaussian-splat clips.
The Transform tab becomes scene-navigation controls for the active camera clip. In FPS mode, the preview accepts WASD/QE navigation plus uncapped mouse look. Free scene navigation now belongs to camera clips rather than gaussian-splat clips.

Camera rotation keyframes interpolate through the shortest angular path so timeline flights do not spin the long way around when yaw, pitch, or roll crosses a 360-degree wrap. FPS-look camera segments with keyed position/forward travel render through world-pose interpolation, keeping vertical-look roll moves from drifting away between keyframes.

## Gaussian Splats

Expand All @@ -98,6 +100,7 @@ Gaussian splat clips are imported through the SuperSplat-compatible `@playcanvas
- Realtime splat rendering uses a worker-backed back-to-front order buffer based on the SuperSplat/PlayCanvas sorter approach. Precise export can still fall back to the existing GPU sort path.
- Sequence splats follow the same shared runtime contract and are no longer treated as a permanent legacy-only scene path.
- The Transform tab now exposes normal object transforms for gaussian splats. Scene navigation lives on camera clips.
- Large gaussian splats show viewport loading progress during project restore, URL fetch, parser work, normalization, and GPU upload.

Some gaussian-splat settings exist in the data model and export pipeline but are not yet surfaced as a full dedicated UI:

Expand Down
57 changes: 57 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -10041,6 +10041,63 @@ input[type="checkbox"] {
pointer-events: none;
}

.preview-splat-progress-overlay {
position: absolute;
left: 50%;
bottom: 42px;
width: min(460px, calc(100% - 32px));
transform: translateX(-50%);
padding: 10px 12px;
color: var(--text-primary);
background: rgba(10, 12, 16, 0.86);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(8px);
z-index: 24;
pointer-events: none;
}

.preview-splat-progress-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
font-weight: 600;
line-height: 1.3;
}

.preview-splat-progress-name {
margin-top: 3px;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.preview-splat-progress-track {
height: 5px;
margin-top: 8px;
overflow: hidden;
background: rgba(255, 255, 255, 0.14);
border-radius: 999px;
}

.preview-splat-progress-fill {
height: 100%;
width: 0;
background: var(--accent);
border-radius: inherit;
transition: width 0.16s ease-out;
}

.preview-splat-progress-overlay.error .preview-splat-progress-fill {
background: var(--danger);
}

.preview-canvas-wrapper {
transform-origin: center center;
transition: transform 0.1s ease-out;
Expand Down
21 changes: 21 additions & 0 deletions src/changelog-data.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,25 @@
[
{
"date": "2026-04-24",
"type": "fix",
"title": "3D Video Planes Now Render Through the Active Scene Camera",
"description": "Video clips converted into 3D layers now share the same renderable scene camera path as splats and nested compositions, so the 3D viewport no longer disappears or breaks when video planes are added.",
"section": "3D / Native Scene"
},
{
"date": "2026-04-24",
"type": "improve",
"title": "Gaussian Splat Loading Shows Viewport Progress",
"description": "Large splats now report fetch, read, parse, and upload progress directly in the preview viewport during import, refresh, and timeline restore.",
"section": "3D / Gaussian Splats"
},
{
"date": "2026-04-24",
"type": "fix",
"title": "FPS Camera Rotation Tweens as a Real World Pose",
"description": "FPS camera look is no longer pitch-capped, and camera keyframes now interpolate the recorded world pose so vertical look and roll-style moves avoid unwanted fly-out arcs between keyframes.",
"section": "3D / Camera Controls"
},
{
"date": "2026-04-24",
"type": "new",
Expand Down
64 changes: 59 additions & 5 deletions src/components/preview/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const log = Logger.create('Preview');
import { useEngine } from '../../hooks/useEngine';
import { useShortcut } from '../../hooks/useShortcut';
import {
selectActiveGaussianSplatLoadProgress,
selectSceneNavClipId,
selectSceneNavFpsMode,
selectSceneNavFpsMoveSpeed,
Expand Down Expand Up @@ -56,7 +57,34 @@ function getSharedSceneDefaultCameraDistance(fovDegrees: number): number {
}

const CAMERA_NAV_FPS_LOOK_SPEED = 0.18;
const CAMERA_NAV_FPS_PITCH_LIMIT = 89.5;

function formatSplatLoadPercent(percent: number): number {
if (!Number.isFinite(percent)) {
return 0;
}
return Math.round(Math.max(0, Math.min(1, percent)) * 100);
}

function getSplatLoadPhaseLabel(phase: string): string {
switch (phase) {
case 'fetching':
return 'Fetching splat';
case 'reading':
return 'Reading splat';
case 'parsing':
return 'Parsing splat';
case 'normalizing':
return 'Preparing splat';
case 'uploading':
return 'Uploading splat';
case 'complete':
return 'Splat loaded';
case 'error':
return 'Splat load failed';
default:
return 'Loading splat';
}
}

interface PreviewProps {
panelId: string;
Expand All @@ -75,6 +103,7 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
const sceneNavClipId = useEngineStore(selectSceneNavClipId);
const sceneNavFpsMode = useEngineStore(selectSceneNavFpsMode);
const sceneNavFpsMoveSpeed = useEngineStore(selectSceneNavFpsMoveSpeed);
const activeSplatLoadProgress = useEngineStore(selectActiveGaussianSplatLoadProgress);
const setSceneNavFpsMoveSpeed = useEngineStore((s) => s.setSceneNavFpsMoveSpeed);
const { clips, selectedClipIds, primarySelectedClipId, selectClip, updateClipTransform, maskEditMode, layers, selectedLayerId, selectLayer, updateLayer, tracks } = useTimelineStore(useShallow(s => ({
clips: s.clips,
Expand Down Expand Up @@ -738,10 +767,7 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)

if (deltaX === 0 && deltaY === 0) return;

const nextPitch = Math.max(
-CAMERA_NAV_FPS_PITCH_LIMIT,
Math.min(CAMERA_NAV_FPS_PITCH_LIMIT, freshTransform.rotation.x + deltaY * CAMERA_NAV_FPS_LOOK_SPEED),
);
const nextPitch = freshTransform.rotation.x + deltaY * CAMERA_NAV_FPS_LOOK_SPEED;
const nextYaw = freshTransform.rotation.y - deltaX * CAMERA_NAV_FPS_LOOK_SPEED;
const nextTranslation = resolveOrbitCameraTranslationForFixedEye(
freshTransform,
Expand Down Expand Up @@ -1131,6 +1157,12 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
const viewTransform = editMode ? {
transform: `scale(${viewZoom}) translate(${viewPan.x / viewZoom}px, ${viewPan.y / viewZoom}px)`,
} : {};
const splatLoadPercent = activeSplatLoadProgress
? formatSplatLoadPercent(activeSplatLoadProgress.percent)
: 0;
const splatLoadPhaseLabel = activeSplatLoadProgress
? getSplatLoadPhaseLabel(activeSplatLoadProgress.phase)
: '';

return (
<div
Expand Down Expand Up @@ -1248,6 +1280,28 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
)}
</div>

{activeSplatLoadProgress && (
<div
className={`preview-splat-progress-overlay ${activeSplatLoadProgress.phase === 'error' ? 'error' : ''}`}
role="status"
aria-live="polite"
>
<div className="preview-splat-progress-header">
<span>{splatLoadPhaseLabel}</span>
<span>{splatLoadPercent}%</span>
</div>
<div className="preview-splat-progress-name">
{activeSplatLoadProgress.fileName}
</div>
<div className="preview-splat-progress-track">
<div
className="preview-splat-progress-fill"
style={{ width: `${splatLoadPercent}%` }}
/>
</div>
</div>
)}

{/* Edit mode overlay - covers full container for pasteboard support */}
{editMode && isEngineReady && (
<canvas
Expand Down
4 changes: 3 additions & 1 deletion src/components/timeline/hooks/useLayerSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ export function useLayerSync({

// Interpolate transform using keyframes (supports opacity fades, position animations, etc.)
const transform = keyframes.length > 0
? getInterpolatedClipTransform(keyframes, nestedLocalTime, baseTransform)
? getInterpolatedClipTransform(keyframes, nestedLocalTime, baseTransform, {
rotationMode: nestedClip.source?.type === 'camera' ? 'shortest' : 'linear',
})
: baseTransform;

// Interpolate effect parameters if there are effect keyframes
Expand Down
4 changes: 3 additions & 1 deletion src/engine/export/ExportLayerBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,9 @@ function buildNestedBaseLayer(nestedClip: TimelineClip, nestedClipLocalTime: num

// Interpolate transform using keyframes (supports opacity fades, position animations, etc.)
const transform = keyframes.length > 0
? getInterpolatedClipTransform(keyframes, nestedClipLocalTime, baseTransform)
? getInterpolatedClipTransform(keyframes, nestedClipLocalTime, baseTransform, {
rotationMode: nestedClip.source?.type === 'camera' ? 'shortest' : 'linear',
})
: baseTransform;

// Interpolate effect parameters if there are effect keyframes
Expand Down
3 changes: 3 additions & 0 deletions src/engine/gaussian/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export type {
GaussianSplatBuffer,
GaussianSplatFrame,
GaussianSplatAsset,
GaussianSplatLoadOptions,
GaussianSplatLoadProgress,
GaussianSplatLoadProgressCallback,
SplatCache,
} from './loaders';

Expand Down
Loading
Loading