Spaces:
Running
Running
Commit
·
f485648
0
Parent(s):
Clean repo with background music feature (no binary files)
Browse files- .gitignore +35 -0
- context.py +0 -0
- demo.py +239 -0
- main.py +157 -0
- manim_video_generator/gemini_client.py +177 -0
- manim_video_generator/video_executor.py +140 -0
- prompts.py +21 -0
- requirements.txt +5 -0
.gitignore
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Video files
|
2 |
+
*.mp4
|
3 |
+
*.avi
|
4 |
+
*.mov
|
5 |
+
*.mkv
|
6 |
+
*.wmv
|
7 |
+
*.flv
|
8 |
+
|
9 |
+
# Output directories
|
10 |
+
output/
|
11 |
+
test_output/
|
12 |
+
|
13 |
+
# Python
|
14 |
+
__pycache__/
|
15 |
+
*.pyc
|
16 |
+
*.pyo
|
17 |
+
*.pyd
|
18 |
+
.Python
|
19 |
+
env/
|
20 |
+
venv/
|
21 |
+
.env
|
22 |
+
|
23 |
+
# IDE
|
24 |
+
.vscode/
|
25 |
+
.idea/
|
26 |
+
*.swp
|
27 |
+
*.swo
|
28 |
+
|
29 |
+
# OS
|
30 |
+
.DS_Store
|
31 |
+
Thumbs.db
|
32 |
+
|
33 |
+
# Temporary files
|
34 |
+
*.tmp
|
35 |
+
*.temp
|
context.py
ADDED
File without changes
|
demo.py
ADDED
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# gradio_manim_gemini_app.py – **v3**
|
2 |
+
"""Gradio demo
|
3 |
+
============
|
4 |
+
— third revision —
|
5 |
+
• **Правильная структура history** — теперь `Chatbot` получает список *пар*
|
6 |
+
`(user_text, bot_text)`. Чанки бота апдей‑тят второй элемент последней пары,
|
7 |
+
поэтому «дубли» и «робот‑юзер» исчезают.
|
8 |
+
• **Ошибки рендера** публикуются *как пользовательское сообщение* и немедленно
|
9 |
+
отправляются в Gemini; модель отвечает, мы снова пытаемся сгенерировать код —
|
10 |
+
полностью автоматический цикл, как в вашем CLI‑скрипте.
|
11 |
+
• Управление состоянием сведено к чётким этапам: `await_task`, `coding_loop`,
|
12 |
+
`review_loop`, `finished`.
|
13 |
+
|
14 |
+
Запуск:
|
15 |
+
```bash
|
16 |
+
pip install --upgrade gradio google-genai manim_video_generator
|
17 |
+
export GEMINI_API_KEY="YOUR_KEY"
|
18 |
+
python gradio_manim_gemini_app.py
|
19 |
+
```
|
20 |
+
"""
|
21 |
+
from __future__ import annotations
|
22 |
+
|
23 |
+
import asyncio
|
24 |
+
import os
|
25 |
+
import re
|
26 |
+
import traceback
|
27 |
+
from pathlib import Path
|
28 |
+
from typing import List, Tuple
|
29 |
+
|
30 |
+
import gradio as gr
|
31 |
+
from google import genai
|
32 |
+
from google.genai.chats import Chat
|
33 |
+
from google.genai.types import GenerateContentConfig, ThinkingConfig, UploadFileConfig
|
34 |
+
|
35 |
+
from manim_video_generator.video_executor import VideoExecutor # type: ignore
|
36 |
+
from prompts import SYSTEM_PROMPT_SCENARIO_GENERATOR, SYSTEM_PROMPT_CODEGEN, REVIEW_PROMPT
|
37 |
+
|
38 |
+
# ──────────────────────────────── Config ─────────────────────────────────────
|
39 |
+
|
40 |
+
API_KEY = os.getenv("GEMINI_API_KEY")
|
41 |
+
if not API_KEY:
|
42 |
+
raise EnvironmentError("GEMINI_API_KEY env variable not set.")
|
43 |
+
|
44 |
+
client = genai.Client(api_key=API_KEY)
|
45 |
+
MODEL = "gemini-2.5-flash-preview-05-20"
|
46 |
+
video_executor = VideoExecutor()
|
47 |
+
|
48 |
+
# ─────────────────────── Helpers to work with Chatbot ─────────────────────────
|
49 |
+
|
50 |
+
def add_user_msg(history: List[Tuple[str, str]], text: str):
|
51 |
+
"""Append new (user, «») pair."""
|
52 |
+
history.append((text, ""))
|
53 |
+
|
54 |
+
|
55 |
+
def append_bot_chunk(history: List[Tuple[str, str]], chunk: str):
|
56 |
+
"""Add chunk to bot part of the last pair."""
|
57 |
+
user, bot = history[-1]
|
58 |
+
history[-1] = (user, bot + chunk)
|
59 |
+
|
60 |
+
|
61 |
+
class StreamPart:
|
62 |
+
def __init__(self, text: str):
|
63 |
+
self.text = text
|
64 |
+
|
65 |
+
class ThinkingStreamPart(StreamPart): pass
|
66 |
+
class TextStreamPart(StreamPart): pass
|
67 |
+
|
68 |
+
|
69 |
+
def stream_parts(chat: Chat, prompt):
|
70 |
+
cfg = GenerateContentConfig(thinking_config=ThinkingConfig(include_thoughts=True))
|
71 |
+
for chunk in chat.send_message_stream(prompt, config=cfg):
|
72 |
+
if chunk.candidates:
|
73 |
+
cand = chunk.candidates[0]
|
74 |
+
if cand.content and cand.content.parts:
|
75 |
+
for part in cand.content.parts:
|
76 |
+
if part.text:
|
77 |
+
if part.thought:
|
78 |
+
yield ThinkingStreamPart(part.text)
|
79 |
+
else:
|
80 |
+
yield TextStreamPart(part.text)
|
81 |
+
|
82 |
+
|
83 |
+
def extract_python(md: str) -> str:
|
84 |
+
m = re.search(r"```python(.*?)```", md, re.S)
|
85 |
+
if not m:
|
86 |
+
raise ValueError("No ```python``` block found in model output.")
|
87 |
+
return m.group(1).strip()
|
88 |
+
|
89 |
+
# ────────────────────────── Session state ────────────────────────────────────
|
90 |
+
|
91 |
+
class Session(dict):
|
92 |
+
phase: str # await_task | coding_loop | review_loop | finished
|
93 |
+
chat: Chat | None
|
94 |
+
last_video: Path | None
|
95 |
+
|
96 |
+
def __init__(self):
|
97 |
+
super().__init__(phase="await_task", chat=None, last_video=None)
|
98 |
+
self.phase = "await_task"
|
99 |
+
self.chat = None
|
100 |
+
self.last_video = None
|
101 |
+
|
102 |
+
# ──────────────────────── Main chat handler ──────────────────────────────────
|
103 |
+
|
104 |
+
async def chat_handler(user_msg: str, history: List[Tuple[str, str]], state: Session):
|
105 |
+
history = history or []
|
106 |
+
|
107 |
+
# 0. Always reflect user input
|
108 |
+
add_user_msg(history, user_msg)
|
109 |
+
yield history, state, state.last_video
|
110 |
+
|
111 |
+
# bootstrap chat on very first user request
|
112 |
+
if state.phase == "await_task":
|
113 |
+
state.chat = client.chats.create(model=MODEL)
|
114 |
+
# ── Scenario generation ────────────────────────────────────────────────
|
115 |
+
scenario_prompt = f"{SYSTEM_PROMPT_SCENARIO_GENERATOR}\n\n{user_msg}"
|
116 |
+
for txt in stream_parts(state.chat, scenario_prompt):
|
117 |
+
append_bot_chunk(history, txt.text)
|
118 |
+
yield history, state, state.last_video
|
119 |
+
await asyncio.sleep(0)
|
120 |
+
|
121 |
+
append_bot_chunk(history, "\n\n*(type **continue** to proceed to code generation)*")
|
122 |
+
state.phase = "coding_loop"
|
123 |
+
yield history, state, state.last_video
|
124 |
+
return
|
125 |
+
|
126 |
+
# later phases require chat obj
|
127 |
+
if not state.chat:
|
128 |
+
append_bot_chunk(history, "⚠️ Internal error: lost chat session.")
|
129 |
+
yield history, state, state.last_video
|
130 |
+
return
|
131 |
+
|
132 |
+
# ── Coding loop ─────────────────────────────────────────────────────────────
|
133 |
+
if state.phase == "coding_loop":
|
134 |
+
if user_msg.strip().lower() not in {"c", "continue", "с"}:
|
135 |
+
append_bot_chunk(history, "⚠️ Type **continue** to move on.")
|
136 |
+
yield history, state, state.last_video
|
137 |
+
return
|
138 |
+
prompt = (
|
139 |
+
"Thanks. It is good scenario. Now generate code for it.\n\n" + SYSTEM_PROMPT_CODEGEN
|
140 |
+
)
|
141 |
+
while True: # keep cycling until render succeeds
|
142 |
+
# 1. Ask for code
|
143 |
+
for chunk in stream_parts(state.chat, prompt):
|
144 |
+
append_bot_chunk(history, chunk.text)
|
145 |
+
yield history, state, state.last_video
|
146 |
+
await asyncio.sleep(0)
|
147 |
+
|
148 |
+
full_answer = history[-1][1]
|
149 |
+
try:
|
150 |
+
py_code = extract_python(full_answer)
|
151 |
+
except ValueError as e:
|
152 |
+
# send formatting error to model, loop again
|
153 |
+
err_msg = f"Error: {e}. Please wrap the code in ```python``` fence."
|
154 |
+
prompt = err_msg
|
155 |
+
add_user_msg(history, err_msg)
|
156 |
+
yield history, state, state.last_video
|
157 |
+
continue # restart loop
|
158 |
+
|
159 |
+
# 2. Render
|
160 |
+
try:
|
161 |
+
video_path = video_executor.execute_manim_code(py_code)
|
162 |
+
state.last_video = video_path
|
163 |
+
except Exception as e:
|
164 |
+
tb = traceback.format_exc(limit=10)
|
165 |
+
err_msg = f"Error, your code is not valid: {e}. Traceback: {tb}. Please fix this error and regenerate the code again."
|
166 |
+
prompt = err_msg
|
167 |
+
add_user_msg(history, err_msg) # error == user message
|
168 |
+
yield history, state, state.last_video
|
169 |
+
continue # Gemini will answer with a fix
|
170 |
+
|
171 |
+
append_bot_chunk(history, "\n🎞️ Rendering done, uploading for review…")
|
172 |
+
yield history, state, state.last_video
|
173 |
+
|
174 |
+
# 3. Upload
|
175 |
+
try:
|
176 |
+
file_ref = client.files.upload(
|
177 |
+
file=video_path, config=UploadFileConfig(display_name=video_path.name)
|
178 |
+
)
|
179 |
+
while file_ref.state and file_ref.state.name == "PROCESSING":
|
180 |
+
await asyncio.sleep(3)
|
181 |
+
if file_ref.name:
|
182 |
+
file_ref = client.files.get(name=file_ref.name)
|
183 |
+
if file_ref.state and file_ref.state.name == "FAILED":
|
184 |
+
raise RuntimeError("Gemini failed to process upload")
|
185 |
+
except Exception as up_err:
|
186 |
+
err_msg = f"Upload error: {up_err}"
|
187 |
+
add_user_msg(history, err_msg)
|
188 |
+
yield history, state, state.last_video
|
189 |
+
continue # ask Gemini to fix
|
190 |
+
|
191 |
+
# 4. Review
|
192 |
+
review_prompt = [file_ref, REVIEW_PROMPT]
|
193 |
+
add_user_msg(history, "# system → review video")
|
194 |
+
for chunk in stream_parts(state.chat, review_prompt):
|
195 |
+
append_bot_chunk(history, chunk.text)
|
196 |
+
yield history, state, state.last_video
|
197 |
+
await asyncio.sleep(0)
|
198 |
+
|
199 |
+
if "no issues found" in history[-1][1].lower():
|
200 |
+
append_bot_chunk(history, "\n✅ Video accepted! 🎉")
|
201 |
+
state.phase = "finished"
|
202 |
+
yield history, state, state.last_video
|
203 |
+
return
|
204 |
+
else:
|
205 |
+
append_bot_chunk(history, "\n🔄 Issues found. Trying again…")
|
206 |
+
# let the loop run again (Gemini will generate corrected code)
|
207 |
+
continue
|
208 |
+
|
209 |
+
# ── Finished phase ──────────────────────────────────────────────────────────
|
210 |
+
if state.phase == "finished":
|
211 |
+
append_bot_chunk(history, "Session complete. Refresh page to start over.")
|
212 |
+
yield history, state, state.last_video
|
213 |
+
|
214 |
+
# ─────────────────────────────── UI ──────────────────────────────────────────
|
215 |
+
|
216 |
+
def build_app():
|
217 |
+
with gr.Blocks(title="Gemini‑Manim Video Creator") as demo:
|
218 |
+
gr.Markdown("# 🎬 Gemini‑Manim Video Creator\nCreate an explanatory animation from a single prompt.")
|
219 |
+
|
220 |
+
history = gr.Chatbot(height=850)
|
221 |
+
session = gr.State(Session())
|
222 |
+
|
223 |
+
with gr.Row():
|
224 |
+
txt = gr.Textbox(placeholder="Describe the concept…", scale=4)
|
225 |
+
btn = gr.Button("Send", variant="primary")
|
226 |
+
|
227 |
+
vid = gr.Video(label="Rendered video", interactive=False)
|
228 |
+
|
229 |
+
def get_vid(state: Session):
|
230 |
+
return state.last_video if state.last_video else None
|
231 |
+
|
232 |
+
btn.click(chat_handler, [txt, history, session], [history, session, vid]) \
|
233 |
+
.then(lambda: "", None, txt)
|
234 |
+
|
235 |
+
return demo
|
236 |
+
|
237 |
+
|
238 |
+
if __name__ == "__main__":
|
239 |
+
build_app().launch()
|
main.py
ADDED
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
import time
|
3 |
+
from google import genai
|
4 |
+
import os
|
5 |
+
from dotenv import load_dotenv
|
6 |
+
from google.genai.chats import Chat
|
7 |
+
from manim_video_generator.video_executor import VideoExecutor
|
8 |
+
from prompts import SYSTEM_PROMPT_SCENARIO_GENERATOR, SYSTEM_PROMPT_CODEGEN, REVIEW_PROMPT
|
9 |
+
from google.genai.types import (
|
10 |
+
GenerateContentResponse,
|
11 |
+
ThinkingConfig,
|
12 |
+
GenerateContentConfig,
|
13 |
+
UploadFileConfig,
|
14 |
+
)
|
15 |
+
from pathlib import Path
|
16 |
+
import traceback
|
17 |
+
|
18 |
+
load_dotenv()
|
19 |
+
|
20 |
+
|
21 |
+
|
22 |
+
async def main():
|
23 |
+
client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
|
24 |
+
video_executor = VideoExecutor()
|
25 |
+
|
26 |
+
chat: Chat = client.chats.create(model="gemini-2.5-flash-preview-05-20")
|
27 |
+
|
28 |
+
user_task = input("Enter your task: ")
|
29 |
+
assert (
|
30 |
+
len(user_task) > 0 and len(user_task) < 10000
|
31 |
+
), "Task must be between 1 and 10000 characters"
|
32 |
+
|
33 |
+
user_input = SYSTEM_PROMPT_SCENARIO_GENERATOR + "\n\n" + user_task
|
34 |
+
# Generate scenario
|
35 |
+
for iter in range(1000):
|
36 |
+
answer = ""
|
37 |
+
chunk: GenerateContentResponse
|
38 |
+
for chunk in chat.send_message_stream(
|
39 |
+
user_input,
|
40 |
+
config=GenerateContentConfig(
|
41 |
+
thinking_config=ThinkingConfig(
|
42 |
+
include_thoughts=True,
|
43 |
+
),
|
44 |
+
),
|
45 |
+
):
|
46 |
+
print()
|
47 |
+
if chunk.candidates:
|
48 |
+
candidate = chunk.candidates[0]
|
49 |
+
if candidate.content and candidate.content.parts:
|
50 |
+
for part in candidate.content.parts:
|
51 |
+
if part.thought:
|
52 |
+
print('💭: ', part.text, end="", flush=True)
|
53 |
+
elif part.text:
|
54 |
+
print(part.text, end="", flush=True)
|
55 |
+
answer += part.text
|
56 |
+
user_input = input("Answer answer to scenario manager or continue (c)?")
|
57 |
+
if user_input.lower() in ("c", "continue", 'с'):
|
58 |
+
print("Scenario created")
|
59 |
+
scenario = answer
|
60 |
+
break
|
61 |
+
|
62 |
+
# Generate code
|
63 |
+
user_input = "Thanks. It is good scenario. Now generate code for it.\n\n" + SYSTEM_PROMPT_CODEGEN
|
64 |
+
print('Generating code...')
|
65 |
+
for iter in range(1000):
|
66 |
+
answer = ""
|
67 |
+
chunk: GenerateContentResponse
|
68 |
+
for chunk in chat.send_message_stream(
|
69 |
+
user_input,
|
70 |
+
config=GenerateContentConfig(
|
71 |
+
thinking_config=ThinkingConfig(
|
72 |
+
include_thoughts=True,
|
73 |
+
),
|
74 |
+
),
|
75 |
+
):
|
76 |
+
print()
|
77 |
+
if chunk.candidates:
|
78 |
+
candidate = chunk.candidates[0]
|
79 |
+
if candidate.content and candidate.content.parts:
|
80 |
+
for part in candidate.content.parts:
|
81 |
+
if part.thought:
|
82 |
+
print('💭: ', part.text, end="", flush=True)
|
83 |
+
elif part.text:
|
84 |
+
print(part.text, end="", flush=True)
|
85 |
+
answer += part.text
|
86 |
+
try:
|
87 |
+
code = answer.split("```python")[1].split("```")[0]
|
88 |
+
except Exception as e:
|
89 |
+
print(f"Error: {e}")
|
90 |
+
user_input = f"Error, your answer is not valid formated manim code."
|
91 |
+
continue
|
92 |
+
|
93 |
+
|
94 |
+
try:
|
95 |
+
video_path: Path = video_executor.execute_manim_code(code)
|
96 |
+
print(f"Video generated at {video_path}")
|
97 |
+
except Exception as e:
|
98 |
+
print(f"Error: {e}")
|
99 |
+
traceback_str = traceback.format_exc()
|
100 |
+
user_input = f"Error, your code is not valid: {e}. Please fix it. Traceback: {traceback_str}"
|
101 |
+
continue
|
102 |
+
|
103 |
+
myfile = client.files.upload(file=video_path.absolute(),
|
104 |
+
config=UploadFileConfig(
|
105 |
+
display_name=video_path.name
|
106 |
+
))
|
107 |
+
assert myfile.name, "File name is not set"
|
108 |
+
assert myfile.state, "File state is not set"
|
109 |
+
|
110 |
+
print('Uploading video file to google genai...')
|
111 |
+
while myfile.state.name == "PROCESSING":
|
112 |
+
print('.', end='', flush=True)
|
113 |
+
time.sleep(10)
|
114 |
+
myfile = client.files.get(name=myfile.name)
|
115 |
+
print(f"File uploaded at {myfile.name}")
|
116 |
+
|
117 |
+
if myfile.state.name == "FAILED":
|
118 |
+
raise ValueError(myfile.state.name)
|
119 |
+
|
120 |
+
print(f"File uploaded at {myfile.name}")
|
121 |
+
|
122 |
+
|
123 |
+
|
124 |
+
for chunk in chat.send_message_stream(
|
125 |
+
[myfile, REVIEW_PROMPT],
|
126 |
+
config=GenerateContentConfig(
|
127 |
+
thinking_config=ThinkingConfig(
|
128 |
+
include_thoughts=True,
|
129 |
+
),
|
130 |
+
),
|
131 |
+
):
|
132 |
+
if chunk.candidates:
|
133 |
+
candidate = chunk.candidates[0]
|
134 |
+
if candidate.content and candidate.content.parts:
|
135 |
+
for part in candidate.content.parts:
|
136 |
+
if part.text:
|
137 |
+
if part.thought:
|
138 |
+
print('💭: ', part.text, end="", flush=True)
|
139 |
+
else:
|
140 |
+
print(part.text, end="", flush=True)
|
141 |
+
answer += part.text
|
142 |
+
if "no issues found" in answer.lower():
|
143 |
+
print("No issues found")
|
144 |
+
break
|
145 |
+
else:
|
146 |
+
print("Issues found")
|
147 |
+
user_prompt = input("Prompt for fixing issues (or n to exit): ")
|
148 |
+
if user_prompt.lower() == 'n':
|
149 |
+
break
|
150 |
+
else:
|
151 |
+
user_input = f"Fix this problems please."
|
152 |
+
if user_prompt.strip():
|
153 |
+
user_input += f" TIP: {user_prompt}"
|
154 |
+
|
155 |
+
|
156 |
+
if __name__ == "__main__":
|
157 |
+
asyncio.run(main())
|
manim_video_generator/gemini_client.py
ADDED
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import google.generativeai as genai
|
3 |
+
from loguru import logger
|
4 |
+
from dotenv import load_dotenv
|
5 |
+
from typing import Optional, TYPE_CHECKING
|
6 |
+
import re
|
7 |
+
|
8 |
+
if TYPE_CHECKING:
|
9 |
+
from .context_manager import ConversationContext
|
10 |
+
|
11 |
+
load_dotenv()
|
12 |
+
|
13 |
+
|
14 |
+
class GeminiClient:
|
15 |
+
def __init__(self, api_key: str = None, context_manager: Optional['ConversationContext'] = None):
|
16 |
+
self.api_key = api_key or os.getenv("GEMINI_API_KEY")
|
17 |
+
if not self.api_key:
|
18 |
+
raise ValueError("GEMINI_API_KEY not found in environment variables")
|
19 |
+
|
20 |
+
genai.configure(api_key=self.api_key)
|
21 |
+
self.model = genai.GenerativeModel("gemini-2.0-flash-thinking-exp")
|
22 |
+
self.context_manager = context_manager
|
23 |
+
logger.info("Gemini client initialized")
|
24 |
+
|
25 |
+
def generate_manim_code(self, user_request: str) -> str:
|
26 |
+
"""Generate Manim code based on the user's request"""
|
27 |
+
|
28 |
+
# Системный промпт с инструкциями
|
29 |
+
system_prompt = """You are a Manim code generator. Your ONLY task is to generate executable Python code using the Manim library.
|
30 |
+
|
31 |
+
CRITICAL RULES:
|
32 |
+
- You MUST respond with ONLY Python code, nothing else
|
33 |
+
- NO explanations, NO text, NO comments outside the code
|
34 |
+
- The code MUST be ready to execute immediately
|
35 |
+
- The response should start with "from manim import *" and contain a complete Scene class
|
36 |
+
|
37 |
+
CODE REQUIREMENTS:
|
38 |
+
1. Always import: from manim import *
|
39 |
+
2. Create a class that inherits from Scene (default name: VideoScene)
|
40 |
+
3. Implement the construct(self) method
|
41 |
+
4. Use self.play() for animations
|
42 |
+
5. End with self.wait(1) or self.wait(2)
|
43 |
+
6. Use Text() instead of MathTex() for all text (MathTex requires LaTeX setup)
|
44 |
+
7. For mathematical formulas, use Text() with Unicode symbols: ², ³, ∫, ∑, π, etc.
|
45 |
+
8. Use simple geometric shapes: Square(), Circle(), Rectangle(), Line(), etc.
|
46 |
+
9. Common animations: Write(), Create(), Transform(), FadeIn(), FadeOut(), DrawBorderThenFill()
|
47 |
+
10. Position objects with .move_to(), .shift(), .to_edge(), .next_to()
|
48 |
+
|
49 |
+
EXAMPLE OUTPUT FORMAT (this is exactly how your response should look):
|
50 |
+
```python
|
51 |
+
from manim import *
|
52 |
+
|
53 |
+
class VideoScene(Scene):
|
54 |
+
def construct(self):
|
55 |
+
title = Text("Example Title")
|
56 |
+
self.play(Write(title))
|
57 |
+
self.wait(2)
|
58 |
+
```
|
59 |
+
|
60 |
+
IMPORTANT:
|
61 |
+
- Your entire response must be valid Python code
|
62 |
+
- Do not include any text before or after the code
|
63 |
+
- If the request is in any language other than English, still generate code with English variable names and comments
|
64 |
+
- Focus on creating visually appealing animations that demonstrate the requested concept"""
|
65 |
+
|
66 |
+
# Получаем контекст предыдущих сообщений
|
67 |
+
messages = []
|
68 |
+
if self.context_manager:
|
69 |
+
messages = self.context_manager.get_context_for_gemini()
|
70 |
+
|
71 |
+
# Добавляем системный промпт и текущий запрос если истории нет
|
72 |
+
if not messages:
|
73 |
+
messages = [
|
74 |
+
{"role": "user", "parts": [{"text": f"{system_prompt}\n\nCreate a video for the request: {user_request}"}]}
|
75 |
+
]
|
76 |
+
|
77 |
+
# Debug логирование полного контекста
|
78 |
+
logger.debug(f"Sending {len(messages)} messages to Gemini:")
|
79 |
+
for i, message in enumerate(messages):
|
80 |
+
logger.debug(f"Message {i+1} ({message['role']}): {message['parts'][0]['text'][:200]}{'...' if len(message['parts'][0]['text']) > 200 else ''}")
|
81 |
+
|
82 |
+
logger.info(f"Sending request to Gemini with {len(messages)} context messages")
|
83 |
+
response = self.model.generate_content(messages)
|
84 |
+
|
85 |
+
# Extract code from the response
|
86 |
+
code = response.text.strip()
|
87 |
+
|
88 |
+
# Улучшенное извлечение кода
|
89 |
+
if code.startswith("```python"):
|
90 |
+
# Стандартный случай: код начинается с ```python
|
91 |
+
code = code[9:]
|
92 |
+
if code.endswith("```"):
|
93 |
+
code = code[:-3]
|
94 |
+
elif code.startswith("```"):
|
95 |
+
# Код начинается с ```
|
96 |
+
code = code[3:]
|
97 |
+
if code.endswith("```"):
|
98 |
+
code = code[:-3]
|
99 |
+
else:
|
100 |
+
# Ищем первый блок кода внутри текста
|
101 |
+
python_match = re.search(r'```python\s*\n(.*?)\n```', code, re.DOTALL)
|
102 |
+
if python_match:
|
103 |
+
code = python_match.group(1)
|
104 |
+
else:
|
105 |
+
# Ищем любой блок ```
|
106 |
+
code_match = re.search(r'```\s*\n(.*?)\n```', code, re.DOTALL)
|
107 |
+
if code_match:
|
108 |
+
code = code_match.group(1)
|
109 |
+
# Если нет блоков кода, оставляем как есть (весь ответ)
|
110 |
+
|
111 |
+
code = code.strip()
|
112 |
+
logger.info("Manim code generated successfully")
|
113 |
+
return code
|
114 |
+
|
115 |
+
def fix_manim_code(self, current_code: str, error_trace: str, user_hint: str | None = None) -> str:
|
116 |
+
"""Fix Manim code using the error trace and optional user hint"""
|
117 |
+
|
118 |
+
# Получаем контекст
|
119 |
+
messages = []
|
120 |
+
if self.context_manager:
|
121 |
+
messages = self.context_manager.get_context_for_gemini()
|
122 |
+
|
123 |
+
# Если контекста нет, создаем базовое сообщение
|
124 |
+
if not messages:
|
125 |
+
hint_block = f"\nUser hint: {user_hint}" if user_hint else ""
|
126 |
+
prompt = f"""
|
127 |
+
You are an assistant that helps fix errors in Manim code.
|
128 |
+
|
129 |
+
Current code:
|
130 |
+
```python
|
131 |
+
{current_code}
|
132 |
+
```
|
133 |
+
|
134 |
+
Execution error:
|
135 |
+
{error_trace}
|
136 |
+
{hint_block}
|
137 |
+
|
138 |
+
Provide the corrected code. Return ONLY the Python code without explanations.
|
139 |
+
"""
|
140 |
+
messages = [{"role": "user", "parts": [{"text": prompt}]}]
|
141 |
+
|
142 |
+
# Debug логирование полного контекста
|
143 |
+
logger.debug(f"Sending {len(messages)} messages to Gemini for code fix:")
|
144 |
+
for i, message in enumerate(messages):
|
145 |
+
logger.debug(f"Message {i+1} ({message['role']}): {message['parts'][0]['text'][:200]}{'...' if len(message['parts'][0]['text']) > 200 else ''}")
|
146 |
+
|
147 |
+
logger.info("Sending code fix request to Gemini")
|
148 |
+
response = self.model.generate_content(messages)
|
149 |
+
|
150 |
+
fixed = response.text.strip()
|
151 |
+
|
152 |
+
# Улучшенное извлечение кода
|
153 |
+
if fixed.startswith("```python"):
|
154 |
+
# Стандартный случай: код начинается с ```python
|
155 |
+
fixed = fixed[9:]
|
156 |
+
if fixed.endswith("```"):
|
157 |
+
fixed = fixed[:-3]
|
158 |
+
elif fixed.startswith("```"):
|
159 |
+
# Код начинается с ```
|
160 |
+
fixed = fixed[3:]
|
161 |
+
if fixed.endswith("```"):
|
162 |
+
fixed = fixed[:-3]
|
163 |
+
else:
|
164 |
+
# Ищем первый блок кода внутри текста
|
165 |
+
python_match = re.search(r'```python\s*\n(.*?)\n```', fixed, re.DOTALL)
|
166 |
+
if python_match:
|
167 |
+
fixed = python_match.group(1)
|
168 |
+
else:
|
169 |
+
# Ищем любой блок ```
|
170 |
+
code_match = re.search(r'```\s*\n(.*?)\n```', fixed, re.DOTALL)
|
171 |
+
if code_match:
|
172 |
+
fixed = code_match.group(1)
|
173 |
+
# Если нет блоков кода, оставляем как есть (весь ответ)
|
174 |
+
|
175 |
+
fixed = fixed.strip()
|
176 |
+
logger.info("Received fixed code from Gemini")
|
177 |
+
return fixed
|
manim_video_generator/video_executor.py
ADDED
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import tempfile
|
3 |
+
import subprocess
|
4 |
+
import shutil
|
5 |
+
from pathlib import Path
|
6 |
+
from loguru import logger
|
7 |
+
from moviepy.editor import VideoFileClip, AudioFileClip, CompositeAudioClip, concatenate_audioclips
|
8 |
+
|
9 |
+
|
10 |
+
class VideoExecutor:
|
11 |
+
def __init__(self, output_dir: str = "output"):
|
12 |
+
self.output_dir = Path(output_dir)
|
13 |
+
self.output_dir.mkdir(exist_ok=True)
|
14 |
+
self.music_file = Path("data/music.mp3")
|
15 |
+
logger.info(f"VideoExecutor initialized, output directory: {self.output_dir}")
|
16 |
+
|
17 |
+
if not self.music_file.exists():
|
18 |
+
logger.warning(f"Background music file not found: {self.music_file}")
|
19 |
+
|
20 |
+
def execute_manim_code(self, code: str, scene_name: str = "VideoScene") -> Path:
|
21 |
+
"""Execute Manim code in an isolated environment and return the video path"""
|
22 |
+
|
23 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
24 |
+
temp_path = Path(temp_dir)
|
25 |
+
|
26 |
+
# Create a temporary file with the code
|
27 |
+
code_file = temp_path / "scene.py"
|
28 |
+
with open(code_file, "w", encoding="utf-8") as f:
|
29 |
+
f.write(code)
|
30 |
+
|
31 |
+
logger.info(f"Code written to temporary file: {code_file}")
|
32 |
+
|
33 |
+
# Run Manim
|
34 |
+
output_file = self._run_manim(code_file, scene_name, temp_path)
|
35 |
+
|
36 |
+
# Add background music
|
37 |
+
output_file = self._add_background_music(output_file, temp_path)
|
38 |
+
|
39 |
+
# Copy the result to the output folder
|
40 |
+
final_output = self._copy_to_output(output_file)
|
41 |
+
|
42 |
+
return final_output
|
43 |
+
|
44 |
+
def _run_manim(self, code_file: Path, scene_name: str, temp_dir: Path) -> Path:
|
45 |
+
"""Run Manim to render the video"""
|
46 |
+
|
47 |
+
cmd = [
|
48 |
+
"manim", "render",
|
49 |
+
str(code_file),
|
50 |
+
scene_name,
|
51 |
+
"--format", "mp4",
|
52 |
+
"-q", "m", # medium quality: 'l'=low, 'm'=medium, 'h'=high, 'p'=4k, 'k'=8k
|
53 |
+
"--output_file", "video.mp4"
|
54 |
+
]
|
55 |
+
|
56 |
+
logger.info(f"Executing command: {' '.join(cmd)}")
|
57 |
+
|
58 |
+
# Execute the command in the temporary directory
|
59 |
+
result = subprocess.run(
|
60 |
+
cmd,
|
61 |
+
cwd=temp_dir,
|
62 |
+
capture_output=True,
|
63 |
+
text=True,
|
64 |
+
timeout=300 # 5 minutes max
|
65 |
+
)
|
66 |
+
|
67 |
+
if result.returncode != 0:
|
68 |
+
logger.error(f"Manim execution error: {result.stderr}")
|
69 |
+
raise RuntimeError(f"Manim exited with error: {result.stderr}")
|
70 |
+
|
71 |
+
logger.info("Manim executed successfully")
|
72 |
+
|
73 |
+
# Look for the created video
|
74 |
+
media_dir = temp_dir / "media"
|
75 |
+
if not media_dir.exists():
|
76 |
+
raise FileNotFoundError("Media folder not found after running Manim")
|
77 |
+
|
78 |
+
# Search for mp4 file recursively
|
79 |
+
video_files = list(media_dir.rglob("*.mp4"))
|
80 |
+
if not video_files:
|
81 |
+
raise FileNotFoundError("Video file not found after rendering")
|
82 |
+
|
83 |
+
# Take the most recent file
|
84 |
+
video_file = max(video_files, key=lambda f: f.stat().st_mtime)
|
85 |
+
logger.info(f"Video file found: {video_file}")
|
86 |
+
|
87 |
+
return video_file
|
88 |
+
|
89 |
+
def _add_background_music(self, video_file: Path, temp_dir: Path) -> Path:
|
90 |
+
"""Add background music to the video"""
|
91 |
+
logger.info("Adding background music to video")
|
92 |
+
|
93 |
+
video_with_music = temp_dir / "video_with_music.mp4"
|
94 |
+
|
95 |
+
# Load video and music
|
96 |
+
video = VideoFileClip(str(video_file))
|
97 |
+
music = AudioFileClip(str(self.music_file))
|
98 |
+
|
99 |
+
# Adjust music duration to match video
|
100 |
+
video_duration = video.duration
|
101 |
+
if music.duration > video_duration:
|
102 |
+
# Trim music if longer than video
|
103 |
+
music = music.subclip(0, video_duration)
|
104 |
+
else:
|
105 |
+
# Loop music if shorter than video
|
106 |
+
loops_needed = int(video_duration // music.duration) + 1
|
107 |
+
music = concatenate_audioclips([music] * loops_needed).subclip(0, video_duration)
|
108 |
+
|
109 |
+
# Set music volume lower to not overpower original audio (if any)
|
110 |
+
music = music.volumex(0.3) # 30% volume
|
111 |
+
|
112 |
+
# Combine original audio with music
|
113 |
+
if video.audio is not None:
|
114 |
+
final_audio = CompositeAudioClip([video.audio, music])
|
115 |
+
else:
|
116 |
+
final_audio = music
|
117 |
+
|
118 |
+
# Create final video with music
|
119 |
+
final_video = video.set_audio(final_audio)
|
120 |
+
final_video.write_videofile(str(video_with_music), codec='libx264', audio_codec='aac')
|
121 |
+
|
122 |
+
# Clean up
|
123 |
+
video.close()
|
124 |
+
music.close()
|
125 |
+
final_video.close()
|
126 |
+
|
127 |
+
logger.info(f"Background music added: {video_with_music}")
|
128 |
+
return video_with_music
|
129 |
+
|
130 |
+
def _copy_to_output(self, video_file: Path) -> Path:
|
131 |
+
"""Copy video to the output folder with a unique name"""
|
132 |
+
|
133 |
+
import time
|
134 |
+
timestamp = int(time.time())
|
135 |
+
output_file = self.output_dir / f"video_{timestamp}.mp4"
|
136 |
+
|
137 |
+
shutil.copy2(video_file, output_file)
|
138 |
+
logger.info(f"Video copied to: {output_file}")
|
139 |
+
|
140 |
+
return output_file
|
prompts.py
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
SYSTEM_PROMPT_SCENARIO_GENERATOR = """
|
2 |
+
You are a scenario generator for short manim video (5-30 seconds). Your first task is to generate a scenario for a video.
|
3 |
+
User will provide you a video idea and you will need to generate a scenario for the video. You have a technical restrictions:
|
4 |
+
- The video should be 5-30 seconds long.
|
5 |
+
- The video will be generated using Manim library
|
6 |
+
|
7 |
+
Answer is a normal text, not a code. If you have any questions, ask user for clarification.
|
8 |
+
"""
|
9 |
+
|
10 |
+
SYSTEM_PROMPT_CODEGEN = """
|
11 |
+
Now you are a Manim code generator. Your ONLY task is to generate executable Python code using the Manim library.
|
12 |
+
User will provide you a video idea that he has discussed with scenario manager and you will need to generate a Manim code that will execute the user request.
|
13 |
+
Format code inside ```python and ``` tags. Answer only code, no other text. Generate your code in one file. P.S. Do not use latex for text.
|
14 |
+
"""
|
15 |
+
|
16 |
+
REVIEW_PROMPT = """
|
17 |
+
You are a short video reviewer. User with video producer and coder has generated a video. Your task is to review the video and make sure it is correct.
|
18 |
+
First, check the consistency of the video. The text should not overlap, the image should not go off frame, and objects should not hang in the air but be in their proper places.
|
19 |
+
If you see any issues, please point them out in text format, othervise answer "No issues found".
|
20 |
+
"""
|
21 |
+
|
requirements.txt
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
gradio>=4.0.0
|
2 |
+
google-genai
|
3 |
+
manim
|
4 |
+
moviepy==1.0.3
|
5 |
+
loguru
|