diff --git a/README.md b/README.md index d95d26f..0afec14 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pavo/pavo.py b/pavo/pavo.py index 6dcc1b0..6163555 100644 --- a/pavo/pavo.py +++ b/pavo/pavo.py @@ -1,5 +1,6 @@ import json import os +import re import shutil import tempfile @@ -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 @@ -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}") @@ -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") diff --git a/tests/test_render_video.py b/tests/test_render_video.py index e83164a..7e6ebb8 100644 --- a/tests/test_render_video.py +++ b/tests/test_render_video.py @@ -8,6 +8,7 @@ from pavo.pavo import ( render_video, + resolve_placeholders, _add_audio_to_video, _create_background_frame, _build_ducking_expr, @@ -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