Skip to content
Merged
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
73 changes: 73 additions & 0 deletions backend/app/agents/nodes/audio_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
Audio Generator Node.

This LangGraph node generates TTS audio for the current scene
and records the precise audio duration into the state.
"""

import logging
from pathlib import Path

from app.agents.state import AgentState
from app.services.tts_service import generate_scene_audio
from app.config import get_settings

logger = logging.getLogger(__name__)


async def generate_audio_node(state: AgentState) -> AgentState:
"""
Generate TTS audio and subtitles for the current scene.

Args:
state: Current agent state.

Returns:
Updated AgentState with scene_audio_durations.
"""
scene_index = state.get("current_scene_index", 0)
scripts = state.get("scripts", [])
video_id = state.get("video_id")

if not scripts or scene_index >= len(scripts):
logger.error(f"No script found for scene {scene_index}")
return state

current_script = scripts[scene_index]
narration_text = current_script.get("narration", "")

settings = get_settings()
storage_path = Path(getattr(settings, "storage_path", "/app/storage"))
output_dir = storage_path / video_id / f"scene_{scene_index}"

if not narration_text.strip():
logger.warning(f"Empty narration for scene {scene_index}, duration is 0")
durations = state.get("scene_audio_durations", {}).copy()
durations[scene_index] = 0.0
return {**state, "scene_audio_durations": durations}

try:
duration, audio_path, vtt_path = await generate_scene_audio(
narration_text=narration_text,
output_dir=output_dir,
scene_index=scene_index
)

# Update state with the exact duration
durations = state.get("scene_audio_durations", {}).copy()
durations[scene_index] = duration

logger.info(f"Audio generated for scene {scene_index}: {duration}s")
return {**state, "scene_audio_durations": durations}

except Exception as e:
logger.exception(f"Failed to generate audio for scene {scene_index}: {e}")
# In case of failure, provide a fallback duration estimate based on words
words = len(narration_text.split())
estimated_duration = max(3.0, words * 0.4)

durations = state.get("scene_audio_durations", {}).copy()
durations[scene_index] = estimated_duration

logger.warning(f"Using fallback duration {estimated_duration}s for scene {scene_index}")
return {**state, "scene_audio_durations": durations}
10 changes: 10 additions & 0 deletions backend/app/agents/nodes/coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,19 @@ async def generate_code(state: AgentState) -> dict:
llm = create_llm("coder", temperature=0.2)

# Create prompt with few-shot examples
# Get audio duration for current scene (default to 5.0 if missing)
scene_audio_durations = state.get("scene_audio_durations", {})
audio_duration = scene_audio_durations.get(scene_index, 5.0)

# --- TASK 2: VERIFY AUDIO SYNCHRONIZATION ---
logger.info(f"=== SYNCHRONIZATION DIAGNOSTIC ===")
logger.info(f"Coder read audio_duration={audio_duration:.3f}s from state for scene {scene_index+1}")
logger.info(f"==================================")

prompt = create_coder_prompt(
visual_description=visual_description,
narration=narration,
audio_duration=audio_duration,
include_examples=True,
)

Expand Down
22 changes: 21 additions & 1 deletion backend/app/agents/prompts/coder_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@
WRONG: `self.play(Write(MathTex("...")).next_to(...))`
CORRECT: `obj = MathTex("...").next_to(...)` then `self.play(Write(obj))`

CRITICAL TEXT AND LAYOUT RULES:
1. PREVENT TEXT OVERLAP: Texts should NEVER overlap with each other. Use `VGroup(...).arrange(DOWN, buff=0.5)` or `.next_to(...)` to position texts relative to one another.
2. TEXT INSIDE OBJECTS: If an object (like a Rectangle or Circle) needs to have a title or name inside it, the text MUST appear completely inside it and NOT spill out. Use `text.scale_to_fit_width(object.width - 0.5)` or `text.scale_to_fit_height(object.height - 0.5)` or set an appropriately small `font_size`.
3. TEXT CONTAINMENT: Same goes with any text that should appear inside an object, it should NOT appear outside the component.
4. TEXT READABILITY: A solid component/shape should NOT be put on top of text, as the text will not be easily readable. Always render text *after* or *on top of* background objects.
5. STRICT VISUAL PLAN: All the animations and visualizations MUST make sense and strictly follow the provided visual plan step-by-step. Do not invent unrelated visuals.
6. MATHTEX INDEXING: To animate parts of an equation separately, you MUST pass them as separate string arguments to `MathTex`.
- WRONG: `eq = MathTex("a^2 + b^2 = c^2")` followed by `self.play(Write(eq[1]))` (This causes IndexError because the whole equation is `eq[0]`).
- CORRECT: `eq = MathTex("a^2", "+", "b^2", "=", "c^2")` followed by `self.play(Write(eq[1]))` (Now `+` is `eq[1]`).

FORBIDDEN — DO NOT USE:
1. SVGMobject() — no SVG files exist in the render environment.
2. ImageMobject() — no image files exist in the render environment.
Expand All @@ -51,6 +61,7 @@
6. If a scene calls for a complex diagram (e.g., an ant, a human body, a logo),
approximate it using combinations of Circle, Ellipse, Rectangle, Line,
Arc, Polygon, Arrow, Dot, CurvedArrow, and other built-in shapes.
7. DO NOT hallucinate shapes like `DashedCircle` or `DashedSquare`. For dashed borders, wrap a built-in shape in `DashedVMobject`, e.g., `DashedVMobject(Circle())` or `DashedVMobject(Square())`.

COMMON PATTERNS:
- Text: Text("content", font_size=36)
Expand Down Expand Up @@ -109,6 +120,7 @@ def get_few_shot_examples(snippets_dir: Optional[Path] = None) -> str:
def create_coder_prompt(
visual_description: str,
narration: str,
audio_duration: float,
include_examples: bool = True
) -> str:
"""
Expand All @@ -117,6 +129,7 @@ def create_coder_prompt(
Args:
visual_description: What should appear on screen (from scripter).
narration: The narration text (for timing reference).
audio_duration: The precise length of the TTS audio in seconds.
include_examples: Whether to include few-shot examples.

Returns:
Expand All @@ -133,7 +146,14 @@ def create_coder_prompt(

prompt_parts.append("SCENE TO ANIMATE:")
prompt_parts.append(f"Visual Description: {visual_description}")
prompt_parts.append(f"Narration (for timing reference): {narration}")
prompt_parts.append(f"Narration (for context): {narration}")
prompt_parts.append(f"AUDIO DURATION: {audio_duration:.3f} seconds.")
prompt_parts.append(
"\nCRITICAL TIMING RULE: "
f"This scene's audio narration is EXACTLY {audio_duration:.3f} seconds long. "
"Ensure all `run_time` for animations and `self.wait()` pauses add up "
f"perfectly to exactly {audio_duration:.3f} seconds so the video matches the audio."
)
prompt_parts.append("\nGenerate the Manim code for this scene.")

return "\n".join(prompt_parts)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/agents/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class AgentState(TypedDict):
# Code generation (for Sanika's coder node)
# =========================================================================
current_scene_index: int
scene_audio_durations: dict[int, float]
generated_codes: List[str]

# =========================================================================
Expand Down Expand Up @@ -107,6 +108,7 @@ def create_initial_state(
user_feedback=None,
# Code generation
current_scene_index=0,
scene_audio_durations={},
generated_codes=[],
# Execution
last_render_error=None,
Expand Down
11 changes: 8 additions & 3 deletions backend/app/agents/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from app.agents.nodes.scripter import write_scripts
from app.agents.nodes.human_review import wait_for_approval
from app.agents.nodes.coder import generate_code
from app.agents.nodes.audio_generator import generate_audio_node
from app.sandbox.renderer import execute_and_check
from app.sandbox.stitcher import finalize_video

Expand Down Expand Up @@ -58,6 +59,7 @@ def create_workflow():
workflow.add_node("planner", plan_scenes)
workflow.add_node("scripter", write_scripts)
workflow.add_node("human_review", wait_for_approval)
workflow.add_node("audio_generator", generate_audio_node)
workflow.add_node("coder", generate_code)
workflow.add_node("renderer", execute_and_check)
workflow.add_node("finalize", finalize_video)
Expand All @@ -73,11 +75,14 @@ def create_workflow():
"human_review",
route_after_review,
{
"approved": "coder", # Go to code generation
"approved": "audio_generator", # Go to audio generation
"rejected": END,
}
)

# After audio generation, go to coder
workflow.add_edge("audio_generator", "coder")

# After coder, go to renderer
workflow.add_edge("coder", "renderer")

Expand All @@ -86,8 +91,8 @@ def create_workflow():
"renderer",
route_after_render,
{
"retry": "coder", # Error occurred, retry with reflector
"next_scene": "coder", # Move to next scene
"retry": "coder", # Error occurred, retry with coder
"next_scene": "audio_generator", # Move to next scene
"finalize": "finalize", # All scenes done
}
)
Expand Down
40 changes: 22 additions & 18 deletions backend/app/api/routes/videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import List, Optional
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, HTTPException, Query, status, BackgroundTasks

from app.models.schemas import (
SceneResponse,
Expand Down Expand Up @@ -103,33 +103,37 @@ async def create_video_request(
and scripting, then pause for human review.
""",
)
async def start_video_workflow(video_id: str):
async def start_video_workflow(video_id: str, background_tasks: BackgroundTasks):
"""Start the generation workflow for a video."""
video_uuid = validate_uuid(video_id, "video_id")

# Verify video exists
video = await get_video_or_404(video_uuid)

# Start the LangGraph workflow
from app.agents.workflow import start_workflow
try:
await start_workflow(
video_id=str(video_uuid),
user_prompt=video.prompt,
syllabus_context="" # retrieve_context_node will fetch from RAG
)
except Exception as e:
async def _run_start_workflow_bg():
from app.agents.workflow import start_workflow
import logging
logging.error(f"Workflow error for video {video_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Workflow failed to start: {str(e)}"
)
try:
await start_workflow(
video_id=str(video_uuid),
user_prompt=video.prompt,
syllabus_context="" # retrieve_context_node will fetch from RAG
)
except Exception as e:
logging.error(f"Workflow error for video {video_id}: {e}")
from app.services.supabase_client import update_video_status
try:
await update_video_status(video_uuid, VideoStatus.FAILED)
except Exception as update_err:
logging.error(f"Failed to update status to FAILED: {update_err}")

# Start the LangGraph workflow in the background
background_tasks.add_task(_run_start_workflow_bg)

return {
"video_id": str(video_uuid),
"status": VideoStatus.WAITING_APPROVAL.value,
"message": "Scripts ready for review"
"status": VideoStatus.PLANNING.value,
"message": "Workflow started in background"
}


Expand Down
47 changes: 47 additions & 0 deletions backend/app/sandbox/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import logging
from pathlib import Path
from uuid import UUID
import subprocess

from app.agents.state import AgentState
from app.sandbox.executor import ManimExecutor
Expand Down Expand Up @@ -90,6 +91,21 @@ async def upload_to_storage(
return public_url


def _get_video_duration(file_path: Path) -> float:
"""Get the duration of a video file using ffprobe."""
try:
probe_cmd = [
"ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of",
"default=noprint_wrappers=1:nokey=1", str(file_path)
]
dur = subprocess.check_output(probe_cmd, timeout=10).decode('utf-8').strip()
return float(dur)
except Exception as e:
logger.error(f"Failed to get video duration: {e}")
return 0.0


async def execute_and_check(state: AgentState) -> AgentState:
"""
Execute Manim code and check the result.
Expand Down Expand Up @@ -135,6 +151,37 @@ async def execute_and_check(state: AgentState) -> AgentState:
logger.info(
f"Render successful for video {video_id}, scene {scene_index}"
)

# --- TASK 1: SCENE-LEVEL DIAGNOSTICS LOGGING ---
try:
scripts = state.get("scripts", [])
narration = scripts[scene_index].get("narration", "") if scene_index < len(scripts) else ""

audio_dur = state.get("scene_audio_durations", {}).get(scene_index, 0.0)
has_audio = audio_dur > 0

video_path = Path(result["video_path"])
video_dur = _get_video_duration(video_path)

diff = abs(audio_dur - video_dur)
status = "PASS" if diff <= 0.5 else "WARNING"

logger.info("=== SCENE-LEVEL DIAGNOSTICS ===")
logger.info(f"Scene number: {scene_index + 1}")
logger.info(f"Narration generated: '{narration[:50]}...'")
logger.info(f"Audio generated: {'Yes' if has_audio else 'No'}")
logger.info(f"Audio duration: {audio_dur:.3f}s")
logger.info(f"Subtitle generated: {'Yes' if has_audio else 'No'}")
logger.info(f"Code generated: Yes")
logger.info(f"Video rendered: Yes")
logger.info(f"Render retries: {state.get('retry_count', 0)}")
logger.info(f"Video duration: {video_dur:.3f}s")
logger.info(f"Absolute difference: {diff:.3f}s")
logger.info(f"Final status: {status}")
logger.info("===============================")
except Exception as e:
logger.error(f"Failed to log diagnostics: {e}")
# -----------------------------------------------

try:
# Upload to Supabase Storage
Expand Down
Loading
Loading