@valantify/runtime
v0.0.7
Published
Client-side runtime for displaying product variations with animated transitions.
Readme
@valantify/runtime
Client-side runtime for displaying product variations with animated transitions.
Purpose
This package provides the browser runtime that consumes assets generated by the Valantify CLI. It displays product images with smooth animated transitions between variations and perspectives.
Installation
npm install @valantify/runtimeQuick Start
import {
showVariation,
getVariation,
initCameraNavigator,
renderStepOptions,
} from '@valantify/runtime'
import type { Step } from '@valantify/steps'
// Show a variation (automatically initializes everything)
await showVariation('#viewer', 'shoe', 'blue')
// Enable drag-based perspective navigation
initCameraNavigator('#viewer', {
shoe: {
cameraPositions: [
{ file: 'front', x: 0, y: 0, z: -1 },
{ file: 'left', x: 1, y: 0, z: 0 },
{ file: 'right', x: -1, y: 0, z: 0 },
]
}
})
// Get current state
const current = getVariation('#viewer')
// → { group: 'shoe', variation: 'blue', perspective: 'front' }
const STEPS: Step[] = [...]
const rendering = {
optionsContainer: (optionsHtml: string) => `<div class="options">${optionsHtml}</div>`,
option: {
color: (hex: string) => `<button class="option"><span style="background:${hex}"></span></button>`,
image: (url: string) => `<button class="option"><img src="${url}" alt="" /></button>`,
text: (label: string) => `<button class="option">${label}</button>`,
},
}
// Render one step's options into any container
renderStepOptions('#step-options', STEPS, 0, ['#viewer'], rendering)How It Works
The runtime expects assets in this structure (generated by the CLI):
public/
└── valantify/
└── shoe/ # Product group
├── product.json # Product metadata (title, description, perspectives, dimensions)
├── red/ # Variation
│ ├── variant.json # Variation metadata (features, description)
│ ├── front.png
│ ├── side.png
│ └── transitions/ # Videos between perspectives
│ └── front-to-side.mp4
│ └── frames/ # Extracted frames (0-100.webp)
│ └── front-to-side/0.webp
└── blue/
├── variant.json
├── front.png
└── side.pngTransition Behavior
- Same perspective, different variation → Instant image swap
- Same variation, different perspective → Plays transition frames (if available)
- Both change → Swaps variation first, then animates perspective
API Reference
Core Functions
showVariation(element, group, variation, options?)
Display a product variation with optional animation.
await showVariation('#viewer', 'shoe', 'blue')
await showVariation('#viewer', 'shoe', 'blue', { perspective: 'side' })
await showVariation('#viewer', 'shoe', 'blue', { animated: false })getVariation(element)
Get the current state from DOM attributes.
const info = getVariation('#viewer')
// → { group: 'shoe', variation: 'blue', perspective: 'front' }initCameraNavigator(element, options)
Enable drag-based perspective navigation. Returns a cleanup function.
const cleanup = initCameraNavigator('#viewer', {
shoe: {
threshold: 30,
dragToScrub: true,
maxScrubDistance: 200,
cameraPositions: [
{ file: 'front', x: 0, y: 0, z: -1 },
{ file: 'left', x: 1, y: 0, z: 0 },
]
}
})
// Later: remove all listeners
cleanup.destroy()renderStepOptions(element, steps, stepIndex, viewers, rendering)
Render selectable options for a single step into any container. The runtime handles available options, selection state, cascade updates, and click wiring.
renderStepOptions('#step-options', STEPS, 0, ['#viewer'], rendering)
renderStepOptions('#step-options', STEPS, 1, ['#viewer'], rendering) // replace render for step 1Behavior:
- Calls your
rendering.option.{color|image|text}for each available option - Wraps options with
rendering.optionsContainer(...) - Marks each option root with
data-active="true|false" - Replacing the same element is idempotent (no teardown needed)
- Auto-cleans when element is removed from DOM
- Auto-cascades across all rendered containers that share any viewer
- Dispatches
valantify:changeon viewers after a selection
Selection/cascade event:
viewer.addEventListener('valantify:change', (event) => {
const { visibleSteps } = (event as CustomEvent).detail
// update title/counter/prev-next UI
})Main Exports
The runtime exports:
showVariation(element, group, variation, options?)- Display a product variationgetVariation(element)- Get current state from DOM attributesinitCameraNavigator(element, options)- Enable drag-based perspective navigationrenderStepOptions(element, steps, stepIndex, viewers, rendering)- Render step options with auto-cascade
Drag-Based Perspective Navigation
Configuration Options
interface CameraGroupConfig {
threshold?: number; // Min drag distance to trigger (default: 30px)
dragToScrub?: boolean; // Scrub frames during drag (default: false)
maxScrubDistance?: number; // Drag distance for full progress (default: 200px)
cameraPositions: CameraPosition[];
}
interface CameraPosition {
file: string; // Perspective filename (without extension)
x: number; // Horizontal position on the unit circle
y: number; // Vertical elevation (typically 0 for flat rotation)
z: number; // Depth position on the unit circle
}How Camera Positions Work
Each perspective is placed at a point on a unit circle. The runtime computes relative directions between positions and matches them to drag direction using dot product. Only relative placement matters — coordinates are normalized internally.
Drag mapping: drag right → +x, drag down → -z
How to determine coordinates: Place perspectives around a circle so adjacent angles are ~45-90° apart. Use cos/sin to convert angles to (x, z) coordinates. The exact values don't matter as long as the relative spacing is correct.
Common Patterns
Simple 4-direction rotation (90° apart):
const CAMERA_POSITIONS = [
{ file: 'front', x: 0, y: 0, z: -1 },
{ file: 'left', x: 1, y: 0, z: 0 },
{ file: 'back', x: 0, y: 0, z: 1 },
{ file: 'right', x: -1, y: 0, z: 0 },
]With diagonal perspectives (45° apart):
const CAMERA_POSITIONS = [
{ file: 'front', x: 0, y: 0, z: -1 },
{ file: 'front-left', x: 0.707, y: 0, z: -0.707 }, // 45° between front and left
{ file: 'left', x: 1, y: 0, z: 0 },
]With elevated views:
const CAMERA_POSITIONS = [
{ file: 'front', x: 0, y: 0, z: -1 },
{ file: 'front-top', x: 0, y: 0.5, z: -0.866 }, // 30° above front
{ file: 'left', x: 1, y: 0, z: 0 },
]How Drag Navigation Works
- On drag start: Captures pointer, stores current perspective
- During drag:
- Converts drag delta to 3D direction vector
- Uses dot product to find best-matching perspective
- If
dragToScrubenabled, scrubs transition frames to match progress - Applies hysteresis to prevent jitter between similar perspectives
- On release:
- Past 50% progress → completes transition to target
- Before 50% → smoothly animates back to original perspective
Frame Scrubbing
When dragToScrub: true, transition frames scrub based on drag progress:
initCameraNavigator('#viewer', {
shoe: {
dragToScrub: true, // Enable scrubbing
maxScrubDistance: 200, // 200px drag = 100% progress
cameraPositions: [...]
}
})The scrubbing:
- Projects drag distance along the target direction (not raw distance)
- Handles both forward and reverse frame sequences automatically
- Falls back to instant swap if no frames exist
Frame URL patterns (resolution based on scrub velocity, with idle upgrades):
/valantify/<group>/<variation>/transitions/frames/<from>-to-<to>/<0-100>.webp # Low (rapid scrubbing only)
/valantify/<group>/<variation>/transitions/frames/<from>-to-<to>/<0-100>-medium.webp # Medium (normal scrubbing)
/valantify/<group>/<variation>/transitions/frames/<from>-to-<to>/<0-100>-high.webp # High (slow/stopped)When scrubbing slows (sustained low velocity) or stops, the runtime automatically upgrades from low → medium → high after a short pause, even if the last drag velocity was high.
Debug Mode
Enable debug mode to log frame resolution statistics:
import { configure } from '@valantify/runtime'
configure({ debug: true })With debug enabled, the runtime logs the percentage of frames displayed at each resolution tier after 5 seconds of inactivity:
[valantify] Frame resolution stats: low=15.2% (23), medium=61.8% (94), high=23.0% (35) [total=152]This helps analyze scrubbing behavior and optimize the resolution thresholds.
Scrub Debug Logging
For detailed scrub controller debugging, pass debugScrub in the settings:
initCameraNavigator('#viewer', {
shoe: {
dragToScrub: true,
cameraPositions: [...]
}
}, { debugScrub: true })This logs every frame decision with the reason:
[scrub] quality-decision: frame=42 quality=medium reason=velocity-medium velocity=0.00450 target=medium
[scrub] frame-render: frame=42 quality=medium reason=velocity-medium velocity=0.00450
[scrub] upgrade-scheduled: frame=42 quality=medium reason=scheduled-upgrade velocity=0.00120 target=high
[scrub] upgrade-applied: frame=42 quality=high reason=idle-upgrade velocity=0.00000Quality reasons:
velocity-high- Rapid scrubbing, using low qualityvelocity-medium- Normal scrubbing, using medium qualityvelocity-low- Slow/stopped, using high qualitycached-upgrade- Found higher quality in cachecached-fallback- Fell back to lower cached qualityscheduled-upgrade- Scheduled upgrade after delayidle-upgrade- Upgraded after idle timeout
TypeScript
All types are exported:
import type {
// Core types
VariationInfo,
ShowVariationOptions,
ValantifyConfig,
Rendering,
ValantifyChangeEventDetail,
// Camera navigator types
CameraPosition,
CameraGroupConfig,
CameraNavigatorOptions,
CameraNavigatorCleanup,
// Scrub debug types
ScrubQualityReason,
ScrubDebugEvent,
ScrubDebugLogger,
} from '@valantify/runtime'Development
# Type check
yarn typecheckIntegration
This package is used by:
clients/main- Web interface for building visualizers- CLI-generated static sites - Embedded via
valantify templatecommand (camera positions provided at generation time)
The CLI (clients/cli) generates the assets that this runtime consumes.
