Skip to content

Commit eaa9292

Browse files
fix(motion-graphics): agent tool wiring + ffmpeg frame clipping (v0.2.26) (#29)
End-to-end agent loop now works with real LLMs (OpenAI gpt-4o-mini verified). Caught by running the Example 03 agent factory live against gpt-4o-mini and capturing the full failure chain. Unit tests previously mocked these paths so the bugs were invisible. Three real bugs fixed: 1. Tool registration (agent.py) Passing class instances (FileTools(), RenderTools()) as tools caused the OpenAI adapter to log 'Tool ... not recognized' and the agent to skip tool calls entirely. Now we expose individual bound methods: - file_tools.read_file / write_file / list_files - lint_composition / render_composition (sync wrappers, see #2) 2. Async tools in sync agent path (agent.py) RenderTools.lint_composition / render_composition are async but the Agent sync path does not await coroutines. Result: 'Object of type coroutine is not JSON serializable' Fix: wrap each async tool with a local sync function that uses asyncio.run(...). Bytes are also stripped from the render_composition return (unserializable, and the file already lives at output_path). 3. Odd-height screenshots break libx264 (backend_html.py) page.screenshot(full_page=True) captured 1920x1167 for the LLM-authored composition (SVG overflowed viewport). libx264 rejects odd height: 'height not divisible by 2 (1920x1167)' Fix: clip to the exact 1920x1080 viewport via page.screenshot(clip={x:0,y:0,width:1920,height:1080}) Tests updated (test_motion_graphics_agent.py): - MockFileTools now exposes read_file/write_file/list_files methods - tool-count assertions updated from 2 class instances to 5 callables Verification (live, not mocked): PRAISONAI_AUTO_APPROVE=true MOTION_LLM=gpt-4o-mini \ python examples/python/video/03_motion_graphics_agent_factory.py Produces: index.html (LLM-authored, 1031 bytes) intro.mp4 (1920x1080 H.264 yuv420p, 30fps, 1.5s, 13.5KB) 87/87 unit tests pass. Version bump 0.2.25 -> 0.2.26.
1 parent ddc77bc commit eaa9292

File tree

4 files changed

+75
-22
lines changed

4 files changed

+75
-22
lines changed

praisonai_tools/video/motion_graphics/agent.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Motion graphics agent factory."""
22

3+
import asyncio
34
import tempfile
45
from pathlib import Path
56
from typing import Union, Any
@@ -193,16 +194,47 @@ def create_motion_graphics_agent(
193194
Workspace directory: {workspace}
194195
"""
195196

196-
# Create tools.
197-
# FileTools is a utility class with bound methods; pass the instance so the
198-
# Agent can register read_file/write_file/list_files as callable tools.
197+
# Create tools. Expose bound methods individually so the Agent (which
198+
# treats each tool entry as a callable) can register them across all LLM
199+
# adapters (OpenAI, Anthropic, LiteLLM). Passing class instances is not
200+
# portable — the OpenAI adapter logs "Tool ... not recognized".
199201
file_tools = FileTools()
200202
render_tools = RenderTools(render_backend, workspace, max_retries)
201-
203+
204+
# Sync wrappers around the async render tools — the Agent's sync call path
205+
# does not await coroutines automatically and would otherwise fail with
206+
# "Object of type coroutine is not JSON serializable".
207+
def lint_composition(strict: bool = False) -> dict:
208+
"""Lint the motion graphics composition for common issues."""
209+
return asyncio.run(render_tools.lint_composition(strict=strict))
210+
211+
def render_composition(
212+
output_name: str = "video.mp4",
213+
fps: int = 30,
214+
quality: str = "standard",
215+
) -> dict:
216+
"""Render the motion graphics composition to MP4."""
217+
result = asyncio.run(
218+
render_tools.render_composition(
219+
output_name=output_name, fps=fps, quality=quality
220+
)
221+
)
222+
# Strip the raw bytes — they are not JSON-serializable for the next
223+
# LLM turn and the file is already on disk at result["output_path"].
224+
return {k: v for k, v in result.items() if k != "bytes"}
225+
226+
tool_callables = [
227+
file_tools.read_file,
228+
file_tools.write_file,
229+
file_tools.list_files,
230+
lint_composition,
231+
render_composition,
232+
]
233+
202234
# Create agent
203235
agent = Agent(
204236
instructions=base_instructions + "\n\n" + MOTION_GRAPHICS_SKILL,
205-
tools=[file_tools, render_tools],
237+
tools=tool_callables,
206238
llm=llm,
207239
**agent_kwargs
208240
)

praisonai_tools/video/motion_graphics/backend_html.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,15 @@ async def _render_with_playwright(self, workspace: Path, opts: RenderOpts) -> Re
231231
# Wait a bit for animations to settle
232232
await page.wait_for_timeout(50)
233233

234-
# Capture frame
234+
# Capture frame. Clip to the fixed 1920x1080 viewport —
235+
# `full_page=True` can produce odd-height images when
236+
# content overflows, which libx264 rejects (height must
237+
# be divisible by 2).
235238
frame_path = temp_path / f"frame_{frame:06d}.png"
236-
await page.screenshot(path=str(frame_path), full_page=True)
239+
await page.screenshot(
240+
path=str(frame_path),
241+
clip={"x": 0, "y": 0, "width": 1920, "height": 1080},
242+
)
237243
frame_paths.append(frame_path)
238244

239245
# Encode to MP4 using FFmpeg

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "praisonai-tools"
3-
version = "0.2.25"
3+
version = "0.2.26"
44
description = "Extended tools for PraisonAI Agents"
55
authors = [
66
{name = "Mervin Praison"}

tests/unit/video/test_motion_graphics_agent.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,24 @@ def __init__(self, instructions="", tools=None, llm="", **kwargs):
2424

2525

2626
class MockFileTools:
27-
"""Mock FileTools for testing."""
28-
27+
"""Mock FileTools for testing.
28+
29+
Exposes the same method surface as `praisonaiagents.tools.file_tools.FileTools`
30+
so the factory can reference bound methods as tools.
31+
"""
32+
2933
def __init__(self, base_dir=""):
3034
self.base_dir = base_dir
3135

36+
def read_file(self, filepath, encoding="utf-8"):
37+
return ""
38+
39+
def write_file(self, filepath, content, encoding="utf-8"):
40+
return True
41+
42+
def list_files(self, directory=".", pattern="*"):
43+
return []
44+
3245

3346
class MockBackend:
3447
"""Mock render backend for testing."""
@@ -115,7 +128,13 @@ def test_create_agent_defaults(self):
115128

116129
assert isinstance(agent, MockAgent)
117130
assert agent.llm == "claude-sonnet-4"
118-
assert len(agent.tools) == 2 # FileTools and RenderTools
131+
# Tools are now exposed as individual callables (bound methods +
132+
# sync render wrappers): read_file, write_file, list_files,
133+
# lint_composition, render_composition.
134+
assert len(agent.tools) == 5
135+
tool_names = {getattr(t, "__name__", "") for t in agent.tools}
136+
assert {"read_file", "write_file", "list_files",
137+
"lint_composition", "render_composition"} <= tool_names
119138
assert "motion graphics specialist" in agent.instructions.lower()
120139

121140
@patch('praisonai_tools.video.motion_graphics.agent.Agent', MockAgent)
@@ -207,14 +226,10 @@ def test_agent_tools_configuration(self):
207226
with tempfile.TemporaryDirectory() as tmpdir:
208227
agent = create_motion_graphics_agent(workspace=tmpdir)
209228

210-
assert len(agent.tools) == 2
211-
212-
# Check FileTools
213-
file_tools = agent.tools[0]
214-
assert isinstance(file_tools, MockFileTools)
215-
assert file_tools.base_dir == str(tmpdir)
216-
217-
# Check RenderTools
218-
render_tools = agent.tools[1]
219-
assert isinstance(render_tools, RenderTools)
220-
assert render_tools.workspace == Path(tmpdir)
229+
# Tools are now exposed as individual callables (bound methods +
230+
# sync render wrappers): read_file, write_file, list_files,
231+
# lint_composition, render_composition.
232+
assert len(agent.tools) == 5
233+
tool_names = {getattr(t, "__name__", "") for t in agent.tools}
234+
assert {"read_file", "write_file", "list_files",
235+
"lint_composition", "render_composition"} <= tool_names

0 commit comments

Comments
 (0)