diff --git a/README.md b/README.md
index cb954ceb..ba6685b2 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
GPU-first editing with 32 effects , 37 blend modes , 79 AI tools , native WebGPU 3D , and only 15 runtime dependencies .
Built from scratch in 2,700+ lines of WGSL and 165k lines of TypeScript .
- Import .lottie, Lottie JSON, OBJ, glTF, GLB, PLY, SPLAT, KSPLAT, SPZ, SOG, LCC assets and play PLY / GLB sequences directly on the timeline.
+ Import .lottie, .riv, Lottie JSON, OBJ, glTF, GLB, PLY, SPLAT, KSPLAT, SPZ, SOG, LCC assets and play PLY / GLB sequences directly on the timeline.
@@ -55,7 +55,7 @@ Decoding depends on what the **browser** supports — the container is just the
Video codecs H.264 (AVC), H.265 (HEVC)¹, VP8, VP9, AV1
Audio files WAV, MP3, OGG, FLAC, AAC, M4A, WMA, AIFF, OPUS
Image PNG, JPG/JPEG, WebP, GIF, BMP, SVG
-Vector animation .lottie packages and Lottie JSON files (content-sniffed)
+Vector animation .lottie packages, .riv files, and Lottie JSON files (content-sniffed)
3D Models OBJ, glTF, GLB - rendered through the native WebGPU shared-scene path
3D sequences PLY and GLB frame sequences played as timeline media
Gaussian Splats PLY, compressed PLY, SPLAT, KSPLAT, SPZ, SOG, LCC, SOG-style ZIP payloads
@@ -87,7 +87,7 @@ Most browser-based video editors share a pattern: Canvas 2D compositing, heavywe
**3-tier scrubbing cache.** **300 GPU textures in VRAM** for instant scrub (Tier 1), per-video last-frame cache for seek transitions (Tier 2), and a **900-frame RAM Preview** with CPU/GPU promotion (Tier 3). When the cache is warm, **scrubbing doesn't decode at all**.
-**15 runtime dependencies.** React/React DOM, Zustand, MediaBunny, mp4box, PlayCanvas / splat-transform helpers, dotLottie, HuggingFace Transformers, ONNX Runtime, SoundTouch, WebGPU types, plus an **experimental FFmpeg WASM path**. **Everything else is custom-built from scratch**: the WebGPU compositor, all 32 effect shaders, the keyframe animation system, the export engine, the audio mixer, the text renderer, the mask engine, the video scope renderers, the dock/panel system, the timeline UI, and the native shared 3D scene path. Zero runtime abstraction layers between your timeline and the GPU.
+**16 runtime dependencies.** React/React DOM, Zustand, MediaBunny, mp4box, PlayCanvas / splat-transform helpers, dotLottie, Rive WASM, HuggingFace Transformers, ONNX Runtime, SoundTouch, WebGPU types, plus an **experimental FFmpeg WASM path**. **Everything else is custom-built from scratch**: the WebGPU compositor, all 32 effect shaders, the keyframe animation system, the export engine, the audio mixer, the text renderer, the mask engine, the video scope renderers, the dock/panel system, the timeline UI, and the native shared 3D scene path. Zero runtime abstraction layers between your timeline and the GPU.
**Nested composition rendering.** Compositions within compositions, each with their own resolution. Rendered to **pooled GPU textures** with frame-level caching, composited in the parent's ping-pong pass, all in a **single `device.queue.submit()`**.
@@ -146,7 +146,7 @@ This requires the Native Helper to be running, a MasterSelects editor tab to be
| [**Export Pipeline**](docs/Features/Export.md) | WebCodecs Fast/Precise, FFmpeg intermediates, image/audio-only export, FCPXML, and project-persistent presets |
| [**Live EQ & Audio**](docs/Features/Audio.md) | 10-band parametric EQ with real-time Web Audio preview |
| [**Download Panel**](docs/Features/Download-Panel.md) | YouTube, TikTok, Instagram, Twitter/X, Vimeo, and other yt-dlp-supported sites via Native Helper |
-| [**Vector Animation**](docs/Features/Vector-Animation.md) | `.lottie` and Lottie JSON clips with bounce playback, render resolution overrides, keyframed state machines, and deterministic preview/export |
+| [**Vector Animation**](docs/Features/Vector-Animation.md) | `.lottie`, `.riv`, and Lottie JSON clips with bounce playback, render resolution overrides, state-machine keyframes, Rive data binding, and preview/export |
| [**Text & Solids**](docs/Features/Text-Clips.md) | 50 Google Fonts, stroke, shadow, and solid color clips |
| [**Proxy System**](docs/Features/Proxy-System.md) | GPU-accelerated proxies with resume and cache indicator |
| [**Output Manager**](docs/Features/Preview.md) | Multi-window outputs, source routing, corner pin warping, slice masks |
@@ -324,7 +324,7 @@ src/
│ ├── nativeHelper/ # Native decoder + WebSocket client
│ ├── layerBuilder/ # Layer building + video sync
│ ├── mediaRuntime/ # Media runtime bindings + playback
-│ ├── vectorAnimation/ # Lottie metadata sniffing + runtime canvas playback
+│ ├── vectorAnimation/ # Lottie/Rive metadata + runtime canvas playback
│ └── export/ # FCPXML export
├── shaders/ # WGSL (composite, effects, output, optical flow, slice)
├── hooks/ # React hooks (useEngine, useGlobalHistory, useMIDI, useTheme)
diff --git a/docs/Features/Color-Correction.md b/docs/Features/Color-Correction.md
index c3ff3288..38693ce2 100644
--- a/docs/Features/Color-Correction.md
+++ b/docs/Features/Color-Correction.md
@@ -90,7 +90,7 @@ This is the core product rule: same grade, different professional control surfac
Add a `Color` tab beside `Transform`, `Effects`, and `Masks` for visual clips.
-Recommended tab order for video/image/text/solid/Lottie/3D clips:
+Recommended tab order for video/image/text/solid/Lottie/Rive/3D clips:
```text
Transform | Color | Effects | Masks | Transcript | Analysis
@@ -500,7 +500,7 @@ Integration points:
- Add `color` to `PropertiesTab` in `src/components/panels/properties/index.tsx`.
- Add `color-workspace` to the dock panel system when the expanded workspace is implemented.
-- Insert the tab for visual clips, including text, solid, Lottie, image, video, model, gaussian avatar, and gaussian splat clips. Keep it hidden for audio-only clips and camera/controller clips.
+- Insert the tab for visual clips, including text, solid, Lottie, Rive, image, video, model, gaussian avatar, and gaussian splat clips. Keep it hidden for audio-only clips and camera/controller clips.
- Keep the active tab reset logic aware of `color` so switching selected clips does not bounce the user back to Transform.
- Reuse existing `DraggableNumber`, `KeyframeToggle`, `MIDIParameterLabel`, and history batching patterns from `EffectsTab`.
- Share list/inspector editing primitives between the Properties tab and workspace where practical. The workspace owns the large node graph layout.
diff --git a/docs/Features/Export.md b/docs/Features/Export.md
index 1c472b7d..8fc1a13d 100644
--- a/docs/Features/Export.md
+++ b/docs/Features/Export.md
@@ -43,7 +43,7 @@ FCPXML is exposed as a selectable export container for NLE interchange.
`FrameExporter` is used for both the WebCodecs and HTMLVideo export buttons.
-Canvas-backed sources such as text, solids, and Lottie are re-rendered for every export frame before capture, so the exported frame matches the current timeline time instead of reusing a stale first-frame texture. Motion shape clips are built as `motion` layer sources and rendered by the WebGPU motion renderer at export frame time before compositing.
+Canvas-backed sources such as text, solids, Lottie, and Rive are re-rendered for every export frame before capture, so the exported frame matches the current timeline time instead of reusing a stale first-frame texture. Motion shape clips are built as `motion` layer sources and rendered by the WebGPU motion renderer at export frame time before compositing.
### Fast Mode
diff --git a/docs/Features/Keyframes.md b/docs/Features/Keyframes.md
index 65a1842c..8836ac80 100644
--- a/docs/Features/Keyframes.md
+++ b/docs/Features/Keyframes.md
@@ -83,14 +83,15 @@ The numeric mask properties use the same curve and easing behavior as transform
### Vector Animation Properties
-Lottie state machines use the same keyframe store as transform and effect properties:
+Vector animation state machines and Rive Data Binding use the same keyframe store as transform and effect properties:
```text
lottieState.{stateMachine}
lottieInput.{stateMachine}.{input}
+riveData.{property}
```
-`lottieState.*` keyframes are discrete named states. They render as blue diamonds and stepped curves because a state change should hold until the next state keyframe, not ease between values. Boolean and numeric `lottieInput.*` properties use the normal stopwatch/keyframe workflow.
+`lottieState.*` keyframes are discrete named states. They render as blue diamonds and stepped curves because a state change should hold until the next state keyframe, not ease between values. Boolean and numeric `lottieInput.*` properties use the normal stopwatch/keyframe workflow. Rive Data Binding properties use `riveData.*` for numeric, integer, boolean, and color values; string and enum bindings remain static clip settings.
### Motion Shape Properties
@@ -177,7 +178,7 @@ When recording is enabled:
- Dragging a handle updates the stored handle position and switches the keyframe to Bezier mode.
- `Shift+drag` on a keyframe constrains movement to one axis in the curve editor.
- Right-clicking a handle resets it to the default 1/3-distance handle for that segment.
-- Lottie state keyframes show state labels on the value axis and draw stepped segments instead of Bezier curves.
+- Vector animation state keyframes show state labels on the value axis and draw stepped segments instead of Bezier curves.
- Mask path rows expose timing and easing in the timeline; their value is a whole shape snapshot rather than a numeric scalar.
### Delete and Copy/Paste
diff --git a/docs/Features/Media-Panel.md b/docs/Features/Media-Panel.md
index f7c01290..8d806377 100644
--- a/docs/Features/Media-Panel.md
+++ b/docs/Features/Media-Panel.md
@@ -32,14 +32,14 @@ Import, organize, and manage media assets with folder structure, proxy generatio
| **Video** | MP4, WebM, MOV, AVI, MKV, WMV, M4V, FLV |
| **Audio** | WAV, MP3, OGG, FLAC, AAC, M4A, WMA, AIFF, OPUS |
| **Image** | PNG, JPG/JPEG, GIF, WebP, BMP, SVG |
-| **Vector Animation** | `.lottie`, Lottie JSON (`.json`, content-sniffed) |
+| **Vector Animation** | `.lottie`, `.riv`, Lottie JSON (`.json`, content-sniffed) |
The panel also accepts a few specialized asset types that flow into the timeline as 3D clips:
- `model` files: OBJ, glTF/GLB
- `gaussian-splat` files: PLY, compressed PLY, SPLAT, KSPLAT, SPZ, SOG, LCC, and SOG-style ZIP payloads
-Lottie imports are treated as first-class media items. `.json` files are only accepted when their contents actually match Lottie structure, so arbitrary JSON data is not misclassified as animation.
+Lottie and Rive imports are treated as first-class media items. `.json` files are only accepted when their contents actually match Lottie structure, so arbitrary JSON data is not misclassified as animation.
### Import Methods
@@ -462,7 +462,7 @@ interface MediaFile {
### Drag Types
| Item Type | Drag Payload Kind | Data Transfer Key |
|-----------|-------------------|-------------------|
-| Media file (video/image/lottie) | `media-file` | `application/x-media-file-id` |
+| Media file (video/image/lottie/rive) | `media-file` | `application/x-media-file-id` |
| Media file (audio) | `media-file` (marked as audio) | `application/x-media-file-id` |
| Composition | `composition` | `application/x-composition-id` |
| Text item | `text` | `application/x-text-item-id` |
@@ -481,7 +481,7 @@ interface MediaFile {
### Track Type Enforcement
| Media Type | Allowed Tracks |
|------------|----------------|
-| Video/Image/Lottie/Composition/Text/Solid/Mesh | Video tracks only |
+| Video/Image/Lottie/Rive/Composition/Text/Solid/Mesh | Video tracks only |
| Audio | Audio tracks only |
---
diff --git a/docs/Features/README.md b/docs/Features/README.md
index 216257b3..cf6911ee 100644
--- a/docs/Features/README.md
+++ b/docs/Features/README.md
@@ -24,7 +24,7 @@ The docs in this folder were re-audited against the current codebase and now tra
| **AI Control** | OpenAI/Cloud or local Lemonade chat with 79 exported tools plus local/native bridge access for external agents |
| **AI Video Workspace** | Classic AI Video plus FlashBoard board-mode generation and media import |
| **3D Layers** | Shared-scene 3D layers, camera clips, Gaussian splats, and splat effectors |
-| **Vector Animation** | Lottie clips with deterministic canvas playback, bounce modes, render resolution overrides, keyframed state machines, and export |
+| **Vector Animation** | Lottie and Rive clips with canvas playback, bounce modes, render resolution overrides, keyframed state/data inputs, and export |
| **Audio** | Element-synced playback, drift correction, waveform extraction, EQ, and audio export |
| **Project Storage** | `project.json` source of truth, RAW-copy-first media flow, autosave, relink, backups |
| **Native Helper** | Firefox storage backend, yt-dlp download flow, local AI bridge, native jobs |
@@ -59,7 +59,7 @@ The docs in this folder were re-audited against the current codebase and now tra
| [Text Clips](./Text-Clips.md) | Canvas-backed text rendering, typography controls, and timeline text items |
| [Motion Design](./Motion-Design.md) | Motion layer schema, property registry, rectangle/ellipse shape editing, GPU renderer, and persistence/export plumbing |
| [3D Layers](./3D-Layers.md) | Shared-scene path, native Gaussian splats, cameras, and splat effectors |
-| [Vector Animation](./Vector-Animation.md) | Lottie import, runtime playback, bounce modes, state-machine keyframes, and export behavior |
+| [Vector Animation](./Vector-Animation.md) | Lottie/Rive import, runtime playback, bounce modes, state-machine keyframes, Rive data binding, and export behavior |
| [Audio](./Audio.md) | Playback sync, EQ, waveform extraction, audio clip behavior, and export |
| [Export](./Export.md) | WebCodecs fast/precise export, animated GIF, FFmpeg intermediates, image frame/sequence export, audio-only export, FCPXML, and project-persistent presets |
| [Proxy System](./Proxy-System.md) | Proxy generation, on-disk frame layout, audio proxies, and warmup behavior |
diff --git a/docs/Features/Timeline.md b/docs/Features/Timeline.md
index ba351337..88d301d0 100644
--- a/docs/Features/Timeline.md
+++ b/docs/Features/Timeline.md
@@ -2,14 +2,14 @@
[<- Back to Index](./README.md)
-The Timeline is the core editing interface for multi-track editing. It now covers video, audio, image, Lottie, text, solid, motion shape, mesh, composition, camera, and splat-effector clips, with keyframe lanes, transitions, multicam grouping, pick-whip parenting, and slot-grid playback.
+The Timeline is the core editing interface for multi-track editing. It now covers video, audio, image, Lottie, Rive, text, solid, motion shape, mesh, composition, camera, and splat-effector clips, with keyframe lanes, transitions, multicam grouping, pick-whip parenting, and slot-grid playback.
---
## Track Types
### Video Tracks
-- Hold video, image, Lottie, text, solid, motion shape, mesh, composition, camera, and splat-effector clips.
+- Hold video, image, Lottie, Rive, text, solid, motion shape, mesh, composition, camera, and splat-effector clips.
- Higher tracks render on top of lower tracks.
- Expanded tracks can show keyframe property rows and curve editors.
- Default layout starts with `Video 2` above `Video 1`.
@@ -53,12 +53,14 @@ getTrackChildren() // Query child tracks
- Created through the timeline text slice.
- Supports typography, stroke, shadow, and path text.
-### Lottie
-- Imported from `.lottie` packages or Lottie JSON files from the Media Panel.
-- Uses the same canvas-backed render path as text and solids, so preview, nested comps, and export stay deterministic.
+### Vector Animation
+- Lottie is imported from `.lottie` packages or Lottie JSON files from the Media Panel.
+- Rive is imported from `.riv` files and rendered through the Rive WASM canvas runtime.
+- Both providers use the same canvas-backed render path as text and solids, so preview, nested comps, and export stay aligned.
- Exposes per-clip loop, end behavior, playback mode, fit, render resolution, animation selection, and background controls in the Properties panel.
-- `.lottie` state machines can be selected in the Lottie tab, with state changes stored as blue stepped keyframes.
-- Boolean and numeric `.lottie` state-machine inputs appear as normal stopwatch-keyframed properties.
+- State machines can be selected in the provider tab, with state changes stored as blue stepped keyframes when state names are available.
+- Boolean and numeric state-machine inputs appear as normal stopwatch-keyframed properties.
+- Rive Data Binding exposes view models, instances, static string/enum values, and keyframed numeric/boolean/color values.
- When loop is enabled, the clip can be extended beyond its source duration on the right trim edge without freezing on the first pass.
### Solid
@@ -107,7 +109,7 @@ getTrackChildren() // Query child tracks
### Copy and Paste
- Copying clips includes linked audio automatically when the video clip is selected.
-- Copy/paste preserves Lottie clip type and vector animation settings.
+- Copy/paste preserves vector animation clip type and vector animation settings.
- Copy/paste preserves motion shape definitions.
- Copying keyframes stores them relative to the earliest copied keyframe.
- Pasting keyframes targets the selected clip when exactly one clip is selected; otherwise it falls back to the original clip from the clipboard data.
@@ -143,7 +145,7 @@ getTrackChildren() // Query child tracks
- The UI hides `rotation.x`, `rotation.y`, `position.z`, and `scale.z` for 2D clips.
- Camera clips and native-render gaussian splats keep the camera-style property model visible.
- Numeric effect parameters appear as `effect.{effectId}.{paramName}` lanes.
-- Lottie state changes appear as `lottieState.{stateMachine}` lanes; state-machine inputs appear as `lottieInput.{stateMachine}.{input}` lanes.
+- Vector animation state changes appear as `lottieState.{stateMachine}` lanes; state-machine inputs appear as `lottieInput.{stateMachine}.{input}` lanes. Rive Data Binding values appear as `riveData.{property}` lanes.
- Motion shape numeric lanes use registry paths such as `shape.size.w` and `appearance.{id}.stroke.width`.
- Audio EQ lanes sort `volume` and the band parameters first.
@@ -162,7 +164,7 @@ getTrackChildren() // Query child tracks
- Composition changes propagate into nested render data.
- Selected clips can be converted into a new nested composition from the clip context menu.
- Composition switches trigger clip entrance/exit animations in the timeline UI.
-- Lottie clips inside nested comps render through the same canvas path used in the primary timeline and export flow.
+- Vector animation clips inside nested comps render through the same canvas path used in the primary timeline and export flow.
### Transitions
- Transitions operate between adjacent clips on the same track.
diff --git a/docs/Features/UI-Panels.md b/docs/Features/UI-Panels.md
index 7ed2ce32..7c5afe94 100644
--- a/docs/Features/UI-Panels.md
+++ b/docs/Features/UI-Panels.md
@@ -343,7 +343,7 @@ The unified Properties panel adapts its tabs to the selected clip type and to sl
| Clip Type | Tabs |
|-----------|------|
-| **Lottie** | Lottie, Transform, Effects, Masks |
+| **Vector Animation** | Lottie/Rive, Transform, Effects, Masks |
| **Gaussian avatar** | Blendshapes, Transform, Effects, Masks |
| **Gaussian splat** | Transform, Gaussian Splat, Effects, Masks |
| **Camera** | Transform |
diff --git a/docs/Features/Vector-Animation.md b/docs/Features/Vector-Animation.md
index 6edfa995..9c557e01 100644
--- a/docs/Features/Vector-Animation.md
+++ b/docs/Features/Vector-Animation.md
@@ -2,9 +2,7 @@
# Vector Animation
-Vector animation clips currently ship through the Lottie path. `.lottie` packages and Lottie JSON files import as first-class media items, render through the same timeline/export pipeline as other clips, and expose clip-specific controls in the Properties panel.
-
-`rive` is still only a reserved type in the data model. It is not wired into import, runtime playback, or export yet.
+Vector animation clips support Lottie and Rive as first-class media items. `.lottie`, Lottie JSON, and `.riv` files import into the Media panel, render through the same timeline/export pipeline as other clips, and expose clip-specific controls in the Properties panel.
---
@@ -12,15 +10,16 @@ Vector animation clips currently ship through the Lottie path. `.lottie` package
- `.lottie` packages
- Lottie JSON files when the JSON structure is positively identified as a Lottie animation
+- `.riv` Rive files via `@rive-app/canvas`
-The import path does not treat arbitrary `.json` files as animation. Files are sniffed first, then promoted to `type: 'lottie'` only when the payload matches expected Lottie structure.
+The import path does not treat arbitrary `.json` files as animation. Files are sniffed first, then promoted to `type: 'lottie'` only when the payload matches expected Lottie structure. Rive imports are extension-based and use `type: 'rive'`.
---
## Timeline Behavior
-- Lottie clips live on video tracks.
-- The clip bar shows an `L` badge in the timeline.
+- Vector animation clips live on video tracks.
+- The clip bar shows an `L` badge for Lottie and an `R` badge for Rive.
- `naturalDuration`, frame rate, dimensions, animation names, and other vector metadata are extracted during import.
- Loop-enabled clips can be extended beyond their source duration on the right trim edge.
- Copy/paste, nested compositions, slot decks, and background-layer playback preserve the clip type and vector animation settings.
@@ -29,7 +28,7 @@ The import path does not treat arbitrary `.json` files as animation. Files are s
## Properties Panel
-Lottie clips add a dedicated `Lottie` tab in the unified Properties panel.
+Vector animation clips add a dedicated provider tab in the unified Properties panel: `Lottie` for Lottie clips and `Rive` for Rive clips.
Current controls:
@@ -38,10 +37,14 @@ Current controls:
- Playback mode: `forward`, `reverse`, `bounce`, or `reverse-bounce`
- Fit: `contain`, `cover`, or `fill`
- Render resolution override with fallback to the imported animation size
-- Animation picker when a `.lottie` package exposes multiple animations
-- State Machine picker when a `.lottie` package exposes state machines
+- Rive artboard picker when the file exposes multiple artboards
+- Animation picker when the file exposes multiple animations
+- State Machine picker when the file exposes state machines
- State override plus stepped state keyframes for discrete timeline-driven state changes
- Boolean and numeric state-machine inputs as normal stopwatch keyframe properties
+- Rive view model and instance picker for Data Binding
+- Rive boolean, numeric, integer, and color Data Binding properties as stopwatch keyframe properties
+- Rive string and enum Data Binding properties as static clip settings
- Background color override
The tab also shows the clip name plus imported width, height, and frame rate metadata when available.
@@ -50,7 +53,10 @@ The tab also shows the clip name plus imported width, height, and frame rate met
## Rendering
-Lottie playback is driven by `src/services/vectorAnimation/LottieRuntimeManager.ts`.
+Runtime playback is split by provider and routed through `src/services/vectorAnimation/VectorAnimationRuntimeManager.ts`.
+
+- Lottie playback is driven by `src/services/vectorAnimation/LottieRuntimeManager.ts`.
+- Rive playback is driven by `src/services/vectorAnimation/RiveRuntimeManager.ts` using `@rive-app/canvas`.
- Each clip gets a dedicated runtime canvas.
- The runtime canvas can use the imported animation size or the clip-level render resolution override.
@@ -58,6 +64,9 @@ Lottie playback is driven by `src/services/vectorAnimation/LottieRuntimeManager.
- Bounce modes are resolved in the timeline-time mapping, so preview and export render the same ping-pong frames.
- If a state machine is selected, `lottieState.{stateMachine}` keyframes resolve the active state at the current timeline time before the frame is rendered.
- If state-machine inputs are keyframed, the interpolated input values are applied before the frame is rendered.
+- Rive Data Binding values use `riveData.{property}` keyframes for numeric, boolean, integer, and color properties and are applied before draw.
+- Rive Events are subscribed through `EventType.RiveEvent` with automatic event side effects disabled. Events are logged for debugging rather than opening URLs or running implicit browser actions.
+- Rive runtime asset loading keeps the Rive CDN fallback enabled and leaves a custom asset-loader hook in place for future project-local asset resolution.
- The runtime canvas is marked as dynamic, so `TextureManager` re-uploads it every frame instead of caching only the first frame.
- The same canvas-backed source flows through preview, nested comps, slot/background playback, thumbnails, and export.
@@ -71,11 +80,11 @@ Saved data includes:
- media-level vector metadata
- clip-level `vectorAnimationSettings`
-- Lottie playback mode, render resolution, state machine selection, static state override, state keyframes, and state-machine input values
-- serialized timeline clip type `lottie`
+- playback mode, render resolution, artboard, state machine selection, static state override, state keyframes, state-machine input values, view model selection, and Data Binding values
+- serialized timeline clip type `lottie` or `rive`
- clipboard payloads and nested-composition clip data
-On project load, the app restores the Lottie clip metadata from project data and recreates the runtime from the file, the copied `Raw/` media, or a recovered file handle.
+On project load, the app restores vector animation metadata from project data and recreates the runtime from the file, the copied `Raw/` media, or a recovered file handle.
If a retained `File` object still exists after refresh but the browser object URL is dead, the Media panel regenerates the missing URL and image/video thumbnail automatically.
@@ -83,21 +92,21 @@ If a retained `File` object still exists after refresh but the browser object UR
## Export
-Lottie export does not use a separate renderer.
+Vector animation export does not use a separate renderer.
- The export layer builder asks the runtime for the correct frame at the current export time.
- That frame is composited through the normal GPU path with effects, transforms, masks, nested comps, and other layers.
- Output is rasterized into the final render like any other canvas-backed source.
-This keeps Lottie clips deterministic in fast preview, precise export, and image export.
+This keeps vector animation clips aligned in fast preview, precise export, and image export.
---
## Current Limits
-- Only Lottie is implemented today. Rive is not.
-- State machine support currently targets `.lottie` packages through `@lottiefiles/dotlottie-web`; Rive state machines are still not wired.
-- Boolean and numeric state-machine inputs are exposed as keyframe controls. String inputs are static for now, and trigger/event inputs are not deterministic timeline controls yet.
+- Rive state machines use the public high-level WASM runtime. Input values are timeline-driven, but state-machine internal progression is limited by the high-level runtime API.
+- Boolean and numeric state-machine inputs are exposed as keyframe controls. String inputs are static for Lottie, and trigger/event inputs are not deterministic timeline controls yet.
+- Rive image/font/audio asset loading currently relies on embedded assets or the Rive CDN fallback. Project-local asset binding is a future extension point.
- State selection uses stepped `lottieState.{stateMachine}` keyframes rather than bezier curves because named states are discrete strings.
- Export output is rasterized; there is no vector-native export target.
- If no `Raw/` copy or file handle is available after reload, the clip still needs the normal relink flow.
diff --git a/package-lock.json b/package-lock.json
index 3f009099..47924526 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@huggingface/transformers": "^3.8.1",
"@lottiefiles/dotlottie-web": "^0.71.0",
"@playcanvas/splat-transform": "^1.10.1",
+ "@rive-app/canvas": "^2.37.7",
"@webgpu/types": "^0.1.66",
"fflate": "^0.8.2",
"gifenc": "^1.0.3",
@@ -2113,6 +2114,12 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
+ "node_modules/@rive-app/canvas": {
+ "version": "2.37.7",
+ "resolved": "https://registry.npmjs.org/@rive-app/canvas/-/canvas-2.37.7.tgz",
+ "integrity": "sha512-dS2W4igbETc3zxWhDO8x8wyB8HrtZCG48ofODBijHbV5lINYTz8Xx3P1mHtK/ywzKWhaSSAI95QTz9FqJG1ghQ==",
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
diff --git a/package.json b/package.json
index 5e8c897a..634953f3 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"@huggingface/transformers": "^3.8.1",
"@lottiefiles/dotlottie-web": "^0.71.0",
"@playcanvas/splat-transform": "^1.10.1",
+ "@rive-app/canvas": "^2.37.7",
"@webgpu/types": "^0.1.66",
"fflate": "^0.8.2",
"gifenc": "^1.0.3",
diff --git a/src/components/panels/properties/LottieTab.tsx b/src/components/panels/properties/LottieTab.tsx
index 9c1b73e7..87ccb79c 100644
--- a/src/components/panels/properties/LottieTab.tsx
+++ b/src/components/panels/properties/LottieTab.tsx
@@ -4,17 +4,24 @@ import { useMediaStore } from '../../../stores/mediaStore';
import { useTimelineStore } from '../../../stores/timeline';
import {
DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
+ coerceVectorAnimationDataBindingValue,
coerceVectorAnimationInputValue,
+ createVectorAnimationDataBindingProperty,
createVectorAnimationInputProperty,
createVectorAnimationStateProperty,
+ getVectorAnimationDataBindingDefaultValue,
getVectorAnimationInputNumericValue,
getVectorAnimationStateIndex,
+ isVectorAnimationSourceType,
normalizeVectorAnimationRenderDimension,
normalizeVectorAnimationStateCues,
normalizeVectorAnimationStateName,
+ vectorAnimationDataBindingValueToNumber,
vectorAnimationInputValueToNumber,
type VectorAnimationPlaybackMode,
type VectorAnimationClipSettings,
+ type VectorAnimationDataBindingProperty,
+ type VectorAnimationDataBindingValue,
type VectorAnimationStateMachineInput,
type VectorAnimationStateMachineInputValue,
} from '../../../types/vectorAnimation';
@@ -49,10 +56,31 @@ function formatInputType(input: VectorAnimationStateMachineInput): string {
return 'Trigger';
}
+function formatDataBindingType(property: VectorAnimationDataBindingProperty): string {
+ if (property.type === 'boolean') return 'Bool';
+ if (property.type === 'integer') return 'Integer';
+ if (property.type === 'number') return 'Number';
+ if (property.type === 'color') return 'Color';
+ if (property.type === 'enum') return 'Enum';
+ if (property.type === 'string') return 'Text';
+ return 'Trigger';
+}
+
function formatDimensionValue(value: number | undefined): string {
return value === undefined ? '' : String(value);
}
+function riveColorToHex(value: VectorAnimationDataBindingValue | undefined): string {
+ const numericValue = vectorAnimationDataBindingValueToNumber(value);
+ const rgb = numericValue & 0xffffff;
+ return `#${rgb.toString(16).padStart(6, '0')}`;
+}
+
+function hexToRiveColor(value: string): number {
+ const normalized = /^#[0-9a-f]{6}$/i.test(value) ? value.slice(1) : '000000';
+ return 0xff000000 | Number.parseInt(normalized, 16);
+}
+
export function LottieTab({ clipId }: LottieTabProps) {
const clip = useTimelineStore((state) => state.clips.find((current) => current.id === clipId));
const playheadPosition = useTimelineStore((state) => state.playheadPosition);
@@ -69,11 +97,13 @@ export function LottieTab({ clipId }: LottieTabProps) {
? files.find((file) => file.id === clip.source?.mediaFileId)
: undefined;
const metadata = mediaFile?.vectorAnimation;
+ const providerName = metadata?.provider === 'rive' ? 'Rive' : 'Lottie';
const settings: VectorAnimationClipSettings = {
...DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
...clip?.source?.vectorAnimationSettings,
};
const animationNames = metadata?.animationNames ?? [];
+ const artboardNames = metadata?.artboardNames ?? [];
const stateMachineNames = metadata?.stateMachineNames ?? [];
const selectedStateMachineName = settings.stateMachineName ?? '';
const stateMachineStateNames = selectedStateMachineName
@@ -113,11 +143,17 @@ export function LottieTab({ clipId }: LottieTabProps) {
: settings;
const currentStateName = liveSettings.stateMachineState ?? settings.stateMachineState ?? stateMachineStateNames[0] ?? '';
const currentStateIndex = getVectorAnimationStateIndex(stateMachineStateNames, currentStateName);
+ const viewModels = metadata?.viewModels ?? [];
+ const selectedViewModelName = settings.viewModelName ?? metadata?.defaultViewModelName ?? viewModels[0]?.name ?? '';
+ const selectedViewModel = selectedViewModelName
+ ? viewModels.find((viewModel) => viewModel.name === selectedViewModelName)
+ : undefined;
+ const dataBindingProperties = selectedViewModel?.properties ?? [];
const updateSettings = useCallback((updates: Partial) => {
const { clips } = useTimelineStore.getState();
const current = clips.find((candidate) => candidate.id === clipId);
- if (!current?.source || current.source.type !== 'lottie') {
+ if (!current?.source || !isVectorAnimationSourceType(current.source.type)) {
return;
}
@@ -247,6 +283,45 @@ export function LottieTab({ clipId }: LottieTabProps) {
);
};
+ const getDataBindingValue = (
+ property: VectorAnimationDataBindingProperty,
+ ): VectorAnimationDataBindingValue => (
+ coerceVectorAnimationDataBindingValue(
+ property,
+ liveSettings.dataBindingValues?.[property.name] ??
+ settings.dataBindingValues?.[property.name] ??
+ getVectorAnimationDataBindingDefaultValue(property),
+ )
+ );
+
+ const updateDataBindingValue = (
+ property: VectorAnimationDataBindingProperty,
+ value: VectorAnimationDataBindingValue,
+ ) => {
+ if (property.type === 'trigger') {
+ return;
+ }
+
+ const normalizedValue = coerceVectorAnimationDataBindingValue(property, value);
+ if (property.type === 'string' || property.type === 'enum') {
+ updateSettings({
+ viewModelName: selectedViewModelName || settings.viewModelName,
+ dataBindingValues: {
+ ...(settings.dataBindingValues ?? {}),
+ [property.name]: normalizedValue,
+ },
+ });
+ return;
+ }
+
+ const propertyPath = createVectorAnimationDataBindingProperty(property.name);
+ setPropertyValue(
+ clipId,
+ propertyPath as AnimatableProperty,
+ vectorAnimationDataBindingValueToNumber(normalizedValue),
+ );
+ };
+
const commitRenderDimensions = (draft: Pick = resolutionDraft) => {
const width = normalizeVectorAnimationRenderDimension(Number(draft.width));
const height = normalizeVectorAnimationRenderDimension(Number(draft.height));
@@ -276,7 +351,7 @@ export function LottieTab({ clipId }: LottieTabProps) {
}
};
- if (!clip || clip.source?.type !== 'lottie') {
+ if (!clip || !isVectorAnimationSourceType(clip.source?.type)) {
return null;
}
@@ -287,7 +362,7 @@ export function LottieTab({ clipId }: LottieTabProps) {
{clip.name}
- {metadata?.width && metadata?.height ? `${metadata.width} x ${metadata.height}` : 'Canvas-backed animation'}
+ {metadata?.width && metadata?.height ? `${metadata.width} x ${metadata.height}` : `${providerName} canvas animation`}
{metadata?.fps ? ` - ${metadata.fps.toFixed(2)} fps` : ''}
@@ -364,6 +439,33 @@ export function LottieTab({ clipId }: LottieTabProps) {
+ {artboardNames.length > 0 && (
+
+ Artboard
+ updateSettings({
+ artboard: event.target.value || undefined,
+ animationName: undefined,
+ stateMachineName: undefined,
+ stateMachineState: undefined,
+ stateMachineInputValues: undefined,
+ viewModelName: undefined,
+ viewModelInstanceName: undefined,
+ dataBindingValues: undefined,
+ })}
+ >
+ Default
+ {artboardNames.map((artboardName) => (
+
+ {artboardName}
+
+ ))}
+
+
+ )}
+
{animationNames.length > 0 && (
Animation
@@ -623,6 +725,149 @@ export function LottieTab({ clipId }: LottieTabProps) {
)}
+ {viewModels.length > 0 && (
+
+
Data Binding
+
+
+ View Model
+ updateSettings({
+ viewModelName: event.target.value || undefined,
+ viewModelInstanceName: undefined,
+ dataBindingValues: undefined,
+ })}
+ >
+ {viewModels.map((viewModel) => (
+
+ {viewModel.name}
+
+ ))}
+
+
+
+ {selectedViewModel?.instanceNames && selectedViewModel.instanceNames.length > 0 && (
+
+ Instance
+ updateSettings({
+ viewModelInstanceName: event.target.value || undefined,
+ dataBindingValues: undefined,
+ })}
+ >
+ Default
+ {selectedViewModel.instanceNames.map((instanceName) => (
+
+ {instanceName}
+
+ ))}
+
+
+ )}
+
+ {dataBindingProperties.length > 0 && (
+
+
Properties
+ {dataBindingProperties.map((property) => {
+ const propertyPath = createVectorAnimationDataBindingProperty(property.name);
+ const value = getDataBindingValue(property);
+ const numericValue = vectorAnimationDataBindingValueToNumber(value);
+ const isBooleanOn = Boolean(value);
+ const hasKeyframesForProperty = (clipKeyframes.get(clipId) ?? []).some((keyframe) => keyframe.property === propertyPath);
+
+ return (
+
+
+ {property.name}
+ {formatDataBindingType(property)}
+
+
+ {property.type !== 'string' && property.type !== 'enum' && property.type !== 'trigger' && (
+
+ )}
+
+ {property.type === 'boolean' && (
+
+ updateDataBindingValue(property, false)}
+ >
+ Off
+
+ updateDataBindingValue(property, true)}
+ >
+ On
+
+
+ )}
+
+ {(property.type === 'number' || property.type === 'integer') && (
+
updateDataBindingValue(property, nextValue)}
+ defaultValue={vectorAnimationDataBindingValueToNumber(property.defaultValue)}
+ decimals={property.type === 'integer' ? 0 : 2}
+ sensitivity={20}
+ />
+ )}
+
+ {property.type === 'color' && (
+
+ updateDataBindingValue(property, hexToRiveColor(event.target.value))}
+ aria-label={`${property.name} color`}
+ />
+
+ )}
+
+ {property.type === 'string' && (
+ updateDataBindingValue(property, event.target.value)}
+ />
+ )}
+
+ {property.type === 'enum' && (
+ updateDataBindingValue(property, event.target.value)}
+ >
+ {(property.values ?? []).map((enumValue) => (
+
+ {enumValue}
+
+ ))}
+
+ )}
+
+ {property.type === 'trigger' && (
+ Trigger
+ )}
+
+ );
+ })}
+
+ )}
+
+ )}
+
Background
diff --git a/src/components/panels/properties/index.tsx b/src/components/panels/properties/index.tsx
index 3d47d914..be517945 100644
--- a/src/components/panels/properties/index.tsx
+++ b/src/components/panels/properties/index.tsx
@@ -4,6 +4,7 @@ import { useMediaStore } from '../../../stores/mediaStore';
import { useTimelineStore } from '../../../stores/timeline';
import { useEngineStore } from '../../../stores/engineStore';
import { DEFAULT_TEXT_3D_PROPERTIES } from '../../../stores/timeline/constants';
+import { isVectorAnimationSourceType } from '../../../types/vectorAnimation';
import { TextTab } from '../TextTab';
import './PropertiesPanel.css';
import './EffectsTab.css';
@@ -77,7 +78,8 @@ export function PropertiesPanel() {
const isSolidClip = selectedClip?.source?.type === 'solid';
const isMathSceneClip = selectedClip?.source?.type === 'math-scene';
const isMotionShapeClip = selectedClip?.source?.type === 'motion-shape';
- const isLottieClip = selectedClip?.source?.type === 'lottie';
+ const isVectorAnimationClip = isVectorAnimationSourceType(selectedClip?.source?.type);
+ const vectorAnimationTabLabel = selectedClip?.source?.type === 'rive' ? 'Rive' : 'Lottie';
const selectedMeshType = selectedClip?.meshType ?? selectedClip?.source?.meshType;
const is3DTextClip = selectedClip?.source?.type === 'model' && selectedMeshType === 'text3d';
const selectedText3DProperties = is3DTextClip
@@ -145,7 +147,7 @@ export function PropertiesPanel() {
// Set appropriate default tab based on clip type
if (isGaussianAvatar) {
setActiveTab('blendshapes');
- } else if (isLottieClip) {
+ } else if (isVectorAnimationClip) {
setActiveTab('lottie');
} else if (isCameraClip) {
setActiveTab('transform');
@@ -178,13 +180,13 @@ export function PropertiesPanel() {
(!isGaussianSplat && activeTab === 'gaussian-splat') ||
(!isCameraClip && activeTab === 'camera') ||
(!isSplatEffectorClip && activeTab === 'splat-effector') ||
- (!isLottieClip && activeTab === 'lottie')
+ (!isVectorAnimationClip && activeTab === 'lottie')
)
) {
setActiveTab('transform');
}
}
- }, [selectedClipId, isAudioClip, isTextClip, is3DTextClip, isMathSceneClip, isMotionShapeClip, isSolidClip, isLottieClip, isGaussianAvatar, isGaussianSplat, isCameraClip, isSplatEffectorClip, isSlotMode, lastClipId, activeTab]);
+ }, [selectedClipId, isAudioClip, isTextClip, is3DTextClip, isMathSceneClip, isMotionShapeClip, isSolidClip, isVectorAnimationClip, isGaussianAvatar, isGaussianSplat, isCameraClip, isSplatEffectorClip, isSlotMode, lastClipId, activeTab]);
// Listen for external tab navigation requests (e.g. badge clicks in MediaPanel)
useEffect(() => {
@@ -340,9 +342,9 @@ export function PropertiesPanel() {
>
) : (
<>
- {isLottieClip && (
+ {isVectorAnimationClip && (
setActiveTab('lottie')}>
- Lottie
+ {vectorAnimationTabLabel}
)}
setActiveTab('transform')}>Transform
@@ -370,7 +372,7 @@ export function PropertiesPanel() {
setActiveTab('masks')}>
Masks {selectedClip.masks && selectedClip.masks.length > 0 && {selectedClip.masks.length} }
- {!isSolidClip && !isLottieClip && (
+ {!isSolidClip && !isVectorAnimationClip && (
<>
setActiveTab('transcript')}>
Transcript {selectedClip.transcript && selectedClip.transcript.length > 0 && {selectedClip.transcript.length} }
@@ -399,7 +401,7 @@ export function PropertiesPanel() {
{activeTab === '3d-text' && is3DTextClip && selectedText3DProperties && (
)}
- {activeTab === 'lottie' && isLottieClip && (
+ {activeTab === 'lottie' && isVectorAnimationClip && (
)}
{activeTab === 'math' && isMathSceneClip && selectedClip.mathScene && (
diff --git a/src/components/timeline/TimelineClip.tsx b/src/components/timeline/TimelineClip.tsx
index 6d69b3a6..b206a063 100644
--- a/src/components/timeline/TimelineClip.tsx
+++ b/src/components/timeline/TimelineClip.tsx
@@ -10,6 +10,7 @@ import { getLabelHex } from '../panels/media/labelColors';
// PickWhip disabled
import { Logger } from '../../services/logger';
import {
+ isVectorAnimationSourceType,
shouldLoopVectorAnimation,
} from '../../types/vectorAnimation';
import { ClipWaveform } from './components/ClipWaveform';
@@ -21,7 +22,7 @@ const log = Logger.create('TimelineClip');
const KEYFRAME_TICK_SNAP_THRESHOLD_PX = 10;
function canLoopExtendVectorClip(clip: TimelineClipProps['clip']): boolean {
- return clip.source?.type === 'lottie' &&
+ return isVectorAnimationSourceType(clip.source?.type) &&
shouldLoopVectorAnimation(clip.source.vectorAnimationSettings);
}
@@ -295,7 +296,9 @@ function TimelineClipComponent({
// Determine if this is a solid clip
const isSolidClip = clip.source?.type === 'solid';
const isMathSceneClip = clip.source?.type === 'math-scene';
- const isLottieClip = clip.source?.type === 'lottie';
+ const isVectorAnimationClip = isVectorAnimationSourceType(clip.source?.type);
+ const vectorAnimationIcon = clip.source?.type === 'rive' ? 'R' : 'L';
+ const vectorAnimationTitle = clip.source?.type === 'rive' ? 'Rive Clip' : 'Lottie Clip';
const isCameraClip = clip.source?.type === 'camera';
const isGaussianSplatClip = clip.source?.type === 'gaussian-splat';
const isSplatEffectorClip = clip.source?.type === 'splat-effector';
@@ -953,8 +956,8 @@ function TimelineClipComponent({
{isText3DClip ? '3T' : 'T'}
)}
- {isLottieClip && (
- L
+ {isVectorAnimationClip && (
+ {vectorAnimationIcon}
)}
{isMathSceneClip && (
ƒ
diff --git a/src/components/timeline/TimelineHeader.tsx b/src/components/timeline/TimelineHeader.tsx
index fca73151..636956b2 100644
--- a/src/components/timeline/TimelineHeader.tsx
+++ b/src/components/timeline/TimelineHeader.tsx
@@ -13,11 +13,14 @@ import {
parseMaskProperty,
} from '../../types';
import {
+ isVectorAnimationSourceType,
mergeVectorAnimationSettings,
+ parseVectorAnimationDataBindingProperty,
parseVectorAnimationInputProperty,
parseVectorAnimationStateProperty,
getVectorAnimationStateIndex,
getVectorAnimationStateLabelAtIndex,
+ vectorAnimationDataBindingValueToNumber,
vectorAnimationInputValueToNumber,
} from '../../types/vectorAnimation';
import { interpolateKeyframes } from '../../utils/keyframeInterpolation';
@@ -120,6 +123,10 @@ const getPropertyLabel = (prop: string, clip?: KeyframeTrackClip | null): string
if (lottieInput) {
return lottieInput.inputName;
}
+ const riveData = parseVectorAnimationDataBindingProperty(prop);
+ if (riveData) {
+ return riveData.propertyName;
+ }
if (parseVectorAnimationStateProperty(prop)) {
return 'State';
}
@@ -303,14 +310,14 @@ const formatValue = (value: number, prop: string, clip?: KeyframeTrackClip | nul
if (prop.includes('.volume')) return (value * 100).toFixed(0) + '%';
if (prop.includes('.band')) return (value > 0 ? '+' : '') + value.toFixed(1) + 'dB';
const lottieState = parseVectorAnimationStateProperty(prop);
- if (lottieState && clip?.source?.type === 'lottie') {
+ if (lottieState && isVectorAnimationSourceType(clip?.source?.type)) {
const mediaFileId = (clip as TimelineClip).mediaFileId ?? (clip as TimelineClip).source?.mediaFileId;
const stateNames = mediaFileId
? useMediaStore.getState().files.find((file) => file.id === mediaFileId)?.vectorAnimation?.stateMachineStates?.[lottieState.stateMachineName] ?? []
: [];
return getVectorAnimationStateLabelAtIndex(stateNames, value) ?? `State ${Math.round(value)}`;
}
- if (parseVectorAnimationInputProperty(prop)) return value === 0 || value === 1 ? (value >= 0.5 ? 'On' : 'Off') : value.toFixed(2);
+ if (parseVectorAnimationInputProperty(prop) || parseVectorAnimationDataBindingProperty(prop)) return value === 0 || value === 1 ? (value >= 0.5 ? 'On' : 'Off') : value.toFixed(2);
return value.toFixed(1);
};
@@ -340,12 +347,15 @@ const getValueFromVectorAnimationSettings = (
clipLocalTime: number,
): number | null => {
const parsed = parseVectorAnimationInputProperty(prop);
- if (!parsed || clip.source?.type !== 'lottie') {
+ const dataBinding = parseVectorAnimationDataBindingProperty(prop);
+ if ((!parsed && !dataBinding) || !isVectorAnimationSourceType(clip.source?.type)) {
return null;
}
const settings = mergeVectorAnimationSettings(clip.source.vectorAnimationSettings);
- const baseValue = vectorAnimationInputValueToNumber(settings.stateMachineInputValues?.[parsed.inputName]);
+ const baseValue = parsed
+ ? vectorAnimationInputValueToNumber(settings.stateMachineInputValues?.[parsed.inputName])
+ : vectorAnimationDataBindingValueToNumber(settings.dataBindingValues?.[dataBinding!.propertyName]);
return interpolateKeyframes(
keyframes as Keyframe[],
prop as AnimatableProperty,
@@ -361,7 +371,7 @@ const getValueFromVectorAnimationState = (
clipLocalTime: number,
): number | null => {
const parsed = parseVectorAnimationStateProperty(prop);
- if (!parsed || clip.source?.type !== 'lottie') {
+ if (!parsed || !isVectorAnimationSourceType(clip.source?.type)) {
return null;
}
@@ -522,7 +532,7 @@ function PropertyRow({
// Audio effect properties
if (prop.includes('.volume')) return 0.005; // 0-1 range
if (prop.includes('.band')) return 0.1; // dB range (-12 to 12)
- if (parseVectorAnimationInputProperty(prop)) return 0.02;
+ if (parseVectorAnimationInputProperty(prop) || parseVectorAnimationDataBindingProperty(prop)) return 0.02;
if (parseVectorAnimationStateProperty(prop)) return 0.2;
return 0.1;
};
@@ -549,7 +559,7 @@ function PropertyRow({
// Audio effect properties
if (prop.includes('.volume')) return 1; // 100%
if (prop.includes('.band')) return 0; // 0 dB (no boost/cut)
- if (parseVectorAnimationInputProperty(prop)) return 0;
+ if (parseVectorAnimationInputProperty(prop) || parseVectorAnimationDataBindingProperty(prop)) return 0;
if (parseVectorAnimationStateProperty(prop)) return 0;
return 0;
};
@@ -847,6 +857,8 @@ function TrackPropertyLabels({
const bLottieInput = parseVectorAnimationInputProperty(b);
const aLottieState = parseVectorAnimationStateProperty(a);
const bLottieState = parseVectorAnimationStateProperty(b);
+ const aRiveData = parseVectorAnimationDataBindingProperty(a);
+ const bRiveData = parseVectorAnimationDataBindingProperty(b);
if (aLottieState && bLottieState) return 0;
if (aLottieState) return -1;
if (bLottieState) return 1;
@@ -855,6 +867,11 @@ function TrackPropertyLabels({
}
if (aLottieInput) return -1;
if (bLottieInput) return 1;
+ if (aRiveData && bRiveData) {
+ return aRiveData.propertyName.localeCompare(bRiveData.propertyName);
+ }
+ if (aRiveData) return -1;
+ if (bRiveData) return 1;
// For effect properties, extract the param name and sort
if (a.startsWith('effect.') && b.startsWith('effect.')) {
diff --git a/src/components/timeline/hooks/useClipDrag.ts b/src/components/timeline/hooks/useClipDrag.ts
index 1bc22a03..a9713ac5 100644
--- a/src/components/timeline/hooks/useClipDrag.ts
+++ b/src/components/timeline/hooks/useClipDrag.ts
@@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import type { TimelineClip, TimelineTrack } from '../../../types';
+import { isVectorAnimationSourceType } from '../../../types/vectorAnimation';
import type { ClipDragState } from '../types';
import { Logger } from '../../../services/logger';
@@ -164,7 +165,7 @@ export function useClipDrag({
const sourceType = clipForTrackCheck?.source?.type;
const requiredTrackType: 'video' | 'audio' | null =
sourceType === 'audio' ? 'audio' :
- (sourceType === 'video' || sourceType === 'image' || sourceType === 'lottie' || sourceType === 'text' || sourceType === 'solid' || sourceType === 'model' || sourceType === 'gaussian-splat' || sourceType === 'camera' || sourceType === 'splat-effector' || sourceType === 'math-scene') ? 'video' :
+ (sourceType === 'video' || sourceType === 'image' || isVectorAnimationSourceType(sourceType) || sourceType === 'text' || sourceType === 'solid' || sourceType === 'model' || sourceType === 'gaussian-splat' || sourceType === 'camera' || sourceType === 'splat-effector' || sourceType === 'math-scene') ? 'video' :
null;
let currentY = 24;
diff --git a/src/components/timeline/hooks/useClipTrim.ts b/src/components/timeline/hooks/useClipTrim.ts
index be5fb7e4..e2cf2328 100644
--- a/src/components/timeline/hooks/useClipTrim.ts
+++ b/src/components/timeline/hooks/useClipTrim.ts
@@ -3,7 +3,7 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import type { TimelineClip, TimelineTrack } from '../../../types';
-import { shouldLoopVectorAnimation } from '../../../types/vectorAnimation';
+import { isVectorAnimationSourceType, shouldLoopVectorAnimation } from '../../../types/vectorAnimation';
import type { ClipTrimState } from '../types';
interface UseClipTrimProps {
@@ -28,7 +28,7 @@ interface UseClipTrimReturn {
}
function canLoopExtendVectorClip(clip: TimelineClip): boolean {
- return clip.source?.type === 'lottie' &&
+ return isVectorAnimationSourceType(clip.source?.type) &&
shouldLoopVectorAnimation(clip.source.vectorAnimationSettings);
}
diff --git a/src/components/timeline/hooks/useLayerSync.ts b/src/components/timeline/hooks/useLayerSync.ts
index 608bc265..5a0297e4 100644
--- a/src/components/timeline/hooks/useLayerSync.ts
+++ b/src/components/timeline/hooks/useLayerSync.ts
@@ -14,7 +14,8 @@ import { Logger } from '../../../services/logger';
import { getInterpolatedClipTransform } from '../../../utils/keyframeInterpolation';
import { getEffectiveScale } from '../../../utils/transformScale';
import { DEFAULT_TRANSFORM } from '../../../stores/timeline/constants';
-import { lottieRuntimeManager } from '../../../services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../../services/vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType } from '../../../types/vectorAnimation';
const log = Logger.create('useLayerSync');
@@ -269,8 +270,8 @@ export function useLayerSync({
},
});
} else if (nestedClip.source?.textCanvas) {
- if (nestedClip.source.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ if (isVectorAnimationSourceType(nestedClip.source.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
nestedClip,
nestedClip.startTime + nestedLocalTime,
getInterpolatedVectorAnimationSettings(nestedClip.id, nestedLocalTime),
@@ -807,8 +808,8 @@ export function useLayerSync({
}
} else if (clip?.source?.textCanvas) {
const textClipLocalTime = playheadPosition - clip.startTime;
- if (clip.source.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ if (isVectorAnimationSourceType(clip.source.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
clip,
playheadPosition,
getInterpolatedVectorAnimationSettings(clip.id, textClipLocalTime),
diff --git a/src/engine/export/ClipPreparation.ts b/src/engine/export/ClipPreparation.ts
index cab0f47c..eb9db491 100644
--- a/src/engine/export/ClipPreparation.ts
+++ b/src/engine/export/ClipPreparation.ts
@@ -15,7 +15,8 @@ import { bindSourceRuntimeForOwner } from '../../services/mediaRuntime/clipBindi
import { mediaRuntimeRegistry } from '../../services/mediaRuntime/registry';
import { ParallelDecodeManager } from '../ParallelDecodeManager';
import type { WebCodecsPlayer } from '../WebCodecsPlayer';
-import { lottieRuntimeManager } from '../../services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../services/vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType } from '../../types/vectorAnimation';
import type { MediaFile } from '../../stores/mediaStore/types';
const log = Logger.create('ClipPreparation');
@@ -368,26 +369,26 @@ export async function prepareClipsForExport(
return clip.startTime < endTime && clipEnd > startTime;
});
- const lottieClips: TimelineClip[] = [];
+ const vectorAnimationClips: TimelineClip[] = [];
for (const clip of videoClips) {
- if (clip.source?.type === 'lottie') {
- lottieClips.push(clip);
+ if (isVectorAnimationSourceType(clip.source?.type)) {
+ vectorAnimationClips.push(clip);
}
if (clip.isComposition && clip.nestedClips?.length) {
for (const nestedClip of clip.nestedClips) {
- if (nestedClip.source?.type === 'lottie') {
- lottieClips.push(nestedClip);
+ if (isVectorAnimationSourceType(nestedClip.source?.type)) {
+ vectorAnimationClips.push(nestedClip);
}
}
}
}
- if (lottieClips.length > 0) {
- await Promise.all(lottieClips.map(async (clip) => {
+ if (vectorAnimationClips.length > 0) {
+ await Promise.all(vectorAnimationClips.map(async (clip) => {
if (!clip.file) {
return;
}
- await lottieRuntimeManager.prepareClipSource(clip, clip.file);
+ await vectorAnimationRuntimeManager.prepareClipSource(clip, clip.file);
}));
}
diff --git a/src/engine/export/ExportLayerBuilder.ts b/src/engine/export/ExportLayerBuilder.ts
index a59d2bfb..91846828 100644
--- a/src/engine/export/ExportLayerBuilder.ts
+++ b/src/engine/export/ExportLayerBuilder.ts
@@ -15,7 +15,8 @@ import { getEffectiveScale } from '../../utils/transformScale';
import { getInterpolatedMotionLayer } from '../../utils/motionInterpolation';
import { DEFAULT_TEXT_3D_PROPERTIES, DEFAULT_TRANSFORM } from '../../stores/timeline/constants';
import { DEFAULT_GAUSSIAN_SPLAT_SETTINGS, type GaussianSplatSettings } from '../gaussian/types';
-import { lottieRuntimeManager } from '../../services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../services/vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType } from '../../types/vectorAnimation';
import {
getGaussianSplatSequenceFrame,
getGaussianSplatSequenceFrameRuntimeKey,
@@ -305,10 +306,10 @@ export function buildLayersAtTime(
is3D: true,
});
}
- // Handle text, solid, Lottie, and Math Scene clips
- else if ((clip.source?.type === 'text' || clip.source?.type === 'solid' || clip.source?.type === 'lottie' || clip.source?.type === 'math-scene') && clip.source.textCanvas) {
- if (clip.source.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ // Handle text, solid, vector animation, and Math Scene clips
+ else if ((clip.source?.type === 'text' || clip.source?.type === 'solid' || isVectorAnimationSourceType(clip.source?.type) || clip.source?.type === 'math-scene') && clip.source.textCanvas) {
+ if (isVectorAnimationSourceType(clip.source.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
clip,
time,
getVectorAnimationSettingsForExport(clip, clipLocalTime, ctx),
@@ -702,9 +703,9 @@ function buildNestedLayerForExport(
} as Layer;
}
- if ((nestedClip.source?.type === 'text' || nestedClip.source?.type === 'solid' || nestedClip.source?.type === 'lottie' || nestedClip.source?.type === 'math-scene') && nestedClip.source.textCanvas) {
- if (nestedClip.source.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ if ((nestedClip.source?.type === 'text' || nestedClip.source?.type === 'solid' || isVectorAnimationSourceType(nestedClip.source?.type) || nestedClip.source?.type === 'math-scene') && nestedClip.source.textCanvas) {
+ if (isVectorAnimationSourceType(nestedClip.source.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
nestedClip,
nestedClip.startTime + nestedClipLocalTime,
getVectorAnimationSettingsForExport(nestedClip, nestedClipLocalTime),
diff --git a/src/services/compositionRenderer.ts b/src/services/compositionRenderer.ts
index e28a5b9c..fdfeb999 100644
--- a/src/services/compositionRenderer.ts
+++ b/src/services/compositionRenderer.ts
@@ -24,13 +24,14 @@ import {
updateRuntimePlaybackTime,
} from './mediaRuntime/runtimePlayback';
import { mediaRuntimeRegistry } from './mediaRuntime/registry';
-import { lottieRuntimeManager } from './vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from './vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType, type VectorAnimationProvider } from '../types/vectorAnimation';
import { mathSceneRenderer } from './mathScene/MathSceneRenderer';
import { getEffectiveScale } from '../utils/transformScale';
type CompositionClipSourceEntry = {
clipId: string;
- type: 'video' | 'image' | 'audio' | 'text' | 'math-scene' | 'lottie';
+ type: 'video' | 'image' | 'audio' | 'text' | 'math-scene' | VectorAnimationProvider;
videoElement?: HTMLVideoElement;
webCodecsPlayer?: LayerSource['webCodecsPlayer'];
imageElement?: HTMLImageElement;
@@ -112,7 +113,8 @@ class CompositionRendererService {
};
}
- private buildSerializableLottieClip(clip: SerializableClip, file: File): TimelineClip {
+ private buildSerializableVectorAnimationClip(clip: SerializableClip, file: File): TimelineClip {
+ const sourceType = isVectorAnimationSourceType(clip.sourceType) ? clip.sourceType : 'lottie';
return {
id: clip.id,
trackId: clip.trackId,
@@ -123,7 +125,7 @@ class CompositionRendererService {
inPoint: clip.inPoint,
outPoint: clip.outPoint,
source: {
- type: 'lottie',
+ type: sourceType,
mediaFileId: clip.mediaFileId,
naturalDuration: clip.naturalDuration ?? clip.duration,
vectorAnimationSettings: clip.vectorAnimationSettings,
@@ -340,13 +342,13 @@ class CompositionRendererService {
continue;
}
- if ((sourceType === 'text' || sourceType === 'math-scene' || sourceType === 'lottie') && timelineClip.source.textCanvas) {
+ if ((sourceType === 'text' || sourceType === 'math-scene' || isVectorAnimationSourceType(sourceType)) && timelineClip.source.textCanvas) {
sources.clipSources.set(clip.id, {
clipId: clip.id,
type: sourceType,
textCanvas: timelineClip.source.textCanvas,
naturalDuration: clip.duration,
- ...(sourceType === 'lottie' ? { lottieClip: timelineClip } : {}),
+ ...(isVectorAnimationSourceType(sourceType) ? { lottieClip: timelineClip } : {}),
...(sourceType === 'math-scene' ? { mathSceneClip: timelineClip } : {}),
});
continue;
@@ -385,13 +387,13 @@ class CompositionRendererService {
timelineClip.source
),
});
- } else if ((sourceType === 'text' || sourceType === 'math-scene' || sourceType === 'lottie') && timelineClip.source.textCanvas) {
+ } else if ((sourceType === 'text' || sourceType === 'math-scene' || isVectorAnimationSourceType(sourceType)) && timelineClip.source.textCanvas) {
sources.clipSources.set(clip.id, {
clipId: clip.id,
type: sourceType,
textCanvas: timelineClip.source.textCanvas,
naturalDuration: clip.duration,
- ...(sourceType === 'lottie' ? { lottieClip: timelineClip } : {}),
+ ...(isVectorAnimationSourceType(sourceType) ? { lottieClip: timelineClip } : {}),
...(sourceType === 'math-scene' ? { mathSceneClip: timelineClip } : {}),
});
}
@@ -444,8 +446,8 @@ class CompositionRendererService {
loadPromises.push(this.loadVideoSource(sources, serializableClip, mediaFile.file));
} else if (sourceType === 'image') {
loadPromises.push(this.loadImageSource(sources, serializableClip, mediaFile.file));
- } else if (sourceType === 'lottie') {
- loadPromises.push(this.loadLottieSource(sources, serializableClip, mediaFile.file));
+ } else if (isVectorAnimationSourceType(sourceType)) {
+ loadPromises.push(this.loadVectorAnimationSource(sources, serializableClip, mediaFile.file));
}
}
@@ -563,26 +565,26 @@ class CompositionRendererService {
});
}
- private async loadLottieSource(sources: CompositionSources, clip: SerializableClip, file: File): Promise {
+ private async loadVectorAnimationSource(sources: CompositionSources, clip: SerializableClip, file: File): Promise {
try {
- const lottieClip = this.buildSerializableLottieClip(clip, file);
- const runtime = await lottieRuntimeManager.prepareClipSource(lottieClip, file);
- lottieClip.source = {
- ...lottieClip.source!,
+ const vectorClip = this.buildSerializableVectorAnimationClip(clip, file);
+ const runtime = await vectorAnimationRuntimeManager.prepareClipSource(vectorClip, file);
+ vectorClip.source = {
+ ...vectorClip.source!,
textCanvas: runtime.canvas,
naturalDuration: runtime.metadata.duration ?? clip.naturalDuration ?? clip.duration,
};
sources.clipSources.set(clip.id, {
clipId: clip.id,
- type: 'lottie',
+ type: isVectorAnimationSourceType(clip.sourceType) ? clip.sourceType : 'lottie',
textCanvas: runtime.canvas,
file,
- lottieClip,
+ lottieClip: vectorClip,
naturalDuration: runtime.metadata.duration ?? clip.naturalDuration ?? clip.duration,
});
} catch (error) {
- log.error(`Failed to load lottie: ${file.name}`, error);
+ log.error(`Failed to load vector animation: ${file.name}`, error);
}
}
@@ -703,14 +705,14 @@ class CompositionRendererService {
);
} else if (source.imageElement) {
layerSource = this.getBaseLayerSource(source);
- } else if (source.type === 'lottie') {
+ } else if (isVectorAnimationSourceType(source.type)) {
const runtimeClip =
- isActiveComp && timelineClip.source?.type === 'lottie'
+ isActiveComp && isVectorAnimationSourceType(timelineClip.source?.type)
? timelineClip
: source.lottieClip;
if (runtimeClip) {
const runtimeClipLocalTime = Math.max(0, time - runtimeClip.startTime);
- lottieRuntimeManager.renderClipAtTime(
+ vectorAnimationRuntimeManager.renderClipAtTime(
runtimeClip,
time,
useTimelineStore.getState().getInterpolatedVectorAnimationSettings(
@@ -887,8 +889,8 @@ class CompositionRendererService {
},
} as Layer);
} else if (nestedClip.source?.textCanvas) {
- if (nestedClip.source.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ if (isVectorAnimationSourceType(nestedClip.source.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
nestedClip,
nestedTime,
useTimelineStore.getState().getInterpolatedVectorAnimationSettings(
diff --git a/src/services/layerBuilder/LayerBuilderService.ts b/src/services/layerBuilder/LayerBuilderService.ts
index 1ce624b5..c2ad8d32 100644
--- a/src/services/layerBuilder/LayerBuilderService.ts
+++ b/src/services/layerBuilder/LayerBuilderService.ts
@@ -43,7 +43,8 @@ import { getExpectedProxyFrameCount } from '../../stores/mediaStore/helpers/prox
import { DEFAULT_TRANSFORM, MAX_NESTING_DEPTH } from '../../stores/timeline/constants';
import { prewarmGaussianSplatRuntime } from '../../engine/scene/runtime/SharedSplatRuntimeCache';
import { resolveSharedSplatUseNativeRenderer } from '../../engine/scene/runtime/SharedSplatRuntimeUtils';
-import { lottieRuntimeManager } from '../vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType } from '../../types/vectorAnimation';
import { mathSceneRenderer } from '../mathScene/MathSceneRenderer';
const log = Logger.create('LayerBuilder');
@@ -255,7 +256,7 @@ export class LayerBuilderService {
buildLayersFromStore(): Layer[] {
// Create frame context (single store read)
const ctx = createFrameContext();
- this.syncActiveLottieClips(ctx);
+ this.syncActiveVectorAnimationClips(ctx);
this.syncActiveMathSceneClips(ctx);
const { activeLayerSlots = {}, activeCompositionId } = useMediaStore.getState();
const slotGridActive = useTimelineStore.getState().slotGridProgress > 0.5;
@@ -342,10 +343,10 @@ export class LayerBuilderService {
return ids;
}
- private syncActiveLottieClips(ctx: FrameContext): void {
+ private syncActiveVectorAnimationClips(ctx: FrameContext): void {
for (const clip of ctx.clipsAtTime) {
- if (clip.source?.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ if (isVectorAnimationSourceType(clip.source?.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
clip,
ctx.playheadPosition,
ctx.getInterpolatedVectorAnimationSettings(clip.id, ctx.playheadPosition - clip.startTime),
@@ -353,7 +354,7 @@ export class LayerBuilderService {
}
}
- lottieRuntimeManager.pruneClipRuntimes(this.collectKnownClipIds(ctx.clips));
+ vectorAnimationRuntimeManager.pruneClipRuntimes(this.collectKnownClipIds(ctx.clips));
}
private syncActiveMathSceneClips(ctx: FrameContext): void {
@@ -529,9 +530,9 @@ export class LayerBuilderService {
else if (clip.source?.imageElement) {
layer = this.buildImageLayer(clip, layerIndex, ctx, opacityOverride);
}
- // Lottie/vector canvas-backed clip
- else if (clip.source?.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ // Vector animation canvas-backed clip
+ else if (isVectorAnimationSourceType(clip.source?.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
clip,
ctx.playheadPosition,
ctx.getInterpolatedVectorAnimationSettings(clip.id, ctx.playheadPosition - clip.startTime),
@@ -1600,8 +1601,8 @@ export class LayerBuilderService {
source: { type: 'text', textCanvas: nestedClip.source.textCanvas },
} as Layer;
}
- } else if (nestedClip.source?.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ } else if (isVectorAnimationSourceType(nestedClip.source?.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
nestedClip,
nestedClip.startTime + nestedClipLocalTime,
ctx.getInterpolatedVectorAnimationSettings(nestedClip.id, nestedClipLocalTime),
diff --git a/src/services/layerPlaybackManager.ts b/src/services/layerPlaybackManager.ts
index 22569e7c..3e5cc890 100644
--- a/src/services/layerPlaybackManager.ts
+++ b/src/services/layerPlaybackManager.ts
@@ -17,7 +17,8 @@ import {
import { flags } from '../engine/featureFlags';
import { Logger } from './logger';
import { slotDeckManager } from './slotDeckManager';
-import { lottieRuntimeManager } from './vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from './vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType, type VectorAnimationProvider } from '../types/vectorAnimation';
import { getEffectiveScale } from '../utils/transformScale';
const log = Logger.create('LayerPlayback');
@@ -164,15 +165,15 @@ class LayerPlaybackManager {
} else if (sourceType === 'image' && fileUrl) {
clip.isLoading = true;
this.loadImageForClip(clip, layerIndex, fileUrl);
- } else if (sourceType === 'lottie' && mediaFile?.file) {
+ } else if (isVectorAnimationSourceType(sourceType) && mediaFile?.file) {
clip.isLoading = true;
clip.source = {
- type: 'lottie',
+ type: sourceType,
mediaFileId: serializedClip.mediaFileId,
naturalDuration: serializedClip.naturalDuration,
vectorAnimationSettings: serializedClip.vectorAnimationSettings,
};
- this.loadLottieForClip(clip, mediaFile.file);
+ this.loadVectorAnimationForClip(clip, mediaFile.file, sourceType);
} else {
clip.isLoading = false;
}
@@ -227,8 +228,8 @@ class LayerPlaybackManager {
clip.source.audioElement.src = '';
clip.source.audioElement.load();
}
- if (clip.source?.type === 'lottie') {
- lottieRuntimeManager.destroyClipRuntime(clip.id);
+ if (isVectorAnimationSourceType(clip.source?.type)) {
+ vectorAnimationRuntimeManager.destroyClipRuntime(clip.id, clip.source.type);
}
if (clip.source?.runtimeSourceId && clip.source.runtimeSessionKey) {
mediaRuntimeRegistry.releaseSession(
@@ -542,8 +543,8 @@ class LayerPlaybackManager {
// Build transform
const transform = clip.transform || DEFAULT_TRANSFORM;
- if (clip.source?.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(
+ if (isVectorAnimationSourceType(clip.source?.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(
clip,
time,
useTimelineStore.getState().getInterpolatedVectorAnimationSettings(clip.id, clipLocalTime),
@@ -812,34 +813,34 @@ class LayerPlaybackManager {
}, { once: true });
}
- private loadLottieForClip(clip: TimelineClip, file: File): void {
+ private loadVectorAnimationForClip(clip: TimelineClip, file: File, sourceType: VectorAnimationProvider): void {
void (async () => {
try {
- if (clip.source?.type !== 'lottie') {
+ if (clip.source?.type !== sourceType) {
clip.source = {
- type: 'lottie',
+ type: sourceType,
mediaFileId: clip.mediaFileId,
naturalDuration: clip.duration,
};
}
- const runtime = await lottieRuntimeManager.prepareClipSource(clip, file);
+ const runtime = await vectorAnimationRuntimeManager.prepareClipSource(clip, file);
const naturalDuration =
runtime.metadata.duration ??
clip.source?.naturalDuration ??
clip.duration;
clip.file = file;
clip.source = {
- type: 'lottie',
+ type: sourceType,
textCanvas: runtime.canvas,
mediaFileId: clip.mediaFileId,
naturalDuration,
vectorAnimationSettings: clip.source?.vectorAnimationSettings,
};
clip.isLoading = false;
- lottieRuntimeManager.renderClipAtTime(clip, clip.startTime);
+ vectorAnimationRuntimeManager.renderClipAtTime(clip, clip.startTime);
} catch (error) {
clip.isLoading = false;
- log.warn(`Failed to load lottie for background clip ${clip.name}`, error);
+ log.warn(`Failed to load vector animation for background clip ${clip.name}`, error);
}
})();
}
diff --git a/src/services/project/projectLoad.ts b/src/services/project/projectLoad.ts
index 7af72a1b..d1062844 100644
--- a/src/services/project/projectLoad.ts
+++ b/src/services/project/projectLoad.ts
@@ -47,7 +47,8 @@ import {
findRelinkMatch,
} from './relinkMedia';
import { fromProjectTransform } from './transformSerialization';
-import { lottieRuntimeManager } from '../vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType } from '../../types/vectorAnimation';
import { mathSceneRenderer } from '../mathScene/MathSceneRenderer';
import type {
GaussianSplatSequenceData,
@@ -1807,35 +1808,36 @@ async function reloadNestedCompositionClips(): Promise {
}));
};
- if (sourceType === 'lottie') {
+ if (isVectorAnimationSourceType(sourceType)) {
try {
nestedClip.source = {
- type: 'lottie',
+ type: sourceType,
mediaFileId: nestedSerializedClip.mediaFileId,
naturalDuration: nestedSerializedClip.naturalDuration,
vectorAnimationSettings: nestedSerializedClip.vectorAnimationSettings,
};
- const runtime = await lottieRuntimeManager.prepareClipSource(nestedClip, nestedMediaFile.file);
+ const runtime = await vectorAnimationRuntimeManager.prepareClipSource(nestedClip, nestedMediaFile.file);
const naturalDuration =
runtime.metadata.duration ??
nestedSerializedClip.naturalDuration ??
nestedSerializedClip.duration;
nestedClip.source = {
- type: 'lottie',
+ type: sourceType,
textCanvas: runtime.canvas,
mediaFileId: nestedSerializedClip.mediaFileId,
naturalDuration,
vectorAnimationSettings: nestedSerializedClip.vectorAnimationSettings,
};
nestedClip.isLoading = false;
- lottieRuntimeManager.renderClipAtTime(nestedClip, nestedClip.startTime);
+ vectorAnimationRuntimeManager.renderClipAtTime(nestedClip, nestedClip.startTime);
notifyNestedReload();
} catch (error) {
nestedClip.isLoading = false;
- log.warn('Failed to reload nested lottie clip', {
+ log.warn('Failed to reload nested vector animation clip', {
compClipId: compClip.id,
nestedClipId: nestedClip.id,
+ sourceType,
error,
});
}
diff --git a/src/services/properties/registerCoreProperties.ts b/src/services/properties/registerCoreProperties.ts
index 483205de..ae0321bd 100644
--- a/src/services/properties/registerCoreProperties.ts
+++ b/src/services/properties/registerCoreProperties.ts
@@ -9,11 +9,17 @@ import {
} from '../../types';
import {
DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
+ coerceVectorAnimationDataBindingValue,
+ createVectorAnimationDataBindingProperty,
createVectorAnimationInputProperty,
createVectorAnimationStateProperty,
+ getVectorAnimationDataBindingDefaultValue,
+ isVectorAnimationSourceType,
mergeVectorAnimationSettings,
+ parseVectorAnimationDataBindingProperty,
parseVectorAnimationInputProperty,
parseVectorAnimationStateProperty,
+ type VectorAnimationDataBindingProperty,
} from '../../types/vectorAnimation';
import type {
AppearanceItem,
@@ -32,6 +38,7 @@ import { getAllEffects, getEffect } from '../../effects';
import type { PropertyDescriptor, PropertyValueType } from '../../types/propertyRegistry';
import type { PropertyRegistry } from './PropertyRegistry';
import { propertyRegistry } from './PropertyRegistry';
+import { useMediaStore } from '../../stores/mediaStore';
const colorParamDefsByKey = new Map(RUNTIME_COLOR_PARAM_DEFS.map((def) => [def.key, def]));
@@ -320,8 +327,25 @@ function getMaskDescriptorsForClip(clip: TimelineClip): PropertyDescriptor[] {
].filter((descriptor): descriptor is PropertyDescriptor => Boolean(descriptor)));
}
+function getVectorDataBindingProperty(
+ clip: TimelineClip,
+ propertyName: string,
+): VectorAnimationDataBindingProperty | undefined {
+ const mediaFileId = clip.mediaFileId ?? clip.source?.mediaFileId;
+ const metadata = mediaFileId
+ ? useMediaStore.getState().files.find((file) => file.id === mediaFileId)?.vectorAnimation
+ : undefined;
+ const settings = mergeVectorAnimationSettings(clip.source?.vectorAnimationSettings);
+ const viewModelName = settings.viewModelName ?? metadata?.defaultViewModelName;
+
+ return metadata?.dataBindingProperties?.find((property) => (
+ property.name === propertyName &&
+ (!viewModelName || !property.viewModelName || property.viewModelName === viewModelName)
+ ));
+}
+
function getVectorDescriptorForPath(path: string, clip?: TimelineClip): PropertyDescriptor | undefined {
- if (clip?.source?.type !== 'lottie') return undefined;
+ if (!isVectorAnimationSourceType(clip?.source?.type)) return undefined;
const stateProperty = parseVectorAnimationStateProperty(path);
if (stateProperty) {
@@ -333,7 +357,7 @@ function getVectorDescriptorForPath(path: string, clip?: TimelineClip): Property
valueType: 'enum',
animatable: true,
defaultValue: settings.stateMachineState ?? '',
- ui: { aliases: ['lottie state', stateProperty.stateMachineName] },
+ ui: { aliases: ['vector state', 'lottie state', 'rive state', stateProperty.stateMachineName] },
read: (targetClip) => mergeVectorAnimationSettings(targetClip.source?.vectorAnimationSettings).stateMachineState ?? '',
write: (targetClip, value) => ({
...targetClip,
@@ -354,7 +378,59 @@ function getVectorDescriptorForPath(path: string, clip?: TimelineClip): Property
}
const inputProperty = parseVectorAnimationInputProperty(path);
- if (!inputProperty) return undefined;
+ if (!inputProperty) {
+ const dataBindingPropertyPath = parseVectorAnimationDataBindingProperty(path);
+ if (!dataBindingPropertyPath) return undefined;
+
+ const settings = mergeVectorAnimationSettings(clip.source.vectorAnimationSettings);
+ const metadataProperty = getVectorDataBindingProperty(clip, dataBindingPropertyPath.propertyName);
+ const currentValue =
+ settings.dataBindingValues?.[dataBindingPropertyPath.propertyName] ??
+ (metadataProperty ? getVectorAnimationDataBindingDefaultValue(metadataProperty) : 0);
+ return {
+ path,
+ label: dataBindingPropertyPath.propertyName,
+ group: 'Vector Animation / Data Binding',
+ valueType: metadataProperty?.type === 'boolean'
+ ? 'boolean'
+ : metadataProperty?.type === 'string' || metadataProperty?.type === 'enum'
+ ? 'enum'
+ : metadataProperty?.type === 'color'
+ ? 'color'
+ : 'number',
+ animatable: metadataProperty?.type !== 'string' && metadataProperty?.type !== 'enum' && metadataProperty?.type !== 'trigger',
+ defaultValue: currentValue,
+ ui: {
+ aliases: ['rive data', 'data binding', dataBindingPropertyPath.propertyName],
+ options: metadataProperty?.values?.map((value) => ({ value, label: value })),
+ },
+ read: (targetClip) => {
+ const targetSettings = mergeVectorAnimationSettings(targetClip.source?.vectorAnimationSettings);
+ return targetSettings.dataBindingValues?.[dataBindingPropertyPath.propertyName] ?? currentValue;
+ },
+ write: (targetClip, value) => {
+ const targetSettings = mergeVectorAnimationSettings(targetClip.source?.vectorAnimationSettings);
+ const nextValue = metadataProperty
+ ? coerceVectorAnimationDataBindingValue(metadataProperty, value as boolean | number | string)
+ : value as boolean | number | string;
+ return {
+ ...targetClip,
+ source: targetClip.source
+ ? {
+ ...targetClip.source,
+ vectorAnimationSettings: {
+ ...targetSettings,
+ dataBindingValues: {
+ ...(targetSettings.dataBindingValues ?? {}),
+ [dataBindingPropertyPath.propertyName]: nextValue,
+ },
+ },
+ }
+ : targetClip.source,
+ };
+ },
+ };
+ }
const settings = mergeVectorAnimationSettings(clip.source.vectorAnimationSettings);
const currentValue = settings.stateMachineInputValues?.[inputProperty.inputName] ?? 0;
@@ -365,7 +441,7 @@ function getVectorDescriptorForPath(path: string, clip?: TimelineClip): Property
valueType: typeof currentValue === 'boolean' ? 'boolean' : typeof currentValue === 'string' ? 'enum' : 'number',
animatable: typeof currentValue !== 'string',
defaultValue: currentValue,
- ui: { aliases: ['lottie input', inputProperty.stateMachineName] },
+ ui: { aliases: ['vector input', 'lottie input', 'rive input', inputProperty.stateMachineName] },
read: (targetClip) => {
const targetSettings = mergeVectorAnimationSettings(targetClip.source?.vectorAnimationSettings);
return targetSettings.stateMachineInputValues?.[inputProperty.inputName] ?? currentValue;
@@ -393,7 +469,7 @@ function getVectorDescriptorForPath(path: string, clip?: TimelineClip): Property
}
function getVectorDescriptorsForClip(clip: TimelineClip): PropertyDescriptor[] {
- if (clip.source?.type !== 'lottie') return [];
+ if (!isVectorAnimationSourceType(clip.source?.type)) return [];
const settings = mergeVectorAnimationSettings(clip.source.vectorAnimationSettings);
const descriptors: PropertyDescriptor[] = [];
@@ -412,6 +488,13 @@ function getVectorDescriptorsForClip(clip: TimelineClip): PropertyDescriptor[] {
if (descriptor) descriptors.push(descriptor);
});
}
+ Object.keys(settings.dataBindingValues ?? {}).forEach((propertyName) => {
+ const descriptor = getVectorDescriptorForPath(
+ createVectorAnimationDataBindingProperty(propertyName),
+ clip,
+ );
+ if (descriptor) descriptors.push(descriptor);
+ });
return descriptors;
}
diff --git a/src/services/slotDeckManager.ts b/src/services/slotDeckManager.ts
index 065a7fd0..6204a916 100644
--- a/src/services/slotDeckManager.ts
+++ b/src/services/slotDeckManager.ts
@@ -6,7 +6,8 @@ import { useMediaStore } from '../stores/mediaStore';
import { DEFAULT_TRANSFORM } from '../stores/timeline/constants';
import { bindSourceRuntimeForOwner } from './mediaRuntime/clipBindings';
import { mediaRuntimeRegistry } from './mediaRuntime/registry';
-import { lottieRuntimeManager } from './vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from './vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType, type VectorAnimationProvider } from '../types/vectorAnimation';
type DecoderMode = SlotDeckState['decoderMode'];
type SlotDeckStatus = SlotDeckState['status'];
@@ -226,8 +227,8 @@ class SlotDeckManager {
clip.source.audioElement.src = '';
clip.source.audioElement.load();
}
- if (clip.source?.type === 'lottie') {
- lottieRuntimeManager.destroyClipRuntime(clip.id);
+ if (isVectorAnimationSourceType(clip.source?.type)) {
+ vectorAnimationRuntimeManager.destroyClipRuntime(clip.id, clip.source.type);
}
}
this.decks.delete(entry.slotIndex);
@@ -339,20 +340,20 @@ class SlotDeckManager {
}, { once: true });
}
- private loadLottieForClip(entry: SlotDeckEntry, clip: TimelineClip, file: File): void {
+ private loadVectorAnimationForClip(entry: SlotDeckEntry, clip: TimelineClip, file: File, sourceType: VectorAnimationProvider): void {
void (async () => {
try {
- if (clip.source?.type !== 'lottie') {
+ if (clip.source?.type !== sourceType) {
clip.source = {
- type: 'lottie',
+ type: sourceType,
mediaFileId: clip.mediaFileId,
naturalDuration: clip.duration,
};
}
- const runtime = await lottieRuntimeManager.prepareClipSource(clip, file);
+ const runtime = await vectorAnimationRuntimeManager.prepareClipSource(clip, file);
if (this.decks.get(entry.slotIndex) !== entry || entry.pendingDispose) {
- lottieRuntimeManager.destroyClipRuntime(clip.id);
+ vectorAnimationRuntimeManager.destroyClipRuntime(clip.id, sourceType);
return;
}
@@ -362,14 +363,14 @@ class SlotDeckManager {
clip.duration;
clip.file = file;
clip.source = {
- type: 'lottie',
+ type: sourceType,
textCanvas: runtime.canvas,
mediaFileId: clip.mediaFileId,
naturalDuration,
vectorAnimationSettings: clip.source?.vectorAnimationSettings,
};
clip.isLoading = false;
- lottieRuntimeManager.renderClipAtTime(clip, clip.startTime);
+ vectorAnimationRuntimeManager.renderClipAtTime(clip, clip.startTime);
this.markClipReady(entry, 'html', { visual: true });
} catch (error) {
clip.isLoading = false;
@@ -497,16 +498,16 @@ class SlotDeckManager {
entry.preparedClipCount += 1;
clip.isLoading = true;
this.loadImageForClip(entry, clip, fileUrl);
- } else if (sourceType === 'lottie' && mediaFile?.file) {
+ } else if (isVectorAnimationSourceType(sourceType) && mediaFile?.file) {
entry.preparedClipCount += 1;
clip.isLoading = true;
clip.source = {
- type: 'lottie',
+ type: sourceType,
mediaFileId: serializedClip.mediaFileId,
naturalDuration: serializedClip.naturalDuration,
vectorAnimationSettings: serializedClip.vectorAnimationSettings,
};
- this.loadLottieForClip(entry, clip, mediaFile.file);
+ this.loadVectorAnimationForClip(entry, clip, mediaFile.file, sourceType);
} else {
clip.isLoading = false;
}
diff --git a/src/services/thumbnailRenderer.ts b/src/services/thumbnailRenderer.ts
index f81537c1..d3ca92c1 100644
--- a/src/services/thumbnailRenderer.ts
+++ b/src/services/thumbnailRenderer.ts
@@ -2,6 +2,7 @@
// Shows all layers with effects, not just the first video
import { Logger } from './logger';
+import { isVectorAnimationSourceType } from '../types/vectorAnimation';
import { compositionRenderer } from './compositionRenderer';
import type { Effect, Layer } from '../types';
import { useMediaStore } from '../stores/mediaStore';
@@ -379,7 +380,7 @@ class ThumbnailRendererService {
c.sourceType === 'text' ||
c.sourceType === 'solid' ||
c.sourceType === 'math-scene' ||
- c.sourceType === 'lottie';
+ isVectorAnimationSourceType(c.sourceType);
return isOnVideoTrack && isVisualType;
});
diff --git a/src/services/vectorAnimation/RiveRuntimeManager.ts b/src/services/vectorAnimation/RiveRuntimeManager.ts
new file mode 100644
index 00000000..d067fbb4
--- /dev/null
+++ b/src/services/vectorAnimation/RiveRuntimeManager.ts
@@ -0,0 +1,693 @@
+import {
+ Alignment,
+ EventType,
+ Fit,
+ Layout,
+ Rive,
+ StateMachineInputType,
+ type AssetLoadCallback,
+ type Event as RiveRuntimeEvent,
+ type StateMachineInput,
+ type ViewModelInstance,
+} from '@rive-app/canvas';
+
+import type { TimelineClip } from '../../types';
+import {
+ coerceVectorAnimationDataBindingValue,
+ coerceVectorAnimationInputValue,
+ getVectorAnimationDataBindingDefaultValue,
+ getVectorAnimationInputDefaultValue,
+ isVectorAnimationBounceMode,
+ isVectorAnimationReverseStartMode,
+ mergeVectorAnimationSettings,
+ normalizeVectorAnimationRenderDimension,
+ normalizeVectorAnimationStateName,
+ shouldLoopVectorAnimation,
+ type VectorAnimationClipSettings,
+ type VectorAnimationDataBindingProperty,
+} from '../../types/vectorAnimation';
+import { Logger } from '../logger';
+import { prepareRiveAsset } from './riveMetadata';
+import type {
+ PreparedRiveAsset,
+ RiveRuntimePrepareResult,
+} from './types';
+
+const log = Logger.create('RiveRuntime');
+const DEFAULT_CANVAS_SIZE = 512;
+const DEFAULT_RIVE_DURATION = 5;
+const FRAME_EPSILON = 1 / 120;
+
+interface RiveRuntimeEntry {
+ asset: PreparedRiveAsset;
+ canvas: HTMLCanvasElement;
+ clipId: string;
+ isReady: boolean;
+ player: Rive;
+ settingsKey: string;
+ instanceKey: string;
+ activeStateMachineName?: string;
+ stateMachineInputs: Map;
+ lastInputValuesKey?: string;
+ lastTriggerValuesKey?: string;
+ boundViewModelKey?: string;
+ viewModelInstance?: ViewModelInstance;
+ lastDataBindingValuesKey?: string;
+ lastDataBindingTriggerKey?: string;
+ riveEventHandler: (event: RiveRuntimeEvent) => void;
+}
+
+function createCanvas(width?: number, height?: number): HTMLCanvasElement {
+ const canvas = document.createElement('canvas');
+ canvas.width = width && width > 0 ? width : DEFAULT_CANVAS_SIZE;
+ canvas.height = height && height > 0 ? height : DEFAULT_CANVAS_SIZE;
+ canvas.dataset.masterselectsDynamic = 'rive';
+ return canvas;
+}
+
+function getFit(settings: VectorAnimationClipSettings): Fit {
+ if (settings.fit === 'cover') return Fit.Cover;
+ if (settings.fit === 'fill') return Fit.Fill;
+ return Fit.Contain;
+}
+
+function createLayout(settings: VectorAnimationClipSettings): Layout {
+ return new Layout({
+ fit: getFit(settings),
+ alignment: Alignment.Center,
+ });
+}
+
+function waitForRiveLoad(params: ConstructorParameters[0]): Promise {
+ let player: Rive | null = null;
+
+ return new Promise((resolve, reject) => {
+ try {
+ player = new Rive({
+ ...params,
+ onLoad: () => {
+ if (player) {
+ resolve(player);
+ }
+ },
+ onLoadError: (event) => {
+ reject(event.data instanceof Error ? event.data : new Error('Failed to load Rive runtime'));
+ },
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+}
+
+function createAssetLoader(clipId: string): AssetLoadCallback {
+ return (_asset, bytes) => {
+ log.debug('Rive asset requested', { clipId, byteLength: bytes.byteLength });
+ return false;
+ };
+}
+
+function getSettingsKey(settings: VectorAnimationClipSettings): string {
+ return JSON.stringify({
+ backgroundColor: settings.backgroundColor ?? null,
+ fit: settings.fit,
+ loop: settings.loop,
+ endBehavior: settings.endBehavior,
+ playbackMode: settings.playbackMode,
+ renderWidth: settings.renderWidth ?? null,
+ renderHeight: settings.renderHeight ?? null,
+ });
+}
+
+function getInstanceKey(settings: VectorAnimationClipSettings): string {
+ return JSON.stringify({
+ animationName: settings.animationName ?? null,
+ artboard: settings.artboard ?? null,
+ stateMachineName: settings.stateMachineName ?? null,
+ viewModelName: settings.viewModelName ?? null,
+ viewModelInstanceName: settings.viewModelInstanceName ?? null,
+ });
+}
+
+function getRenderSize(
+ entry: RiveRuntimeEntry,
+ settings: VectorAnimationClipSettings,
+): { width: number; height: number } {
+ const width = normalizeVectorAnimationRenderDimension(settings.renderWidth)
+ ?? entry.asset.metadata.width
+ ?? DEFAULT_CANVAS_SIZE;
+ const height = normalizeVectorAnimationRenderDimension(settings.renderHeight)
+ ?? entry.asset.metadata.height
+ ?? DEFAULT_CANVAS_SIZE;
+ return { width, height };
+}
+
+function clearCanvas(canvas: HTMLCanvasElement): void {
+ const context = canvas.getContext('2d');
+ context?.clearRect(0, 0, canvas.width, canvas.height);
+}
+
+function getSourceDuration(clip: TimelineClip, duration: number): number {
+ if (Number.isFinite(duration) && duration > 0) {
+ return duration;
+ }
+ if (Number.isFinite(clip.source?.naturalDuration) && (clip.source?.naturalDuration ?? 0) > 0) {
+ return clip.source!.naturalDuration!;
+ }
+ return Math.max(clip.duration, FRAME_EPSILON);
+}
+
+function normalizeModulo(value: number, divisor: number): number {
+ if (!Number.isFinite(divisor) || divisor <= 0) {
+ return 0;
+ }
+ const result = value % divisor;
+ return result < 0 ? result + divisor : result;
+}
+
+function resolveAnimationTime(
+ clip: TimelineClip,
+ animationDuration: number,
+ settings: VectorAnimationClipSettings,
+ timelineTime: number,
+): number | null {
+ const clipLocalTime = Math.max(0, timelineTime - clip.startTime);
+ const sourceDuration = getSourceDuration(clip, animationDuration);
+ const sourceMaxTime = Math.max(0, sourceDuration - FRAME_EPSILON);
+ const sourceInPoint = Math.max(0, Math.min(clip.inPoint, sourceMaxTime));
+ const rawSourceOutPoint =
+ Number.isFinite(clip.outPoint) && clip.outPoint > sourceInPoint
+ ? clip.outPoint
+ : sourceDuration;
+ const sourceOutPoint = Math.max(
+ sourceInPoint + FRAME_EPSILON,
+ Math.min(rawSourceOutPoint, sourceDuration),
+ );
+ const sourceWindowDuration = Math.max(sourceOutPoint - sourceInPoint, FRAME_EPSILON);
+ const shouldLoop = shouldLoopVectorAnimation(settings);
+ const isBounceMode = isVectorAnimationBounceMode(settings.playbackMode);
+ const cycleDuration = isBounceMode
+ ? sourceWindowDuration * 2
+ : sourceWindowDuration;
+
+ if (!shouldLoop && settings.endBehavior === 'clear' && clipLocalTime >= cycleDuration) {
+ return null;
+ }
+
+ const wrappedLocalTime = shouldLoop
+ ? normalizeModulo(clipLocalTime, cycleDuration)
+ : Math.max(0, Math.min(clipLocalTime, Math.max(0, cycleDuration - FRAME_EPSILON)));
+ const sourceWindowLocalTime = isBounceMode && wrappedLocalTime > sourceWindowDuration
+ ? cycleDuration - wrappedLocalTime
+ : Math.min(wrappedLocalTime, sourceWindowDuration - FRAME_EPSILON);
+ const startsReverse = isVectorAnimationReverseStartMode(settings.playbackMode);
+ const reversePlayback = Boolean(clip.reversed) !== startsReverse;
+
+ const sourceTime = reversePlayback
+ ? sourceOutPoint - sourceWindowLocalTime
+ : sourceInPoint + sourceWindowLocalTime;
+
+ const maxTime = Math.max(0, animationDuration - FRAME_EPSILON);
+ return Math.max(0, Math.min(sourceTime, maxTime));
+}
+
+function getSelectedAnimationName(
+ entry: RiveRuntimeEntry,
+ settings: VectorAnimationClipSettings,
+): string | undefined {
+ return (
+ normalizeVectorAnimationStateName(settings.animationName) ??
+ entry.asset.metadata.defaultAnimationName ??
+ entry.asset.metadata.animationNames?.[0]
+ );
+}
+
+function parseRiveColorValue(value: string | number | boolean): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return Math.round(value);
+ }
+ if (typeof value === 'boolean') {
+ return value ? 0xffffffff : 0xff000000;
+ }
+ if (typeof value !== 'string') {
+ return null;
+ }
+ const trimmed = value.trim();
+ if (/^#[0-9a-f]{6}$/i.test(trimmed)) {
+ const rgb = Number.parseInt(trimmed.slice(1), 16);
+ return 0xff000000 | rgb;
+ }
+ if (/^#[0-9a-f]{8}$/i.test(trimmed)) {
+ return Number.parseInt(trimmed.slice(1), 16);
+ }
+ const numericValue = Number(trimmed);
+ return Number.isFinite(numericValue) ? Math.round(numericValue) : null;
+}
+
+function getDataBindingPropertiesForSettings(
+ entry: RiveRuntimeEntry,
+ settings: VectorAnimationClipSettings,
+): VectorAnimationDataBindingProperty[] {
+ const viewModelName = settings.viewModelName ?? entry.asset.metadata.defaultViewModelName;
+ const properties = entry.asset.metadata.dataBindingProperties ?? [];
+ return viewModelName
+ ? properties.filter((property) => property.viewModelName === viewModelName)
+ : properties;
+}
+
+export class RiveRuntimeManager {
+ private entries = new Map();
+ private preparePromises = new Map>();
+
+ async prepareClipSource(
+ clip: TimelineClip,
+ fileOverride?: File,
+ ): Promise {
+ if (clip.source?.type !== 'rive') {
+ throw new Error(`prepareClipSource called for non-Rive clip ${clip.id}`);
+ }
+
+ const existingPromise = this.preparePromises.get(clip.id);
+ if (existingPromise) {
+ return existingPromise;
+ }
+
+ const preparePromise = this.prepareClipSourceInternal(clip, fileOverride).finally(() => {
+ this.preparePromises.delete(clip.id);
+ });
+
+ this.preparePromises.set(clip.id, preparePromise);
+ return preparePromise;
+ }
+
+ private async prepareClipSourceInternal(
+ clip: TimelineClip,
+ fileOverride?: File,
+ ): Promise {
+ const file = fileOverride ?? clip.file;
+ if (!file) {
+ throw new Error(`Missing file for Rive clip ${clip.id}`);
+ }
+
+ const asset = await prepareRiveAsset(file);
+ const existing = this.entries.get(clip.id);
+ if (existing && existing.asset.payload.sourceKey === asset.payload.sourceKey) {
+ this.applySettings(existing, clip);
+ return {
+ canvas: existing.canvas,
+ metadata: existing.asset.metadata,
+ };
+ }
+
+ if (existing) {
+ this.destroyClipRuntime(clip.id);
+ }
+
+ const settings = mergeVectorAnimationSettings(clip.source?.vectorAnimationSettings);
+ const canvas = createCanvas(asset.metadata.width, asset.metadata.height);
+ const player = await waitForRiveLoad({
+ canvas,
+ buffer: asset.payload.data.slice(0),
+ artboard: settings.artboard,
+ animations: getSelectedAnimationName(
+ {
+ asset,
+ canvas,
+ clipId: clip.id,
+ isReady: false,
+ player: null as unknown as Rive,
+ settingsKey: '',
+ instanceKey: '',
+ stateMachineInputs: new Map(),
+ riveEventHandler: () => undefined,
+ },
+ settings,
+ ),
+ stateMachines: normalizeVectorAnimationStateName(settings.stateMachineName),
+ layout: createLayout(settings),
+ autoplay: false,
+ autoBind: false,
+ enableRiveAssetCDN: true,
+ shouldDisableRiveListeners: true,
+ automaticallyHandleEvents: false,
+ assetLoader: createAssetLoader(clip.id),
+ });
+
+ player.pause();
+
+ const entry: RiveRuntimeEntry = {
+ asset,
+ canvas,
+ clipId: clip.id,
+ isReady: true,
+ player,
+ settingsKey: '',
+ instanceKey: '',
+ stateMachineInputs: new Map(),
+ riveEventHandler: (event) => {
+ log.debug('Rive event', { clipId: clip.id, data: event.data });
+ },
+ };
+ player.on(EventType.RiveEvent, entry.riveEventHandler);
+
+ this.applySettings(entry, clip);
+ this.entries.set(clip.id, entry);
+
+ return {
+ canvas,
+ metadata: asset.metadata,
+ };
+ }
+
+ private applySettings(
+ entry: RiveRuntimeEntry,
+ clip: TimelineClip,
+ settingsOverride?: VectorAnimationClipSettings,
+ ): void {
+ const settings = mergeVectorAnimationSettings(settingsOverride ?? clip.source?.vectorAnimationSettings);
+ const settingsKey = getSettingsKey(settings);
+ const instanceKey = getInstanceKey(settings);
+
+ if (instanceKey !== entry.instanceKey) {
+ this.resetRiveInstance(entry, clip.id, settings, instanceKey);
+ }
+
+ if (settingsKey === entry.settingsKey) {
+ return;
+ }
+
+ const renderSize = getRenderSize(entry, settings);
+ if (entry.canvas.width !== renderSize.width || entry.canvas.height !== renderSize.height) {
+ entry.canvas.width = renderSize.width;
+ entry.canvas.height = renderSize.height;
+ }
+
+ entry.player.layout = createLayout(settings);
+ entry.player.resizeToCanvas();
+ entry.settingsKey = settingsKey;
+ }
+
+ private resetRiveInstance(
+ entry: RiveRuntimeEntry,
+ clipId: string,
+ settings: VectorAnimationClipSettings,
+ instanceKey: string,
+ ): void {
+ try {
+ entry.player.reset({
+ artboard: settings.artboard,
+ animations: getSelectedAnimationName(entry, settings),
+ stateMachines: normalizeVectorAnimationStateName(settings.stateMachineName),
+ autoplay: false,
+ autoBind: false,
+ });
+ entry.player.pause();
+ entry.activeStateMachineName = normalizeVectorAnimationStateName(settings.stateMachineName);
+ entry.stateMachineInputs = new Map(
+ entry.activeStateMachineName
+ ? entry.player.stateMachineInputs(entry.activeStateMachineName).map((input) => [input.name, input])
+ : [],
+ );
+ entry.lastInputValuesKey = undefined;
+ entry.lastTriggerValuesKey = undefined;
+ entry.lastDataBindingValuesKey = undefined;
+ entry.lastDataBindingTriggerKey = undefined;
+ entry.instanceKey = instanceKey;
+ this.resetViewModelBinding(entry);
+ this.applyDataBindingSelection(entry, settings);
+ } catch (error) {
+ log.warn('Failed to reset Rive instance', { clipId, error });
+ }
+ }
+
+ private resetViewModelBinding(entry: RiveRuntimeEntry): void {
+ if (entry.viewModelInstance) {
+ try {
+ entry.player.bindViewModelInstance(null);
+ entry.viewModelInstance.cleanup();
+ } catch (error) {
+ log.debug('Failed to cleanup Rive view model instance', { clipId: entry.clipId, error });
+ }
+ }
+
+ entry.viewModelInstance = undefined;
+ entry.boundViewModelKey = undefined;
+ }
+
+ private applyDataBindingSelection(
+ entry: RiveRuntimeEntry,
+ settings: VectorAnimationClipSettings,
+ ): void {
+ const hasBindingValues = Object.keys(settings.dataBindingValues ?? {}).length > 0;
+ const viewModelName = settings.viewModelName ?? (hasBindingValues ? entry.asset.metadata.defaultViewModelName : undefined);
+ if (!viewModelName) {
+ return;
+ }
+
+ const key = `${viewModelName}:${settings.viewModelInstanceName ?? ''}`;
+ if (entry.boundViewModelKey === key && entry.viewModelInstance) {
+ return;
+ }
+
+ this.resetViewModelBinding(entry);
+
+ try {
+ const viewModel = entry.player.viewModelByName(viewModelName) ?? entry.player.defaultViewModel();
+ if (!viewModel) {
+ return;
+ }
+
+ const instance = settings.viewModelInstanceName
+ ? viewModel.instanceByName(settings.viewModelInstanceName)
+ : viewModel.defaultInstance() ?? viewModel.instance();
+ if (!instance) {
+ return;
+ }
+
+ entry.player.bindViewModelInstance(instance);
+ entry.viewModelInstance = instance;
+ entry.boundViewModelKey = key;
+ } catch (error) {
+ log.warn('Failed to bind Rive view model', { clipId: entry.clipId, viewModelName, error });
+ }
+ }
+
+ private applyStateMachineInputs(
+ entry: RiveRuntimeEntry,
+ clipId: string,
+ settings: VectorAnimationClipSettings,
+ ): void {
+ const stateMachineName = normalizeVectorAnimationStateName(settings.stateMachineName);
+ if (!stateMachineName || entry.stateMachineInputs.size === 0) {
+ return;
+ }
+
+ const metadataInputs = entry.asset.metadata.stateMachineInputs?.[stateMachineName] ?? [];
+ if (metadataInputs.length === 0) {
+ return;
+ }
+
+ const values = metadataInputs.map((input) => ({
+ input,
+ value: coerceVectorAnimationInputValue(
+ input,
+ settings.stateMachineInputValues?.[input.name] ?? getVectorAnimationInputDefaultValue(input),
+ ),
+ }));
+ const valueKey = JSON.stringify(values.map(({ input, value }) => [stateMachineName, input.name, input.type, value]));
+ const triggerKey = JSON.stringify(values
+ .filter(({ input, value }) => input.type === 'trigger' && Boolean(value))
+ .map(({ input, value }) => [stateMachineName, input.name, value]));
+
+ if (entry.lastInputValuesKey === valueKey && entry.lastTriggerValuesKey === triggerKey) {
+ return;
+ }
+
+ for (const { input, value } of values) {
+ const runtimeInput = entry.stateMachineInputs.get(input.name);
+ if (!runtimeInput) {
+ continue;
+ }
+
+ try {
+ if (runtimeInput.type === StateMachineInputType.Boolean) {
+ runtimeInput.value = Boolean(value);
+ } else if (runtimeInput.type === StateMachineInputType.Number) {
+ const numericValue = typeof value === 'number' ? value : Number(value);
+ if (Number.isFinite(numericValue)) {
+ runtimeInput.value = numericValue;
+ }
+ } else if (
+ runtimeInput.type === StateMachineInputType.Trigger &&
+ Boolean(value) &&
+ entry.lastTriggerValuesKey !== triggerKey
+ ) {
+ runtimeInput.fire();
+ }
+ } catch (error) {
+ log.warn('Failed to apply Rive state machine input', {
+ clipId,
+ stateMachineName,
+ inputName: input.name,
+ error,
+ });
+ }
+ }
+
+ entry.lastInputValuesKey = valueKey;
+ entry.lastTriggerValuesKey = triggerKey;
+ }
+
+ private applyDataBindingValues(
+ entry: RiveRuntimeEntry,
+ settings: VectorAnimationClipSettings,
+ ): void {
+ const values = settings.dataBindingValues ?? {};
+ if (Object.keys(values).length === 0) {
+ return;
+ }
+
+ this.applyDataBindingSelection(entry, settings);
+ const instance = entry.viewModelInstance;
+ if (!instance) {
+ return;
+ }
+
+ const properties = getDataBindingPropertiesForSettings(entry, settings);
+ const valueKey = JSON.stringify(properties.map((property) => [
+ property.viewModelName,
+ property.name,
+ property.type,
+ values[property.name] ?? property.defaultValue ?? null,
+ ]));
+ const triggerKey = JSON.stringify(properties
+ .filter((property) => property.type === 'trigger' && Boolean(values[property.name]))
+ .map((property) => [property.viewModelName, property.name, values[property.name]]));
+
+ if (entry.lastDataBindingValuesKey === valueKey && entry.lastDataBindingTriggerKey === triggerKey) {
+ return;
+ }
+
+ for (const property of properties) {
+ const value = coerceVectorAnimationDataBindingValue(
+ property,
+ values[property.name] ?? getVectorAnimationDataBindingDefaultValue(property),
+ );
+
+ try {
+ if (property.type === 'boolean') {
+ const binding = instance.boolean(property.name);
+ if (binding) binding.value = Boolean(value);
+ } else if (property.type === 'number' || property.type === 'integer') {
+ const binding = instance.number(property.name);
+ const numericValue = typeof value === 'number' ? value : Number(value);
+ if (binding && Number.isFinite(numericValue)) binding.value = numericValue;
+ } else if (property.type === 'string') {
+ const binding = instance.string(property.name);
+ if (binding) binding.value = String(value);
+ } else if (property.type === 'enum') {
+ const binding = instance.enum(property.name);
+ if (binding) binding.value = String(value);
+ } else if (property.type === 'color') {
+ const binding = instance.color(property.name);
+ const colorValue = parseRiveColorValue(value);
+ if (binding && colorValue !== null) binding.value = colorValue;
+ } else if (
+ property.type === 'trigger' &&
+ Boolean(value) &&
+ entry.lastDataBindingTriggerKey !== triggerKey
+ ) {
+ instance.trigger(property.name)?.trigger();
+ }
+ } catch (error) {
+ log.warn('Failed to apply Rive data binding', {
+ clipId: entry.clipId,
+ propertyName: property.name,
+ type: property.type,
+ error,
+ });
+ }
+ }
+
+ entry.lastDataBindingValuesKey = valueKey;
+ entry.lastDataBindingTriggerKey = triggerKey;
+ }
+
+ renderClipAtTime(
+ clip: TimelineClip,
+ timelineTime: number,
+ settingsOverride?: VectorAnimationClipSettings,
+ ): HTMLCanvasElement | null {
+ if (clip.source?.type !== 'rive') {
+ return clip.source?.textCanvas ?? null;
+ }
+
+ const entry = this.entries.get(clip.id);
+ if (!entry?.isReady) {
+ if (clip.file) {
+ void this.prepareClipSource(clip).catch((error) => {
+ log.warn('Failed to prepare Rive runtime during render', { clipId: clip.id, error });
+ });
+ }
+ return clip.source?.textCanvas ?? null;
+ }
+
+ this.applySettings(entry, clip, settingsOverride);
+ const settings = mergeVectorAnimationSettings(settingsOverride ?? clip.source?.vectorAnimationSettings);
+ this.applyStateMachineInputs(entry, clip.id, settings);
+ this.applyDataBindingValues(entry, settings);
+
+ const animationDuration =
+ entry.asset.metadata.duration ??
+ clip.source?.naturalDuration ??
+ clip.outPoint ??
+ clip.duration ??
+ DEFAULT_RIVE_DURATION;
+ const animationTime = resolveAnimationTime(clip, animationDuration, settings, timelineTime);
+
+ if (animationTime == null) {
+ clearCanvas(entry.canvas);
+ return entry.canvas;
+ }
+
+ const animationName = getSelectedAnimationName(entry, settings);
+ if (animationName) {
+ entry.player.scrub(animationName, animationTime);
+ }
+ entry.player.drawFrame();
+ return entry.canvas;
+ }
+
+ pruneClipRuntimes(knownClipIds: Iterable): void {
+ const keep = new Set(knownClipIds);
+ for (const clipId of this.entries.keys()) {
+ if (!keep.has(clipId)) {
+ this.destroyClipRuntime(clipId);
+ }
+ }
+ }
+
+ destroyClipRuntime(clipId: string): void {
+ const entry = this.entries.get(clipId);
+ if (!entry) {
+ return;
+ }
+
+ try {
+ entry.player.off(EventType.RiveEvent, entry.riveEventHandler);
+ this.resetViewModelBinding(entry);
+ entry.player.cleanup();
+ } catch (error) {
+ log.warn('Failed to destroy Rive runtime', { clipId, error });
+ }
+ this.entries.delete(clipId);
+ }
+
+ destroyAll(): void {
+ for (const clipId of this.entries.keys()) {
+ this.destroyClipRuntime(clipId);
+ }
+ }
+}
+
+export const riveRuntimeManager = new RiveRuntimeManager();
diff --git a/src/services/vectorAnimation/VectorAnimationRuntimeManager.ts b/src/services/vectorAnimation/VectorAnimationRuntimeManager.ts
new file mode 100644
index 00000000..df3cd477
--- /dev/null
+++ b/src/services/vectorAnimation/VectorAnimationRuntimeManager.ts
@@ -0,0 +1,64 @@
+import type { TimelineClip } from '../../types';
+import type { VectorAnimationClipSettings } from '../../types/vectorAnimation';
+import { isVectorAnimationSourceType } from '../../types/vectorAnimation';
+import { lottieRuntimeManager } from './LottieRuntimeManager';
+import { riveRuntimeManager } from './RiveRuntimeManager';
+import type { VectorAnimationRuntimePrepareResult } from './types';
+
+class VectorAnimationRuntimeManager {
+ async prepareClipSource(
+ clip: TimelineClip,
+ fileOverride?: File,
+ ): Promise {
+ if (clip.source?.type === 'lottie') {
+ return lottieRuntimeManager.prepareClipSource(clip, fileOverride);
+ }
+ if (clip.source?.type === 'rive') {
+ return riveRuntimeManager.prepareClipSource(clip, fileOverride);
+ }
+ throw new Error(`prepareClipSource called for non-vector clip ${clip.id}`);
+ }
+
+ renderClipAtTime(
+ clip: TimelineClip,
+ timelineTime: number,
+ settingsOverride?: VectorAnimationClipSettings,
+ ): HTMLCanvasElement | null {
+ if (clip.source?.type === 'lottie') {
+ return lottieRuntimeManager.renderClipAtTime(clip, timelineTime, settingsOverride);
+ }
+ if (clip.source?.type === 'rive') {
+ return riveRuntimeManager.renderClipAtTime(clip, timelineTime, settingsOverride);
+ }
+ return clip.source?.textCanvas ?? null;
+ }
+
+ destroyClipRuntime(clipId: string, sourceType?: unknown): void {
+ if (sourceType === 'lottie') {
+ lottieRuntimeManager.destroyClipRuntime(clipId);
+ return;
+ }
+ if (sourceType === 'rive') {
+ riveRuntimeManager.destroyClipRuntime(clipId);
+ return;
+ }
+ lottieRuntimeManager.destroyClipRuntime(clipId);
+ riveRuntimeManager.destroyClipRuntime(clipId);
+ }
+
+ pruneClipRuntimes(knownClipIds: Iterable): void {
+ lottieRuntimeManager.pruneClipRuntimes(knownClipIds);
+ riveRuntimeManager.pruneClipRuntimes(knownClipIds);
+ }
+
+ destroyAll(): void {
+ lottieRuntimeManager.destroyAll();
+ riveRuntimeManager.destroyAll();
+ }
+
+ isVectorClip(clip: TimelineClip): boolean {
+ return isVectorAnimationSourceType(clip.source?.type);
+ }
+}
+
+export const vectorAnimationRuntimeManager = new VectorAnimationRuntimeManager();
diff --git a/src/services/vectorAnimation/riveMetadata.ts b/src/services/vectorAnimation/riveMetadata.ts
new file mode 100644
index 00000000..9b7a76bf
--- /dev/null
+++ b/src/services/vectorAnimation/riveMetadata.ts
@@ -0,0 +1,280 @@
+import {
+ DataType,
+ Rive,
+ StateMachineInputType,
+ type ViewModel,
+ type ViewModelInstance,
+} from '@rive-app/canvas';
+
+import type {
+ VectorAnimationDataBindingProperty,
+ VectorAnimationDataBindingType,
+ VectorAnimationDataBindingValue,
+ VectorAnimationMetadata,
+ VectorAnimationStateMachineInput,
+} from '../../types/vectorAnimation';
+import { Logger } from '../logger';
+import type { PreparedRiveAsset } from './types';
+
+const log = Logger.create('RiveMetadata');
+
+const preparedAssetCache = new Map>();
+
+function getAssetCacheKey(file: File): string {
+ return `${file.name}:${file.size}:${file.lastModified}`;
+}
+
+function createMetadataCanvas(): HTMLCanvasElement {
+ const canvas = document.createElement('canvas');
+ canvas.width = 1;
+ canvas.height = 1;
+ return canvas;
+}
+
+function waitForRiveLoad(params: ConstructorParameters[0]): Promise {
+ let player: Rive | null = null;
+
+ return new Promise((resolve, reject) => {
+ try {
+ player = new Rive({
+ ...params,
+ onLoad: () => {
+ if (player) {
+ resolve(player);
+ }
+ },
+ onLoadError: (event) => {
+ reject(event.data instanceof Error ? event.data : new Error('Failed to load Rive asset'));
+ },
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+}
+
+function normalizeStateMachineInput(input: {
+ name: string;
+ type: StateMachineInputType;
+ initialValue?: boolean | number;
+}): VectorAnimationStateMachineInput | null {
+ if (!input.name.trim()) {
+ return null;
+ }
+
+ if (input.type === StateMachineInputType.Boolean) {
+ return {
+ name: input.name,
+ type: 'boolean',
+ defaultValue: typeof input.initialValue === 'boolean' ? input.initialValue : Boolean(input.initialValue),
+ };
+ }
+
+ if (input.type === StateMachineInputType.Number) {
+ return {
+ name: input.name,
+ type: 'number',
+ defaultValue: typeof input.initialValue === 'number' && Number.isFinite(input.initialValue)
+ ? input.initialValue
+ : 0,
+ };
+ }
+
+ if (input.type === StateMachineInputType.Trigger) {
+ return {
+ name: input.name,
+ type: 'trigger',
+ };
+ }
+
+ return null;
+}
+
+function mapDataBindingType(type: DataType): VectorAnimationDataBindingType | null {
+ if (type === DataType.boolean) return 'boolean';
+ if (type === DataType.number) return 'number';
+ if (type === DataType.integer || type === DataType.listIndex) return 'integer';
+ if (type === DataType.string) return 'string';
+ if (type === DataType.color) return 'color';
+ if (type === DataType.enumType) return 'enum';
+ if (type === DataType.trigger) return 'trigger';
+ return null;
+}
+
+function getDataBindingDefaultValue(
+ instance: ViewModelInstance | null,
+ propertyName: string,
+ type: VectorAnimationDataBindingType,
+): { value?: VectorAnimationDataBindingValue; values?: string[] } {
+ if (!instance) {
+ return {};
+ }
+
+ try {
+ if (type === 'boolean') {
+ return { value: instance.boolean(propertyName)?.value };
+ }
+ if (type === 'number' || type === 'integer') {
+ return { value: instance.number(propertyName)?.value };
+ }
+ if (type === 'string') {
+ return { value: instance.string(propertyName)?.value };
+ }
+ if (type === 'color') {
+ return { value: instance.color(propertyName)?.value };
+ }
+ if (type === 'enum') {
+ const enumValue = instance.enum(propertyName);
+ return {
+ value: enumValue?.value,
+ values: enumValue?.values,
+ };
+ }
+ } catch (error) {
+ log.debug('Failed to read Rive data binding default', { propertyName, type, error });
+ }
+
+ return {};
+}
+
+function readViewModelMetadata(viewModel: ViewModel): {
+ name: string;
+ instanceNames?: string[];
+ properties: VectorAnimationDataBindingProperty[];
+} {
+ const instance = viewModel.defaultInstance() ?? viewModel.instance();
+
+ try {
+ const properties = viewModel.properties
+ .map((property): VectorAnimationDataBindingProperty | null => {
+ const type = mapDataBindingType(property.type as DataType);
+ if (!type || !property.name.trim()) {
+ return null;
+ }
+
+ const defaults = getDataBindingDefaultValue(instance, property.name, type);
+ return {
+ name: property.name,
+ type,
+ viewModelName: viewModel.name,
+ ...(defaults.value !== undefined ? { defaultValue: defaults.value } : {}),
+ ...(defaults.values && defaults.values.length > 0 ? { values: defaults.values } : {}),
+ };
+ })
+ .filter((property): property is VectorAnimationDataBindingProperty => Boolean(property))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ return {
+ name: viewModel.name,
+ instanceNames: viewModel.instanceNames.length > 0 ? viewModel.instanceNames : undefined,
+ properties,
+ };
+ } finally {
+ instance?.cleanup();
+ }
+}
+
+function buildMetadata(player: Rive): VectorAnimationMetadata {
+ const contents = player.contents;
+ const artboards = contents.artboards ?? [];
+ const activeArtboardName = player.activeArtboard;
+ const activeArtboard =
+ artboards.find((artboard) => artboard.name === activeArtboardName) ??
+ artboards[0];
+
+ const stateMachineNames = activeArtboard?.stateMachines.map((stateMachine) => stateMachine.name) ?? player.stateMachineNames;
+ const stateMachineInputs: Record = {};
+ activeArtboard?.stateMachines.forEach((stateMachine) => {
+ const inputs = stateMachine.inputs
+ .map(normalizeStateMachineInput)
+ .filter((input): input is VectorAnimationStateMachineInput => Boolean(input))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ if (inputs.length > 0) {
+ stateMachineInputs[stateMachine.name] = inputs;
+ }
+ });
+
+ const viewModels = Array.from({ length: player.viewModelCount }, (_, index) => player.viewModelByIndex(index))
+ .filter((viewModel): viewModel is ViewModel => Boolean(viewModel))
+ .map(readViewModelMetadata)
+ .filter((viewModel) => viewModel.properties.length > 0);
+ const defaultViewModelName = player.defaultViewModel()?.name;
+ const dataBindingProperties = viewModels.flatMap((viewModel) => viewModel.properties);
+
+ const duration = player.durations.find((candidate) => Number.isFinite(candidate) && candidate > 0);
+ const fps = Number.isFinite(player.fps) && player.fps > 0 ? player.fps : undefined;
+
+ return {
+ provider: 'rive',
+ width: player.artboardWidth || undefined,
+ height: player.artboardHeight || undefined,
+ fps,
+ duration,
+ animationNames: activeArtboard?.animations.length ? activeArtboard.animations : player.animationNames,
+ defaultAnimationName: activeArtboard?.animations[0] ?? player.animationNames[0],
+ artboardNames: artboards.map((artboard) => artboard.name).filter(Boolean),
+ stateMachineNames: stateMachineNames.length > 0 ? stateMachineNames : undefined,
+ stateMachineInputs: Object.keys(stateMachineInputs).length > 0 ? stateMachineInputs : undefined,
+ viewModelNames: viewModels.map((viewModel) => viewModel.name),
+ defaultViewModelName,
+ viewModels: viewModels.length > 0 ? viewModels : undefined,
+ dataBindingProperties: dataBindingProperties.length > 0 ? dataBindingProperties : undefined,
+ };
+}
+
+async function readRiveMetadataFromBuffer(buffer: ArrayBuffer): Promise {
+ const canvas = createMetadataCanvas();
+ const player = await waitForRiveLoad({
+ canvas,
+ buffer: buffer.slice(0),
+ autoplay: false,
+ autoBind: false,
+ enableRiveAssetCDN: true,
+ shouldDisableRiveListeners: true,
+ automaticallyHandleEvents: false,
+ });
+
+ try {
+ player.pause();
+ return buildMetadata(player);
+ } finally {
+ player.cleanup();
+ }
+}
+
+async function prepareRiveAssetInternal(file: File): Promise {
+ const lowerName = file.name.toLowerCase();
+ if (!lowerName.endsWith('.riv')) {
+ throw new Error(`Unsupported Rive file: ${file.name}`);
+ }
+
+ const buffer = await file.arrayBuffer();
+ return {
+ metadata: await readRiveMetadataFromBuffer(buffer),
+ payload: {
+ data: buffer,
+ sourceKey: getAssetCacheKey(file),
+ },
+ };
+}
+
+export async function prepareRiveAsset(file: File): Promise {
+ const cacheKey = getAssetCacheKey(file);
+ const existing = preparedAssetCache.get(cacheKey);
+ if (existing) {
+ return existing;
+ }
+
+ const promise = prepareRiveAssetInternal(file).catch((error) => {
+ preparedAssetCache.delete(cacheKey);
+ log.warn('Failed to prepare Rive asset', { file: file.name, error });
+ throw error;
+ });
+
+ preparedAssetCache.set(cacheKey, promise);
+ return promise;
+}
+
+export async function readRiveMetadata(file: File): Promise {
+ return (await prepareRiveAsset(file)).metadata;
+}
diff --git a/src/services/vectorAnimation/types.ts b/src/services/vectorAnimation/types.ts
index f1ddfcb3..f0f5db66 100644
--- a/src/services/vectorAnimation/types.ts
+++ b/src/services/vectorAnimation/types.ts
@@ -21,3 +21,22 @@ export interface LottieRuntimePrepareResult {
canvas: HTMLCanvasElement;
metadata: VectorAnimationMetadata;
}
+
+export interface RiveRuntimePayload {
+ data: ArrayBuffer;
+ sourceKey: string;
+}
+
+export interface PreparedRiveAsset {
+ metadata: VectorAnimationMetadata;
+ payload: RiveRuntimePayload;
+}
+
+export interface RiveRuntimePrepareResult {
+ canvas: HTMLCanvasElement;
+ metadata: VectorAnimationMetadata;
+}
+
+export type VectorAnimationRuntimePrepareResult =
+ | LottieRuntimePrepareResult
+ | RiveRuntimePrepareResult;
diff --git a/src/stores/mediaStore/helpers/importPipeline.ts b/src/stores/mediaStore/helpers/importPipeline.ts
index 0ef24077..504f4632 100644
--- a/src/stores/mediaStore/helpers/importPipeline.ts
+++ b/src/stores/mediaStore/helpers/importPipeline.ts
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../../settingsStore';
import { Logger } from '../../../services/logger';
import { prewarmGaussianSplatRuntime } from '../../../engine/scene/runtime/SharedSplatRuntimeCache';
import { prepareLottieAsset } from '../../../services/vectorAnimation/lottieMetadata';
+import { prepareRiveAsset } from '../../../services/vectorAnimation/riveMetadata';
import { readGaussianSplatFileStats } from './gaussianSplatStats';
const log = Logger.create('Import');
@@ -64,8 +65,8 @@ export async function processImport(params: ImportParams): Promise
let canonicalFile = file;
let url = URL.createObjectURL(file);
- const vectorAnimationInfo = type === 'lottie'
- ? await prepareLottieAsset(file).then((prepared) => ({
+ const vectorAnimationInfo = type === 'lottie' || type === 'rive'
+ ? await (type === 'lottie' ? prepareLottieAsset(file) : prepareRiveAsset(file)).then((prepared) => ({
duration: prepared.metadata.duration,
fileSize: file.size,
fps: prepared.metadata.fps,
diff --git a/src/stores/mediaStore/slices/fileManageSlice.ts b/src/stores/mediaStore/slices/fileManageSlice.ts
index c7080c18..f6d01326 100644
--- a/src/stores/mediaStore/slices/fileManageSlice.ts
+++ b/src/stores/mediaStore/slices/fileManageSlice.ts
@@ -10,8 +10,10 @@ import { useTimelineStore } from '../../timeline';
import { Logger } from '../../../services/logger';
import { engine } from '../../../engine/WebGPUEngine';
import { thumbnailCacheService } from '../../../services/thumbnailCacheService';
-import { lottieRuntimeManager } from '../../../services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../../services/vectorAnimation/VectorAnimationRuntimeManager';
import { readLottieMetadata } from '../../../services/vectorAnimation/lottieMetadata';
+import { readRiveMetadata } from '../../../services/vectorAnimation/riveMetadata';
+import { isVectorAnimationSourceType } from '../../../types/vectorAnimation';
import { createThumbnail, handleThumbnailDedup } from '../helpers/thumbnailHelpers';
import { resolveGaussianSplatSequenceData } from '../../../utils/gaussianSplatSequence';
@@ -444,10 +446,12 @@ export async function updateTimelineClips(mediaFileId: string, file: File): Prom
isLoading: false,
});
}, { once: true });
- } else if (sourceType === 'lottie') {
+ } else if (isVectorAnimationSourceType(sourceType)) {
try {
- const metadata = await readLottieMetadata(file);
- const runtime = await lottieRuntimeManager.prepareClipSource({
+ const metadata = sourceType === 'lottie'
+ ? await readLottieMetadata(file)
+ : await readRiveMetadata(file);
+ const runtime = await vectorAnimationRuntimeManager.prepareClipSource({
...clip,
file,
source: {
@@ -462,14 +466,14 @@ export async function updateTimelineClips(mediaFileId: string, file: File): Prom
isLoading: false,
source: {
...clip.source!,
- type: 'lottie',
+ type: sourceType,
textCanvas: runtime.canvas,
naturalDuration: metadata.duration ?? clip.duration,
mediaFileId,
},
});
} catch (error) {
- log.warn('Failed to reload lottie for clip', { clipName: clip.name, error });
+ log.warn('Failed to reload vector animation for clip', { clipName: clip.name, sourceType, error });
timelineStore.updateClip(clip.id, {
needsReload: false,
isLoading: false,
diff --git a/src/stores/timeline/clip/addCompClip.ts b/src/stores/timeline/clip/addCompClip.ts
index f6e3a005..25b7a8d5 100644
--- a/src/stores/timeline/clip/addCompClip.ts
+++ b/src/stores/timeline/clip/addCompClip.ts
@@ -2,7 +2,7 @@
// Handles nested composition loading, audio mixdown, and linked audio creation
import type { TimelineClip, TimelineTrack, CompositionTimelineData, SerializableClip, Keyframe } from '../../../types';
-import type { VectorAnimationClipSettings } from '../../../types/vectorAnimation';
+import { isVectorAnimationSourceType, type VectorAnimationClipSettings, type VectorAnimationProvider } from '../../../types/vectorAnimation';
import type { Composition } from '../types';
import { DEFAULT_TRANSFORM, calculateNativeScale, MAX_NESTING_DEPTH } from '../constants';
import { useMediaStore } from '../../mediaStore';
@@ -14,7 +14,7 @@ import { blobUrlManager } from '../helpers/blobUrlManager';
import { updateClipById } from '../helpers/clipStateHelpers';
import { Logger } from '../../../services/logger';
import { thumbnailRenderer } from '../../../services/thumbnailRenderer';
-import { lottieRuntimeManager } from '../../../services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../../services/vectorAnimation/VectorAnimationRuntimeManager';
import { mathSceneRenderer } from '../../../services/mathScene/MathSceneRenderer';
import { cloneClipNodeGraph } from '../../../services/nodeGraph';
// Note: compositionRenderer is used elsewhere for cache invalidation
@@ -166,8 +166,8 @@ export async function buildClipSegments(
log.warn('Failed to generate image segment thumbnail', { clipId: serializedClip.id });
}
} else if (nestedClip?.source?.textCanvas) {
- if (nestedClip.source.type === 'lottie') {
- lottieRuntimeManager.renderClipAtTime(nestedClip, nestedClip.startTime);
+ if (isVectorAnimationSourceType(nestedClip.source.type)) {
+ vectorAnimationRuntimeManager.renderClipAtTime(nestedClip, nestedClip.startTime);
}
try {
@@ -408,7 +408,7 @@ async function loadSubNestedClips(
// Load media directly on the clip object (no store update needed)
const type = sc.sourceType;
- const fileUrl = type === 'lottie'
+ const fileUrl = isVectorAnimationSourceType(type)
? null
: blobUrlManager.create(
clipId,
@@ -456,28 +456,28 @@ async function loadSubNestedClips(
clip.source = { type: 'audio', audioElement: audio, naturalDuration: audio.duration };
clip.isLoading = false;
}, { once: true });
- } else if (type === 'lottie') {
+ } else if (isVectorAnimationSourceType(type)) {
clip.source = {
- type: 'lottie',
+ type,
mediaFileId: sc.mediaFileId,
naturalDuration: sc.naturalDuration,
vectorAnimationSettings: sc.vectorAnimationSettings,
};
- void lottieRuntimeManager.prepareClipSource(clip, mediaFile.file).then((runtime) => {
+ void vectorAnimationRuntimeManager.prepareClipSource(clip, mediaFile.file).then((runtime) => {
const naturalDuration = runtime.metadata.duration ?? sc.naturalDuration ?? sc.duration;
clip.source = {
- type: 'lottie',
+ type,
textCanvas: runtime.canvas,
mediaFileId: sc.mediaFileId,
naturalDuration,
vectorAnimationSettings: sc.vectorAnimationSettings,
};
clip.isLoading = false;
- lottieRuntimeManager.renderClipAtTime(clip, clip.startTime);
- log.debug('Sub-nested lottie loaded', { clipId, name: clip.name, depth });
+ vectorAnimationRuntimeManager.renderClipAtTime(clip, clip.startTime);
+ log.debug('Sub-nested vector animation loaded', { clipId, name: clip.name, type, depth });
}).catch((error) => {
clip.isLoading = false;
- log.warn('Failed to load sub-nested lottie', { clipId, error });
+ log.warn('Failed to load sub-nested vector animation', { clipId, type, error });
});
} else if (type === 'model') {
clip.source = { type: 'model', modelUrl: fileUrl!, naturalDuration: 3600 };
@@ -683,7 +683,7 @@ export async function loadNestedClips(params: LoadNestedClipsParams): Promise {
+ void vectorAnimationRuntimeManager.prepareClipSource(runtimeClip, file).then((runtime) => {
const naturalDuration =
runtime.metadata.duration ??
sourceInfo.naturalDuration ??
runtimeClip.duration;
runtimeClip.source = {
- type: 'lottie',
+ type: sourceType,
textCanvas: runtime.canvas,
mediaFileId: sourceInfo.mediaFileId,
naturalDuration,
vectorAnimationSettings: sourceInfo.vectorAnimationSettings,
};
- lottieRuntimeManager.renderClipAtTime(runtimeClip, runtimeClip.startTime);
+ vectorAnimationRuntimeManager.renderClipAtTime(runtimeClip, runtimeClip.startTime);
set({
clips: updateNestedClipInCompClip(get().clips, compClipId, nestedClipId, {
@@ -950,14 +952,14 @@ function loadLottieNestedClip(
const { invalidateCache } = get();
invalidateCache?.();
- log.debug('Nested lottie loaded', { compClipId, nestedClipId });
+ log.debug('Nested vector animation loaded', { compClipId, nestedClipId, sourceType });
}).catch((error) => {
set({
clips: updateNestedClipInCompClip(get().clips, compClipId, nestedClipId, {
isLoading: false,
}),
});
- log.warn('Nested lottie load failed', { compClipId, nestedClipId, error });
+ log.warn('Nested vector animation load failed', { compClipId, nestedClipId, sourceType, error });
});
}
diff --git a/src/stores/timeline/clip/addRiveClip.ts b/src/stores/timeline/clip/addRiveClip.ts
new file mode 100644
index 00000000..7cee3a91
--- /dev/null
+++ b/src/stores/timeline/clip/addRiveClip.ts
@@ -0,0 +1,87 @@
+import type { TimelineClip } from '../../../types';
+import {
+ DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
+ type VectorAnimationMetadata,
+} from '../../../types/vectorAnimation';
+import { riveRuntimeManager } from '../../../services/vectorAnimation/RiveRuntimeManager';
+import { DEFAULT_TRANSFORM, calculateNativeScale } from '../constants';
+import { generateClipId } from '../helpers/idGenerator';
+
+export interface AddRiveClipParams {
+ trackId: string;
+ file: File;
+ startTime: number;
+ estimatedDuration: number;
+ mediaFileId?: string;
+ metadata?: VectorAnimationMetadata;
+}
+
+export function createRiveClipPlaceholder(params: AddRiveClipParams): TimelineClip {
+ const { trackId, file, startTime, estimatedDuration, mediaFileId, metadata } = params;
+ const clipId = generateClipId('clip-rive');
+ const duration = metadata?.duration ?? estimatedDuration;
+ const nativeScale = (metadata?.width && metadata?.height)
+ ? calculateNativeScale(metadata.width, metadata.height)
+ : { x: 1, y: 1 };
+
+ return {
+ id: clipId,
+ trackId,
+ name: file.name,
+ file,
+ startTime,
+ duration,
+ inPoint: 0,
+ outPoint: duration,
+ source: {
+ type: 'rive',
+ naturalDuration: metadata?.duration ?? duration,
+ mediaFileId,
+ vectorAnimationSettings: { ...DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS },
+ },
+ mediaFileId,
+ transform: { ...DEFAULT_TRANSFORM, scale: nativeScale },
+ effects: [],
+ isLoading: true,
+ };
+}
+
+export interface LoadRiveMediaParams {
+ clip: TimelineClip;
+ file: File;
+ mediaFileId?: string;
+ metadata?: VectorAnimationMetadata;
+ updateClip: (id: string, updates: Partial) => void;
+}
+
+export async function loadRiveMedia(params: LoadRiveMediaParams): Promise {
+ const { clip, file, mediaFileId, metadata, updateClip } = params;
+ const runtime = await riveRuntimeManager.prepareClipSource(clip, file);
+ const resolvedMetadata = metadata ?? runtime.metadata;
+ const naturalDuration = resolvedMetadata.duration ?? clip.duration;
+ const nativeScale = (resolvedMetadata.width && resolvedMetadata.height)
+ ? calculateNativeScale(resolvedMetadata.width, resolvedMetadata.height)
+ : clip.transform.scale;
+
+ updateClip(clip.id, {
+ file,
+ duration: naturalDuration,
+ outPoint: naturalDuration,
+ source: {
+ ...clip.source!,
+ type: 'rive',
+ mediaFileId,
+ naturalDuration,
+ textCanvas: runtime.canvas,
+ vectorAnimationSettings: {
+ ...DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
+ ...clip.source?.vectorAnimationSettings,
+ },
+ },
+ transform: {
+ ...clip.transform,
+ scale: nativeScale,
+ },
+ isLoading: false,
+ });
+}
diff --git a/src/stores/timeline/clipSlice.ts b/src/stores/timeline/clipSlice.ts
index d77afc9d..683e07db 100644
--- a/src/stores/timeline/clipSlice.ts
+++ b/src/stores/timeline/clipSlice.ts
@@ -45,6 +45,7 @@ import { loadVideoMedia } from './clip/addVideoClip';
import { createAudioClipPlaceholder, loadAudioMedia } from './clip/addAudioClip';
import { createImageClipPlaceholder, loadImageMedia } from './clip/addImageClip';
import { createLottieClipPlaceholder, loadLottieMedia } from './clip/addLottieClip';
+import { createRiveClipPlaceholder, loadRiveMedia } from './clip/addRiveClip';
import { createModelClipPlaceholder, loadModelMedia } from './clip/addModelClip';
import { createGaussianSplatClipPlaceholder, loadGaussianSplatMedia } from './clip/addGaussianSplatClip';
import { createVideoElement, createAudioElement } from './helpers/webCodecsHelpers';
@@ -62,7 +63,9 @@ import {
import { blobUrlManager } from './helpers/blobUrlManager';
import { updateClipById } from './helpers/clipStateHelpers';
import { readLottieMetadata } from '../../services/vectorAnimation/lottieMetadata';
-import { lottieRuntimeManager } from '../../services/vectorAnimation/LottieRuntimeManager';
+import { readRiveMetadata } from '../../services/vectorAnimation/riveMetadata';
+import { vectorAnimationRuntimeManager } from '../../services/vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType } from '../../types/vectorAnimation';
export const createClipSlice: SliceCreator = (set, get) => ({
addClip: async (trackId, file, startTime, providedDuration, mediaFileId, mediaTypeOverride?) => {
@@ -88,7 +91,7 @@ export const createClipSlice: SliceCreator = (set, get) => ({
return;
}
- if ((mediaType === 'video' || mediaType === 'image' || mediaType === 'lottie' || mediaType === 'model' || mediaType === 'gaussian-avatar' || mediaType === 'gaussian-splat') && targetTrack.type !== 'video') {
+ if ((mediaType === 'video' || mediaType === 'image' || mediaType === 'lottie' || mediaType === 'rive' || mediaType === 'model' || mediaType === 'gaussian-avatar' || mediaType === 'gaussian-splat') && targetTrack.type !== 'video') {
log.warn('Cannot add visual clip to audio track');
return;
}
@@ -282,6 +285,31 @@ export const createClipSlice: SliceCreator = (set, get) => ({
return;
}
+ if (mediaType === 'rive') {
+ const vectorAnimationMetadata = sourceMediaFile?.vectorAnimation ?? await readRiveMetadata(file);
+ const riveClip = createRiveClipPlaceholder({
+ trackId,
+ file,
+ startTime,
+ estimatedDuration: providedDuration ?? vectorAnimationMetadata.duration ?? estimatedDuration,
+ mediaFileId,
+ metadata: vectorAnimationMetadata,
+ });
+ set({ clips: [...clips, riveClip] });
+ updateDuration();
+
+ await loadRiveMedia({
+ clip: riveClip,
+ file,
+ mediaFileId,
+ metadata: vectorAnimationMetadata,
+ updateClip,
+ });
+
+ invalidateCache();
+ return;
+ }
+
// Handle image files
if (mediaType === 'image') {
const imageClip = createImageClipPlaceholder({ trackId, file, startTime, estimatedDuration });
@@ -465,8 +493,8 @@ export const createClipSlice: SliceCreator = (set, get) => ({
audio.src = '';
audio.load();
}
- if (clip.source?.type === 'lottie') {
- lottieRuntimeManager.destroyClipRuntime(clip.id);
+ if (isVectorAnimationSourceType(clip.source?.type)) {
+ vectorAnimationRuntimeManager.destroyClipRuntime(clip.id, clip.source.type);
}
blobUrlManager.revokeAll(removeId);
}
@@ -521,7 +549,7 @@ export const createClipSlice: SliceCreator = (set, get) => ({
const targetTrack = tracks.find(t => t.id === newTrackId);
const sourceType = movingClip.source?.type;
if (targetTrack && sourceType) {
- if ((sourceType === 'video' || sourceType === 'image' || sourceType === 'lottie' || sourceType === 'camera' || sourceType === 'math-scene') && targetTrack.type !== 'video') return;
+ if ((sourceType === 'video' || sourceType === 'image' || sourceType === 'lottie' || sourceType === 'rive' || sourceType === 'camera' || sourceType === 'math-scene') && targetTrack.type !== 'video') return;
if (sourceType === 'audio' && targetTrack.type !== 'audio') return;
}
}
diff --git a/src/stores/timeline/clipboardSlice.ts b/src/stores/timeline/clipboardSlice.ts
index cca854cc..99b8f50b 100644
--- a/src/stores/timeline/clipboardSlice.ts
+++ b/src/stores/timeline/clipboardSlice.ts
@@ -12,7 +12,8 @@ import { Logger } from '../../services/logger';
import { captureSnapshot } from '../historyStore';
import { DEFAULT_SCENE_CAMERA_SETTINGS } from '../mediaStore/types';
import { DEFAULT_SPLAT_EFFECTOR_SETTINGS } from '../../types/splatEffector';
-import { lottieRuntimeManager } from '../../services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../services/vectorAnimation/VectorAnimationRuntimeManager';
+import { isVectorAnimationSourceType } from '../../types/vectorAnimation';
import { mathSceneRenderer } from '../../services/mathScene/MathSceneRenderer';
import { generateEffectId } from './helpers/idGenerator';
import { cloneClipNodeGraph, remapClipNodeGraphEffectIds } from '../../services/nodeGraph';
@@ -280,8 +281,8 @@ export const createClipboardSlice: SliceCreator = (set, get) =
type: clipData.sourceType,
mediaFileId: clipData.mediaFileId,
naturalDuration: clipData.naturalDuration ?? clipData.duration,
- } : clipData.sourceType === 'lottie' && clipData.mediaFileId ? {
- type: 'lottie' as const,
+ } : isVectorAnimationSourceType(clipData.sourceType) && clipData.mediaFileId ? {
+ type: clipData.sourceType,
mediaFileId: clipData.mediaFileId,
naturalDuration: clipData.naturalDuration ?? clipData.duration,
vectorAnimationSettings: clipData.vectorAnimationSettings,
@@ -472,24 +473,24 @@ export const createClipboardSlice: SliceCreator = (set, get) =
const sourceType = newClip.source?.type;
- if (sourceType === 'lottie') {
+ if (isVectorAnimationSourceType(sourceType)) {
try {
const runtimeClip: TimelineClip = {
...newClip,
file: mediaFile.file,
source: {
- type: 'lottie',
+ type: sourceType,
mediaFileId,
naturalDuration: newClip.source?.naturalDuration ?? newClip.duration,
vectorAnimationSettings: newClip.source?.vectorAnimationSettings,
},
};
- const runtime = await lottieRuntimeManager.prepareClipSource(runtimeClip, mediaFile.file);
+ const runtime = await vectorAnimationRuntimeManager.prepareClipSource(runtimeClip, mediaFile.file);
const naturalDuration =
runtime.metadata.duration ??
newClip.source?.naturalDuration ??
newClip.duration;
- lottieRuntimeManager.renderClipAtTime(runtimeClip, runtimeClip.startTime);
+ vectorAnimationRuntimeManager.renderClipAtTime(runtimeClip, runtimeClip.startTime);
set((state) => ({
clips: state.clips.map((c) =>
@@ -498,7 +499,7 @@ export const createClipboardSlice: SliceCreator = (set, get) =
...c,
file: mediaFile.file!,
source: {
- type: 'lottie' as const,
+ type: sourceType,
textCanvas: runtime.canvas,
mediaFileId,
naturalDuration,
@@ -511,7 +512,7 @@ export const createClipboardSlice: SliceCreator = (set, get) =
),
}));
} catch (error) {
- log.warn('Failed to restore pasted lottie clip', { clipId: newClip.id, error });
+ log.warn('Failed to restore pasted vector animation clip', { clipId: newClip.id, sourceType, error });
set((state) => ({
clips: state.clips.map((c) =>
c.id === newClip.id
diff --git a/src/stores/timeline/keyframeSlice.ts b/src/stores/timeline/keyframeSlice.ts
index 63f7bd30..09691f15 100644
--- a/src/stores/timeline/keyframeSlice.ts
+++ b/src/stores/timeline/keyframeSlice.ts
@@ -34,14 +34,20 @@ import {
} from '../../utils/keyframeInterpolation';
import {
DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
+ coerceVectorAnimationDataBindingValue,
getVectorAnimationInputDefaultValue,
+ getVectorAnimationDataBindingDefaultValue,
getVectorAnimationStateIndex,
getVectorAnimationStateNameAtIndex,
+ isVectorAnimationSourceType,
mergeVectorAnimationSettings,
+ parseVectorAnimationDataBindingProperty,
parseVectorAnimationInputProperty,
parseVectorAnimationStateProperty,
+ vectorAnimationDataBindingValueToNumber,
vectorAnimationInputValueToNumber,
type VectorAnimationClipSettings,
+ type VectorAnimationDataBindingProperty,
} from '../../types/vectorAnimation';
import { isMotionProperty } from '../../types/motionDesign';
import { propertyRegistry } from '../../services/properties';
@@ -241,6 +247,47 @@ function getVectorAnimationInputBaseValue(
: 0;
}
+function getVectorAnimationDataBindingProperty(
+ clip: TimelineClip,
+ propertyName: string,
+): VectorAnimationDataBindingProperty | undefined {
+ const mediaFileId = clip.mediaFileId ?? clip.source?.mediaFileId;
+ if (!mediaFileId) {
+ return undefined;
+ }
+
+ const metadata = useMediaStore
+ .getState()
+ .files
+ .find((file) => file.id === mediaFileId)
+ ?.vectorAnimation;
+ const settings = mergeVectorAnimationSettings(clip.source?.vectorAnimationSettings);
+ const viewModelName = settings.viewModelName ?? metadata?.defaultViewModelName;
+
+ return metadata
+ ?.dataBindingProperties
+ ?.find((property) => (
+ property.name === propertyName &&
+ (!viewModelName || !property.viewModelName || property.viewModelName === viewModelName)
+ ));
+}
+
+function getVectorAnimationDataBindingBaseValue(
+ clip: TimelineClip,
+ settings: VectorAnimationClipSettings,
+ propertyName: string,
+): number {
+ const explicitValue = settings.dataBindingValues?.[propertyName];
+ if (explicitValue !== undefined) {
+ return vectorAnimationDataBindingValueToNumber(explicitValue);
+ }
+
+ const property = getVectorAnimationDataBindingProperty(clip, propertyName);
+ return property
+ ? vectorAnimationDataBindingValueToNumber(getVectorAnimationDataBindingDefaultValue(property))
+ : 0;
+}
+
function getVectorAnimationStateNames(
clip: TimelineClip,
stateMachineName: string,
@@ -669,7 +716,7 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
if (!clip) return;
const normalizedEasing = normalizeEasingType(easing, 'linear');
const vectorAnimationState = parseVectorAnimationStateProperty(property);
- const keyframeValue = vectorAnimationState && clip.source?.type === 'lottie'
+ const keyframeValue = vectorAnimationState && isVectorAnimationSourceType(clip.source?.type)
? normalizeVectorAnimationStateKeyframeValue(clip, vectorAnimationState.stateMachineName, value)
: value;
@@ -912,7 +959,7 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
}
const vectorAnimationState = parseVectorAnimationStateProperty(k.property);
- const normalizedUpdates = vectorAnimationState && clip?.source?.type === 'lottie' && baseNormalizedUpdates.value !== undefined
+ const normalizedUpdates = vectorAnimationState && isVectorAnimationSourceType(clip?.source?.type) && baseNormalizedUpdates.value !== undefined
? {
...baseNormalizedUpdates,
value: normalizeVectorAnimationStateKeyframeValue(
@@ -1189,83 +1236,110 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
const { clips, clipKeyframes } = get();
const clip = findClipById(clips, clipId);
const baseSettings = mergeVectorAnimationSettings(clip?.source?.vectorAnimationSettings);
- if (!clip || clip.source?.type !== 'lottie') {
+ if (!clip || !isVectorAnimationSourceType(clip.source?.type)) {
return baseSettings;
}
const activeStateMachineName = baseSettings.stateMachineName;
- if (!activeStateMachineName) {
- return baseSettings;
- }
-
const keyframes = clipKeyframes.get(clipId) || [];
- const statePropertyKey = keyframes
- .map((keyframe) => keyframe.property)
- .find((property) => parseVectorAnimationStateProperty(property)?.stateMachineName === activeStateMachineName);
let stateMachineState = baseSettings.stateMachineState;
let stateMachineStateCues = baseSettings.stateMachineStateCues;
+ let stateMachineInputValues = baseSettings.stateMachineInputValues;
- if (statePropertyKey) {
- const stateNames = getVectorAnimationStateNames(clip, activeStateMachineName);
- const stateValue = getSteppedKeyframeValue(
- keyframes,
- statePropertyKey,
- clipLocalTime,
- getVectorAnimationStateBaseValue(clip, baseSettings, activeStateMachineName),
- );
- stateMachineState = getVectorAnimationStateNameAtIndex(stateNames, stateValue) ?? stateMachineState;
- stateMachineStateCues = undefined;
- }
+ if (activeStateMachineName) {
+ const statePropertyKey = keyframes
+ .map((keyframe) => keyframe.property)
+ .find((property) => parseVectorAnimationStateProperty(property)?.stateMachineName === activeStateMachineName);
+
+ if (statePropertyKey) {
+ const stateNames = getVectorAnimationStateNames(clip, activeStateMachineName);
+ const stateValue = getSteppedKeyframeValue(
+ keyframes,
+ statePropertyKey,
+ clipLocalTime,
+ getVectorAnimationStateBaseValue(clip, baseSettings, activeStateMachineName),
+ );
+ stateMachineState = getVectorAnimationStateNameAtIndex(stateNames, stateValue) ?? stateMachineState;
+ stateMachineStateCues = undefined;
+ }
- const inputKeyframes = keyframes.filter((keyframe) => {
- const parsed = parseVectorAnimationInputProperty(keyframe.property);
- return parsed?.stateMachineName === activeStateMachineName;
- });
+ const inputKeyframes = keyframes.filter((keyframe) => {
+ const parsed = parseVectorAnimationInputProperty(keyframe.property);
+ return parsed?.stateMachineName === activeStateMachineName;
+ });
- if (inputKeyframes.length === 0) {
- return {
- ...baseSettings,
- stateMachineState,
- stateMachineStateCues,
- };
- }
+ if (inputKeyframes.length > 0) {
+ const inputValues = { ...(baseSettings.stateMachineInputValues ?? {}) };
+ const inputNames = new Set();
+ inputKeyframes.forEach((keyframe) => {
+ const parsed = parseVectorAnimationInputProperty(keyframe.property);
+ if (parsed) {
+ inputNames.add(parsed.inputName);
+ }
+ });
- const inputValues = { ...(baseSettings.stateMachineInputValues ?? {}) };
- const inputNames = new Set();
- inputKeyframes.forEach((keyframe) => {
- const parsed = parseVectorAnimationInputProperty(keyframe.property);
- if (parsed) {
- inputNames.add(parsed.inputName);
- }
- });
+ inputNames.forEach((inputName) => {
+ const property = [...inputKeyframes]
+ .map((keyframe) => keyframe.property)
+ .find((candidate) => parseVectorAnimationInputProperty(candidate)?.inputName === inputName);
+ if (!property) {
+ return;
+ }
- inputNames.forEach((inputName) => {
- const property = [...inputKeyframes]
- .map((keyframe) => keyframe.property)
- .find((candidate) => parseVectorAnimationInputProperty(candidate)?.inputName === inputName);
- if (!property) {
- return;
+ const baseValue = getVectorAnimationInputBaseValue(
+ clip,
+ baseSettings,
+ activeStateMachineName,
+ inputName,
+ );
+ inputValues[inputName] = interpolateKeyframes(
+ keyframes,
+ property,
+ clipLocalTime,
+ baseValue,
+ );
+ });
+ stateMachineInputValues = inputValues;
}
+ }
- const baseValue = getVectorAnimationInputBaseValue(
- clip,
- baseSettings,
- activeStateMachineName,
- inputName,
- );
- inputValues[inputName] = interpolateKeyframes(
- keyframes,
- property,
- clipLocalTime,
- baseValue,
- );
- });
+ const dataBindingKeyframes = keyframes.filter((keyframe) => parseVectorAnimationDataBindingProperty(keyframe.property));
+ let dataBindingValues = baseSettings.dataBindingValues;
+
+ if (dataBindingKeyframes.length > 0) {
+ const nextDataBindingValues = { ...(baseSettings.dataBindingValues ?? {}) };
+ const propertyNames = new Set();
+ dataBindingKeyframes.forEach((keyframe) => {
+ const parsed = parseVectorAnimationDataBindingProperty(keyframe.property);
+ if (parsed) {
+ propertyNames.add(parsed.propertyName);
+ }
+ });
+
+ propertyNames.forEach((propertyName) => {
+ const property = [...dataBindingKeyframes]
+ .map((keyframe) => keyframe.property)
+ .find((candidate) => parseVectorAnimationDataBindingProperty(candidate)?.propertyName === propertyName);
+ if (!property) {
+ return;
+ }
+
+ nextDataBindingValues[propertyName] = interpolateKeyframes(
+ keyframes,
+ property,
+ clipLocalTime,
+ getVectorAnimationDataBindingBaseValue(clip, baseSettings, propertyName),
+ );
+ });
+ dataBindingValues = nextDataBindingValues;
+ }
return {
...baseSettings,
stateMachineState,
stateMachineStateCues,
- stateMachineInputValues: inputValues,
+ stateMachineInputValues,
+ dataBindingValues,
};
},
@@ -1492,7 +1566,7 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
if (!clip) return;
const vectorAnimationState = parseVectorAnimationStateProperty(property);
- if (vectorAnimationState && clip.source?.type === 'lottie') {
+ if (vectorAnimationState && isVectorAnimationSourceType(clip.source?.type)) {
const currentSettings = mergeVectorAnimationSettings(clip.source.vectorAnimationSettings);
const normalizedValue = normalizeVectorAnimationStateKeyframeValue(
clip,
@@ -1523,7 +1597,7 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
}
const vectorAnimationInput = parseVectorAnimationInputProperty(property);
- if (vectorAnimationInput && clip.source?.type === 'lottie') {
+ if (vectorAnimationInput && isVectorAnimationSourceType(clip.source?.type)) {
const currentSettings = mergeVectorAnimationSettings(clip.source.vectorAnimationSettings);
const inputValues = {
...(currentSettings.stateMachineInputValues ?? {}),
@@ -1547,6 +1621,36 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
return;
}
+ const vectorAnimationDataBinding = parseVectorAnimationDataBindingProperty(property);
+ if (vectorAnimationDataBinding && isVectorAnimationSourceType(clip.source?.type)) {
+ const currentSettings = mergeVectorAnimationSettings(clip.source.vectorAnimationSettings);
+ const metadataProperty = getVectorAnimationDataBindingProperty(
+ clip,
+ vectorAnimationDataBinding.propertyName,
+ );
+ const nextValue = metadataProperty
+ ? coerceVectorAnimationDataBindingValue(metadataProperty, value)
+ : value;
+ set({
+ clips: clips.map(c => c.id === clipId ? {
+ ...c,
+ source: c.source ? {
+ ...c.source,
+ vectorAnimationSettings: {
+ ...DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
+ ...c.source.vectorAnimationSettings,
+ dataBindingValues: {
+ ...(currentSettings.dataBindingValues ?? {}),
+ [vectorAnimationDataBinding.propertyName]: nextValue,
+ },
+ },
+ } : c.source,
+ } : c),
+ });
+ get().invalidateCache();
+ return;
+ }
+
const maskProperty = parseMaskProperty(property);
if (maskProperty && maskProperty.property !== 'path') {
const mask = clip.masks?.find(candidate => candidate.id === maskProperty.maskId);
@@ -1814,7 +1918,7 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
// 1. Write current value to base clip value (same logic as setPropertyValue static path)
const vectorAnimationState = parseVectorAnimationStateProperty(property);
- if (vectorAnimationState && clip.source?.type === 'lottie') {
+ if (vectorAnimationState && isVectorAnimationSourceType(clip.source?.type)) {
const stateName = getVectorAnimationStateNameAtIndex(
getVectorAnimationStateNames(clip, vectorAnimationState.stateMachineName),
currentValue,
@@ -1836,7 +1940,7 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
});
} else {
const vectorAnimationInput = parseVectorAnimationInputProperty(property);
- if (vectorAnimationInput && clip.source?.type === 'lottie') {
+ if (vectorAnimationInput && isVectorAnimationSourceType(clip.source?.type)) {
set({
clips: get().clips.map(c => c.id === clipId ? {
...c,
@@ -1853,6 +1957,31 @@ export const createKeyframeSlice: SliceCreator = (set, get) =>
} : c.source,
} : c),
});
+ } else if (parseVectorAnimationDataBindingProperty(property) && isVectorAnimationSourceType(clip.source?.type)) {
+ const vectorAnimationDataBinding = parseVectorAnimationDataBindingProperty(property)!;
+ const metadataProperty = getVectorAnimationDataBindingProperty(
+ clip,
+ vectorAnimationDataBinding.propertyName,
+ );
+ const nextValue = metadataProperty
+ ? coerceVectorAnimationDataBindingValue(metadataProperty, currentValue)
+ : currentValue;
+ set({
+ clips: get().clips.map(c => c.id === clipId ? {
+ ...c,
+ source: c.source ? {
+ ...c.source,
+ vectorAnimationSettings: {
+ ...DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
+ ...c.source.vectorAnimationSettings,
+ dataBindingValues: {
+ ...(c.source.vectorAnimationSettings?.dataBindingValues ?? {}),
+ [vectorAnimationDataBinding.propertyName]: nextValue,
+ },
+ },
+ } : c.source,
+ } : c),
+ });
} else if (parseCameraProperty(property) && clip.source?.type === 'camera') {
const cameraProperty = parseCameraProperty(property)!;
set({
diff --git a/src/stores/timeline/serializationUtils.ts b/src/stores/timeline/serializationUtils.ts
index 5a966f5c..d5cce1cd 100644
--- a/src/stores/timeline/serializationUtils.ts
+++ b/src/stores/timeline/serializationUtils.ts
@@ -26,8 +26,10 @@ import {
DEFAULT_GAUSSIAN_SPLAT_SETTINGS,
resolveGaussianSplatSettingsForSource,
} from '../../engine/gaussian/types';
-import { lottieRuntimeManager } from '../../services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../services/vectorAnimation/VectorAnimationRuntimeManager';
import { readLottieMetadata } from '../../services/vectorAnimation/lottieMetadata';
+import { readRiveMetadata } from '../../services/vectorAnimation/riveMetadata';
+import { isVectorAnimationSourceType } from '../../types/vectorAnimation';
import { mathSceneRenderer } from '../../services/mathScene/MathSceneRenderer';
import { markDynamicCanvasUpdated } from '../../services/canvasVersion';
import { resolveGaussianSplatSequenceData } from '../../utils/gaussianSplatSequence';
@@ -1531,7 +1533,7 @@ export const createSerializationUtils: SliceCreator = (set,
if (
!loadFile &&
fileUrl &&
- (type === 'video' || type === 'audio' || type === 'image' || type === 'lottie') &&
+ (type === 'video' || type === 'audio' || type === 'image' || type === 'lottie' || type === 'rive') &&
NativeHelperClient.parseFileReferenceUrl(fileUrl)
) {
const referencedFile = await NativeHelperClient.getReferencedFile(fileUrl, mediaFile.name);
@@ -1730,9 +1732,9 @@ export const createSerializationUtils: SliceCreator = (set,
}));
wakePreviewAfterRestore();
}, { once: true });
- } else if (type === 'lottie') {
+ } else if (isVectorAnimationSourceType(type)) {
if (!loadFile) {
- log.warn('Skipping lottie restore - file object not available', { clip: clip.name });
+ log.warn('Skipping vector animation restore - file object not available', { clip: clip.name, type });
set((state) => ({
clips: state.clips.map((currentClip) =>
currentClip.id === clip.id
@@ -1749,14 +1751,16 @@ export const createSerializationUtils: SliceCreator = (set,
...clip,
file: loadFile,
source: {
- type: 'lottie',
+ type,
mediaFileId: serializedClip.mediaFileId,
naturalDuration: serializedClip.naturalDuration,
vectorAnimationSettings: serializedClip.vectorAnimationSettings,
},
};
- const metadata = await readLottieMetadata(loadFile);
- const runtime = await lottieRuntimeManager.prepareClipSource(runtimeClip, loadFile);
+ const metadata = type === 'lottie'
+ ? await readLottieMetadata(loadFile)
+ : await readRiveMetadata(loadFile);
+ const runtime = await vectorAnimationRuntimeManager.prepareClipSource(runtimeClip, loadFile);
set((state) => ({
clips: state.clips.map((currentClip) =>
currentClip.id === clip.id
@@ -1764,7 +1768,7 @@ export const createSerializationUtils: SliceCreator = (set,
...currentClip,
file: loadFile,
source: {
- type: 'lottie',
+ type,
textCanvas: runtime.canvas,
mediaFileId: serializedClip.mediaFileId,
naturalDuration: metadata.duration ?? serializedClip.naturalDuration ?? serializedClip.duration,
@@ -1923,8 +1927,8 @@ export const createSerializationUtils: SliceCreator = (set,
audio.removeAttribute('src');
audio.load();
}
- if (clip.source?.type === 'lottie') {
- lottieRuntimeManager.destroyClipRuntime(clip.id);
+ if (isVectorAnimationSourceType(clip.source?.type)) {
+ vectorAnimationRuntimeManager.destroyClipRuntime(clip.id, clip.source.type);
}
// WebCodecsPlayers stay in globalWcpCache — don't destroy them.
// Pause them so the decoder isn't running while detached.
diff --git a/src/types/index.ts b/src/types/index.ts
index f636e7ba..a917d7d0 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -2,6 +2,7 @@
import type {
VectorAnimationClipSettings,
+ VectorAnimationDataBindingPropertyPath,
VectorAnimationInputProperty,
VectorAnimationProvider,
VectorAnimationStateProperty,
@@ -900,7 +901,7 @@ export type TextBoundsNumericProperty = `textBounds.${TextBoundsNumericPropertyN
export type TextBoundsProperty = TextBoundsPathProperty | TextBoundsNumericProperty;
// Combined animatable property type
-export type AnimatableProperty = TransformProperty | CameraProperty | EffectProperty | NodeGraphParamProperty | ColorProperty | MaskProperty | TextBoundsProperty | VectorAnimationInputProperty | VectorAnimationStateProperty | MotionProperty;
+export type AnimatableProperty = TransformProperty | CameraProperty | EffectProperty | NodeGraphParamProperty | ColorProperty | MaskProperty | TextBoundsProperty | VectorAnimationInputProperty | VectorAnimationStateProperty | VectorAnimationDataBindingPropertyPath | MotionProperty;
export function isCameraProperty(property: string): property is CameraProperty {
return /^camera\.(fov|near|far|resolutionWidth|resolutionHeight)$/.test(property);
diff --git a/src/types/vectorAnimation.ts b/src/types/vectorAnimation.ts
index 439d8496..a1d8b9bc 100644
--- a/src/types/vectorAnimation.ts
+++ b/src/types/vectorAnimation.ts
@@ -6,14 +6,40 @@ export type VectorAnimationStateMachineInputType = 'boolean' | 'number' | 'strin
export type VectorAnimationStateMachineInputValue = boolean | number | string;
+export type VectorAnimationDataBindingType =
+ | 'boolean'
+ | 'number'
+ | 'integer'
+ | 'string'
+ | 'color'
+ | 'enum'
+ | 'trigger';
+
+export type VectorAnimationDataBindingValue = boolean | number | string;
+
export interface VectorAnimationStateMachineInput {
name: string;
type: VectorAnimationStateMachineInputType;
defaultValue?: VectorAnimationStateMachineInputValue;
}
+export interface VectorAnimationDataBindingProperty {
+ name: string;
+ type: VectorAnimationDataBindingType;
+ viewModelName?: string;
+ defaultValue?: VectorAnimationDataBindingValue;
+ values?: string[];
+}
+
+export interface VectorAnimationViewModelMetadata {
+ name: string;
+ instanceNames?: string[];
+ properties: VectorAnimationDataBindingProperty[];
+}
+
export type VectorAnimationInputProperty = `lottieInput.${string}.${string}`;
export type VectorAnimationStateProperty = `lottieState.${string}`;
+export type VectorAnimationDataBindingPropertyPath = `riveData.${string}`;
export interface VectorAnimationMetadata {
provider: VectorAnimationProvider;
@@ -28,6 +54,10 @@ export interface VectorAnimationMetadata {
stateMachineNames?: string[];
stateMachineStates?: Record;
stateMachineInputs?: Record;
+ viewModelNames?: string[];
+ defaultViewModelName?: string;
+ viewModels?: VectorAnimationViewModelMetadata[];
+ dataBindingProperties?: VectorAnimationDataBindingProperty[];
}
export interface VectorAnimationStateCue {
@@ -51,6 +81,9 @@ export interface VectorAnimationClipSettings {
stateMachineState?: string;
stateMachineStateCues?: VectorAnimationStateCue[];
stateMachineInputValues?: Record;
+ viewModelName?: string;
+ viewModelInstanceName?: string;
+ dataBindingValues?: Record;
}
export const DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS: VectorAnimationClipSettings = {
@@ -76,6 +109,10 @@ export function shouldLoopVectorAnimation(
return settings.loop || settings.endBehavior === 'loop';
}
+export function isVectorAnimationSourceType(value: unknown): value is VectorAnimationProvider {
+ return value === 'lottie' || value === 'rive';
+}
+
export function isVectorAnimationBounceMode(
playbackMode: VectorAnimationPlaybackMode | undefined,
): boolean {
@@ -160,6 +197,12 @@ export function createVectorAnimationStateProperty(
return `lottieState.${encodePropertyPart(stateMachineName)}` as VectorAnimationStateProperty;
}
+export function createVectorAnimationDataBindingProperty(
+ propertyName: string,
+): VectorAnimationDataBindingPropertyPath {
+ return `riveData.${encodePropertyPart(propertyName)}` as VectorAnimationDataBindingPropertyPath;
+}
+
export function parseVectorAnimationInputProperty(
property: string,
): { stateMachineName: string; inputName: string } | null {
@@ -193,6 +236,22 @@ export function parseVectorAnimationStateProperty(
return { stateMachineName };
}
+export function parseVectorAnimationDataBindingProperty(
+ property: string,
+): { propertyName: string } | null {
+ const parts = property.split('.');
+ if (parts.length !== 2 || parts[0] !== 'riveData') {
+ return null;
+ }
+
+ const propertyName = decodePropertyPart(parts[1]);
+ if (!propertyName) {
+ return null;
+ }
+
+ return { propertyName };
+}
+
export function getVectorAnimationStateIndex(
stateNames: readonly string[],
stateName: string | undefined,
@@ -272,6 +331,69 @@ export function coerceVectorAnimationInputValue(
return fallback;
}
+export function getVectorAnimationDataBindingDefaultValue(
+ property: VectorAnimationDataBindingProperty,
+): VectorAnimationDataBindingValue {
+ if (property.defaultValue !== undefined) {
+ return property.defaultValue;
+ }
+ if (property.type === 'boolean') {
+ return false;
+ }
+ if (property.type === 'number' || property.type === 'integer' || property.type === 'color') {
+ return 0;
+ }
+ return '';
+}
+
+export function coerceVectorAnimationDataBindingValue(
+ property: VectorAnimationDataBindingProperty,
+ value: VectorAnimationDataBindingValue | undefined,
+): VectorAnimationDataBindingValue {
+ const fallback = getVectorAnimationDataBindingDefaultValue(property);
+
+ if (property.type === 'boolean') {
+ if (typeof value === 'boolean') return value;
+ if (typeof value === 'number') return value >= 0.5;
+ if (typeof value === 'string') return value === 'true' || value === '1';
+ return fallback;
+ }
+
+ if (property.type === 'number' || property.type === 'integer' || property.type === 'color') {
+ const numericValue = typeof value === 'number'
+ ? value
+ : typeof value === 'boolean'
+ ? Number(value)
+ : Number(value);
+ if (!Number.isFinite(numericValue)) {
+ return fallback;
+ }
+ return property.type === 'integer' || property.type === 'color'
+ ? Math.round(numericValue)
+ : numericValue;
+ }
+
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+ return fallback;
+}
+
+export function vectorAnimationDataBindingValueToNumber(
+ value: VectorAnimationDataBindingValue | undefined,
+): number {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === 'boolean') {
+ return value ? 1 : 0;
+ }
+ if (typeof value === 'string') {
+ const numericValue = Number(value);
+ return Number.isFinite(numericValue) ? numericValue : 0;
+ }
+ return 0;
+}
+
export function vectorAnimationInputValueToNumber(
value: VectorAnimationStateMachineInputValue | undefined,
): number {
diff --git a/tests/unit/exportLayerBuilder.test.ts b/tests/unit/exportLayerBuilder.test.ts
index 3267a50c..c09eb6d0 100644
--- a/tests/unit/exportLayerBuilder.test.ts
+++ b/tests/unit/exportLayerBuilder.test.ts
@@ -7,7 +7,7 @@ import {
import type { ExportClipState, FrameContext } from '../../src/engine/export/types';
import { useMediaStore } from '../../src/stores/mediaStore';
import { useTimelineStore } from '../../src/stores/timeline';
-import { lottieRuntimeManager } from '../../src/services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../src/services/vectorAnimation/VectorAnimationRuntimeManager';
import type { TimelineClip, TimelineTrack } from '../../src/stores/timeline/types';
import type { ParallelDecodeManager } from '../../src/engine/ParallelDecodeManager';
@@ -813,7 +813,7 @@ describe('ExportLayerBuilder', () => {
solo: false,
} as unknown as TimelineTrack;
const canvas = document.createElement('canvas');
- const renderSpy = vi.spyOn(lottieRuntimeManager, 'renderClipAtTime').mockReturnValue(canvas);
+ const renderSpy = vi.spyOn(vectorAnimationRuntimeManager, 'renderClipAtTime').mockReturnValue(canvas);
const clip = {
id: 'clip-lottie',
diff --git a/tests/unit/importPipeline.test.ts b/tests/unit/importPipeline.test.ts
index 8c98daa4..b990965b 100644
--- a/tests/unit/importPipeline.test.ts
+++ b/tests/unit/importPipeline.test.ts
@@ -22,6 +22,17 @@ const mocks = vi.hoisted(() => ({
totalFrames: 120,
},
})),
+ prepareRiveAsset: vi.fn(async () => ({
+ metadata: {
+ provider: 'rive',
+ width: 512,
+ height: 512,
+ fps: 60,
+ duration: 5,
+ animationNames: ['Idle'],
+ artboardNames: ['Main'],
+ },
+ })),
}));
vi.mock('../../src/stores/timeline/helpers/mediaTypeHelpers', () => ({
@@ -66,6 +77,10 @@ vi.mock('../../src/services/vectorAnimation/lottieMetadata', () => ({
prepareLottieAsset: mocks.prepareLottieAsset,
}));
+vi.mock('../../src/services/vectorAnimation/riveMetadata', () => ({
+ prepareRiveAsset: mocks.prepareRiveAsset,
+}));
+
vi.mock('../../src/stores/settingsStore', () => ({
useSettingsStore: {
getState: () => ({ copyMediaToProject: true }),
@@ -151,6 +166,33 @@ describe('processImport', () => {
expect(result.mediaFile.duration).toBe(4);
});
+ it('stores Rive metadata without using HTML media probing', async () => {
+ mocks.classifyMediaType.mockResolvedValue('rive');
+ const riveFile = new File(['rive-bytes'], 'anim.riv', {
+ type: 'application/octet-stream',
+ lastModified: 3,
+ });
+
+ const result = await processImport({
+ file: riveFile,
+ id: 'media-rive-1',
+ });
+
+ expect(mocks.prepareRiveAsset).toHaveBeenCalledWith(riveFile);
+ expect(mocks.getMediaInfo).not.toHaveBeenCalled();
+ expect(result.mediaFile.type).toBe('rive');
+ expect(result.mediaFile.vectorAnimation).toEqual(expect.objectContaining({
+ provider: 'rive',
+ width: 512,
+ height: 512,
+ duration: 5,
+ }));
+ expect(result.mediaFile.width).toBe(512);
+ expect(result.mediaFile.height).toBe(512);
+ expect(result.mediaFile.fps).toBe(60);
+ expect(result.mediaFile.duration).toBe(5);
+ });
+
it('does not mark a partial existing proxy as ready during import', async () => {
mocks.getProxyFrameCount.mockResolvedValue(300);
mocks.getProxyFrameIndices.mockResolvedValue(
diff --git a/tests/unit/layerBuilderService.test.ts b/tests/unit/layerBuilderService.test.ts
index 0e5aa171..e9dce6f8 100644
--- a/tests/unit/layerBuilderService.test.ts
+++ b/tests/unit/layerBuilderService.test.ts
@@ -13,7 +13,7 @@ import {
import { mediaRuntimeRegistry } from '../../src/services/mediaRuntime/registry';
import { scrubSettleState } from '../../src/services/scrubSettleState';
import { proxyFrameCache } from '../../src/services/proxyFrameCache';
-import { lottieRuntimeManager } from '../../src/services/vectorAnimation/LottieRuntimeManager';
+import { vectorAnimationRuntimeManager } from '../../src/services/vectorAnimation/VectorAnimationRuntimeManager';
import type { RuntimeFrameProvider } from '../../src/services/mediaRuntime/types';
import type { TimelineClip } from '../../src/types';
@@ -213,7 +213,7 @@ describe('LayerBuilderService paused visual provider selection', () => {
it('routes lottie clips through the existing canvas layer path', () => {
const service = new LayerBuilderService();
const canvas = document.createElement('canvas');
- const renderSpy = vi.spyOn(lottieRuntimeManager, 'renderClipAtTime').mockReturnValue(canvas);
+ const renderSpy = vi.spyOn(vectorAnimationRuntimeManager, 'renderClipAtTime').mockReturnValue(canvas);
useMediaStore.setState({
activeCompositionId: null,
diff --git a/tests/unit/vectorAnimation.test.ts b/tests/unit/vectorAnimation.test.ts
index fcd212db..0005057b 100644
--- a/tests/unit/vectorAnimation.test.ts
+++ b/tests/unit/vectorAnimation.test.ts
@@ -2,10 +2,14 @@ import { describe, expect, it } from 'vitest';
import {
DEFAULT_VECTOR_ANIMATION_CLIP_SETTINGS,
+ coerceVectorAnimationDataBindingValue,
+ createVectorAnimationDataBindingProperty,
createVectorAnimationInputProperty,
isVectorAnimationBounceMode,
+ isVectorAnimationSourceType,
normalizeVectorAnimationRenderDimension,
normalizeVectorAnimationStateCues,
+ parseVectorAnimationDataBindingProperty,
parseVectorAnimationInputProperty,
resolveVectorAnimationStateName,
} from '../../src/types/vectorAnimation';
@@ -47,6 +51,14 @@ describe('vector animation input properties', () => {
inputName: 'On Off',
});
});
+
+ it('round-trips encoded Rive data binding properties', () => {
+ const property = createVectorAnimationDataBindingProperty('Count.Value');
+
+ expect(parseVectorAnimationDataBindingProperty(property)).toEqual({
+ propertyName: 'Count.Value',
+ });
+ });
});
describe('vector animation playback settings', () => {
@@ -57,5 +69,16 @@ describe('vector animation playback settings', () => {
expect(isVectorAnimationBounceMode('bounce')).toBe(true);
expect(isVectorAnimationBounceMode('reverse-bounce')).toBe(true);
expect(isVectorAnimationBounceMode('forward')).toBe(false);
+ expect(isVectorAnimationSourceType('lottie')).toBe(true);
+ expect(isVectorAnimationSourceType('rive')).toBe(true);
+ expect(isVectorAnimationSourceType('video')).toBe(false);
+ });
+});
+
+describe('vector animation data binding values', () => {
+ it('coerces Rive boolean, numeric, and text data binding values', () => {
+ expect(coerceVectorAnimationDataBindingValue({ name: 'enabled', type: 'boolean' }, 1)).toBe(true);
+ expect(coerceVectorAnimationDataBindingValue({ name: 'count', type: 'integer' }, 2.6)).toBe(3);
+ expect(coerceVectorAnimationDataBindingValue({ name: 'label', type: 'string' }, 42)).toBe('42');
});
});