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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,57 @@ except ValueError as exc:
print(f"Invalid timeline JSON: {exc}")
```

#### Placeholder variables (dynamic pipelines)

Any string value in the timeline JSON may contain `${VAR_NAME}` placeholders.
Pass a `variables` dictionary to `render_video` and every placeholder is
substituted before validation or rendering takes place. This lets you keep a
single reusable template and inject runtime values such as paths, titles, and
timestamps.

**Template file (`template.json`)**

```json
{
"timeline": {
"n_frames": 75,
"background": "#000000",
"tracks": [
{
"track_id": 0,
"strips": [
{
"asset": { "type": "video", "src": "${ASSET_DIR}/intro.mp4" },
"start": 0,
"length": 75
}
]
}
],
"soundtrack": { "src": "${ASSET_DIR}/music.mp3" }
},
"output": { "format": "mp4", "fps": 25, "width": 1280, "height": 720 }
}
```

**Rendering with variables**

```python
from pavo import render_video

render_video(
'template.json',
'output/final.mp4',
variables={
"ASSET_DIR": "/projects/my_video/assets",
},
)
```

> **Note:** Placeholders are resolved only in the *JSON content*. If a
> placeholder has no matching key in `variables` a `ValueError` is raised
> naming the missing variable.

## 🗂️ JSON Schema

Pavo Engine validates every timeline file against a strict [Pydantic](https://docs.pydantic.dev/) schema before rendering begins. Any missing or invalid field raises a `ValueError` with a clear, human-readable message that lists every problem found.
Expand Down
47 changes: 45 additions & 2 deletions pavo/pavo.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import re
import shutil
import tempfile

Expand Down Expand Up @@ -232,7 +233,42 @@ def _detect_speech_segments(timeline, fps):
return speech_segments


def render_video(json_path, output="output.mp4"):
def resolve_placeholders(data, variables):
"""Recursively replace ``${VAR_NAME}`` placeholders in *data* using *variables*.

Parameters
----------
data:
Any JSON-compatible value (dict, list, str, or scalar).
variables : dict
Mapping of variable names to replacement strings.

Returns
-------
The same structure with all placeholders replaced.

Raises
------
ValueError
If a placeholder is found that has no matching key in *variables*.
"""
if isinstance(data, dict):
return {k: resolve_placeholders(v, variables) for k, v in data.items()}
if isinstance(data, list):
return [resolve_placeholders(item, variables) for item in data]
if isinstance(data, str):
def _replace(match):
name = match.group(1)
if name not in variables:
raise ValueError(
f"Placeholder '${{{name}}}' has no matching key in the provided variables"
)
return str(variables[name])
return re.sub(r"\$\{([^}]+)\}", _replace, data)
return data


def render_video(json_path, output="output.mp4", variables=None):
"""Render a JSON timeline specification to an MP4 video file.

Parameters
Expand All @@ -241,13 +277,17 @@ def render_video(json_path, output="output.mp4"):
Path to the JSON timeline file.
output : str
Path for the output MP4 file.
variables : dict, optional
Key-value map used to resolve ``${VAR_NAME}`` placeholders found in
any string value of the JSON before validation and rendering.

Raises
------
FileNotFoundError
If *json_path* does not exist.
ValueError
If the JSON content is invalid or missing required fields.
If the JSON content is invalid, missing required fields, or a
placeholder has no matching key in *variables*.
"""
if not os.path.exists(json_path):
raise FileNotFoundError(f"JSON file not found: {json_path}")
Expand All @@ -258,6 +298,9 @@ def render_video(json_path, output="output.mp4"):
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {json_path}: {exc}") from exc

if variables:
video_json = resolve_placeholders(video_json, variables)

validate_timeline_json(video_json)

timeline = video_json.get("timeline")
Expand Down
143 changes: 143 additions & 0 deletions tests/test_render_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from pavo.pavo import (
render_video,
resolve_placeholders,
_add_audio_to_video,
_create_background_frame,
_build_ducking_expr,
Expand Down Expand Up @@ -593,3 +594,145 @@ def test_ducking_disabled_by_default(
mock_detect.assert_not_called()
mock_duck.assert_not_called()
mock_add_audio.assert_called_once()


# ---------------------------------------------------------------------------
# resolve_placeholders – unit tests
# ---------------------------------------------------------------------------

class TestResolvePlaceholders:
def test_string_substitution(self):
result = resolve_placeholders("${FOO}/bar", {"FOO": "baz"})
assert result == "baz/bar"

def test_multiple_placeholders_in_string(self):
result = resolve_placeholders("${A}-${B}", {"A": "hello", "B": "world"})
assert result == "hello-world"

def test_dict_values_substituted(self):
data = {"src": "${ASSET_DIR}/clip.mp4", "title": "My Video"}
result = resolve_placeholders(data, {"ASSET_DIR": "/assets"})
assert result == {"src": "/assets/clip.mp4", "title": "My Video"}

def test_nested_dict_and_list(self):
data = {"tracks": [{"src": "${DIR}/a.jpg"}, {"src": "${DIR}/b.jpg"}]}
result = resolve_placeholders(data, {"DIR": "/imgs"})
assert result["tracks"][0]["src"] == "/imgs/a.jpg"
assert result["tracks"][1]["src"] == "/imgs/b.jpg"

def test_non_string_values_unchanged(self):
data = {"count": 5, "flag": True, "value": None}
result = resolve_placeholders(data, {})
assert result == data

def test_missing_placeholder_raises_value_error(self):
with pytest.raises(ValueError, match=r"\$\{MISSING\}"):
resolve_placeholders("${MISSING}", {})

def test_no_placeholders_unchanged(self):
result = resolve_placeholders("plain string", {})
assert result == "plain string"

def test_empty_variables_with_no_placeholders(self):
data = {"key": "value", "num": 42}
assert resolve_placeholders(data, {}) == data


# ---------------------------------------------------------------------------
# render_video – placeholder variables integration
# ---------------------------------------------------------------------------

class TestRenderVideoPlaceholders:
@patch("pavo.pavo.render")
@patch("pavo.pavo.render_video_from_strips")
def test_variables_resolved_before_render(
self, mock_strips, mock_render, tmp_path
):
"""Placeholders in JSON are substituted before rendering."""
mock_render.return_value = []

timeline_with_placeholders = {
"timeline": {
"n_frames": 5,
"background": "#000000",
"tracks": [
{
"track_id": 0,
"strips": [
{
"asset": {"type": "image", "src": "${ASSET_DIR}/img.jpg"},
"start": 0,
"video_start_frame": 0,
"length": 5,
"effect": None,
"transition": {},
}
],
}
],
},
"output": {"format": "mp4", "fps": 25, "width": 320, "height": 240},
}
json_file = tmp_path / "template.json"
_write_json(str(json_file), timeline_with_placeholders)
output = str(tmp_path / "out.mp4")

render_video(str(json_file), output, variables={"ASSET_DIR": "/my/assets"})

assert mock_render.called

@patch("pavo.pavo.render")
@patch("pavo.pavo.render_video_from_strips")
def test_missing_variable_raises_value_error(
self, mock_strips, mock_render, tmp_path
):
"""render_video raises ValueError when a placeholder has no matching key."""
timeline_with_missing_var = {
"timeline": {
"n_frames": 5,
"background": "#000000",
"tracks": [
{
"track_id": 0,
"strips": [
{
"asset": {
"type": "image",
"src": "${UNDEFINED_VAR}/img.jpg",
},
"start": 0,
"video_start_frame": 0,
"length": 5,
"effect": None,
"transition": {},
}
],
}
],
},
"output": {"format": "mp4", "fps": 25, "width": 320, "height": 240},
}
json_file = tmp_path / "missing_var.json"
_write_json(str(json_file), timeline_with_missing_var)

with pytest.raises(ValueError, match=r"UNDEFINED_VAR"):
render_video(
str(json_file),
str(tmp_path / "out.mp4"),
variables={"OTHER_VAR": "value"},
)

@patch("pavo.pavo.render")
@patch("pavo.pavo.render_video_from_strips")
def test_no_variables_arg_works_as_before(
self, mock_strips, mock_render, tmp_path
):
"""render_video without variables= behaves exactly as before."""
mock_render.return_value = []

json_file = tmp_path / "timeline.json"
_write_json(str(json_file), MINIMAL_TIMELINE)

render_video(str(json_file), str(tmp_path / "out.mp4"))

assert mock_render.called
Loading