Skip to content

Commit 8dfbb4b

Browse files
committed
feat: rework includes
1 parent 151df22 commit 8dfbb4b

File tree

7 files changed

+747
-12
lines changed

7 files changed

+747
-12
lines changed

.claude/skills/rustmotion/SKILL.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,11 +448,87 @@ Scene entries can reference external scenario files to inject their scenes inlin
448448
| --- | --- | --- | --- |
449449
| `include` | string | required | Path (relative to parent) or URL to a scenario JSON file |
450450
| `scenes` | array of usize | `null` | Only include scenes at these 0-based indices |
451+
| `config` | object | `null` | Config overrides for structural components |
451452

452453
- The included file's `video` config is ignored
453454
- Audio tracks from included files are merged
454455
- Includes can be nested (max depth: 8)
455456

457+
#### Structural Components (Config)
458+
459+
Structural components are reusable scenarios with **declared config** (type + default). When rendered standalone, defaults apply. When included, the parent can override config values. Config supports all types including `array` and `object`, allowing full component trees (e.g. rich_text spans) to be passed as parameters.
460+
461+
**Defining a structural component (`components/outro.json`):**
462+
```json
463+
{
464+
"config": {
465+
"cta_text": { "type": "string", "default": "Book your demo" },
466+
"accent_color": { "type": "string", "default": "#5C39EE" },
467+
"logo_src": { "type": "string", "default": "assets/logo.svg" },
468+
"counter_target": { "type": "number", "default": 400 },
469+
"tagline_spans": {
470+
"type": "array",
471+
"default": [
472+
{ "text": "Don't " },
473+
{ "text": "miss ", "color": "#B041F0" },
474+
{ "text": "any lead" }
475+
]
476+
}
477+
},
478+
"video": { "width": 1080, "height": 1920, "fps": 30 },
479+
"scenes": [
480+
{
481+
"duration": 7.0,
482+
"children": [
483+
{ "type": "svg", "src": "$logo_src" },
484+
{ "type": "text", "content": "$cta_text", "style": { "color": "$accent_color" } },
485+
{ "type": "counter", "from": 0, "to": { "$var": "counter_target" } },
486+
{ "type": "rich_text", "spans": { "$var": "tagline_spans" } }
487+
]
488+
}
489+
]
490+
}
491+
```
492+
493+
**Config reference syntax:**
494+
495+
| Syntax | When to use | Behavior |
496+
| --- | --- | --- |
497+
| `"$name"` | Whole string value | Replaced by the config value (preserves type: number, boolean, etc.) |
498+
| `"text $name text"` | String interpolation | Inline substitution (value must be string/number/boolean) |
499+
| `{ "$var": "name" }` | Non-string in object position | Replaced by the config value (arrays, objects, numbers) |
500+
| `"$$literal"` | Escape | Produces literal `"$literal"` |
501+
502+
**Including with overrides:**
503+
```json
504+
{
505+
"scenes": [
506+
{ "duration": 5.0, "children": [...] },
507+
{
508+
"include": "components/outro.json",
509+
"config": {
510+
"cta_text": "Try WhatsApp",
511+
"accent_color": "#25D366",
512+
"tagline_spans": [
513+
{ "text": "Stop losing " },
514+
{ "text": "customers", "color": "#25D366" }
515+
]
516+
}
517+
}
518+
]
519+
}
520+
```
521+
522+
Config types: `string`, `number`, `boolean`, `object`, `array`. Omitted overrides use defaults. Referencing an undefined config key is an error.
523+
524+
**Rules for generation:**
525+
- Always declare config entries with a `type` and `default`
526+
- Use `"$name"` for string fields (src, content, color) — replaces the whole value
527+
- Use `{ "$var": "name" }` for non-string fields (numbers, arrays, objects) to preserve the type
528+
- Use `array` type to pass component trees (spans, children, gradient color stops)
529+
- Use `$$` to escape literal dollar signs
530+
- Never reference config values inside the `"config"` definition block itself
531+
456532
#### Transitions
457533

458534
```json

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,92 @@ Scene entries can reference external scenario files to inject their scenes inlin
216216
|---|---|---|---|
217217
| `include` | `string` | (required) | Path (relative to parent file) or URL to a scenario JSON |
218218
| `scenes` | `usize[]` | | Only include scenes at these 0-based indices. Omit to include all |
219+
| `config` | `object` | | Config overrides to pass to a structural component (see below) |
219220

220221
- The included file's `video` config is ignored
221222
- Audio tracks from included files are merged
222223
- Includes can be nested (max depth: 8)
223224

224225
---
225226

227+
## Structural Components (Variables)
228+
229+
Structural components are reusable scenario files with **declared variables**. When rendered standalone, default values apply. When included from a parent, the parent can override any variable.
230+
231+
### Defining config
232+
233+
Add a `config` object at the root of a scenario. Each entry has a `type`, a `default` value, and an optional `description`:
234+
235+
```json
236+
{
237+
"config": {
238+
"cta_text": { "type": "string", "default": "Book your demo" },
239+
"accent_color": { "type": "string", "default": "#5C39EE" },
240+
"logo_src": { "type": "string", "default": "assets/logo.svg" },
241+
"counter_target": { "type": "number", "default": 400 },
242+
"tagline_spans": {
243+
"type": "array",
244+
"default": [
245+
{ "text": "Don't ", "color": "#5C39EE" },
246+
{ "text": "miss any lead" }
247+
]
248+
}
249+
},
250+
"video": { "width": 1080, "height": 1920, "fps": 30 },
251+
"scenes": [
252+
{
253+
"duration": 7.0,
254+
"children": [
255+
{ "type": "svg", "src": "$logo_src" },
256+
{ "type": "text", "content": "$cta_text", "style": { "color": "$accent_color" } },
257+
{ "type": "counter", "from": 0, "to": { "$var": "counter_target" } },
258+
{ "type": "rich_text", "spans": { "$var": "tagline_spans" } }
259+
]
260+
}
261+
]
262+
}
263+
```
264+
265+
Supported types: `string`, `number`, `boolean`, `object`, `array`. Array and object types allow passing full components (e.g. rich_text spans, children arrays).
266+
267+
### Referencing config values
268+
269+
| Syntax | Context | Behavior |
270+
|---|---|---|
271+
| `"$var_name"` | Entire string value | Replaced by the config value (any type) |
272+
| `"prefix $var_name suffix"` | String interpolation | Replaced inline (value must be string/number/boolean) |
273+
| `{ "$var": "var_name" }` | Any position | Replaced by the config value (for non-string types in object position) |
274+
| `"$$literal"` | Escape | Produces the literal string `"$literal"` |
275+
276+
### Including with overrides
277+
278+
```json
279+
{
280+
"scenes": [
281+
{ "duration": 5.0, "children": [ ... ] },
282+
{
283+
"include": "components/outro.json",
284+
"config": {
285+
"cta_text": "Try WhatsApp",
286+
"accent_color": "#25D366",
287+
"tagline_spans": [
288+
{ "text": "Stop losing " },
289+
{ "text": "customers", "color": "#25D366" }
290+
]
291+
}
292+
}
293+
]
294+
}
295+
```
296+
297+
Config entries not listed in overrides keep their default values. Referencing an undefined config key produces an error.
298+
299+
### Standalone rendering
300+
301+
When rendering a structural component directly (`rustmotion render components/outro.json`), all default values are applied automatically.
302+
303+
---
304+
226305
## Components
227306

228307
All components are discriminated by `"type"`. Rendered in array order (first = bottom, last = top).

src/error.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ pub enum RustmotionError {
8282
#[error("Scenario cannot have both top-level 'scenes' and 'composition' — use one or the other")]
8383
CompositionAndScenesConflict,
8484

85+
// --- Variables ---
86+
#[error("Variable '${name}' is not defined in '{path}'")]
87+
UndefinedVariable { name: String, path: String },
88+
89+
#[error("Variable '{name}' in '{path}' is missing a default value")]
90+
VariableMissingDefault { name: String, path: String },
91+
92+
#[error("Unresolved variable reference '${name}' after substitution in '{path}'")]
93+
UnresolvedVariable { name: String, path: String },
94+
95+
#[error("Cannot interpolate non-string variable '${name}' into string in '{path}'")]
96+
VariableInterpolationTypeError { name: String, path: String },
97+
8598
// --- Encoding ---
8699
#[error("No frames to render (total duration is 0)")]
87100
NoFrames,

src/include.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub enum IncludeSource {
2121
/// Expand all include directives in a scenario, producing resolved views.
2222
pub fn resolve_includes(scenario: Scenario, source: &IncludeSource) -> Result<ResolvedScenario> {
2323
let mut audio = scenario.audio;
24+
let mut included_paths = Vec::new();
2425
let has_scenes = !scenario.scenes.is_empty();
2526
let has_composition = scenario.composition.is_some();
2627

@@ -32,7 +33,7 @@ pub fn resolve_includes(scenario: Scenario, source: &IncludeSource) -> Result<Re
3233
// New format: composition with views
3334
let mut views = Vec::with_capacity(composition.len());
3435
for view in composition {
35-
let scenes = resolve_entries(view.scenes, source, 0, &mut audio)?;
36+
let scenes = resolve_entries(view.scenes, source, 0, &mut audio, &mut included_paths)?;
3637
views.push(ResolvedView {
3738
view_type: view.view_type,
3839
scenes,
@@ -46,7 +47,7 @@ pub fn resolve_includes(scenario: Scenario, source: &IncludeSource) -> Result<Re
4647
views
4748
} else {
4849
// Backward compat: wrap top-level scenes in a single slide view
49-
let scenes = resolve_entries(scenario.scenes, source, 0, &mut audio)?;
50+
let scenes = resolve_entries(scenario.scenes, source, 0, &mut audio, &mut included_paths)?;
5051
vec![ResolvedView {
5152
view_type: ViewType::Slide,
5253
scenes,
@@ -63,6 +64,7 @@ pub fn resolve_includes(scenario: Scenario, source: &IncludeSource) -> Result<Re
6364
audio,
6465
fonts: scenario.fonts,
6566
views,
67+
included_paths,
6668
})
6769
}
6870

@@ -71,6 +73,7 @@ fn resolve_entries(
7173
source: &IncludeSource,
7274
depth: u8,
7375
audio: &mut Vec<crate::schema::AudioTrack>,
76+
included_paths: &mut Vec<PathBuf>,
7477
) -> Result<Vec<Scene>> {
7578
let mut result = Vec::new();
7679

@@ -86,7 +89,7 @@ fn resolve_entries(
8689
path: directive.include.clone(),
8790
}.into());
8891
}
89-
let scenes = fetch_and_resolve(&directive, source, depth + 1, audio)?;
92+
let scenes = fetch_and_resolve(&directive, source, depth + 1, audio, included_paths)?;
9093
result.extend(scenes);
9194
}
9295
}
@@ -100,6 +103,7 @@ fn fetch_and_resolve(
100103
parent_source: &IncludeSource,
101104
depth: u8,
102105
audio: &mut Vec<crate::schema::AudioTrack>,
106+
included_paths: &mut Vec<PathBuf>,
103107
) -> Result<Vec<Scene>> {
104108
let is_remote = directive.include.starts_with("http://")
105109
|| directive.include.starts_with("https://");
@@ -113,18 +117,30 @@ fn fetch_and_resolve(
113117
let body = std::fs::read_to_string(&path).map_err(|_| {
114118
RustmotionError::IncludeFileNotFound { path: path.display().to_string() }
115119
})?;
120+
// Track this included file for watch mode
121+
included_paths.push(path.clone());
116122
let child_source = IncludeSource::File(path);
117123
(body, child_source)
118124
};
119125

120-
let child_scenario: Scenario = serde_json::from_str(&json_str)
121-
.map_err(RustmotionError::from)?;
126+
// Parse as raw Value first, apply variable substitution, then deserialize
127+
let mut json_value: serde_json::Value =
128+
serde_json::from_str(&json_str).map_err(RustmotionError::from)?;
129+
130+
crate::variables::apply_variables(
131+
&mut json_value,
132+
directive.config.as_ref(),
133+
&directive.include,
134+
)?;
135+
136+
let child_scenario: Scenario =
137+
serde_json::from_value(json_value).map_err(RustmotionError::from)?;
122138

123139
// Merge audio tracks from the included file
124140
audio.extend(child_scenario.audio);
125141

126142
// Recursively resolve any nested includes
127-
let mut scenes = resolve_entries(child_scenario.scenes, &child_source, depth, audio)?;
143+
let mut scenes = resolve_entries(child_scenario.scenes, &child_source, depth, audio, included_paths)?;
128144

129145
// Apply scene index filter if specified
130146
if let Some(ref indices) = directive.scenes {

0 commit comments

Comments
 (0)