From f90dc05f59f6f1b85a48898adf7c5166726f8500 Mon Sep 17 00:00:00 2001
From: Anno Yanzhe Chen <54897166+ChenAnno@users.noreply.github.com>
Date: Mon, 29 Sep 2025 08:30:07 +0000
Subject: [PATCH] Add files via upload
---
README.md | 207 +
agent.py | 912 +++
api_config.json | 39 +
assets/icon/car.png | Bin 0 -> 1589 bytes
assets/icon/card.png | Bin 0 -> 1777 bytes
assets/icon/carrot.png | Bin 0 -> 5521 bytes
assets/icon/cat.png | Bin 0 -> 146969 bytes
assets/icon/cats.png | Bin 0 -> 3235 bytes
assets/icon/cell.png | Bin 0 -> 628 bytes
assets/icon/cellphone.png | Bin 0 -> 628 bytes
assets/icon/chameleon.png | Bin 0 -> 7057 bytes
assets/icon/character.png | Bin 0 -> 17143 bytes
...tation_for_powers_logarithms_and_roots.jpg | Bin 0 -> 29914 bytes
...heorem_and_independence_in_probability.png | Bin 0 -> 21650 bytes
...etry_of_changing_probabilistic_beliefs.jpg | Bin 0 -> 14392 bytes
assets/reference/Binomial_distributions.png | Bin 0 -> 17256 bytes
...ntegrals_and_their_surprising_patterns.jpg | Bin 0 -> 29858 bytes
assets/reference/Central_Limit_Theorem.png | Bin 0 -> 17066 bytes
.../Dandelin_spheres_and_conic_sections.jpg | Bin 0 -> 628792 bytes
assets/reference/Dot_products_and_duality.jpg | Bin 0 -> 28445 bytes
.../reference/Eulers_Formula_and_eπi_=_-1.jpg | Bin 0 -> 2512 bytes
.../Eulers_formula_and_e{pi_i}_=_-1.jpg | Bin 0 -> 2512 bytes
assets/reference/Eulers_formula_e{iπ}.jpg | Bin 0 -> 2512 bytes
assets/reference/Fourier_Transform.jpg | Bin 0 -> 446631 bytes
..._equation_and_circular_representations.jpg | Bin 0 -> 66847 bytes
assets/reference/GRID.png | Bin 0 -> 567350 bytes
.../reference/History_and_definition_of_π.jpg | Bin 0 -> 23136 bytes
...ynamics_and_iterated_complex_functions.jpg | Bin 0 -> 12814 bytes
...se_to_light_and_the_barber_pole_effect.png | Bin 0 -> 128967 bytes
...ship_between_integrals_and_derivatives.jpg | Bin 0 -> 32963 bytes
...od_and_Newtons_fractal_in_root-finding.jpg | Bin 0 -> 69430 bytes
...ns_winding_numbers_and_domain_coloring.jpg | Bin 0 -> 12488 bytes
..._dependence_of_the_index_of_refraction.png | Bin 0 -> 27050 bytes
..._distribution_and_the_Gaussian_integral.jpg | Bin 0 -> 104920 bytes
..._approximations_and_Dirichlets_theorem.jpg | Bin 0 -> 66126 bytes
assets/reference/Proof_of_Snells_law.png | Bin 0 -> 293842 bytes
assets/reference/Pure_Fourier_series.jpg | Bin 0 -> 50874 bytes
...e_behavior_of_light_in_different_media.png | Bin 0 -> 366755 bytes
...ship_between_integrals_and_derivatives.jpg | Bin 0 -> 47444 bytes
assets/reference/Riemann_zeta_function.jpg | Bin 0 -> 27121 bytes
...nd_quantum_states_in_quantum_mechanics.jpg | Bin 0 -> 25976 bytes
assets/reference/The_essence_of_calculus.jpg | Bin 0 -> 24392 bytes
...e_in_the_Context_of_Fourier_Transforms.jpg | Bin 0 -> 29686 bytes
eval_AES.py | 352 +
eval_TQ.py | 365 +
external_assets.py | 219 +
figures/approach.png | Bin 0 -> 1022677 bytes
figures/first.png | Bin 0 -> 859148 bytes
figures/logo.png | Bin 0 -> 12449 bytes
gpt_request.py | 1062 +++
json_files/long_video_ref_mapping.json | 119 +
json_files/long_video_topics_list.json | 119 +
json_files/long_video_topics_list_safe.json | 119 +
json_files/questions_by_topic_10.json | 6086 +++++++++++++++++
json_files/topics_list_safe.json | 119 +
prompts/__init__.py | 24 +
prompts/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1009 bytes
.../__pycache__/base_class.cpython-311.pyc | Bin 0 -> 1828 bytes
prompts/__pycache__/stage1.cpython-311.pyc | Bin 0 -> 2340 bytes
prompts/__pycache__/stage2.cpython-311.pyc | Bin 0 -> 5901 bytes
prompts/__pycache__/stage3.cpython-311.pyc | Bin 0 -> 3631 bytes
prompts/__pycache__/stage4.cpython-311.pyc | Bin 0 -> 4433 bytes
.../__pycache__/stage5_eva.cpython-311.pyc | Bin 0 -> 4614 bytes
.../stage5_unlearning.cpython-311.pyc | Bin 0 -> 3436 bytes
prompts/base_class.py | 43 +
prompts/stage1.py | 49 +
prompts/stage2.py | 126 +
prompts/stage3.py | 78 +
prompts/stage4.py | 101 +
prompts/stage5_eva.py | 108 +
prompts/stage5_unlearning.py | 59 +
requirements.txt | 104 +
run_agent.sh | 39 +
run_agent_single.sh | 48 +
scope_refine.py | 802 +++
utils.py | 209 +
76 files changed, 11508 insertions(+)
create mode 100644 README.md
create mode 100644 agent.py
create mode 100644 api_config.json
create mode 100644 assets/icon/car.png
create mode 100644 assets/icon/card.png
create mode 100644 assets/icon/carrot.png
create mode 100644 assets/icon/cat.png
create mode 100644 assets/icon/cats.png
create mode 100644 assets/icon/cell.png
create mode 100644 assets/icon/cellphone.png
create mode 100644 assets/icon/chameleon.png
create mode 100644 assets/icon/character.png
create mode 100644 assets/reference/Alternate_notation_for_powers_logarithms_and_roots.jpg
create mode 100644 assets/reference/Bayes_theorem_and_independence_in_probability.png
create mode 100644 assets/reference/Bayes_theorem_and_the_geometry_of_changing_probabilistic_beliefs.jpg
create mode 100644 assets/reference/Binomial_distributions.png
create mode 100644 assets/reference/Borwein_integrals_and_their_surprising_patterns.jpg
create mode 100644 assets/reference/Central_Limit_Theorem.png
create mode 100644 assets/reference/Dandelin_spheres_and_conic_sections.jpg
create mode 100644 assets/reference/Dot_products_and_duality.jpg
create mode 100644 assets/reference/Eulers_Formula_and_eπi_=_-1.jpg
create mode 100644 assets/reference/Eulers_formula_and_e{pi_i}_=_-1.jpg
create mode 100644 assets/reference/Eulers_formula_e{iπ}.jpg
create mode 100644 assets/reference/Fourier_Transform.jpg
create mode 100644 assets/reference/Fourier_series_and_their_connection_to_the_heat_equation_and_circular_representations.jpg
create mode 100644 assets/reference/GRID.png
create mode 100644 assets/reference/History_and_definition_of_π.jpg
create mode 100644 assets/reference/Holomorphic_dynamics_and_iterated_complex_functions.jpg
create mode 100644 assets/reference/How_wiggling_charges_give_rise_to_light_and_the_barber_pole_effect.png
create mode 100644 assets/reference/Integration_the_Fundamental_Theorem_of_Calculus_and_the_inverse_relationship_between_integrals_and_derivatives.jpg
create mode 100644 assets/reference/Newtons_method_and_Newtons_fractal_in_root-finding.jpg
create mode 100644 assets/reference/Numerical_algorithms_for_solving_2D_equations_winding_numbers_and_domain_coloring.jpg
create mode 100644 assets/reference/Origin_and_color_dependence_of_the_index_of_refraction.png
create mode 100644 assets/reference/Origin_of_π_in_the_normal_distribution_and_the_Gaussian_integral.jpg
create mode 100644 assets/reference/Prime_patterns_pi_approximations_and_Dirichlets_theorem.jpg
create mode 100644 assets/reference/Proof_of_Snells_law.png
create mode 100644 assets/reference/Pure_Fourier_series.jpg
create mode 100644 assets/reference/Refraction_and_the_behavior_of_light_in_different_media.png
create mode 100644 assets/reference/Relationship_between_integrals_and_derivatives.jpg
create mode 100644 assets/reference/Riemann_zeta_function.jpg
create mode 100644 assets/reference/Superposition_and_quantum_states_in_quantum_mechanics.jpg
create mode 100644 assets/reference/The_essence_of_calculus.jpg
create mode 100644 assets/reference/Uncertainty_Principle_in_the_Context_of_Fourier_Transforms.jpg
create mode 100644 eval_AES.py
create mode 100644 eval_TQ.py
create mode 100644 external_assets.py
create mode 100644 figures/approach.png
create mode 100644 figures/first.png
create mode 100644 figures/logo.png
create mode 100644 gpt_request.py
create mode 100644 json_files/long_video_ref_mapping.json
create mode 100644 json_files/long_video_topics_list.json
create mode 100644 json_files/long_video_topics_list_safe.json
create mode 100644 json_files/questions_by_topic_10.json
create mode 100644 json_files/topics_list_safe.json
create mode 100644 prompts/__init__.py
create mode 100644 prompts/__pycache__/__init__.cpython-311.pyc
create mode 100644 prompts/__pycache__/base_class.cpython-311.pyc
create mode 100644 prompts/__pycache__/stage1.cpython-311.pyc
create mode 100644 prompts/__pycache__/stage2.cpython-311.pyc
create mode 100644 prompts/__pycache__/stage3.cpython-311.pyc
create mode 100644 prompts/__pycache__/stage4.cpython-311.pyc
create mode 100644 prompts/__pycache__/stage5_eva.cpython-311.pyc
create mode 100644 prompts/__pycache__/stage5_unlearning.cpython-311.pyc
create mode 100644 prompts/base_class.py
create mode 100644 prompts/stage1.py
create mode 100644 prompts/stage2.py
create mode 100644 prompts/stage3.py
create mode 100644 prompts/stage4.py
create mode 100644 prompts/stage5_eva.py
create mode 100644 prompts/stage5_unlearning.py
create mode 100644 requirements.txt
create mode 100644 run_agent.sh
create mode 100644 run_agent_single.sh
create mode 100644 scope_refine.py
create mode 100644 utils.py
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..647130e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,207 @@
+
+# Code2Video: Agentic Code-Centric Framework for Educational Video Generation
+
+
+
+
+
+
+
+
+
+
+ From code to classroom-ready videos, powered by agents that teach.
+
+
+
+ 教学相长,代码为梁;知识作航,动画生光
+
+
+
+
+ Yanzhe Chen,
+ Kevin Lin Qinghong,
+ Mike Zheng Shou
+ Show Lab @ National University of Singapore
+
+
+
+
+ 📄 Paper |
+ 🤗 Dataset |
+ 🌐 Project Website |
+ 💬 X (Twitter)
+
+
+---
+
+## 🌟 Overview
+
+
+
+
+
+**Code2Video** is an **agentic, code-centric framework** that generates high-quality **educational videos** from knowledge points.
+Unlike pixel-based text-to-video models, our approach leverages executable **Manim code** to ensure **clarity, coherence, and reproducibility**.
+
+**Key Features**:
+- 🎬 **Code-Centric Paradigm** — executable code as the unified medium for both temporal sequencing and spatial organization of educational videos.
+- 🤖 **Modular Tri-Agent Design** — Planner (storyboard expansion), Coder (debuggable code synthesis), and Critic (layout refinement with anchors) work together for structured generation.
+- 📚 **MMMC Benchmark** — the first benchmark for code-driven video generation, covering 117 curated learning topics inspired by 3Blue1Brown, spanning diverse areas.
+- 🧪 **Multi-Dimensional Evaluation** — systematic assessment on efficiency, aesthetics, and end-to-end knowledge transfer.
+
+---
+
+## 🚀 How to Create -- Code2Video
+
+
+
+
+
+### 1. Requirements
+
+```bash
+pip install -r requirements.txt
+````
+
+### 2. Configure LLM API Keys
+
+Fill in your **API credentials** in `gpt_config.json`.
+
+* **LLM API**:
+ * Required for Planner & Coder.
+ * Best Manim code quality achieved with **Claude-4-Opus**.
+* **VLM API**:
+ * Required for Planner Critic.
+ * For layout and aesthetics optimization, provide **Gemini API key**.
+ * Best quality achieved with **gemini-2.5-pro-preview-05-06**.
+
+* **Visual Assets API**:
+
+ * To enrich videos with icons, set `ICONFINDER_API_KEY` from [IconFinder](https://www.iconfinder.com/account/applications).
+
+### 3. Run Agents
+
+We provide two shell scripts for different generation modes:
+
+#### (a) Full Benchmark Mode
+
+Script: `run_agent.sh`
+
+Runs all (or a subset of) learning topics defined in `long_video_topics_list.json`.
+
+```bash
+sh run_agent.sh
+```
+
+**Important parameters inside `run_agent.sh`:**
+
+* `API`: specify which LLM to use.
+* `FOLDER_PREFIX`: name prefix for saving output folders (e.g., `TEST-LIST`).
+* `MAX_CONCEPTS`: number of concepts to include (`-1` means all).
+* `PARALLEL_GROUP_NUM`: number of groups to run in parallel.
+
+---
+
+#### (b) Single Knowledge Point Mode
+
+Script: `run_agent_single.sh`
+
+Generates a video from a single **knowledge point** specified in the script.
+
+```bash
+sh run_agent_single.sh --knowledge_point "Linear transformations and matrices"
+```
+
+**Important parameters inside `run_agent_single.sh`:**
+
+* `API`: specify which LLM to use.
+* `FOLDER_PREFIX`: output folder prefix (e.g., `TEST-single`).
+* `KNOWLEDGE_POINT`: target concept, e.g. `"Linear transformations and matrices"`.
+
+---
+
+### 4. Project Organization
+
+A suggested directory structure:
+
+```
+Code2Video/
+│── agent.py
+│── run_agent.sh
+│── run_agent_single.sh
+│── api_config.json
+│── ...
+│
+├── assets/
+│ ├── icons/ # downloaded visual assets cache via IconFinder API
+│ └── reference/ # reference images
+│
+├── json_files/ # JSON-based topic lists & metadata
+├── prompts/ # prompt templates for LLM calls
+├── CASES/ # generated cases, organized by FOLDER_PREFIX
+│ └── TEST-LIST/ # example multi-topic generation results
+│ └── TEST-single/ # example single-topic generation results
+```
+
+
+---
+
+## 📊 How to Evaluate -- MMMC
+
+We evaluate along **three complementary dimensions**:
+
+1. **Knowledge Transfer (TeachQuiz)**
+
+ ```bash
+ python3 eval_TQ.py
+ ```
+
+2. **Aesthetic & Structural Quality (AES)**
+
+ ```bash
+ python3 eval_AES.py
+ ```
+
+3. **Efficiency Metrics (During Creating)**
+
+ * Token usage
+ * Execution time
+
+
+👉 More data and evaluation scripts are available at:
+[HuggingFace: MMMC Benchmark](https://huggingface.co/datasets/YanzheChen/MMMC)
+
+---
+
+## 🙏 Acknowledgements
+
+* Video data is sourced from the **[3Blue1Brown official lessons](https://www.3blue1brown.com/#lessons)**.
+ These videos represent the **upper bound of clarity and aesthetics** in educational video design and inform our evaluation metrics.
+* We thank all the **Show Lab @ NUS** members for support!
+* This project builds upon open-source contributions from **Manim Community** and the broader AI research ecosystem.
+* High-quality visual assets (icons) are provided by **[IconFinder](https://www.iconfinder.com/)** and **[Icons8](https://icons8.com/icons)**, which were used to enrich the educational videos.
+
+
+---
+
+
diff --git a/agent.py b/agent.py
new file mode 100644
index 0000000..5af7742
--- /dev/null
+++ b/agent.py
@@ -0,0 +1,912 @@
+import re
+import argparse
+import json
+import time
+import random
+import subprocess
+from typing import List, Dict, Any, Optional, Tuple, Callable
+from dataclasses import dataclass
+from pathlib import Path
+from concurrent.futures import ProcessPoolExecutor, as_completed, ThreadPoolExecutor
+
+from gpt_request import *
+from prompts import *
+from utils import *
+from scope_refine import *
+from external_assets import process_storyboard_with_assets
+
+
+@dataclass
+class Section:
+ id: str
+ title: str
+ lecture_lines: List[str]
+ animations: List[str]
+
+
+@dataclass
+class TeachingOutline:
+ topic: str
+ target_audience: str
+ sections: List[Dict[str, Any]]
+
+
+@dataclass
+class VideoFeedback:
+ section_id: str
+ video_path: str
+ has_issues: bool
+ suggested_improvements: List[str]
+ raw_response: Optional[str] = None
+
+
+@dataclass
+class RunConfig:
+ use_feedback: bool = True
+ use_assets: bool = True
+ api: Callable = None
+ feedback_rounds: int = 2
+ iconfinder_api_key: str = ""
+ max_code_token_length: int = 10000
+ max_fix_bug_tries: int = 10
+ max_regenerate_tries: int = 10
+ max_feedback_gen_code_tries: int = 3
+ max_mllm_fix_bugs_tries: int = 3
+
+
+class TeachingVideoAgent:
+ def __init__(
+ self,
+ idx,
+ knowledge_point,
+ folder="CASES",
+ cfg: Optional[RunConfig] = None,
+ ):
+ """1. Global parameter"""
+ self.learning_topic = knowledge_point
+ self.idx = idx
+ self.cfg = cfg
+
+ self.use_feedback = cfg.use_feedback
+ self.use_assets = cfg.use_assets
+ self.API = cfg.api
+ self.feedback_rounds = cfg.feedback_rounds
+ self.iconfinder_api_key = cfg.iconfinder_api_key
+ self.max_code_token_length = cfg.max_code_token_length
+ self.max_fix_bug_tries = cfg.max_fix_bug_tries
+ self.max_regenerate_tries = cfg.max_regenerate_tries
+ self.max_feedback_gen_code_tries = cfg.max_feedback_gen_code_tries
+ self.max_mllm_fix_bugs_tries = cfg.max_mllm_fix_bugs_tries
+
+ """2. Path for output"""
+ self.folder = folder
+ self.output_dir = get_output_dir(idx=idx, knowledge_point=self.learning_topic, base_dir=folder)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ self.assets_dir = Path(*self.output_dir.parts[: self.output_dir.parts.index("CASES")]) / "assets" / "icon"
+ self.assets_dir.mkdir(exist_ok=True)
+
+ """3. ScopeRefine & Anchor Visual"""
+ self.scope_refine_fixer = ScopeRefineFixer(api, self.max_code_token_length)
+ self.extractor = GridPositionExtractor()
+
+ """4. External Database"""
+ knowledge_ref_mapping_path = (
+ Path(*self.output_dir.parts[: self.output_dir.parts.index("CASES")]) / "json_files" / "long_video_ref_mapping.json"
+ )
+ with open(knowledge_ref_mapping_path) as f:
+ self.KNOWLEDGE2PATH = json.load(f)
+ self.knowledge_ref_img_folder = (
+ Path(*self.output_dir.parts[: self.output_dir.parts.index("CASES")]) / "assets" / "reference"
+ )
+ self.GRID_IMG_PATH = self.knowledge_ref_img_folder / "GRID.png"
+
+ """5. Data structure"""
+ self.outline = None
+ self.enhanced_storyboard = None
+ self.sections = []
+ self.section_codes = {}
+ self.section_videos = {}
+ self.video_feedbacks = {}
+
+ """6. For Efficiency"""
+ self.token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
+
+ def _request_api_and_track_tokens(self, prompt, max_tokens=10000):
+ """packages API requests and automatically accumulates token usage"""
+ response, usage = self.API(prompt, max_tokens=max_tokens)
+ if usage:
+ self.token_usage["prompt_tokens"] += usage.get("prompt_tokens", 0)
+ self.token_usage["completion_tokens"] += usage.get("completion_tokens", 0)
+ self.token_usage["total_tokens"] += usage.get("total_tokens", 0)
+ return response
+
+ def _request_video_api_and_track_tokens(self, prompt, video_path):
+ """Wraps video API requests and accumulates token usage automatically"""
+ response, usage = request_gemini_video_img(prompt=prompt, video_path=video_path, image_path=self.GRID_IMG_PATH)
+
+ if usage:
+ self.token_usage["prompt_tokens"] += usage.get("prompt_tokens", 0)
+ self.token_usage["completion_tokens"] += usage.get("completion_tokens", 0)
+ self.token_usage["total_tokens"] += usage.get("total_tokens", 0)
+ return response
+
+ def get_serializable_state(self):
+ """返回可以序列化保存的Agent状态"""
+ return {"idx": self.idx, "knowledge_point": self.learning_topic, "folder": self.folder, "cfg": self.cfg}
+
+ def generate_outline(self) -> TeachingOutline:
+ outline_file = self.output_dir / "outline.json"
+
+ if outline_file.exists():
+ print("📂 ...")
+ with open(outline_file, "r", encoding="utf-8") as f:
+ outline_data = json.load(f)
+ else:
+ """Step 1: Generate teaching outline from topic"""
+ refer_img_path = (
+ self.knowledge_ref_img_folder / img_name
+ if (img_name := self.KNOWLEDGE2PATH.get(self.learning_topic)) is not None
+ else None
+ )
+ prompt1 = get_prompt1_outline(knowledge_point=self.learning_topic, reference_image_path=refer_img_path)
+
+ print(f"📝 Generating Outline...")
+
+ for attempt in range(1, self.max_regenerate_tries + 1):
+ api_func = self._request_api_and_track_tokens if refer_img_path else self._request_api_and_track_tokens
+ response = api_func(prompt1, max_tokens=self.max_code_token_length)
+ if response is None:
+ print(f"⚠️ Attempt {attempt} failed, retrying...")
+ if attempt == self.max_regenerate_tries:
+ raise ValueError("API requests failed multiple times")
+ continue
+ try:
+ content = response.candidates[0].content.parts[0].text
+ except Exception:
+ try:
+ content = response.choices[0].message.content
+ except Exception:
+ content = str(response)
+ content = extract_json_from_markdown(content)
+ try:
+ outline_data = json.loads(content)
+ with open(self.output_dir / "outline.json", "w", encoding="utf-8") as f:
+ json.dump(outline_data, f, ensure_ascii=False, indent=2)
+ break
+ except json.JSONDecodeError:
+ print(f"⚠️ Outline format invalid on attempt {attempt}, retrying...")
+ if attempt == self.max_regenerate_tries:
+ raise ValueError("Outline format invalid multiple times, check prompt or API response")
+
+ self.outline = TeachingOutline(
+ topic=outline_data["topic"],
+ target_audience=outline_data["target_audience"],
+ sections=outline_data["sections"],
+ )
+ print(f"== Outline generated: {self.outline.topic}")
+ return self.outline
+
+ def generate_storyboard(self) -> List[Section]:
+ """Step 2: Generate teaching storyboard from outline (optionally with asset enhancement)"""
+ if not self.outline:
+ raise ValueError("Outline not generated, please generate outline first")
+
+ storyboard_file = self.output_dir / "storyboard.json"
+ enhanced_storyboard_file = self.output_dir / "storyboard_with_assets.json"
+
+ if enhanced_storyboard_file.exists():
+ print("📂 Found enhanced storyboard, loading...")
+ with open(enhanced_storyboard_file, "r", encoding="utf-8") as f:
+ self.enhanced_storyboard = json.load(f)
+ elif storyboard_file.exists():
+ print("📂 Found storyboard, loading...")
+ with open(storyboard_file, "r", encoding="utf-8") as f:
+ storyboard_data = json.load(f)
+ if self.use_assets:
+ self.enhanced_storyboard = self._enhance_storyboard_with_assets(storyboard_data)
+ else:
+ self.enhanced_storyboard = storyboard_data
+ else:
+ print("🎬 Generating storyboard...")
+ refer_img_path = (
+ self.knowledge_ref_img_folder / img_name
+ if (img_name := self.KNOWLEDGE2PATH.get(self.learning_topic)) is not None
+ else None
+ )
+
+ prompt2 = get_prompt2_storyboard(
+ outline=json.dumps(self.outline.__dict__, ensure_ascii=False, indent=2),
+ reference_image_path=refer_img_path,
+ )
+
+ for attempt in range(1, self.max_regenerate_tries + 1):
+ api_func = self._request_api_and_track_tokens
+ response = api_func(prompt2, max_tokens=self.max_code_token_length)
+ if response is None:
+ print(f"⚠️ Outline format invalid on attempt {attempt}, retrying...")
+ if attempt == self.max_regenerate_tries:
+ raise ValueError("API requests failed multiple times")
+ continue
+
+ try:
+ content = response.candidates[0].content.parts[0].text
+ except Exception:
+ try:
+ content = response.choices[0].message.content
+ except Exception:
+ content = str(response)
+
+ try:
+ json_str = extract_json_from_markdown(content)
+ storyboard_data = json.loads(json_str)
+
+ # Save original storyboard
+ with open(storyboard_file, "w", encoding="utf-8") as f:
+ json.dump(storyboard_data, f, ensure_ascii=False, indent=2)
+
+ # Enhance storyboard (add assets)
+ if self.use_assets:
+ self.enhanced_storyboard = self._enhance_storyboard_with_assets(storyboard_data)
+ else:
+ self.enhanced_storyboard = storyboard_data
+ break
+
+ except json.JSONDecodeError:
+ print(f"⚠️ Storyboard format invalid on attempt {attempt}, retrying...")
+ if attempt == self.max_regenerate_tries:
+ raise ValueError("Storyboard format invalid multiple times, check prompt or API response")
+
+ # Parse into Section objects (using enhanced storyboard)
+ self.sections = []
+ for section_data in self.enhanced_storyboard["sections"]:
+ section = Section(
+ id=section_data["id"],
+ title=section_data["title"],
+ lecture_lines=section_data.get("lecture_lines", []),
+ animations=section_data["animations"],
+ )
+ self.sections.append(section)
+
+ print(f"== Storyboard processed, {len(self.sections)} sections generated")
+ return self.sections
+
+ def _enhance_storyboard_with_assets(self, storyboard_data: dict) -> dict:
+ """Enhance storyboard: smart analysis and download assets"""
+ print("🤖 Enhancing storyboard: smart analysis and download assets...")
+
+ try:
+ enhanced_storyboard = process_storyboard_with_assets(
+ storyboard=storyboard_data,
+ api_function=self.API,
+ assets_dir=str(self.assets_dir),
+ iconfinder_api_key=self.iconfinder_api_key,
+ )
+ enhanced_storyboard_file = self.output_dir / "storyboard_with_assets.json"
+ with open(enhanced_storyboard_file, "w", encoding="utf-8") as f:
+ json.dump(enhanced_storyboard, f, ensure_ascii=False, indent=2)
+ print("✅ Storyboard enhanced with assets")
+ return enhanced_storyboard
+
+ except Exception as e:
+ print(f"⚠️ Asset download failed, using original storyboard: {e}")
+ return storyboard_data
+
+ def generate_section_code(self, section: Section, attempt: int = 1, feedback_improvements=None) -> str:
+ """Generate Manim code for a single section"""
+ code_file = self.output_dir / f"{section.id}.py"
+
+ if attempt == 1 and code_file.exists() and not feedback_improvements:
+ print(f"📂 Found existing code for {section.id}, reading...")
+ with open(code_file, "r", encoding="utf-8") as f:
+ code = f.read()
+ self.section_codes[section.id] = code
+ return code
+ # print(f"💻 Generating Manim code for {section.id} (attempt {attempt}/{self.max_regenerate_tries})...")
+ regenerate_note = ""
+ if attempt > 1:
+ regenerate_note = get_regenerate_note(attempt, MAX_REGENERATE_TRIES=self.max_regenerate_tries)
+
+ # Add MLLM feedback and improvement suggestions
+ if feedback_improvements:
+ current_code = self.section_codes.get(section.id, "")
+ try:
+ modifier = GridCodeModifier(current_code)
+ modified_code = modifier.parse_feedback_and_modify(feedback_improvements)
+ with open(code_file, "w", encoding="utf-8") as f:
+ f.write(modified_code)
+
+ self.section_codes[section.id] = modified_code
+ return modified_code
+ except Exception as e:
+ print(f"⚠️ GridCodeModifier failed, falling back to original code: {e}")
+ code_gen_prompt = get_feedback_improve_code(
+ feedback=get_feedback_list_prefix(feedback_improvements), code=current_code
+ )
+
+ else:
+ code_gen_prompt = get_prompt3_code(regenerate_note=regenerate_note, section=section, base_class=base_class)
+
+ response = self._request_api_and_track_tokens(code_gen_prompt, max_tokens=self.max_code_token_length)
+ if response is None:
+ print(f"❌ Failed to generate code for {section.id} via API call.")
+ return ""
+
+ try:
+ code = response.candidates[0].content.parts[0].text
+ except Exception:
+ try:
+ code = response.choices[0].message.content
+ except Exception:
+ code = str(response)
+ if "```python" in code:
+ code = code.split("```python")[1].split("```")[0].strip()
+ elif "```" in code:
+ code = code.split("```")[1].strip()
+
+ # Replace base class
+ code = replace_base_class(code, base_class)
+
+ with open(code_file, "w", encoding="utf-8") as f:
+ f.write(code)
+
+ self.section_codes[section.id] = code
+ return code
+
+ def debug_and_fix_code(self, section_id: str, max_fix_attempts: int = 3) -> bool:
+ """Enhanced debug and fix code method"""
+ if section_id not in self.section_codes:
+ return False
+
+ for fix_attempt in range(max_fix_attempts):
+ print(f"🔧 {self.learning_topic} Debugging {section_id} (attempt {fix_attempt + 1}/{max_fix_attempts})")
+
+ try:
+ scene_name = f"{section_id.title().replace('_', '')}Scene"
+ code_file = f"{section_id}.py"
+ cmd = ["manim", "-ql", str(code_file), scene_name]
+
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd=self.output_dir, timeout=180)
+
+ if result.returncode == 0:
+ video_patterns = [
+ self.output_dir / "media" / "videos" / f"{code_file.replace('.py', '')}" / "480p15" / f"{scene_name}.mp4",
+ self.output_dir / "media" / "videos" / "480p15" / f"{scene_name}.mp4",
+ ]
+
+ for video_path in video_patterns:
+ if video_path.exists():
+ self.section_videos[section_id] = str(video_path)
+ print(f"✅ {self.learning_topic} {section_id} finished")
+ return True
+
+ current_code = self.section_codes[section_id]
+ fixed_code = self.scope_refine_fixer.fix_code_smart(section_id, current_code, result.stderr, self.output_dir)
+
+ if fixed_code:
+ self.section_codes[section_id] = fixed_code
+ with open(self.output_dir / code_file, "w", encoding="utf-8") as f:
+ f.write(fixed_code)
+ else:
+ break
+
+ except subprocess.TimeoutExpired:
+ print(f"❌ {self.learning_topic} {section_id} timed out")
+ break
+ except Exception as e:
+ print(f"❌ {self.learning_topic} {section_id} failed with exception: {e}")
+ break
+
+ return False
+
+ def get_mllm_feedback(self, section: Section, video_path: str, round_number: int = 1) -> VideoFeedback:
+ print(f"🤖 {self.learning_topic} Using MLLM to analyze video ({round_number}/{self.feedback_rounds}): {section.id}")
+
+ current_code = self.section_codes[section.id]
+ positions = self.extractor.extract_grid_positions(current_code)
+ position_table = self.extractor.generate_position_table(positions)
+ analysis_prompt = get_prompt4_layout_feedback(section=section, position_table=position_table)
+
+ def _parse_layout(feedback_content):
+ has_layout_issues, suggested_improvements = False, []
+ try:
+ data = json.loads(feedback_content)
+ lay = data.get("layout", {})
+ has_layout_issues = bool(lay.get("has_issues", False))
+ for it in lay.get("improvements", []) or []:
+ if isinstance(it, dict):
+ prob = str(it.get("problem", "")).strip()
+ sol = str(it.get("solution", "")).strip()
+ if prob or sol:
+ suggested_improvements.append(f"[LAYOUT] Problem: {prob}; Solution: {sol}")
+
+ except json.JSONDecodeError:
+ print(f"⚠️ {self.learning_topic} JSON parse failed, fallback to keyword analysis")
+
+ for m in re.finditer(
+ r"Problem:\s*(.*?);\s*Solution:\s*(.*?)(?=\n|$)", feedback_content, flags=re.IGNORECASE | re.DOTALL
+ ):
+ suggested_improvements.append(f"[LAYOUT] Problem: {m.group(1).strip()}; Solution: {m.group(2).strip()}")
+
+ if not suggested_improvements:
+ for sol in re.findall(r"Solution\s*:\s*(.+)", feedback_content, flags=re.IGNORECASE):
+ suggested_improvements.append(f"[LAYOUT] Problem: ; Solution: {sol.strip()}")
+
+ return has_layout_issues, suggested_improvements
+
+ try:
+ response = request_gemini_video_img(prompt=analysis_prompt, video_path=video_path, image_path=self.GRID_IMG_PATH)
+ feedback_content = extract_answer_from_response(response)
+ has_layout_issues, suggested_improvements = _parse_layout(feedback_content)
+ feedback = VideoFeedback(
+ section_id=section.id,
+ video_path=video_path,
+ has_issues=has_layout_issues,
+ suggested_improvements=suggested_improvements,
+ raw_response=feedback_content,
+ )
+ self.video_feedbacks[f"{section.id}_round{round_number}"] = feedback
+ return feedback
+
+ except Exception as e:
+ print(f"❌ {self.learning_topic} MLLM analysis failed: {str(e)}")
+ return VideoFeedback(
+ section_id=section.id,
+ video_path=video_path,
+ has_issues=False,
+ suggested_improvements=[],
+ raw_response=f"Error: {str(e)}",
+ )
+
+ def optimize_with_feedback(self, section: Section, feedback: VideoFeedback) -> bool:
+ """Optimize the code based on feedback from the MLLM"""
+ if not feedback.has_issues or not feedback.suggested_improvements:
+ print(f"✅ {self.learning_topic} {section.id} no optimization needed")
+ return True
+
+ # === Step 1: back up original code ===
+ original_code_content = self.section_codes[section.id]
+
+ for attempt in range(self.max_feedback_gen_code_tries):
+ print(
+ f"🎯 {self.learning_topic} MLLM feedback optimization {section.id} code, attempt {attempt + 1}/{self.max_feedback_gen_code_tries}"
+ )
+
+ # === Step 2: back up original code and apply improvements ===
+ if attempt > 0:
+ self.section_codes[section.id] = original_code_content
+
+ # === Step 3: re-generate code with feedback ===
+ self.generate_section_code(
+ section=section, attempt=attempt + 1, feedback_improvements=feedback.suggested_improvements
+ )
+ success = self.debug_and_fix_code(section.id, max_fix_attempts=self.max_mllm_fix_bugs_tries)
+ if success:
+ optimized_output_dir = self.output_dir / "optimized_videos"
+ optimized_output_dir.mkdir(exist_ok=True)
+ optimized_video_path = optimized_output_dir / f"{section.id}_optimized.mp4"
+
+ if section.id in self.section_videos:
+ original_video_path = Path(self.section_videos[section.id])
+ if original_video_path.exists():
+ original_video_path.rename(optimized_video_path)
+ self.section_videos[section.id] = str(optimized_video_path)
+ print(f"✨ {self.learning_topic} {section.id} optimized video saved: {optimized_video_path}")
+ else:
+ print(f"⚠️ {self.learning_topic} {section.id} original video file not found: {original_video_path}")
+ else:
+ print(f"⚠️ {self.learning_topic} {section.id} no optimized video path found")
+ return True
+ else:
+ print(
+ f"❌ {self.learning_topic} {section.id} MLLM optimization failed, attempt {attempt + 1}/{self.max_feedback_gen_code_tries}"
+ )
+
+ return False
+
+ def generate_codes(self) -> Dict[str, str]:
+ if not self.sections:
+ raise ValueError(f"{self.learning_topic} Please generate teaching sections first")
+
+ def task(section):
+ try:
+ self.generate_section_code(section, attempt=1)
+ return section.id, None
+ except Exception as e:
+ return section.id, e
+
+ with ThreadPoolExecutor(max_workers=6) as executor:
+ futures = {executor.submit(task, section): section for section in self.sections}
+ for future in as_completed(futures):
+ section_id, err = future.result()
+ if err:
+ print(f"❌ {self.learning_topic} {section_id} code generation failed: {err}")
+
+ return self.section_codes
+
+ def render_section(self, section: Section) -> bool:
+ section_id = section.id
+
+ try:
+ success = False
+ for regenerate_attempt in range(self.max_regenerate_tries):
+ # print(f"🎯 Processing {section_id} (regenerate attempt {regenerate_attempt + 1}/{self.max_regenerate_tries})")
+ try:
+ if regenerate_attempt > 0:
+ self.generate_section_code(section, attempt=regenerate_attempt + 1)
+ success = self.debug_and_fix_code(section_id, max_fix_attempts=self.max_fix_bug_tries)
+ if success:
+ break
+ else:
+ pass
+ except Exception as e:
+ print(f"⚠️ {section_id} attempt {regenerate_attempt + 1} raised exception: {str(e)}")
+ continue
+ if not success:
+ print(f"❌{self.learning_topic} {section_id} all failed, skipping section")
+ return False
+
+ # MLLM feedback
+ if self.use_feedback:
+ try:
+ for round in range(self.feedback_rounds):
+ current_video = self.section_videos.get(section_id)
+ if not current_video:
+ print(f"❌ {self.learning_topic} {section_id} no video available for MLLM feedback")
+ return success
+ try:
+ feedback = self.get_mllm_feedback(section, current_video, round_number=round + 1)
+
+ optimization_success = self.optimize_with_feedback(section, feedback)
+ if optimization_success:
+ pass
+ else:
+ print(
+ f"⚠️ {self.learning_topic} {section_id} round {round+1} MLLM feedback optimization failed, using current version"
+ )
+ except Exception as e:
+ print(
+ f"⚠️ {self.learning_topic} {section_id} round {round+1} MLLM feedback processing exception: {str(e)}"
+ )
+ continue
+
+ except Exception as e:
+ print(f"⚠️ {self.learning_topic} {section_id} MLLM feedback processing exception: {str(e)}")
+
+ return success
+
+ except Exception as e:
+ print(f"❌ {self.learning_topic} {section_id} render process exception: {str(e)}")
+ return False
+
+ def render_section_worker(self, section_data) -> Tuple[str, bool, Optional[str]]:
+ section_id = "unknown"
+ try:
+ section, agent_class, kwargs = section_data
+ section_id = section.id
+ agent = agent_class(**kwargs)
+ success = agent.render_section(section)
+ video_path = agent.section_videos.get(section.id) if success else None
+ return section_id, success, video_path
+
+ except Exception as e:
+ print(f"❌ {self.learning_topic} {section_id} render process exception: {str(e)}")
+ return section_id, False, None
+
+ def render_all_sections(self, max_workers: int = 6) -> Dict[str, str]:
+ print(f"🎥 Start parallel rendering of all section videos (up to {max_workers} processes)...")
+
+ tasks = []
+ for section in self.sections:
+ try:
+ task_data = (section, self.__class__, self.get_serializable_state())
+ tasks.append(task_data)
+ except Exception as e:
+ print(f"⚠️ Error preparing task data for {section.id}: {str(e)}")
+ continue
+
+ if not tasks:
+ print("❌ No valid tasks to execute")
+ return {}
+
+ results = {}
+ successful_count = 0
+ failed_count = 0
+
+ try:
+ with ProcessPoolExecutor(max_workers=max_workers) as executor:
+ future_to_section = {}
+ for task in tasks:
+ try:
+ future = executor.submit(self.render_section_worker, task)
+ future_to_section[future] = task[0].id
+ except Exception as e:
+ section_id = task[0].id if task and len(task) > 0 else "unknown"
+ print(f"⚠️ Error submitting task for {section_id}: {str(e)}")
+ failed_count += 1
+
+ for future in as_completed(future_to_section):
+ section_id = future_to_section[future]
+ try:
+ sid, success, video_path = future.result(timeout=300)
+
+ if success and video_path:
+ results[sid] = video_path
+ successful_count += 1
+ print(f"✅ {sid} video rendered successfully: {video_path}")
+ else:
+ failed_count += 1
+ print(f"⚠️ {sid} video rendering failed")
+
+ except Exception as e:
+ failed_count += 1
+ print(f"❌ {section_id} video rendering process error: {str(e)}")
+
+ except Exception as e:
+ print(f"❌ Critical error in parallel rendering process: {str(e)}")
+
+ # 更新结果并输出统计信息
+ self.section_videos.update(results)
+
+ total_sections = len(self.sections)
+ print(f"\n📊 Rendering Statistics:")
+ print(f" Total Sections: {total_sections}")
+ print(f" Success Rate: {successful_count/total_sections*100:.1f}%" if total_sections > 0 else " Success Rate: 0%")
+
+ if successful_count == 0:
+ print("❌ All section videos failed to render")
+ elif failed_count > 0:
+ print(
+ f"⚠️ {failed_count} section videos failed to render, but {successful_count} section videos rendered successfully"
+ )
+ else:
+ print("🎉 All section videos rendered successfully!")
+
+ return results
+
+ def merge_videos(self, output_filename: str = None) -> str:
+ """Step 5: Merge all section videos"""
+ if not self.section_videos:
+ raise ValueError("No video files available to merge")
+
+ if output_filename is None:
+ safe_name = topic_to_safe_name(self.learning_topic)
+ output_filename = f"{safe_name}.mp4"
+
+ output_path = self.output_dir / output_filename
+
+ print(f"🔗 Start merging section videos...")
+
+ video_list_file = self.output_dir / "video_list.txt"
+ with open(video_list_file, "w", encoding="utf-8") as f:
+ for section_id in sorted(self.section_videos.keys()):
+ video_path = self.section_videos[section_id].replace(f"{self.output_dir}/", "")
+ f.write(f"file '{video_path}'\n")
+
+ # ffmpeg
+ try:
+ result = subprocess.run(
+ ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(video_list_file), "-c", "copy", str(output_path)],
+ capture_output=True,
+ text=True,
+ )
+
+ if result.returncode == 0:
+ return str(output_path)
+ else:
+ print(f"❌ Failed to merge section videos: {result.stderr}")
+ return None
+ except Exception as e:
+ print(f"❌ Failed to merge section videos: {e}")
+ return None
+
+ def GENERATE_VIDEO(self) -> str:
+ """Generate complete video with MLLM feedback optimization"""
+ try:
+ self.generate_outline()
+ self.generate_storyboard()
+ self.generate_codes()
+ self.render_all_sections()
+ final_video = self.merge_videos()
+ if final_video:
+ print(f"🎉 Video generated success: {final_video}")
+ return final_video
+ else:
+ print(f"❌{self.learning_topic} failed")
+ return None
+ except Exception as e:
+ print(f"❌ Video generation failed: {e}")
+ return None
+
+
+def process_knowledge_point(idx, kp, folder_path: Path, cfg: RunConfig):
+ print(f"\n🚀 Processing knowledge topic: {kp}")
+ start_time = time.time()
+
+ agent = TeachingVideoAgent(
+ idx=idx,
+ knowledge_point=kp,
+ folder=folder_path,
+ cfg=cfg,
+ )
+ video_path = agent.GENERATE_VIDEO()
+
+ duration_minutes = (time.time() - start_time) / 60
+ total_tokens = agent.token_usage["total_tokens"]
+
+ print(f"✅ Knowledge topic '{kp}' processed. Cost Time: {duration_minutes:.2f} minutes, Tokens used: {total_tokens}")
+ return kp, video_path, duration_minutes, total_tokens
+
+
+def process_batch(batch_data, cfg: RunConfig):
+ """Process a batch of knowledge points (serial within a batch)"""
+ batch_idx, kp_batch, folder_path = batch_data
+ results = []
+ print(f"Batch {batch_idx + 1} starts processing {len(kp_batch)} knowledge points")
+
+ for local_idx, (idx, kp) in enumerate(kp_batch):
+ try:
+ if local_idx > 0:
+ delay = random.uniform(3, 6)
+ print(f"⏳ Batch {batch_idx + 1} waits {delay:.1f}s before processing {kp}...")
+ time.sleep(delay)
+ results.append(process_knowledge_point(idx, kp, folder_path, cfg))
+ except Exception as e:
+ print(f"❌ Batch {batch_idx + 1} processing {kp} failed: {e}")
+ results.append((kp, None, 0, 0))
+ return batch_idx, results
+
+
+def run_Code2Video(
+ knowledge_points: List[str], folder_path: Path, parallel=True, batch_size=3, max_workers=8, cfg: RunConfig = RunConfig()
+):
+ all_results = []
+
+ if parallel:
+ batches = []
+ for i in range(0, len(knowledge_points), batch_size):
+ batch = [(i + j, kp) for j, kp in enumerate(knowledge_points[i : i + batch_size])]
+ batches.append((i // batch_size, batch, folder_path))
+
+ print(
+ f"🔄 Parallel batch processing mode: {len(batches)} batches, each with {batch_size} knowledge points, {max_workers} concurrent batches"
+ )
+ with ProcessPoolExecutor(max_workers=max_workers) as executor:
+ futures = {executor.submit(process_batch, batch, cfg): batch for batch in batches}
+ for future in as_completed(futures):
+ try:
+ batch_idx, batch_results = future.result()
+ all_results.extend(batch_results)
+ print(f"✅ Batch {batch_idx + 1} completed")
+ except Exception as e:
+ print(f"❌ Batch {batch_idx + 1} processing failed: {e}")
+ else:
+ print("🔄 Serial processing mode")
+ for idx, kp in enumerate(knowledge_points):
+ try:
+ all_results.append(process_knowledge_point(idx, kp, folder_path, cfg))
+ except Exception as e:
+ print(f"❌ Serial processing {kp} failed: {e}")
+ all_results.append((kp, None, 0, 0))
+
+ successful_runs = [r for r in all_results if r[1] is not None]
+ total_runs = len(all_results)
+ if not successful_runs:
+ print("\nAll knowledge points failed, cannot calculate average.")
+ return
+
+ total_duration = sum(r[2] for r in successful_runs)
+ total_tokens_consumed = sum(r[3] for r in successful_runs)
+ num_successful = len(successful_runs)
+
+ print("\n" + "=" * 50)
+ print(f" Total knowledge points: {total_runs}")
+ print(f" Successfully processed: {num_successful} ({num_successful/total_runs*100:.1f}%)")
+ print(f" Average duration [min]: {total_duration/num_successful:.2f} minutes/knowledge point")
+ print(f" Average token consumption: {total_tokens_consumed/num_successful:,.0f} tokens/knowledge point")
+ print("=" * 50)
+
+
+def get_api_and_output(API_name):
+ mapping = {
+ "gpt-41": (request_gpt41_token, "Chatgpt41"),
+ "claude": (request_claude_token, "CLAUDE"),
+ "gpt-5": (request_gpt5_token, "Chatgpt5"),
+ "gpt-4o": (request_gpt4o_token, "Chatgpt4o"),
+ "gpt-o4mini": (request_o4mini_token, "Chatgpto4mini"),
+ "Gemini": (request_gemini_token, "Gemini"),
+ }
+ try:
+ return mapping[API_name]
+ except KeyError:
+ raise ValueError("Invalid API model name")
+
+
+def build_and_parse_args():
+ parser = argparse.ArgumentParser()
+ # TODO: Core hyperparameters
+ parser.add_argument(
+ "--API",
+ type=str,
+ choices=["gpt-41", "claude", "gpt-5", "gpt-4o", "gpt-o4mini", "Gemini"],
+ default="gpt-41",
+ )
+ parser.add_argument(
+ "--folder_prefix",
+ type=str,
+ default="TEST",
+ )
+ parser.add_argument("--knowledge_file", type=str, default="long_video_topics_list.json")
+ parser.add_argument("--iconfinder_api_key", type=str, default="")
+
+ # Basically invariant parameters
+ parser.add_argument("--use_feedback", action="store_true", default=False)
+ parser.add_argument("--no_feedback", action="store_false", dest="use_feedback")
+ parser.add_argument("--use_assets", action="store_true", default=False)
+ parser.add_argument("--no_assets", action="store_false", dest="use_assets")
+
+ parser.add_argument("--max_code_token_length", type=int, help="max # token for generating code", default=10000)
+ parser.add_argument("--max_fix_bug_tries", type=int, help="max # tries for SR to fix bug", default=10)
+ parser.add_argument("--max_regenerate_tries", type=int, help="max # tries to regenerate", default=10)
+ parser.add_argument("--max_feedback_gen_code_tries", type=int, help="max # tries for Critic", default=3)
+ parser.add_argument("--max_mllm_fix_bugs_tries", type=int, help="max # tries for Critic to fix bug", default=3)
+ parser.add_argument("--feedback_rounds", type=int, default=2)
+
+ parser.add_argument("--parallel", action="store_true", default=False)
+ parser.add_argument("--no_parallel", action="store_false", dest="parallel")
+ parser.add_argument("--parallel_group_num", type=int, default=3)
+ parser.add_argument("--max_concepts", type=int, help="Limit # concepts for a quick run, -1 for all", default=-1)
+ parser.add_argument("--knowledge_point", type=str, help="if knowledge_file not given, can ignore", default=None)
+
+ return parser.parse_args()
+
+
+if __name__ == "__main__":
+ args = build_and_parse_args()
+
+ api, folder_name = get_api_and_output(args.API)
+ folder = Path(__file__).resolve().parent / "CASES" / f"{args.folder_prefix}_{folder_name}"
+
+ _CFG_PATH = pathlib.Path(__file__).with_name("api_config.json")
+ with _CFG_PATH.open("r", encoding="utf-8") as _f:
+ _CFG = json.load(_f)
+ iconfinder_cfg = _CFG.get("iconfinder", {})
+ args.iconfinder_api_key = iconfinder_cfg.get("api_key")
+ if args.iconfinder_api_key:
+ print(f"Iconfinder API Key: {args.iconfinder_api_key}")
+ else:
+ print("WARNING: Iconfinder API key not found in config file. Using default (None).")
+
+ if args.knowledge_point:
+ print(f"🔄 Single knowledge point mode: {args.knowledge_point}")
+ knowledge_points = [args.knowledge_point]
+ args.parallel_group_num = 1
+ elif args.knowledge_file:
+ with open(Path(__file__).resolve().parent / "json_files" / args.knowledge_file, "r", encoding="utf-8") as f:
+ knowledge_points = json.load(f)
+ if args.max_concepts is not None:
+ knowledge_points = knowledge_points[: args.max_concepts]
+ else:
+ raise ValueError("Must provide --knowledge_point | --knowledge_file")
+
+ cfg = RunConfig(
+ api=api,
+ iconfinder_api_key=args.iconfinder_api_key,
+ use_feedback=args.use_feedback,
+ use_assets=args.use_assets,
+ max_code_token_length=args.max_code_token_length,
+ max_fix_bug_tries=args.max_fix_bug_tries,
+ max_regenerate_tries=args.max_regenerate_tries,
+ max_feedback_gen_code_tries=args.max_feedback_gen_code_tries,
+ max_mllm_fix_bugs_tries=args.max_mllm_fix_bugs_tries,
+ feedback_rounds=args.feedback_rounds,
+ )
+
+ run_Code2Video(
+ knowledge_points,
+ folder,
+ parallel=args.parallel,
+ batch_size=max(1, int(len(knowledge_points) / args.parallel_group_num)),
+ max_workers=get_optimal_workers(),
+ cfg=cfg,
+ )
diff --git a/api_config.json b/api_config.json
new file mode 100644
index 0000000..4a958bc
--- /dev/null
+++ b/api_config.json
@@ -0,0 +1,39 @@
+{
+ "gemini": {
+ "base_url": "...",
+ "api_version": "2024-03-01-preview",
+ "api_key": "...",
+ "model": "gemini-2.5-pro-preview-05-06"
+ },
+ "gpt41": {
+ "base_url": "...",
+ "api_version": "2024-03-01-preview",
+ "api_key": "...",
+ "model": "gpt-4.1-2025-04-14"
+ },
+ "gpt5": {
+ "base_url": "...",
+ "api_version": "...",
+ "api_key": "...",
+ "model": "gpt-5-chat-2025-08-07"
+ },
+ "gpto4mini": {
+ "base_url": "...",
+ "api_version": "...",
+ "api_key": "...",
+ "model": "o4-mini-2025-04-16"
+ },
+ "gpt4o": {
+ "base_url": "...",
+ "api_version": "...",
+ "api_key": "...",
+ "model": "gpt-4o-2024-11-20"
+ },
+ "claude": {
+ "base_url": "...",
+ "api_key": "..."
+ },
+ "iconfinder": {
+ "api_key": "YOUR_ICONFINDER_KEY"
+ }
+}
diff --git a/assets/icon/car.png b/assets/icon/car.png
new file mode 100644
index 0000000000000000000000000000000000000000..26a1d1586885808514e4881dbf87c164766afb18
GIT binary patch
literal 1589
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
LVFArJ^7a1#a|=_R
literal 0
HcmV?d00001
diff --git a/assets/icon/carrot.png b/assets/icon/carrot.png
new file mode 100644
index 0000000000000000000000000000000000000000..88dfe05e70a6dca73ec0dae546f0b86101db36da
GIT binary patch
literal 5521
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
TSVmUB00000NkvXXu0mjfCOibo
literal 0
HcmV?d00001
diff --git a/assets/icon/cat.png b/assets/icon/cat.png
new file mode 100644
index 0000000000000000000000000000000000000000..4e7bb898a6d5319b90fab970fa51e0b5e7ce4333
GIT binary patch
literal 146969
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
GoE9{R4k<{;nL
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