diff --git a/CodeVideoRenderer/__init__.py b/CodeVideoRenderer/__init__.py
index 67ed6d7..f01a63d 100644
--- a/CodeVideoRenderer/__init__.py
+++ b/CodeVideoRenderer/__init__.py
@@ -1,31 +1,9 @@
-"""
-CodeVideoRenderer – A Python animation library for creating dynamic code demonstration videos.
-
-This package provides tools to transform static code into lively animations that simulate
-real programming processes, built on top of the Manim engine.
-
-Quick start
------------
-
->>> from CodeVideoRenderer import CameraFollowCursorCV
->>> video = CameraFollowCursorCV(
-... code=('string', 'print("Hello, World!")'),
-... language='python',
-... video_name='HelloWorld'
-... )
->>> video.render()
-
-Modules
--------
-
-* :mod:`~.renderer` — Core rendering engine (:class:`~.CameraFollowCursorCV`).
-* :mod:`~.config` — Default constants and configuration values.
-* :mod:`~.typing` — Type aliases used across the library.
-* :mod:`~.utils` — Internal utility functions and helpers.
-* :mod:`~.version` — Package version string.
-"""
+"""Code video rendering built on Manim: animate code being typed while the camera follows the cursor."""
from .renderer import *
from .config import *
from .typing import *
from .utils import *
-from .version import __version__
\ No newline at end of file
+from .postprocess import *
+from .vscode_theme import VSCodeDarkPlusStyle, register_vscode, STYLE_NAME
+from .ime import is_cjk, cjk_run_at, get_ime
+from .version import __version__
diff --git a/CodeVideoRenderer/config.py b/CodeVideoRenderer/config.py
index b6a7cd2..366415d 100644
--- a/CodeVideoRenderer/config.py
+++ b/CodeVideoRenderer/config.py
@@ -1,46 +1,33 @@
+import os
+import sys
+
from manim import config
from rich.console import Console
-import sys
-# 原始标准输出和标准错误流
ORIGINAL_STDOUT = sys.stdout
-"""Original standard output stream, used for console output."""
ORIGINAL_STDERR = sys.stderr
-"""Original standard error stream, used for console error output."""
ORIGINAL_PROGRESS_BAR = config.progress_bar
-"""Original progress bar, used for progress tracking."""
-# 默认设置
DEFAULT_OUTPUT_VALUE = True
-"""Default output value."""
DEFAULT_LINE_SPACING = 0.8
-"""Default line spacing."""
DEFAULT_CURSOR_HEIGHT = 0.35
-"""Default cursor height."""
DEFAULT_CURSOR_WIDTH = 1e-4
-"""Default cursor width."""
DEFAULT_CODE_FONT = 'Consolas'
-"""Default code font."""
DEFAULT_CURSOR_TO_CHAR_BUFFER = 0.07
-"""Default buffer time between cursor movement and character rendering."""
DEFAULT_TYPE_INTERVAL = 0.15
-"""Default typing interval."""
DEFAULT_LINE_BREAK_RUN_TIME = 0.4
-"""Default line break animation run time."""
DEFAULT_TAB_WIDTH = 4
-"""Default tab width."""
DEFAULT_OUTPUT_CONSOLE = Console(file=ORIGINAL_STDOUT)
-"""Default output console."""
DEFAULT_CURSOR_BLINK_RUN_TIME = 0.5
-"""Default cursor blink animation run time."""
-# 其他设置
+# ffmpeg encode tuning: auto threads, fast preset, small buffer keeps memory low
+FFMPEG_THREADS = os.cpu_count() or 4
+FFMPEG_PRESET = "fast"
+FFMPEG_BUFSIZE = "512k"
+
CODE_OFFSET = 0.08
-"""Default offset for code rendering."""
NOT_AVAILABLE_CHARACTERS = '\r\v\f'
-"""Characters that are not available for rendering."""
OCCUPY_CHARACTER = '('
-"""Character that is used to occupy a position in the code."""
__all__ = [
"ORIGINAL_STDOUT",
@@ -57,7 +44,10 @@
"DEFAULT_TAB_WIDTH",
"DEFAULT_OUTPUT_CONSOLE",
"DEFAULT_CURSOR_BLINK_RUN_TIME",
+ "FFMPEG_THREADS",
+ "FFMPEG_PRESET",
+ "FFMPEG_BUFSIZE",
"CODE_OFFSET",
"NOT_AVAILABLE_CHARACTERS",
"OCCUPY_CHARACTER"
-]
\ No newline at end of file
+]
diff --git a/CodeVideoRenderer/icons/function.png b/CodeVideoRenderer/icons/function.png
new file mode 100644
index 0000000..9ce41c1
Binary files /dev/null and b/CodeVideoRenderer/icons/function.png differ
diff --git a/CodeVideoRenderer/icons/variable.png b/CodeVideoRenderer/icons/variable.png
new file mode 100644
index 0000000..dd4a370
Binary files /dev/null and b/CodeVideoRenderer/icons/variable.png differ
diff --git a/CodeVideoRenderer/ime.py b/CodeVideoRenderer/ime.py
new file mode 100644
index 0000000..1f6543e
--- /dev/null
+++ b/CodeVideoRenderer/ime.py
@@ -0,0 +1,95 @@
+"""Chinese IME candidate box: pinyin above, a divider, then candidate words."""
+try:
+ from pypinyin import pinyin as _py, Style as _Style
+ _HAS_PYPINYIN = True
+except ImportError: # pragma: no cover - fall back to the character itself when pypinyin is missing
+ _HAS_PYPINYIN = False
+
+__all__ = ["is_cjk", "cjk_run_at", "get_ime"]
+
+# homophone candidates so the box looks real (pinyin -> candidate list)
+_CANDIDATES = {
+ # single characters
+ "ni": ["你", "泥", "拟", "尼", "逆"],
+ "hao": ["好", "号", "浩", "耗", "毫"],
+ "shi": ["世", "是", "事", "试", "市"],
+ "jie": ["界", "借", "接", "街", "解"],
+ "da": ["打", "大", "答", "达", "搭"],
+ "wan": ["完", "玩", "碗", "晚", "万"],
+ "dai": ["代", "带", "待", "戴", "贷"],
+ "ma": ["码", "马", "妈", "吗", "麻"],
+ "zi": ["自", "字", "资", "紫", "子"],
+ "dong": ["动", "东", "懂", "冬", "洞"],
+ "qing": ["清", "请", "情", "轻", "青"],
+ "ping": ["屏", "平", "评", "苹", "凭"],
+ "zhong": ["中", "种", "重", "终", "众"],
+ "wen": ["文", "问", "闻", "温", "稳"],
+ "yan": ["演", "眼", "盐", "严", "言"],
+ "hou": ["后", "候", "厚", "喉", "猴"],
+ "hui": ["会", "回", "惠", "汇", "灰"],
+ "qi": ["契", "气", "起", "器", "期"],
+ "shu": ["数", "书", "树", "输", "术"],
+ "fei": ["斐", "飞", "非", "肥", "费"],
+ "bo": ["波", "播", "拨", "玻", "伯"],
+ "na": ["那", "哪", "拿", "纳", "呐"],
+ "di": ["第", "地", "的", "底", "弟"],
+ "ge": ["个", "各", "歌", "格", "哥"],
+ # two-character words (joined full pinyin)
+ "nihao": ["你好", "你", "泥", "拟", "尼"],
+ "shijie": ["世界", "试解", "时间", "四季", "师姐"],
+ "daima": ["代码", "大马", "打码", "大妈"],
+ "dawan": ["打完", "大碗", "答万"],
+ "yanshi": ["演示", "严实", "延时", "验尸"],
+ "zhongwen": ["中文", "种闻", "重文"],
+ "zidong": ["自动", "字动", "资东"],
+ "qingping": ["清屏", "轻评"],
+ "houqi": ["后期", "厚起"],
+ "chuli": ["处理", "除理", "矗立"],
+ "feibo": ["斐波", "飞播"],
+ "naqi": ["那契", "纳气"],
+ "shuru": ["输入", "书入", "数如"],
+ "hanzi": ["汉字", "含字"],
+ "wancheng": ["完成", "碗城"],
+ "chengxu": ["程序", "成序", "城需"],
+}
+
+
+def is_cjk(ch):
+ return len(ch) == 1 and "一" <= ch <= "鿿"
+
+
+def cjk_run_at(text, index):
+ if not (0 <= index < len(text)) or not is_cjk(text[index]):
+ return index, index
+ start = index
+ while start > 0 and is_cjk(text[start - 1]):
+ start -= 1
+ end = index + 1
+ while end < len(text) and is_cjk(text[end]):
+ end += 1
+ return start, end
+
+
+def _char_pinyin(ch):
+ if not _HAS_PYPINYIN:
+ return ch
+ try:
+ parts = _py(ch, style=_Style.NORMAL, errors=lambda x: [x])
+ return (parts[0][0] if parts and parts[0] else ch).lower()
+ except Exception:
+ return ch
+
+
+def get_ime(text, index):
+ start, end = cjk_run_at(text, index)
+ if start >= end:
+ return "", []
+ word = text[start:start + 2]
+ pinyin = "-".join(_char_pinyin(c) for c in word)
+ key = pinyin.replace("-", "").lower()
+ candidates = _CANDIDATES.get(key)
+ if not candidates:
+ candidates = [word]
+ elif word not in candidates:
+ candidates = [word] + list(candidates)
+ return pinyin, candidates
diff --git a/CodeVideoRenderer/postprocess.py b/CodeVideoRenderer/postprocess.py
new file mode 100644
index 0000000..3a223f8
--- /dev/null
+++ b/CodeVideoRenderer/postprocess.py
@@ -0,0 +1,758 @@
+"""ffmpeg wrappers for post-processing rendered videos: subtitles, audio, quality, overlays."""
+import json
+import os
+import shutil
+import subprocess
+import tempfile
+from fractions import Fraction
+from pathlib import Path
+
+from .config import FFMPEG_THREADS, FFMPEG_PRESET, FFMPEG_BUFSIZE
+
+_POSITION_ALIASES = {
+ "top-left": "tl",
+ "top-right": "tr",
+ "bottom-left": "bl",
+ "bottom-right": "br",
+ "center": "center",
+ "tl": "tl",
+ "tr": "tr",
+ "bl": "bl",
+ "br": "br",
+}
+
+_RESOLUTION_PRESETS = {
+ "480p": (854, 480),
+ "720p": (1280, 720),
+ "1080p": (1920, 1080),
+ "2k": (2560, 1440),
+ "4k": (3840, 2160),
+}
+
+
+def find_ffmpeg():
+ for var in ("CODEVIDEORENDERER_FFMPEG", "FFMPEG_BINARY"):
+ candidate = os.environ.get(var)
+ if candidate and Path(candidate).exists():
+ return str(Path(candidate))
+
+ which = shutil.which("ffmpeg")
+ if which:
+ return which
+
+ common = [
+ r"C:\ffmpeg\bin\ffmpeg.exe",
+ r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
+ "/usr/local/bin/ffmpeg",
+ "/usr/bin/ffmpeg",
+ "/opt/homebrew/bin/ffmpeg",
+ ]
+ for candidate in common:
+ if Path(candidate).exists():
+ return candidate
+
+ try:
+ import imageio_ffmpeg
+
+ bundled = imageio_ffmpeg.get_ffmpeg_exe()
+ if bundled and Path(bundled).exists():
+ return bundled
+ except Exception:
+ pass
+
+ raise FileNotFoundError(
+ "Could not locate ffmpeg. Install it and make it available on PATH, or "
+ "set the CODEVIDEORENDERER_FFMPEG environment variable to its full path."
+ )
+
+
+def find_ffprobe():
+ ffmpeg = Path(find_ffmpeg())
+ name = "ffprobe.exe" if os.name == "nt" else "ffprobe"
+ candidate = ffmpeg.with_name(name)
+ if candidate.exists():
+ return str(candidate)
+
+ which = shutil.which("ffprobe")
+ if which:
+ return which
+
+ raise FileNotFoundError("Could not locate ffprobe (expected next to ffmpeg).")
+
+
+def _run_ffmpeg(args):
+ # output path is always the last argument; inject encode options before it
+ opts = ["-threads", str(FFMPEG_THREADS)]
+ if "libx264" in args:
+ opts += ["-preset", FFMPEG_PRESET, "-bufsize", FFMPEG_BUFSIZE]
+ cmd = [find_ffmpeg(), "-hide_banner", "-loglevel", "error", "-y", *args[:-1], *opts, args[-1]]
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ if proc.returncode != 0:
+ raise RuntimeError(f"ffmpeg failed ({proc.returncode}): {proc.stderr.strip()}")
+
+
+def _run_ffprobe(args):
+ cmd = [find_ffprobe(), "-v", "error", *args]
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ if proc.returncode != 0:
+ raise RuntimeError(f"ffprobe failed ({proc.returncode}): {proc.stderr.strip()}")
+ return proc.stdout
+
+
+def _default_output(input_path, tag):
+ p = Path(input_path)
+ return str(p.with_name(f"{p.stem}_{tag}{p.suffix}"))
+
+
+def _ffmpeg_filter_path(path):
+ p = str(path).replace("\\", "/")
+ if os.name == "nt":
+ p = p.replace(":", r"\:")
+ return p
+
+
+def _drawtext_escape(text):
+ return (
+ text.replace("\\", "\\\\")
+ .replace(":", "\\:")
+ .replace("'", "\\'")
+ .replace("%", "\\%")
+ )
+
+
+def _default_font():
+ candidates = [
+ r"C:\Windows\Fonts\msyh.ttc", # Microsoft YaHei
+ r"C:\Windows\Fonts\msyh.ttf",
+ r"C:\Windows\Fonts\simhei.ttf",
+ r"C:\Windows\Fonts\simsun.ttc",
+ r"C:\Windows\Fonts\arial.ttf",
+ "/System/Library/Fonts/PingFang.ttc",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+ ]
+ for candidate in candidates:
+ if Path(candidate).exists():
+ return candidate
+ return ""
+
+
+def _has_audio_stream(path):
+ out = _run_ffprobe(["-select_streams", "a", "-show_entries", "stream=index", "-of", "json", str(path)])
+ try:
+ return bool(json.loads(out).get("streams"))
+ except Exception:
+ return False
+
+
+def _probe_video_info(path):
+ out = _run_ffprobe(
+ [
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=width,height,r_frame_rate",
+ "-of",
+ "json",
+ str(path),
+ ]
+ )
+ stream = json.loads(out)["streams"][0]
+ fps = float(Fraction(stream.get("r_frame_rate", "30/1")))
+ return int(stream["width"]), int(stream["height"]), fps
+
+
+def _parse_resolution(resolution):
+ # -2 width keeps the aspect ratio (ffmpeg scale=-2:HEIGHT)
+ if isinstance(resolution, (tuple, list)) and len(resolution) == 2:
+ return int(resolution[0]), int(resolution[1])
+ if isinstance(resolution, int):
+ return -2, int(resolution)
+ key = str(resolution).lower()
+ if key in _RESOLUTION_PRESETS:
+ return _RESOLUTION_PRESETS[key]
+ raise ValueError(
+ f"Unsupported resolution {resolution!r}. Use a preset "
+ f"({', '.join(_RESOLUTION_PRESETS)}), a height (int), or a (width, height) tuple."
+ )
+
+
+def remove_subtitles(input_path, output_path=None):
+ output_path = output_path or _default_output(input_path, "no_subs")
+ _run_ffmpeg(["-i", str(input_path), "-map", "0", "-c", "copy", "-sn", str(output_path)])
+ return str(output_path)
+
+
+def add_subtitles(
+ input_path,
+ subtitle_path,
+ output_path=None,
+ soft=False,
+ style=None,
+ font=None,
+ fontsize=None,
+ color=None,
+ position=None,
+ margin=None,
+):
+ output_path = output_path or _default_output(input_path, "subbed")
+
+ if soft:
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ "-i", str(subtitle_path),
+ "-map", "0", "-map", "1",
+ "-c", "copy", "-c:s", "mov_text",
+ "-metadata:s:s:0", "language=chi",
+ str(output_path),
+ ]
+ )
+ else:
+ vf = f"subtitles='{_ffmpeg_filter_path(subtitle_path)}'"
+ force = dict(style or {})
+ if font:
+ force["Fontname"] = font
+ if fontsize:
+ force["Fontsize"] = str(fontsize)
+ if color:
+ force["PrimaryColour"] = _ass_color(color)
+ if position:
+ force["Alignment"] = str(_ass_alignment(position))
+ if margin:
+ force["MarginV"] = str(margin)
+ if force:
+ vf += ":force_style='" + ",".join(f"{k}={v}" for k, v in force.items()) + "'"
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ "-vf", vf,
+ "-c:v", "libx264", "-c:a", "copy",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+
+def add_background_music(
+ input_path,
+ music_path,
+ output_path=None,
+ volume=0.3,
+ loop=True,
+ mix_original=True,
+):
+ output_path = output_path or _default_output(input_path, "bgm")
+ loop_args = ["-stream_loop", "-1"] if loop else []
+ has_audio = _has_audio_stream(input_path)
+
+ if has_audio and mix_original:
+ filter_complex = (
+ f"[1:a]volume={volume}[bg];"
+ f"[0:a][bg]amix=inputs=2:duration=first:normalize=0[aout]"
+ )
+ audio_map = "[aout]"
+ else:
+ filter_complex = f"[1:a]volume={volume}[bg]"
+ audio_map = "[bg]"
+
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ *loop_args,
+ "-i", str(music_path),
+ "-filter_complex", filter_complex,
+ "-map", "0:v", "-map", audio_map,
+ "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
+ "-shortest",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+
+def extract_audio(input_path, output_path=None, format="mp3"):
+ fmt = format.lower().lstrip(".")
+ codecs = {"mp3": "libmp3lame", "wav": "pcm_s16le", "m4a": "aac", "aac": "aac"}
+ if fmt not in codecs:
+ raise ValueError(f"Unsupported audio format {format!r}. Choose from {sorted(codecs)}.")
+
+ if output_path is None:
+ output_path = str(Path(input_path).with_suffix(f".{fmt}"))
+ _run_ffmpeg(["-i", str(input_path), "-vn", "-acodec", codecs[fmt], str(output_path)])
+ return str(output_path)
+
+
+def remove_audio(input_path, output_path=None):
+ output_path = output_path or _default_output(input_path, "mute")
+ _run_ffmpeg(["-i", str(input_path), "-c", "copy", "-an", str(output_path)])
+ return str(output_path)
+
+
+def concat_videos(
+ input_paths,
+ output_path,
+ reencode=False,
+):
+ paths = [str(Path(p).resolve()) for p in input_paths]
+ if len(paths) < 2:
+ raise ValueError("concat_videos requires at least two input videos.")
+
+ if reencode:
+ inputs = []
+ for p in paths:
+ inputs += ["-i", p]
+ n = len(paths)
+ has_audio_flags = [_has_audio_stream(p) for p in paths]
+
+ # Normalize video streams to yuv420p so differing pixel formats do not break concat
+ v_parts = [f"[{i}:v]format=yuv420p[v{i}]" for i in range(n)]
+
+ if all(has_audio_flags):
+ a_parts = [f"[{i}:a]aformat=sample_rates=44100:channel_layouts=stereo[a{i}]" for i in range(n)]
+ joins = "".join(f"[v{i}][a{i}]" for i in range(n))
+ filter_complex = ";".join(v_parts + a_parts) + ";" + joins + f"concat=n={n}:v=1:a=1[v][a]"
+ _run_ffmpeg(
+ [
+ *inputs,
+ "-filter_complex", filter_complex,
+ "-map", "[v]", "-map", "[a]",
+ "-c:v", "libx264", "-c:a", "aac",
+ str(output_path),
+ ]
+ )
+ elif not any(has_audio_flags):
+ joins = "".join(f"[v{i}]" for i in range(n))
+ filter_complex = ";".join(v_parts) + ";" + joins + f"concat=n={n}:v=1:a=0[v]"
+ _run_ffmpeg(
+ [
+ *inputs,
+ "-filter_complex", filter_complex,
+ "-map", "[v]",
+ "-c:v", "libx264", "-an",
+ str(output_path),
+ ]
+ )
+ else:
+ raise ValueError(
+ "concat_videos(reencode=True) requires all inputs to either have audio or "
+ "all to be silent; mixed audio presence is not supported. Normalise the "
+ "inputs first (e.g. with remove_audio / add_background_music)."
+ )
+ return str(output_path)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ list_file = Path(tmp) / "concat.txt"
+ list_file.write_text(
+ "".join(f"file '{p}'" + "\n" for p in paths),
+ encoding="utf-8",
+ )
+ _run_ffmpeg(["-f", "concat", "-safe", "0", "-i", str(list_file), "-c", "copy", str(output_path)])
+ return str(output_path)
+
+
+def set_quality(
+ input_path,
+ output_path=None,
+ resolution=None,
+ fps=None,
+ bitrate=None,
+ crf=None,
+):
+ output_path = output_path or _default_output(input_path, "quality")
+ args = ["-i", str(input_path)]
+
+ if resolution is not None:
+ width, height = _parse_resolution(resolution)
+ if width == -2:
+ vf = f"scale=-2:{height}"
+ else:
+ vf = f"scale={width}:{height}"
+ args += ["-vf", vf]
+
+ args += ["-c:v", "libx264"]
+ if fps is not None:
+ args += ["-r", str(fps)]
+ if bitrate is not None:
+ args += ["-b:v", str(bitrate)]
+ if crf is not None:
+ args += ["-crf", str(crf)]
+ args += ["-c:a", "aac", "-b:a", "192k", str(output_path)]
+
+ _run_ffmpeg(args)
+ return str(output_path)
+
+
+def trim_video(
+ input_path,
+ output_path=None,
+ start=0.0,
+ end=None,
+ duration=None,
+):
+ output_path = output_path or _default_output(input_path, "trim")
+ args = ["-i", str(input_path), "-ss", str(start)]
+ if duration is not None:
+ args += ["-t", str(duration)]
+ elif end is not None:
+ args += ["-to", str(end)]
+ args += ["-c", "copy", str(output_path)]
+ _run_ffmpeg(args)
+ return str(output_path)
+
+
+def change_speed(
+ input_path,
+ output_path=None,
+ speed=1.0,
+):
+ if speed <= 0:
+ raise ValueError("speed must be greater than 0")
+ output_path = output_path or _default_output(input_path, f"speed{speed}".replace(".", "_"))
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ "-filter_complex", f"[0:v]setpts={1 / speed:.6f}*PTS[v];[0:a]atempo={speed}[a]",
+ "-map", "[v]", "-map", "[a]",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+
+def _overlay_position(position, margin):
+ pos = _POSITION_ALIASES.get(position.lower(), "br")
+ if pos == "tl":
+ return f"{margin}", f"{margin}"
+ if pos == "tr":
+ return f"W-w-{margin}", f"{margin}"
+ if pos == "bl":
+ return f"{margin}", f"H-h-{margin}"
+ if pos == "center":
+ return "(W-w)/2", "(H-h)/2"
+ return f"W-w-{margin}", f"H-h-{margin}" # bottom-right
+
+
+def _drawtext_position(position, margin):
+ pos = _POSITION_ALIASES.get(position.lower(), "br")
+ if pos == "tl":
+ return f"{margin}", f"{margin}"
+ if pos == "tr":
+ return f"w-text_w-{margin}", f"{margin}"
+ if pos == "bl":
+ return f"{margin}", f"h-text_h-{margin}"
+ if pos == "center":
+ return "(w-text_w)/2", "(h-text_h)/2"
+ return f"w-text_w-{margin}", f"h-text_h-{margin}"
+
+
+def add_watermark(
+ input_path,
+ output_path=None,
+ text=None,
+ image_path=None,
+ position="bottom-right",
+ fontsize=24,
+ opacity=0.6,
+ color="white",
+ margin=20,
+):
+ if text is None and image_path is None:
+ raise ValueError("Provide either `text` or `image_path` for the watermark.")
+ output_path = output_path or _default_output(input_path, "wm")
+
+ if image_path is not None:
+ x, y = _overlay_position(position, margin)
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ "-i", str(image_path),
+ "-filter_complex",
+ f"[1]format=rgba,colorchannelmixer=aa={opacity}[wm];[0][wm]overlay={x}:{y}",
+ "-c:a", "copy",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+ font = _default_font()
+ x, y = _drawtext_position(position, margin)
+ drawtext = (
+ f"drawtext=text='{_drawtext_escape(text)}'"
+ f":x={x}:y={y}"
+ f":fontsize={fontsize}"
+ f":fontcolor={color}@{opacity}"
+ )
+ if font:
+ drawtext += f":fontfile='{_ffmpeg_filter_path(font)}'"
+ _run_ffmpeg(["-i", str(input_path), "-vf", drawtext, "-c:a", "copy", str(output_path)])
+ return str(output_path)
+
+
+def add_title_card(
+ input_path,
+ output_path=None,
+ title=None,
+ subtitle=None,
+ duration=3.0,
+ background_color="black",
+ title_color="white",
+ subtitle_color="0xCCCCCC",
+ fontsize=64,
+):
+ output_path = output_path or _default_output(input_path, "titled")
+ title = title or Path(input_path).stem
+ width, height, fps = _probe_video_info(input_path)
+ font = _default_font()
+
+ text = _drawtext_escape(title)
+ drawtext = (
+ f"drawtext=text='{text}'"
+ f":x=(w-text_w)/2:y=(h-text_h)/2-40"
+ f":fontsize={fontsize}:fontcolor={title_color}"
+ )
+ if subtitle:
+ drawtext += (
+ f",drawtext=text='{_drawtext_escape(subtitle)}'"
+ f":x=(w-text_w)/2:y=(h)/2+60"
+ f":fontsize={fontsize // 2}:fontcolor={subtitle_color}"
+ )
+ if font:
+ drawtext = drawtext.replace("fontsize=", f"fontfile='{_ffmpeg_filter_path(font)}':fontsize=")
+
+ with tempfile.TemporaryDirectory() as tmp:
+ title_clip = Path(tmp) / "title.mp4"
+ has_audio = _has_audio_stream(input_path)
+
+ # Keep the title card's audio stream consistent with the source video so concat(reencode) joins cleanly
+ title_args = [
+ "-f", "lavfi",
+ "-i", f"color=c={background_color}:s={width}x{height}:d={duration}:r={fps}",
+ ]
+ if has_audio:
+ title_args += ["-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo"]
+ title_args += ["-vf", drawtext, "-c:v", "libx264", "-pix_fmt", "yuv420p"]
+ if has_audio:
+ title_args += ["-c:a", "aac", "-shortest"]
+ else:
+ title_args += ["-an"]
+ title_args += [str(title_clip)]
+ _run_ffmpeg(title_args)
+
+ concat_videos([title_clip, input_path], output_path, reencode=True)
+ return str(output_path)
+
+
+def extract_cover(
+ input_path,
+ output_path=None,
+ time=0.5,
+ width=None,
+):
+ if output_path is None:
+ output_path = str(Path(input_path).with_name(f"{Path(input_path).stem}_cover.png"))
+ args = ["-ss", str(time), "-i", str(input_path), "-frames:v", "1"]
+ if width is not None:
+ args += ["-vf", f"scale={int(width)}:-2"]
+ args += [str(output_path)]
+ _run_ffmpeg(args)
+ return str(output_path)
+
+
+def _ass_time(seconds):
+ ms = int(round(seconds * 1000))
+ return f"{ms // 3600000}:{(ms // 60000) % 60:02d}:{(ms // 1000) % 60:02d}.{(ms // 10) % 100:02d}"
+
+
+def _ass_color(color):
+ named = {
+ "white": "FFFFFF", "black": "000000", "red": "FF0000", "green": "00FF00",
+ "blue": "0000FF", "yellow": "FFFF00", "cyan": "00FFFF", "magenta": "FF00FF",
+ }
+ c = named.get(str(color).lower(), str(color).lstrip("#").lstrip("0x").lstrip("0X"))
+ if len(c) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in c):
+ c = "FFFFFF"
+ # ASS stores colour as &HAABBGGRR
+ return f"&H00{c[4:6]}{c[2:4]}{c[0:2]}"
+
+
+def _ass_alignment(position):
+ v, _, h = str(position).lower().partition("-")
+ v_index = {"bottom": 0, "middle": 1, "top": 2}.get(v, 0)
+ h_index = {"left": 0, "center": 1, "right": 2}.get(h, 1)
+ return v_index * 3 + h_index + 1
+
+
+def _build_ass(lyrics, width, height, font, fontsize, color, highlight_color, position, margin, karaoke):
+ header = (
+ "[Script Info]\n"
+ "ScriptType: v4.00+\n"
+ f"PlayResX: {width}\n"
+ f"PlayResY: {height}\n"
+ "WrapStyle: 2\n"
+ "\n"
+ "[V4+ Styles]\n"
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
+ f"Style: Default,{font or 'Arial'},{fontsize},{_ass_color(color)},{_ass_color(highlight_color)},{_ass_color(color)},&H00000000,0,0,0,0,100,100,0,0,1,2,0,{_ass_alignment(position)},10,10,{margin},1\n"
+ "\n"
+ "[Events]\n"
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
+ )
+ out = [header]
+ for start, end, text in lyrics:
+ text = str(text).replace("\\", "\\\\").replace("\n", "\\N")
+ if karaoke:
+ text = f"{{\\k{int(round((end - start) * 100))}}}{text}"
+ out.append(f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},Default,,0,0,0,,{text}")
+ return "\n".join(out) + "\n"
+
+
+def add_background(
+ input_path,
+ output_path=None,
+ color=None,
+ image=None,
+ blur=0,
+):
+ output_path = output_path or _default_output(input_path, "bg")
+ width, height, _ = _probe_video_info(input_path)
+
+ if image is not None:
+ filter_complex = (
+ f"[1:v]scale={width}:{height}:force_original_aspect_ratio=increase,"
+ f"crop={width}:{height},gblur=sigma={blur}[bg];"
+ f"[0:v]format=rgba[fg];[bg][fg]overlay=0:0"
+ )
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ "-loop", "1", "-i", str(image),
+ "-filter_complex", filter_complex,
+ "-map", "0:a?", "-c:a", "copy",
+ "-shortest",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+ _run_ffmpeg(
+ [
+ "-f", "lavfi", "-i", f"color=c={color or 'black'}:s={width}x{height}",
+ "-i", str(input_path),
+ "-filter_complex", "[1:v]format=rgba[fg];[0:v][fg]overlay=0:0",
+ "-map", "1:a?", "-c:a", "copy",
+ "-shortest",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+
+def replace_background(
+ input_path,
+ background,
+ output_path=None,
+ key_color="0x00FF00",
+ similarity=0.3,
+ blend=0.1,
+ blur=0,
+):
+ output_path = output_path or _default_output(input_path, "bg")
+ width, height, _ = _probe_video_info(input_path)
+ filter_complex = (
+ f"[0:v]chromakey={key_color}:{similarity}:{blend}[fg];"
+ f"[1:v]scale={width}:{height}:force_original_aspect_ratio=increase,"
+ f"crop={width}:{height},gblur=sigma={blur}[bg];"
+ f"[bg][fg]overlay=0:0"
+ )
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ "-loop", "1", "-i", str(background),
+ "-filter_complex", filter_complex,
+ "-map", "0:a?", "-c:a", "copy",
+ "-shortest",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+
+def add_sound(
+ input_path,
+ sound,
+ output_path=None,
+ at=0.0,
+ volume=1.0,
+):
+ output_path = output_path or _default_output(input_path, "sfx")
+ delay = int(round(at * 1000))
+ sfx = f"[1:a]volume={volume},adelay={delay}|{delay}[sfx]"
+ if _has_audio_stream(input_path):
+ fc = f"{sfx};[0:a][sfx]amix=inputs=2:duration=first:normalize=0[aout]"
+ audio_map = "[aout]"
+ else:
+ fc = sfx
+ audio_map = "[sfx]"
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ "-i", str(sound),
+ "-filter_complex", fc,
+ "-map", "0:v", "-map", audio_map,
+ "-c:v", "copy", "-c:a", "aac",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+
+def add_lyrics(
+ input_path,
+ lyrics,
+ output_path=None,
+ font="",
+ fontsize=48,
+ color="white",
+ highlight_color="yellow",
+ position="bottom-center",
+ margin=80,
+ karaoke=True,
+):
+ output_path = output_path or _default_output(input_path, "lyrics")
+ width, height, _ = _probe_video_info(input_path)
+ ass = _build_ass(lyrics, width, height, font, fontsize, color, highlight_color, position, margin, karaoke)
+ with tempfile.TemporaryDirectory() as tmp:
+ ass_path = Path(tmp) / "lyrics.ass"
+ ass_path.write_text(ass, encoding="utf-8")
+ vf = f"subtitles='{_ffmpeg_filter_path(ass_path)}'"
+ _run_ffmpeg(
+ [
+ "-i", str(input_path),
+ "-vf", vf,
+ "-c:v", "libx264", "-c:a", "copy",
+ str(output_path),
+ ]
+ )
+ return str(output_path)
+
+
+__all__ = [
+ "find_ffmpeg",
+ "find_ffprobe",
+ "remove_subtitles",
+ "add_subtitles",
+ "add_background_music",
+ "extract_audio",
+ "remove_audio",
+ "concat_videos",
+ "set_quality",
+ "trim_video",
+ "change_speed",
+ "add_watermark",
+ "add_title_card",
+ "extract_cover",
+ "add_background",
+ "replace_background",
+ "add_sound",
+ "add_lyrics",
+]
diff --git a/CodeVideoRenderer/renderer.py b/CodeVideoRenderer/renderer.py
index 79d15fe..7d8b9a2 100644
--- a/CodeVideoRenderer/renderer.py
+++ b/CodeVideoRenderer/renderer.py
@@ -1,55 +1,94 @@
-from __future__ import annotations # for Sphinx typehints
-from manim import VGroup, Code, SurroundingRectangle, RoundedRectangle, MovingCameraScene, rate_functions, RendererType, config, WHITE, GREY, UP, DOWN, LEFT, RIGHT, register_font
-from manim.typing import Point3D
+from manim import VGroup, Group, Code, SurroundingRectangle, RoundedRectangle, Rectangle, Line, MovingCameraScene, rate_functions, RendererType, config, WHITE, GREY, UP, DOWN, LEFT, RIGHT, register_font, FadeOut, FadeIn, Text, ImageMobject
from pathlib import Path
from copy import copy
-from typing import Literal, Union, Tuple, List, Dict
from timeit import timeit
from rich import traceback
-from dataclasses import dataclass
-from typeguard import typechecked
import numpy as np
-import random, inspect, os
+import random, inspect, os, re
from .config import *
-from .typing import *
from .utils import *
+from .vscode_theme import register_vscode, resolve_language, STYLE_NAME
+from .ime import is_cjk, get_ime
traceback.install()
+register_vscode()
+
+# griddycode completion icons (Icons/function.png, Icons/variable.png preloaded in settings.gd)
+AUTOCOMPLETE_ICONS = {
+ "function": os.path.join(os.path.dirname(__file__), "icons", "function.png"),
+ "variable": os.path.join(os.path.dirname(__file__), "icons", "variable.png"),
+}
+
+# griddycode completion option colors: LuaSingleton.keywords.function / .variable (settings.gd)
+AUTOCOMPLETE_COLORS = {
+ "function": "#61afef",
+ "variable": "#d19a66",
+}
+
+# completion box colors, matching griddycode "One Dark Pro Darker" gui colors
+_SUGGEST_BG = "#1e2227"
+_SUGGEST_BORDER = "#3d4556"
+_SUGGEST_FG = "#abb2bf"
+_SUGGEST_DETAIL = "#7f848e"
+_SUGGEST_SELECTED_BG = "#2c313a"
+
+
+def detect_functions(code_str):
+ """Port of griddycode `Lua/Plugins/py.lua` `detect_functions`: def / async def names."""
+ names = []
+ for line in code_str.splitlines():
+ m = re.search(r"def\s+([\w_]+)\s*\(", line)
+ if m:
+ names.append(m.group(1))
+ m = re.search(r"async\s+def\s+([\w_]+)\s*\(", line)
+ if m:
+ names.append(m.group(1))
+ return names
+
+
+def detect_variables(code_str):
+ """Port of griddycode `Lua/Plugins/py.lua` `detect_variables`: builtins + `name = ...` assignments."""
+ names = ["self", "__name__", "__annotations__", "__build_class__", "__builtins__",
+ "__cached__", "__dict__", "__doc__", "__file__", "__import__", "__loader__",
+ "__name__", "__package__", "__path__", "__spec__"]
+ for line in code_str.splitlines():
+ m = re.search(r"(\w+)\s*=\s*.+", line)
+ if m:
+ names.append(m.group(1))
+ return names
+
class CameraFollowCursorCV:
- """
- CameraFollowCursorCV is a class designed to create animated videos that simulate the process of typing code. It animates code line by line and character by
- character while smoothly moving the camera to follow the cursor, creating a professional-looking coding demonstration.
-
- Args:
- code (Union[Tuple[Literal['string'], str], Tuple[Literal['file'], StrPath]]): The code to be animated. **When using a string**, provide a tuple with the first element as ``'string'`` and the second element as the code string. **When using a file**, provide a tuple with the first element as ``'file'`` and the second element as the file path.
- language (PygmentsLanguage): The programming language of the code.
- formatter_style (PygmentsFormatterStyle): The style for syntax highlighting. Defaults to ``"material"``.
- line_spacing (Union[float, int]): The line spacing for the code. Defaults to :data:`~.DEFAULT_LINE_SPACING`.
- interval_range (Tuple[Union[float, int], Union[float, int]]): The range of typing intervals between characters. Defaults to (:data:`~.DEFAULT_TYPE_INTERVAL`, :data:`~.DEFAULT_TYPE_INTERVAL`).
- camera_scale (Union[float, int]): The scale factor for the camera. Defaults to 0.5.
- video_name (str): The name of the output video file. Defaults to ``"CameraFollowCursorCV"``.
- renderer (Literal['cairo', 'opengl']): The renderer to use for video rendering. Defaults to ``'cairo'``.
- """
+ """Animate code being typed while the camera follows the cursor."""
__all__ = ["render"]
- @typechecked
def __init__(self,
- code: Union[Tuple[Literal['string'], str], Tuple[Literal['file'], StrPath]],
- language: PygmentsLanguage,
- formatter_style: PygmentsFormatterStyle = "material",
- line_spacing: Union[float, int] = DEFAULT_LINE_SPACING,
- interval_range: Tuple[Union[float, int], Union[float, int]] = (DEFAULT_TYPE_INTERVAL, DEFAULT_TYPE_INTERVAL),
- camera_scale: Union[float, int] = 0.5,
- video_name: str = "CameraFollowCursorCV",
- renderer: Literal['cairo', 'opengl'] = 'cairo',
+ code,
+ language,
+ formatter_style="vscode-dark-plus",
+ line_spacing=DEFAULT_LINE_SPACING,
+ interval_range=(DEFAULT_TYPE_INTERVAL, DEFAULT_TYPE_INTERVAL),
+ camera_scale=0.5,
+ video_name="CameraFollowCursorCV",
+ renderer='cairo',
+ clear_code=False,
+ clear_code_mode='backspace',
+ clear_code_run_time=1.0,
+ clear_code_interval=0.03,
+ autocomplete=False,
+ autocomplete_wait_time=0.6,
+ chinese_ime=False,
+ ime_wait_time=0.6,
+ background_color="#23272e",
+ line_highlight_color="#2c313c",
+ end_wait_time=1.0,
+ quality=None,
+ frame_rate=None,
):
- # ----- 视频名称 -----
if not video_name:
raise ValueError("video_name must be provided")
-
- # ----- 代码输入 -----
+
if code[0] == 'string':
self.code_str = code[1].expandtabs(tabsize=DEFAULT_TAB_WIDTH)
if not all(char not in NOT_AVAILABLE_CHARACTERS for char in self.code_str):
@@ -61,31 +100,23 @@ def __init__(self,
raise ValueError(f"'{code[1]}' contains invalid characters")
except UnicodeDecodeError:
raise ValueError(f"Failed to decode '{code[1]}' with UTF-8 encoding") from None
-
- # ----- 行间距 -----
+
if line_spacing <= 0:
raise ValueError("line_spacing must be greater than 0")
- # ----- 打字间隔 -----
- shortest_possible_duration = round(1/config.frame_rate, 7)
- if not all(interval >= shortest_possible_duration for interval in interval_range):
- raise ValueError(f"interval_range must be greater than or equal to {shortest_possible_duration}")
- del shortest_possible_duration
+ min_interval = round(1/config.frame_rate, 7)
+ if not all(interval >= min_interval for interval in interval_range):
+ raise ValueError(f"interval_range must be greater than or equal to {min_interval}")
+ del min_interval
if interval_range[0] > interval_range[1]:
raise ValueError("The first term of interval_range must be less than or equal to the second term")
- # 参数
+ if clear_code_interval <= 0:
+ raise ValueError("clear_code_interval must be greater than 0")
+
global Parameters
- @dataclass
class Parameters:
- code: Union[Tuple[Literal['string'], str], Tuple[Literal['file'], StrPath]]
- language: PygmentsLanguage
- formatter_style: PygmentsFormatterStyle
- line_spacing: Union[float, int]
- interval_range: Tuple[Union[float, int], Union[float, int]]
- camera_scale: Union[float, int]
- video_name: str
- renderer: Literal['cairo', 'opengl']
+ pass
Parameters.code = code
Parameters.language = language
Parameters.formatter_style = formatter_style
@@ -94,29 +125,55 @@ class Parameters:
Parameters.camera_scale = camera_scale
Parameters.video_name = video_name
Parameters.renderer = renderer
+ Parameters.clear_code = clear_code
+ Parameters.clear_code_mode = clear_code_mode
+ Parameters.clear_code_run_time = clear_code_run_time
+ Parameters.clear_code_interval = clear_code_interval
+ Parameters.autocomplete = autocomplete
+ Parameters.autocomplete_wait_time = autocomplete_wait_time
+ Parameters.chinese_ime = chinese_ime
+ Parameters.ime_wait_time = ime_wait_time
+ Parameters.background_color = background_color
+ Parameters.line_highlight_color = line_highlight_color
+ Parameters.end_wait_time = end_wait_time
- # 其他
self.code_str = stripEmptyLines(self.code_str)
self.space_positions = findSpacePositions(self.code_str)
self.empty_line_positions = findEmptyLinePositions(self.code_str)
+ # griddycode settings.gd: completion options = detected functions + detected variables
+ self.detected_symbols = []
+ _seen = set()
+ for _f in detect_functions(self.code_str):
+ if ("function", _f) not in _seen:
+ _seen.add(("function", _f))
+ self.detected_symbols.append((_f, "function"))
+ for _v in detect_variables(self.code_str):
+ if ("variable", _v) not in _seen:
+ _seen.add(("variable", _v))
+ self.detected_symbols.append((_v, "variable"))
self.code_str = replaceMiddleSpacesWithOccupyCharacter("\n".join([" " if line == "" else line for line in self.code_str.splitlines()]))
self.code_str_lines = self.code_str.splitlines()
self.origin_config = {
'disable_caching': config.disable_caching,
- 'renderer': config.renderer
+ 'renderer': config.renderer,
+ 'background_color': config.background_color,
+ 'quality': config.quality,
+ 'frame_rate': config.frame_rate,
}
config.disable_caching = True
config.renderer = renderer
+ config.background_color = background_color
+ if quality is not None:
+ config.quality = quality
+ if frame_rate is not None:
+ config.frame_rate = frame_rate
self.scene = self._create_scene()
def _create_scene(self):
- """Create manim scene to animate code rendering."""
class CameraFollowCursorCVScene(MovingCameraScene):
def construct(scene):
- """Build the code animation scene."""
- # 初始化光标
cursor = RoundedRectangle(
height=DEFAULT_CURSOR_HEIGHT,
width=DEFAULT_CURSOR_WIDTH,
@@ -126,12 +183,11 @@ def construct(scene):
color=WHITE
)
- # 创建代码块
with register_font(os.path.join(os.path.dirname(__file__), 'fonts/CodeVideoRendererFont.ttf')):
line_number_mobject, code_mobject = Code(
code_string=self.code_str + f"\n{(max([len(line.rstrip()) for line in self.code_str_lines])*2)*' ' + OCCUPY_CHARACTER}",
- language=Parameters.language,
- formatter_style=Parameters.formatter_style,
+ language=resolve_language(Parameters.language),
+ formatter_style=Parameters.formatter_style,
paragraph_config={
'font': 'CodeVideoRendererFont',
'line_spacing': Parameters.line_spacing
@@ -139,10 +195,10 @@ def construct(scene):
).submobjects[1:3]
line_number_mobject.set_color(GREY)
- total_line_numbers = len(self.code_str_lines)
- total_char_numbers = len(''.join(line.strip() for line in self.code_str_lines))
+ total_lines = len(self.code_str_lines)
+ total_chars = len(''.join(line.strip() for line in self.code_str_lines))
- # 调整代码对齐(manim内置bug)
+ # Adjust code alignment (manim built-in bug)
offset_lines = []
for line_index, line in enumerate(self.code_str_lines):
if all(check in "acegmnopqrsuvwxyz+,-.:;<=>_~ " for check in line):
@@ -150,69 +206,160 @@ def construct(scene):
code_mobject.shift(DOWN*CODE_OFFSET)
offset_lines.append(line_index)
del line_index, line
-
- # 创建代码行矩形框
+
code_line_rectangle = SurroundingRectangle(
- VGroup(code_mobject[-1], line_number_mobject[-1]), # type: ignore
- color="#333333",
+ VGroup(code_mobject[-1], line_number_mobject[-1]),
+ color=Parameters.line_highlight_color,
fill_opacity=1,
stroke_width=0
).set_y(code_mobject[0].get_y())
- # 处理第一行出现代码偏移时的code_line_rectangle偏移问题
+ # Handle the code_line_rectangle offset when the first line is offset
if 0 in offset_lines:
code_line_rectangle.shift(UP*CODE_OFFSET/2)
-
- # 初始化光标位置
+
cursor.align_to(code_mobject[0], LEFT).set_y(code_line_rectangle.get_y())
- # 适配opengl
if config.renderer == RendererType.OPENGL:
- scene.camera.frame = scene.camera # type: ignore
+ scene.camera.frame = scene.camera
- # 入场动画
target_center = cursor.get_center()
start_center = target_center + UP * 3
- scene.camera.frame.scale(Parameters.camera_scale).move_to(start_center) # type: ignore
+ scene.camera.frame.scale(Parameters.camera_scale).move_to(start_center)
scene.add(code_line_rectangle, line_number_mobject[0].set_color(WHITE), cursor)
scene.play(
- scene.camera.frame.animate.move_to(target_center), # type: ignore[reportArgumentType, reportAttributeAccessIssue]
+ scene.camera.frame.animate.move_to(target_center),
run_time=1,
rate_func=rate_functions.ease_out_cubic
)
-
- # 定义固定动画
- scene.Animation_list: List[Dict[str, Union[Point3D, float]]] = []
+
+ scene.Animation_list = []
def linebreakAnimation():
- scene.Animation_list.append({"move_to": cursor.get_center()})
+ scene.Animation_list.append({"move_to": cursor.get_center() + drift_offset()})
camera_scale = Parameters.camera_scale
+ longest_line = 0
+
+ # griddycode camera.gd idle sway: offset = (sin(d*speed)*radius, cos(d*speed)*radius)
+ drift_time = [0.0]
+ def drift_offset():
+ radius = scene.camera.frame.height * 0.1
+ t = drift_time[0]
+ return np.array([np.sin(t * 2.0) * radius, np.cos(t * 2.0) * radius, 0.0])
+
def JUDGE_cameraScaleAnimation():
nonlocal camera_scale
- distance = (scene.camera.frame.get_x() - line_number_mobject.get_x()) / 14.22 # type: ignore
- if distance > camera_scale:
- scene.Animation_list.append({"scale": distance/camera_scale})
- camera_scale = distance
+ # griddycode camera.gd: zoom = clamp(10 - (chars + 1) / SCALE, 1, 10), SCALE = 7
+ godot_zoom = max(1.0, min(10.0, 10.0 - (longest_line + 1) / 7.0))
+ target_scale = Parameters.camera_scale * 10.0 / godot_zoom
+ if target_scale != camera_scale:
+ scene.Animation_list.append({"scale": target_scale / camera_scale})
+ camera_scale = target_scale
def playAnimation(**kwargs):
if scene.Animation_list:
- cameraAnimation = scene.camera.frame.animate # type: ignore
+ camera_anim = scene.camera.frame.animate
for anim in scene.Animation_list:
if "move_to" in anim:
- cameraAnimation.move_to(anim["move_to"])
+ camera_anim.move_to(anim["move_to"])
elif "scale" in anim:
- cameraAnimation.scale(anim["scale"])
-
- scene.play(cameraAnimation, **kwargs)
+ camera_anim.scale(anim["scale"])
+
+ scene.play(camera_anim, **kwargs)
+ drift_time[0] += kwargs.get("run_time", 0.0)
scene.Animation_list.clear()
- del cameraAnimation
+ del camera_anim
+
+ typed_mobjects = []
+
+ font_path = os.path.join(os.path.dirname(__file__), 'fonts/CodeVideoRendererFont.ttf')
+
+ # griddycode settings.gd: completion options = detected functions + detected variables
+ completion_symbols = self.detected_symbols
+
+ def showAutocomplete(keyword):
+ matches = [(n, k) for n, k in completion_symbols if n == keyword]
+ if not matches:
+ return
+ with register_font(font_path):
+ rows = []
+ for name, kind in matches:
+ icon = ImageMobject(AUTOCOMPLETE_ICONS[kind]).scale_to_fit_height(0.3)
+ display = name + "()" if kind == "function" else name
+ label_t = Text(display, font="CodeVideoRendererFont", font_size=20, color=AUTOCOMPLETE_COLORS[kind])
+ icon.next_to(label_t, LEFT, buff=0.14)
+ rows.append(Group(icon, label_t))
+
+ # griddycode completion box: icon + colored name per row, no detail column.
+ pad_x, pad_y = 0.25, 0.15
+ row_buff = 0.1
+ max_row_w = max(r.width for r in rows)
+ row_h = max(r.height for r in rows)
+ row_step = row_h + row_buff
+
+ box = RoundedRectangle(
+ width=max_row_w + 2 * pad_x,
+ height=row_step * len(rows) + 2 * pad_y,
+ corner_radius=0.06,
+ color=_SUGGEST_BG, fill_color=_SUGGEST_BG, fill_opacity=1, stroke_width=0,
+ )
+
+ content_left = box.get_left()[0] + pad_x
+ top_y = box.get_top()[1] - pad_y
+ for i, row in enumerate(rows):
+ row_y = top_y - row_step * i - row_h / 2
+ row.move_to([content_left + row.width / 2, row_y, 0])
+
+ # first row is selected (completion_selected_color)
+ sel = Rectangle(
+ width=box.width - 0.1, height=row_step,
+ color=_SUGGEST_SELECTED_BG, fill_opacity=1, stroke_width=0,
+ ).move_to([box.get_x(), rows[0].get_y(), 0])
+
+ popup = Group(box, sel, *rows)
+ popup.next_to(cursor, DOWN, buff=0.3)
+
+ scene.add(popup)
+ scene.play(FadeIn(popup), run_time=0.12)
+ scene.wait(Parameters.autocomplete_wait_time)
+ scene.play(FadeOut(popup), run_time=0.12)
+
+ def showIme(pinyin, candidates):
+ if not candidates:
+ return
+ with register_font(font_path):
+ py_t = Text(pinyin, font="CodeVideoRendererFont", font_size=18, color="#9CDCFE")
+ cands = [Text(c, font="CodeVideoRendererFont", font_size=20, color="#808080") for c in candidates]
+ cands[0].set_color(_SUGGEST_FG)
+
+ cand_row = VGroup(*cands).arrange(RIGHT, aligned_edge=UP, buff=0.22)
+ inner_w = max(py_t.get_width(), cand_row.get_width())
+ bar = Rectangle(width=inner_w, height=0.03, fill_color=_SUGGEST_BORDER, fill_opacity=1, stroke_width=0)
+ content = VGroup(py_t, bar, cand_row).arrange(DOWN, aligned_edge=LEFT, buff=0.12)
+
+ hl = Rectangle(
+ width=cands[0].width + 0.18, height=cands[0].height + 0.12,
+ color=_SUGGEST_SELECTED_BG, fill_opacity=1, stroke_width=0,
+ ).move_to(cands[0])
+
+ box = RoundedRectangle(
+ width=inner_w + 0.44, height=content.height + 0.3,
+ corner_radius=0.06,
+ color=_SUGGEST_BORDER, fill_color=_SUGGEST_BG, fill_opacity=1, stroke_width=1,
+ )
+ popup = VGroup(box, hl, py_t, bar, cand_row)
+ popup.next_to(cursor, DOWN, buff=0.3)
+
+ scene.add(popup)
+ scene.play(FadeIn(popup), run_time=0.12)
+ scene.wait(Parameters.ime_wait_time)
+ scene.play(FadeOut(popup), run_time=0.12)
with copy(DefaultProgressBar(self.output)) as progress:
- total_progress = progress.add_task(description="[yellow]Total[/yellow]", total=total_char_numbers)
+ total_progress = progress.add_task(description="[yellow]Total[/yellow]", total=total_chars)
- # 遍历代码行
- for line in range(total_line_numbers):
+ for line in range(total_lines):
line_number_mobject.set_color(GREY)
line_number_mobject[line].set_color(WHITE)
@@ -221,7 +368,7 @@ def playAnimation(**kwargs):
current_line_progress = progress.add_task(description=f"[green]Line {line+1}[/green]", total=char_num)
code_line_rectangle.set_y(code_mobject[line].get_y())
- # 处理出现代码偏移时的code_line_rectangle偏移问题
+ # Handle the code_line_rectangle offset when the line is offset
if line in offset_lines:
code_line_rectangle.shift(UP*CODE_OFFSET/2)
scene.add(line_number_mobject[line])
@@ -232,77 +379,116 @@ def playAnimation(**kwargs):
JUDGE_cameraScaleAnimation()
playAnimation(run_time=DEFAULT_LINE_BREAK_RUN_TIME)
- # 如果当前行为空行,跳过
if line in self.empty_line_positions:
progress.remove_task(current_line_progress)
continue
-
- first_non_space_index = len(self.code_str_lines[line]) - len(self.code_str_lines[line].lstrip())
- total_typing_chars = char_num # 当前行实际要打的字数
-
- # 遍历当前行的每个字符
- submobjects_char_index = 0
- for column in range(first_non_space_index, char_num + first_non_space_index):
- # 处理manim==0.19.1更新出现的空格消失问题
+
+ indent = len(self.code_str_lines[line]) - len(self.code_str_lines[line].lstrip())
+
+ # Compute the autocomplete trigger point for this line (the moment the symbol is fully typed)
+ char_idx = 0
+ for column in range(indent, char_num + indent):
+ # Handle the disappearing-space issue introduced in manim==0.19.1
if not self.code_str_lines[line][column].isspace():
if [line, column] not in self.space_positions:
- scene.add(code_mobject[line][submobjects_char_index])
- submobjects_char_index += 1
+ scene.add(code_mobject[line][char_idx])
+ typed_mobjects.append(code_mobject[line][char_idx])
+ char_idx += 1
cursor.next_to(
- code_mobject[line][submobjects_char_index-1],
+ code_mobject[line][char_idx-1],
RIGHT,
buff=DEFAULT_CURSOR_TO_CHAR_BUFFER
).set_y(code_line_rectangle.get_y())
-
- # 相机持续摆动逻辑
+
+ # griddycode tracks the longest line as it is typed so the
+ # camera zoom reacts in real time (camera.gd get_longest_line)
+ longest_line = max(longest_line, column + 1)
+
line_break = False
- if column == first_non_space_index and first_non_space_index != 0:
- # 如果是缩进后的第一个字符,先执行换行归位
+ if column == indent and indent != 0:
+ # If this is the first character after indentation, perform the line-break reset first
linebreakAnimation()
line_break = True
else:
- # 计算当前行的进度 (0.0 -> 1.0)
- current_idx = column - first_non_space_index
- max_idx = total_typing_chars - 1
-
- if max_idx > 0:
- alpha = current_idx / max_idx
- else:
- alpha = 1.0
-
- # 包络线 sin(alpha * pi),确保头尾为0
- envelope = np.sin(alpha * np.pi)
-
- # 振荡项: sin(alpha * omega)
- wave_count = total_typing_chars / 15
- omega = wave_count * 2 * np.pi
- oscillation = np.sin(alpha * omega)
-
- # 振幅为相机框高度的 2.5%
- amplitude = scene.camera.frame.height * 0.025 # type: ignore
- offset_y = amplitude * envelope * oscillation
-
- target_pos = cursor.get_center() + UP * offset_y
- scene.Animation_list.append({"move_to": target_pos})
-
- # 缩放检测 & 播放
+ scene.Animation_list.append({"move_to": cursor.get_center() + drift_offset()})
+
JUDGE_cameraScaleAnimation()
playAnimation(
run_time=DEFAULT_LINE_BREAK_RUN_TIME if line_break else random.uniform(*Parameters.interval_range),
rate_func=rate_functions.smooth if line_break else rate_functions.linear
)
- # 输出进度
progress.advance(total_progress, advance=1)
progress.advance(current_line_progress, advance=1)
+ # griddycode settings.gd: completion shows once a detected symbol name is fully typed
+ if Parameters.autocomplete:
+ end = column + 1
+ start = column
+ while start >= 0 and (self.code_str_lines[line][start].isalnum() or self.code_str_lines[line][start] == "_"):
+ start -= 1
+ start += 1
+ word = self.code_str_lines[line][start:end]
+ nxt = self.code_str_lines[line][end] if end < len(self.code_str_lines[line]) else ""
+ if word and (nxt == "" or not (nxt.isalnum() or nxt == "_")):
+ for name, _kind in completion_symbols:
+ if word == name:
+ showAutocomplete(name)
+ break
+
+ # Chinese character typed: show the IME candidate box (triggered on the first char of each CJK run)
+ if Parameters.chinese_ime:
+ ch = self.code_str_lines[line][column]
+ if is_cjk(ch):
+ prev_ch = self.code_str_lines[line][column - 1] if column > 0 else ""
+ if not is_cjk(prev_ch):
+ pinyin, cands = get_ime(self.code_str_lines[line], column)
+ if cands:
+ showIme(pinyin, cands)
+
progress.remove_task(current_line_progress)
progress.remove_task(total_progress)
- scene.wait()
+ if Parameters.clear_code:
+ # Pull the camera back to the full code view and hold it still before
+ # deleting; otherwise the camera stays at the last character during
+ # backspace deletion and looks like it is "deleting along with the code"
+ frame = scene.camera.frame
+ code_center = code_mobject.get_center()
+ fit_h = code_mobject.get_height() * 1.4 + 1.5
+ fit_w = code_mobject.get_width() * 1.4 + 1.5
+ aspect = frame.get_width() / frame.get_height()
+ need_h = max(fit_h, fit_w / aspect)
+ scene.play(
+ frame.animate.move_to(code_center).set_height(need_h),
+ run_time=0.6,
+ rate_func=rate_functions.ease_in_out_cubic,
+ )
+
+ if Parameters.clear_code_mode == "backspace":
+ # Delete character by character in reverse, like pressing backspace
+ for mobject in reversed(typed_mobjects):
+ cursor.next_to(mobject, RIGHT, buff=DEFAULT_CURSOR_TO_CHAR_BUFFER).set_y(code_line_rectangle.get_y())
+ scene.play(
+ FadeOut(mobject),
+ run_time=Parameters.clear_code_interval,
+ rate_func=rate_functions.linear
+ )
+ scene.play(
+ FadeOut(VGroup(line_number_mobject, cursor, code_line_rectangle)),
+ run_time=Parameters.clear_code_run_time,
+ rate_func=rate_functions.ease_in_out_cubic
+ )
+ else:
+ scene.play(
+ FadeOut(VGroup(code_mobject, line_number_mobject, cursor, code_line_rectangle)),
+ run_time=Parameters.clear_code_run_time,
+ rate_func=rate_functions.ease_in_out_cubic
+ )
+
+ scene.wait(Parameters.end_wait_time)
def render(scene):
- """Override render to add timing log."""
if self.output:
DEFAULT_OUTPUT_CONSOLE.log(f"Start rendering {Parameters.video_name}.mp4.")
DEFAULT_OUTPUT_CONSOLE.log("Start rendering CameraFollowCursorCVScene. [dim](by manim)[/]")
@@ -311,26 +497,26 @@ def render(scene):
else:
DEFAULT_OUTPUT_CONSOLE.log('[blue]Currently using GPU (OpenGL Renderer) for rendering.[/]')
DEFAULT_OUTPUT_CONSOLE.log("Manim's config has been modified.")
-
- # 渲染并计算时间
+
with noManimOutput():
total_render_time = timeit(super().render, number=1)
if self.output:
DEFAULT_OUTPUT_CONSOLE.log(f"Successfully rendered CameraFollowCursorCVScene in {total_render_time:,.2f} seconds. [dim](by manim)[/]")
del total_render_time
- # 恢复配置
config.disable_caching = self.origin_config['disable_caching']
config.renderer = self.origin_config['renderer']
+ config.background_color = self.origin_config['background_color']
+ config.quality = self.origin_config['quality']
+ config.frame_rate = self.origin_config['frame_rate']
if self.output:
DEFAULT_OUTPUT_CONSOLE.log("Manim's config has been restored.")
del self.origin_config
if self.output:
DEFAULT_OUTPUT_CONSOLE.log(f"Start adding glow effect to CameraFollowCursorCVScene.mp4. [dim](by moviepy)[/]\n")
- # 添加发光效果
- input_path = str(scene.renderer.file_writer.movie_file_path)
- output_path = '\\'.join(input_path.split('\\')[:-1]) + rf'\{Parameters.video_name}.mp4'
+ input_path = Path(scene.renderer.file_writer.movie_file_path)
+ output_path = str(input_path.with_name(f"{Parameters.video_name}.mp4"))
total_effect_time = timeit(lambda: addGlowEffect(input_path=input_path, output_path=output_path, output=self.output), number=1)
if self.output:
DEFAULT_OUTPUT_CONSOLE.log(f"Successfully added glow effect in {total_effect_time:,.2f} seconds. [dim](by moviepy)[/]")
@@ -338,55 +524,26 @@ def render(scene):
del input_path, output_path, total_effect_time
return CameraFollowCursorCVScene()
-
- @typechecked
- def render(self, output: bool = DEFAULT_OUTPUT_VALUE):
- """
- Render the animated code video.
-
- This method triggers the full rendering pipeline:
-
- 1. **Manim rendering** – the code typing animation is rendered using the configured backend (Cairo or OpenGL).
- 2. **Glow effect** – a soft glow post-processing effect is applied to the raw video via MoviePy.
-
- The final video file is saved next to Manim's default output path with the name specified by ``video_name``.
- Args:
- output (bool): Whether to print progress messages and timing logs to the console during rendering. Defaults to :data:`~.DEFAULT_OUTPUT_VALUE`.
-
- Returns:
- None
-
- Example:
- >>> video = CameraFollowCursorCV(
- ... code=('string', 'print("Hello")'),
- ... language='python',
- ... video_name='HelloWorld'
- ... )
- >>> video.render()
-
- Note:
- The final MP4 file is typically located at ``./media/videos/1080p60/{video_name}.mp4``
- (the exact sub-directory depends on Manim's quality configuration).
- """
+ def render(self, output=DEFAULT_OUTPUT_VALUE):
self.output = output
self.scene.render()
-
+
def __getattribute__(self, name):
frames = inspect.stack()
is_internal_call = False
-
+
for frame in frames[1:]:
frame_self = frame.frame.f_locals.get('self')
if isinstance(frame_self, CameraFollowCursorCV):
is_internal_call = True
break
-
+
if not is_internal_call:
allowed_attrs = super().__getattribute__("__all__")
if name not in allowed_attrs:
raise AttributeError(f"'CameraFollowCursorCV' object has no attribute '{name}'")
-
+
return super().__getattribute__(name)
-__all__ = ["CameraFollowCursorCV"]
\ No newline at end of file
+__all__ = ["CameraFollowCursorCV"]
diff --git a/CodeVideoRenderer/typing.py b/CodeVideoRenderer/typing.py
index 9f3c105..864eff7 100644
--- a/CodeVideoRenderer/typing.py
+++ b/CodeVideoRenderer/typing.py
@@ -1,53 +1,11 @@
-from __future__ import annotations
from typing import Literal, Union
from os import PathLike
-try:
- from typing import TypeAlias
-except ImportError:
- from typing_extensions import TypeAlias
+PygmentsLanguage = Literal['abap', 'amdgpu', 'apl', 'abnf', 'actionscript3', 'as3', 'actionscript', 'as', 'ada', 'ada95', 'ada2005', 'adl', 'agda', 'aheui', 'alloy', 'ambienttalk', 'ambienttalk/2', 'at', 'ampl', 'html+ng2', 'ng2', 'antlr-actionscript', 'antlr-as', 'antlr-csharp', 'antlr-c#', 'antlr-cpp', 'antlr-java', 'antlr', 'antlr-objc', 'antlr-perl', 'antlr-python', 'antlr-ruby', 'antlr-rb', 'apacheconf', 'aconf', 'apache', 'applescript', 'arduino', 'arrow', 'arturo', 'art', 'asc', 'pem', 'asn1', 'aspectj', 'asymptote', 'asy', 'augeas', 'autoit', 'autohotkey', 'ahk', 'awk', 'gawk', 'mawk', 'nawk', 'bbcbasic', 'bbcode', 'bc', 'bqn', 'bst', 'bst-pybtex', 'bare', 'basemake', 'bash', 'sh', 'ksh', 'zsh', 'shell', 'openrc', 'console', 'shell-session', 'batch', 'bat', 'dosbatch', 'winbatch', 'bdd', 'befunge', 'berry', 'be', 'bibtex', 'bib', 'blitzbasic', 'b3d', 'bplus', 'blitzmax', 'bmax', 'blueprint', 'bnf', 'boa', 'boo', 'boogie', 'brainfuck', 'bf', 'bugs', 'winbugs', 'openbugs', 'camkes', 'idl4', 'c', 'cmake', 'c-objdump', 'cpsa', 'css+ul4', 'aspx-cs', 'csharp', 'c#', 'cs', 'ca65', 'cadl', 'capdl', 'capnp', 'carbon', 'cbmbas', 'cddl', 'ceylon', 'cfengine3', 'cf3', 'chaiscript', 'chai', 'chapel', 'chpl', 'charmci', 'html+cheetah', 'html+spitfire', 'htmlcheetah', 'javascript+cheetah', 'js+cheetah', 'javascript+spitfire', 'js+spitfire', 'cheetah', 'spitfire', 'xml+cheetah', 'xml+spitfire', 'cirru', 'clay', 'clean', 'clojure', 'clj', 'clojurescript', 'cljs', 'cobolfree', 'cobol', 'codeql', 'ql', 'coffeescript', 'coffee-script', 'coffee', 'cfc', 'cfm', 'cfs', 'comal', 'comal80', 'common-lisp', 'cl', 'lisp', 'componentpascal', 'cp', 'coq', 'cplint', 'cpp', 'c++', 'cpp-objdump', 'c++-objdumb', 'cxx-objdump', 'crmsh', 'pcmk', 'croc', 'cryptol', 'cry', 'cr', 'crystal', 'csound-document', 'csound-csd', 'csound', 'csound-orc', 'csound-score', 'csound-sco', 'css+django', 'css+jinja', 'css+ruby', 'css+erb', 'css+genshitext', 'css+genshi', 'css', 'css+php', 'css+smarty', 'cuda', 'cu', 'cypher', 'cython', 'pyx', 'pyrex', 'd', 'd-objdump', 'dpatch', 'dart', 'dasm16', 'dax', 'debcontrol', 'control', 'debian.sources', 'delphi', 'pas', 'pascal', 'objectpascal', 'desktop', 'devicetree', 'dts', 'dg', 'diff', 'udiff', 'django', 'jinja', 'zone', 'docker', 'dockerfile', 'dtd', 'duel', 'jbst', 'jsonml+bst', 'dylan-console', 'dylan-repl', 'dylan', 'dylan-lid', 'lid', 'ecl', 'ec', 'earl-grey', 'earlgrey', 'eg', 'easytrieve', 'ebnf', 'eiffel', 'iex', 'elixir', 'ex', 'exs', 'elm', 'elpi', 'emacs-lisp', 'elisp', 'emacs', 'email', 'eml', 'erb', 'erlang', 'erl', 'html+evoque', 'evoque', 'xml+evoque', 'execline', 'ezhil', 'fsharp', 'f#', 'fstar', 'factor', 'fancy', 'fy', 'fan', 'felix', 'flx', 'fennel', 'fnl', 'fift', 'fif', 'fish', 'fishshell', 'flatline', 'floscript', 'flo', 'forth', 'fortranfixed', 'fortran', 'f90', 'foxpro', 'vfp', 'clipper', 'xbase', 'freefem', 'func', 'fc', 'futhark', 'gap-console', 'gap-repl', 'gap', 'gdscript', 'gd', 'glsl', 'gsql', 'gas', 'asm', 'gcode', 'genshi', 'kid', 'xml+genshi', 'xml+kid', 'genshitext', 'pot', 'po', 'gherkin', 'cucumber', 'gleam', 'gnuplot', 'go', 'golang', 'golo', 'gooddata-cl', 'googlesql', 'zetasql', 'gosu', 'gst', 'graphql', 'graphviz', 'dot', 'groff', 'nroff', 'man', 'groovy', 'hlsl', 'html+ul4', 'haml', 'html+handlebars', 'handlebars', 'hare', 'haskell', 'hs', 'haxe', 'hxsl', 'hx', 'hexdump', 'hsail', 'hsa', 'hspec', 'html+django', 'html+jinja', 'htmldjango', 'html+genshi', 'html+kid', 'html', 'html+php', 'html+smarty', 'http', 'haxeml', 'hxml', 'hylang', 'hy', 'hybris', 'idl', 'icon', 'idris', 'idr', 'igor', 'igorpro', 'inform6', 'i6', 'i6t', 'inform7', 'i7', 'ini', 'cfg', 'dosini', 'io', 'ioke', 'ik', 'irc', 'isabelle', 'j', 'jmespath', 'jp', 'jslt', 'jags', 'janet', 'jasmin', 'jasminxt', 'java', 'javascript+django', 'js+django', 'javascript+jinja', 'js+jinja', 'javascript+ruby', 'js+ruby', 'javascript+erb', 'js+erb', 'js+genshitext', 'js+genshi', 'javascript+genshitext', 'javascript+genshi', 'javascript', 'js', 'javascript+php', 'js+php', 'javascript+smarty', 'js+smarty', 'js+ul4', 'jcl', 'jsgf', 'json5', 'jsonld', 'json-ld', 'json', 'json-object', 'jsonnet', 'jsp', 'jsx', 'react', 'jlcon', 'julia-repl', 'julia', 'jl', 'juttle', 'k', 'kal', 'kconfig', 'menuconfig', 'linux-config', 'kernel-config', 'kmsg', 'dmesg', 'koka', 'kotlin', 'kuin', 'kql', 'kusto', 'lsl', 'css+lasso', 'html+lasso', 'javascript+lasso', 'js+lasso', 'lasso', 'lassoscript', 'xml+lasso', 'ldapconf', 'ldaprc', 'ldif', 'lean', 'lean3', 'lean4', 'less', 'lighttpd', 'lighty', 'lilypond', 'limbo', 'liquid', 'literate-agda', 'lagda', 'literate-cryptol', 'lcryptol', 'lcry', 'literate-haskell', 'lhaskell', 'lhs', 'literate-idris', 'lidris', 'lidr', 'livescript', 'live-script', 'llvm', 'llvm-mir-body', 'llvm-mir', 'logos', 'logtalk', 'lua', 'luau', 'mcfunction', 'mcf', 'mcschema', 'mime', 'mips', 'moocode', 'moo', 'doscon', 'macaulay2', 'make', 'makefile', 'mf', 'bsdmake', 'css+mako', 'html+mako', 'javascript+mako', 'js+mako', 'mako', 'xml+mako', 'maple', 'maql', 'markdown', 'md', 'mask', 'mason', 'mathematica', 'mma', 'nb', 'matlab', 'matlabsession', 'maxima', 'macsyma', 'meson', 'meson.build', 'minid', 'miniscript', 'ms', 'modelica', 'modula2', 'm2', 'trac-wiki', 'moin', 'mojo', '🔥', 'monkey', 'monte', 'moonscript', 'moon', 'mosel', 'css+mozpreproc', 'mozhashpreproc', 'javascript+mozpreproc', 'mozpercentpreproc', 'xul+mozpreproc', 'mql', 'mq4', 'mq5', 'mql4', 'mql5', 'mscgen', 'msc', 'mupad', 'mxml', 'mysql', 'css+myghty', 'html+myghty', 'javascript+myghty', 'js+myghty', 'myghty', 'xml+myghty', 'ncl', 'nsis', 'nsi', 'nsh', 'nasm', 'objdump-nasm', 'nemerle', 'nesc', 'nestedtext', 'nt', 'newlisp', 'newspeak', 'nginx', 'nimrod', 'nim', 'nit', 'nixos', 'nix', 'nodejsrepl', 'notmuch', 'nusmv', 'numpy', 'numba_ir', 'numbair', 'objdump', 'objective-c', 'objectivec', 'obj-c', 'objc', 'objective-c++', 'objectivec++', 'obj-c++', 'objc++', 'objective-j', 'objectivej', 'obj-j', 'objj', 'ocaml', 'octave', 'odin', 'omg-idl', 'ooc', 'opa', 'openedge', 'abl', 'progress', 'openscad', 'org', 'orgmode', 'org-mode', 'output', 'pacmanconf', 'pan', 'parasail', 'pawn', 'pddl', 'peg', 'perl6', 'pl6', 'raku', 'perl', 'pl', 'phix', 'php', 'php3', 'php4', 'php5', 'pig', 'pike', 'pkgconfig', 'plpgsql', 'pointless', 'pony', 'portugol', 'postscript', 'postscr', 'psql', 'postgresql-console', 'postgres-console', 'postgres-explain', 'postgresql', 'postgres', 'pov', 'powershell', 'pwsh', 'posh', 'ps1', 'psm1', 'pwsh-session', 'ps1con', 'praat', 'procfile', 'prolog', 'promql', 'promela', 'properties', 'jproperties', 'protobuf', 'proto', 'prql', 'psysh', 'ptx', 'pug', 'jade', 'puppet', 'pypylog', 'pypy', 'python2', 'py2', 'py2tb', 'pycon', 'python-console', 'python', 'py', 'sage', 'python3', 'py3', 'bazel', 'starlark', 'pyi', 'pytb', 'py3tb', 'py+ul4', 'qbasic', 'basic', 'q', 'qvto', 'qvt', 'qlik', 'qlikview', 'qliksense', 'qlikscript', 'qml', 'qbs', 'rconsole', 'rout', 'rng-compact', 'rnc', 'spec', 'racket', 'rkt', 'ragel-c', 'ragel-cpp', 'ragel-d', 'ragel-em', 'ragel-java', 'ragel', 'ragel-objc', 'ragel-ruby', 'ragel-rb', 'rd', 'reasonml', 'reason', 'rebol', 'red', 'red/system', 'redcode', 'registry', 'rego', 'resourcebundle', 'resource', 'rexx', 'arexx', 'rhtml', 'html+erb', 'html+ruby', 'ride', 'rita', 'roboconf-graph', 'roboconf-instances', 'robotframework', 'rql', 'rsl', 'restructuredtext', 'rst', 'rest', 'trafficscript', 'rts', 'rbcon', 'irb', 'ruby', 'rb', 'duby', 'rust', 'rs', 'sas', 'splus', 's', 'r', 'sml', 'snbt', 'sarl', 'sass', 'savi', 'scala', 'scaml', 'scdoc', 'scd', 'scheme', 'scm', 'scilab', 'scss', 'sed', 'gsed', 'ssed', 'shexc', 'shex', 'shen', 'sieve', 'silver', 'singularity', 'slash', 'slim', 'slurm', 'sbatch', 'smali', 'smalltalk', 'squeak', 'st', 'sgf', 'smarty', 'smithy', 'snobol', 'snowball', 'solidity', 'androidbp', 'bp', 'soong', 'sophia', 'sp', 'debsources', 'sourceslist', 'sources.list', 'sparql', 'spice', 'spicelang', 'sql+jinja', 'sql', 'sqlite3', 'squidconf', 'squid.conf', 'squid', 'srcinfo', 'ssp', 'stan', 'stata', 'do', 'supercollider', 'sc', 'swift', 'swig', 'systemverilog', 'sv', 'systemd', 'tap', 'tnt', 'toml', 'tablegen', 'td', 'tact', 'tads3', 'tal', 'uxntal', 'tasm', 'tcl', 'tcsh', 'csh', 'tcshcon', 'tea', 'teal', 'teratermmacro', 'teraterm', 'ttl', 'termcap', 'terminfo', 'terraform', 'tf', 'hcl', 'tex', 'latex', 'text', 'ti', 'thingsdb', 'thrift', 'tid', 'tlb', 'tls', 'todotxt', 'tsql', 't-sql', 'treetop', 'tsx', 'turtle', 'html+twig', 'twig', 'typescript', 'ts', 'typoscriptcssdata', 'typoscripthtmldata', 'typoscript', 'typst', 'ul4', 'ucode', 'unicon', 'unixconfig', 'linuxconfig', 'urbiscript', 'urlencoded', 'usd', 'usda', 'vbscript', 'vcl', 'vclsnippets', 'vclsnippet', 'vctreestatus', 'vgl', 'vala', 'vapi', 'aspx-vb', 'vb.net', 'vbnet', 'lobas', 'oobas', 'sobas', 'visual-basic', 'visualbasic', 'html+velocity', 'velocity', 'xml+velocity', 'verifpal', 'verilog', 'v', 'vhdl', 'vim', 'visualprologgrammar', 'visualprolog', 'vue', 'vyper', 'wdiff', 'wast', 'wat', 'webidl', 'wgsl', 'whiley', 'wikitext', 'mediawiki', 'wowtoc', 'wren', 'x10', 'xten', 'xml+ul4', 'xquery', 'xqy', 'xq', 'xql', 'xqm', 'xml+django', 'xml+jinja', 'xml+ruby', 'xml+erb', 'xml', 'xml+php', 'xml+smarty', 'xorg.conf', 'xpp', 'x++', 'xslt', 'xtend', 'extempore', 'yaml+jinja', 'salt', 'sls', 'yaml', 'yang', 'yara', 'yar', 'zeek', 'bro', 'zephir', 'zig', 'ansys', 'apdl']
-PygmentsLanguage: TypeAlias = Literal['abap', 'amdgpu', 'apl', 'abnf', 'actionscript3', 'as3', 'actionscript', 'as', 'ada', 'ada95', 'ada2005', 'adl', 'agda', 'aheui', 'alloy', 'ambienttalk', 'ambienttalk/2', 'at', 'ampl', 'html+ng2', 'ng2', 'antlr-actionscript', 'antlr-as', 'antlr-csharp', 'antlr-c#', 'antlr-cpp', 'antlr-java', 'antlr', 'antlr-objc', 'antlr-perl', 'antlr-python', 'antlr-ruby', 'antlr-rb', 'apacheconf', 'aconf', 'apache', 'applescript', 'arduino', 'arrow', 'arturo', 'art', 'asc', 'pem', 'asn1', 'aspectj', 'asymptote', 'asy', 'augeas', 'autoit', 'autohotkey', 'ahk', 'awk', 'gawk', 'mawk', 'nawk', 'bbcbasic', 'bbcode', 'bc', 'bqn', 'bst', 'bst-pybtex', 'bare', 'basemake', 'bash', 'sh', 'ksh', 'zsh', 'shell', 'openrc', 'console', 'shell-session', 'batch', 'bat', 'dosbatch', 'winbatch', 'bdd', 'befunge', 'berry', 'be', 'bibtex', 'bib', 'blitzbasic', 'b3d', 'bplus', 'blitzmax', 'bmax', 'blueprint', 'bnf', 'boa', 'boo', 'boogie', 'brainfuck', 'bf', 'bugs', 'winbugs', 'openbugs', 'camkes', 'idl4', 'c', 'cmake', 'c-objdump', 'cpsa', 'css+ul4', 'aspx-cs', 'csharp', 'c#', 'cs', 'ca65', 'cadl', 'capdl', 'capnp', 'carbon', 'cbmbas', 'cddl', 'ceylon', 'cfengine3', 'cf3', 'chaiscript', 'chai', 'chapel', 'chpl', 'charmci', 'html+cheetah', 'html+spitfire', 'htmlcheetah', 'javascript+cheetah', 'js+cheetah', 'javascript+spitfire', 'js+spitfire', 'cheetah', 'spitfire', 'xml+cheetah', 'xml+spitfire', 'cirru', 'clay', 'clean', 'clojure', 'clj', 'clojurescript', 'cljs', 'cobolfree', 'cobol', 'codeql', 'ql', 'coffeescript', 'coffee-script', 'coffee', 'cfc', 'cfm', 'cfs', 'comal', 'comal80', 'common-lisp', 'cl', 'lisp', 'componentpascal', 'cp', 'coq', 'cplint', 'cpp', 'c++', 'cpp-objdump', 'c++-objdumb', 'cxx-objdump', 'crmsh', 'pcmk', 'croc', 'cryptol', 'cry', 'cr', 'crystal', 'csound-document', 'csound-csd', 'csound', 'csound-orc', 'csound-score', 'csound-sco', 'css+django', 'css+jinja', 'css+ruby', 'css+erb', 'css+genshitext', 'css+genshi', 'css', 'css+php', 'css+smarty', 'cuda', 'cu', 'cypher', 'cython', 'pyx', 'pyrex', 'd', 'd-objdump', 'dpatch', 'dart', 'dasm16', 'dax', 'debcontrol', 'control', 'debian.sources', 'delphi', 'pas', 'pascal', 'objectpascal', 'desktop', 'devicetree', 'dts', 'dg', 'diff', 'udiff', 'django', 'jinja', 'zone', 'docker', 'dockerfile', 'dtd', 'duel', 'jbst', 'jsonml+bst', 'dylan-console', 'dylan-repl', 'dylan', 'dylan-lid', 'lid', 'ecl', 'ec', 'earl-grey', 'earlgrey', 'eg', 'easytrieve', 'ebnf', 'eiffel', 'iex', 'elixir', 'ex', 'exs', 'elm', 'elpi', 'emacs-lisp', 'elisp', 'emacs', 'email', 'eml', 'erb', 'erlang', 'erl', 'html+evoque', 'evoque', 'xml+evoque', 'execline', 'ezhil', 'fsharp', 'f#', 'fstar', 'factor', 'fancy', 'fy', 'fan', 'felix', 'flx', 'fennel', 'fnl', 'fift', 'fif', 'fish', 'fishshell', 'flatline', 'floscript', 'flo', 'forth', 'fortranfixed', 'fortran', 'f90', 'foxpro', 'vfp', 'clipper', 'xbase', 'freefem', 'func', 'fc', 'futhark', 'gap-console', 'gap-repl', 'gap', 'gdscript', 'gd', 'glsl', 'gsql', 'gas', 'asm', 'gcode', 'genshi', 'kid', 'xml+genshi', 'xml+kid', 'genshitext', 'pot', 'po', 'gherkin', 'cucumber', 'gleam', 'gnuplot', 'go', 'golang', 'golo', 'gooddata-cl', 'googlesql', 'zetasql', 'gosu', 'gst', 'graphql', 'graphviz', 'dot', 'groff', 'nroff', 'man', 'groovy', 'hlsl', 'html+ul4', 'haml', 'html+handlebars', 'handlebars', 'hare', 'haskell', 'hs', 'haxe', 'hxsl', 'hx', 'hexdump', 'hsail', 'hsa', 'hspec', 'html+django', 'html+jinja', 'htmldjango', 'html+genshi', 'html+kid', 'html', 'html+php', 'html+smarty', 'http', 'haxeml', 'hxml', 'hylang', 'hy', 'hybris', 'idl', 'icon', 'idris', 'idr', 'igor', 'igorpro', 'inform6', 'i6', 'i6t', 'inform7', 'i7', 'ini', 'cfg', 'dosini', 'io', 'ioke', 'ik', 'irc', 'isabelle', 'j', 'jmespath', 'jp', 'jslt', 'jags', 'janet', 'jasmin', 'jasminxt', 'java', 'javascript+django', 'js+django', 'javascript+jinja', 'js+jinja', 'javascript+ruby', 'js+ruby', 'javascript+erb', 'js+erb', 'js+genshitext', 'js+genshi', 'javascript+genshitext', 'javascript+genshi', 'javascript', 'js', 'javascript+php', 'js+php', 'javascript+smarty', 'js+smarty', 'js+ul4', 'jcl', 'jsgf', 'json5', 'jsonld', 'json-ld', 'json', 'json-object', 'jsonnet', 'jsp', 'jsx', 'react', 'jlcon', 'julia-repl', 'julia', 'jl', 'juttle', 'k', 'kal', 'kconfig', 'menuconfig', 'linux-config', 'kernel-config', 'kmsg', 'dmesg', 'koka', 'kotlin', 'kuin', 'kql', 'kusto', 'lsl', 'css+lasso', 'html+lasso', 'javascript+lasso', 'js+lasso', 'lasso', 'lassoscript', 'xml+lasso', 'ldapconf', 'ldaprc', 'ldif', 'lean', 'lean3', 'lean4', 'less', 'lighttpd', 'lighty', 'lilypond', 'limbo', 'liquid', 'literate-agda', 'lagda', 'literate-cryptol', 'lcryptol', 'lcry', 'literate-haskell', 'lhaskell', 'lhs', 'literate-idris', 'lidris', 'lidr', 'livescript', 'live-script', 'llvm', 'llvm-mir-body', 'llvm-mir', 'logos', 'logtalk', 'lua', 'luau', 'mcfunction', 'mcf', 'mcschema', 'mime', 'mips', 'moocode', 'moo', 'doscon', 'macaulay2', 'make', 'makefile', 'mf', 'bsdmake', 'css+mako', 'html+mako', 'javascript+mako', 'js+mako', 'mako', 'xml+mako', 'maple', 'maql', 'markdown', 'md', 'mask', 'mason', 'mathematica', 'mma', 'nb', 'matlab', 'matlabsession', 'maxima', 'macsyma', 'meson', 'meson.build', 'minid', 'miniscript', 'ms', 'modelica', 'modula2', 'm2', 'trac-wiki', 'moin', 'mojo', '🔥', 'monkey', 'monte', 'moonscript', 'moon', 'mosel', 'css+mozpreproc', 'mozhashpreproc', 'javascript+mozpreproc', 'mozpercentpreproc', 'xul+mozpreproc', 'mql', 'mq4', 'mq5', 'mql4', 'mql5', 'mscgen', 'msc', 'mupad', 'mxml', 'mysql', 'css+myghty', 'html+myghty', 'javascript+myghty', 'js+myghty', 'myghty', 'xml+myghty', 'ncl', 'nsis', 'nsi', 'nsh', 'nasm', 'objdump-nasm', 'nemerle', 'nesc', 'nestedtext', 'nt', 'newlisp', 'newspeak', 'nginx', 'nimrod', 'nim', 'nit', 'nixos', 'nix', 'nodejsrepl', 'notmuch', 'nusmv', 'numpy', 'numba_ir', 'numbair', 'objdump', 'objective-c', 'objectivec', 'obj-c', 'objc', 'objective-c++', 'objectivec++', 'obj-c++', 'objc++', 'objective-j', 'objectivej', 'obj-j', 'objj', 'ocaml', 'octave', 'odin', 'omg-idl', 'ooc', 'opa', 'openedge', 'abl', 'progress', 'openscad', 'org', 'orgmode', 'org-mode', 'output', 'pacmanconf', 'pan', 'parasail', 'pawn', 'pddl', 'peg', 'perl6', 'pl6', 'raku', 'perl', 'pl', 'phix', 'php', 'php3', 'php4', 'php5', 'pig', 'pike', 'pkgconfig', 'plpgsql', 'pointless', 'pony', 'portugol', 'postscript', 'postscr', 'psql', 'postgresql-console', 'postgres-console', 'postgres-explain', 'postgresql', 'postgres', 'pov', 'powershell', 'pwsh', 'posh', 'ps1', 'psm1', 'pwsh-session', 'ps1con', 'praat', 'procfile', 'prolog', 'promql', 'promela', 'properties', 'jproperties', 'protobuf', 'proto', 'prql', 'psysh', 'ptx', 'pug', 'jade', 'puppet', 'pypylog', 'pypy', 'python2', 'py2', 'py2tb', 'pycon', 'python-console', 'python', 'py', 'sage', 'python3', 'py3', 'bazel', 'starlark', 'pyi', 'pytb', 'py3tb', 'py+ul4', 'qbasic', 'basic', 'q', 'qvto', 'qvt', 'qlik', 'qlikview', 'qliksense', 'qlikscript', 'qml', 'qbs', 'rconsole', 'rout', 'rng-compact', 'rnc', 'spec', 'racket', 'rkt', 'ragel-c', 'ragel-cpp', 'ragel-d', 'ragel-em', 'ragel-java', 'ragel', 'ragel-objc', 'ragel-ruby', 'ragel-rb', 'rd', 'reasonml', 'reason', 'rebol', 'red', 'red/system', 'redcode', 'registry', 'rego', 'resourcebundle', 'resource', 'rexx', 'arexx', 'rhtml', 'html+erb', 'html+ruby', 'ride', 'rita', 'roboconf-graph', 'roboconf-instances', 'robotframework', 'rql', 'rsl', 'restructuredtext', 'rst', 'rest', 'trafficscript', 'rts', 'rbcon', 'irb', 'ruby', 'rb', 'duby', 'rust', 'rs', 'sas', 'splus', 's', 'r', 'sml', 'snbt', 'sarl', 'sass', 'savi', 'scala', 'scaml', 'scdoc', 'scd', 'scheme', 'scm', 'scilab', 'scss', 'sed', 'gsed', 'ssed', 'shexc', 'shex', 'shen', 'sieve', 'silver', 'singularity', 'slash', 'slim', 'slurm', 'sbatch', 'smali', 'smalltalk', 'squeak', 'st', 'sgf', 'smarty', 'smithy', 'snobol', 'snowball', 'solidity', 'androidbp', 'bp', 'soong', 'sophia', 'sp', 'debsources', 'sourceslist', 'sources.list', 'sparql', 'spice', 'spicelang', 'sql+jinja', 'sql', 'sqlite3', 'squidconf', 'squid.conf', 'squid', 'srcinfo', 'ssp', 'stan', 'stata', 'do', 'supercollider', 'sc', 'swift', 'swig', 'systemverilog', 'sv', 'systemd', 'tap', 'tnt', 'toml', 'tablegen', 'td', 'tact', 'tads3', 'tal', 'uxntal', 'tasm', 'tcl', 'tcsh', 'csh', 'tcshcon', 'tea', 'teal', 'teratermmacro', 'teraterm', 'ttl', 'termcap', 'terminfo', 'terraform', 'tf', 'hcl', 'tex', 'latex', 'text', 'ti', 'thingsdb', 'thrift', 'tid', 'tlb', 'tls', 'todotxt', 'tsql', 't-sql', 'treetop', 'tsx', 'turtle', 'html+twig', 'twig', 'typescript', 'ts', 'typoscriptcssdata', 'typoscripthtmldata', 'typoscript', 'typst', 'ul4', 'ucode', 'unicon', 'unixconfig', 'linuxconfig', 'urbiscript', 'urlencoded', 'usd', 'usda', 'vbscript', 'vcl', 'vclsnippets', 'vclsnippet', 'vctreestatus', 'vgl', 'vala', 'vapi', 'aspx-vb', 'vb.net', 'vbnet', 'lobas', 'oobas', 'sobas', 'visual-basic', 'visualbasic', 'html+velocity', 'velocity', 'xml+velocity', 'verifpal', 'verilog', 'v', 'vhdl', 'vim', 'visualprologgrammar', 'visualprolog', 'vue', 'vyper', 'wdiff', 'wast', 'wat', 'webidl', 'wgsl', 'whiley', 'wikitext', 'mediawiki', 'wowtoc', 'wren', 'x10', 'xten', 'xml+ul4', 'xquery', 'xqy', 'xq', 'xql', 'xqm', 'xml+django', 'xml+jinja', 'xml+ruby', 'xml+erb', 'xml', 'xml+php', 'xml+smarty', 'xorg.conf', 'xpp', 'x++', 'xslt', 'xtend', 'extempore', 'yaml+jinja', 'salt', 'sls', 'yaml', 'yang', 'yara', 'yar', 'zeek', 'bro', 'zephir', 'zig', 'ansys', 'apdl']
-"""
-A string literal type representing programming languages supported by Pygments for syntax highlighting. This type
-is used to ensure that only valid language identifiers are accepted when specifying the language for code
-rendering in the ``CodeVideoRenderer`` class. The list includes a wide range of programming languages, markup
-languages, configuration file formats, and other text-based formats that Pygments can recognize and highlight appropriately.
+PygmentsFormatterStyle = Literal['abap', 'algol', 'algol_nu', 'arduino', 'autumn', 'bw', 'borland', 'coffee', 'colorful', 'default', 'dracula', 'emacs', 'friendly_grayscale', 'friendly', 'fruity', 'github-dark', 'gruvbox-dark', 'gruvbox-light', 'igor', 'inkpot', 'lightbulb', 'lilypond', 'lovelace', 'manni', 'material', 'monokai', 'murphy', 'native', 'nord-darker', 'nord', 'one-dark', 'paraiso-dark', 'paraiso-light', 'pastie', 'perldoc', 'rainbow_dash', 'rrt', 'sas', 'solarized-dark', 'solarized-light', 'staroffice', 'stata-dark', 'stata-light', 'tango', 'trac', 'vim', 'vs', 'xcode', 'zenburn', 'a11y-dark', 'a11y-high-contrast-dark', 'a11y-high-contrast-light', 'a11y-light', 'blinds-dark', 'blinds-light', 'github-dark', 'github-dark-colorblind', 'github-dark-high-contrast', 'github-light', 'github-light-colorblind', 'github-light-high-contrast', 'gotthard-dark', 'gotthard-light', 'greative', 'pitaya-smoothie', 'vsc', 'vscode-dark-plus']
-You can run the following code to get the complete list of supported languages:
-
-.. code-block:: python
-
- from pygments.lexers import get_all_lexers
-
- languages = []
- for language in list(get_all_lexers()):
- if type(language[1]) == tuple:
- for subitem in language[1]:
- languages.append(subitem)
- print(languages)
-"""
-
-PygmentsFormatterStyle: TypeAlias = Literal['abap', 'algol', 'algol_nu', 'arduino', 'autumn', 'bw', 'borland', 'coffee', 'colorful', 'default', 'dracula', 'emacs', 'friendly_grayscale', 'friendly', 'fruity', 'github-dark', 'gruvbox-dark', 'gruvbox-light', 'igor', 'inkpot', 'lightbulb', 'lilypond', 'lovelace', 'manni', 'material', 'monokai', 'murphy', 'native', 'nord-darker', 'nord', 'one-dark', 'paraiso-dark', 'paraiso-light', 'pastie', 'perldoc', 'rainbow_dash', 'rrt', 'sas', 'solarized-dark', 'solarized-light', 'staroffice', 'stata-dark', 'stata-light', 'tango', 'trac', 'vim', 'vs', 'xcode', 'zenburn', 'a11y-dark', 'a11y-high-contrast-dark', 'a11y-high-contrast-light', 'a11y-light', 'blinds-dark', 'blinds-light', 'github-dark', 'github-dark-colorblind', 'github-dark-high-contrast', 'github-light', 'github-light-colorblind', 'github-light-high-contrast', 'gotthard-dark', 'gotthard-light', 'greative', 'pitaya-smoothie', 'vsc']
-"""
-A string literal type representing formatter styles supported by Pygments for syntax highlighting. This type is used
-to ensure that only valid formatter styles are accepted when specifying the formatter style for code rendering in the
-``CodeVideoRenderer`` class. The list includes a variety of styles that can be applied to the syntax highlighting,
-ranging from light and dark themes to styles designed for accessibility and specific color schemes.
-
-You can run the following code to get the complete list of supported formatter styles:
-
-.. code-block:: python
-
- from pygments.styles import get_all_styles
-
- print(list(get_all_styles()))
-"""
-
-StrPath: TypeAlias = Union[str, PathLike]
-"""
-A string or ``os.PathLike`` representing a path to a directory or file.
-"""
+StrPath = Union[str, PathLike]
__all__ = [
'PygmentsLanguage',
diff --git a/CodeVideoRenderer/utils.py b/CodeVideoRenderer/utils.py
index 7359080..5f08bda 100644
--- a/CodeVideoRenderer/utils.py
+++ b/CodeVideoRenderer/utils.py
@@ -1,10 +1,9 @@
-from __future__ import annotations # for Sphinx typehints
from manim import config
from rich.progress import Progress, BarColumn, TextColumn, TimeRemainingColumn, TransferSpeedColumn
from copy import copy
from contextlib import contextmanager
from io import StringIO
-from typing import get_args, get_origin, Generator, Any, Union, List
+from typing import get_args, get_origin, Union
from moviepy.video.io.VideoFileClip import VideoFileClip
from PIL import Image, ImageFilter, ImageEnhance
from proglog import ProgressBarLogger
@@ -18,19 +17,10 @@
UnionType = type(Union[int, str])
from .config import *
-from .typing import StrPath
-@contextmanager
-def noManimOutput() -> Generator[None, Any, None]:
- """
- Context manager used to execute code without outputting Manim logs.
-
- Within the ``with`` block, Manim's standard output, standard error, and
- progress bars are suppressed. Streams are restored when the block exits.
- Yields:
- None
- """
+@contextmanager
+def noManimOutput():
sys.stdout = StringIO()
stderr_buffer = StringIO()
sys.stderr = stderr_buffer
@@ -46,49 +36,30 @@ def noManimOutput() -> Generator[None, Any, None]:
if stderr_content:
print(stderr_content, file=ORIGINAL_STDERR)
-def stripEmptyLines(text: str) -> str:
- """
- Remove empty lines from the beginning and end of a string.
- Args:
- text (str): The input string to process.
-
- Returns:
- str: The string with empty lines removed from the beginning and end.
- """
+def stripEmptyLines(text):
lines = text.split("\n")
-
+
start = 0
while start < len(lines) and lines[start].strip() == '':
start += 1
-
+
end = len(lines)
while end > start and lines[end - 1].strip() == '':
end -= 1
-
+
return '\n'.join(lines[start:end])
-def typeName(item_type: Any) -> str:
- """
- Get the name of a type, handling union types and generic types.
-
- Args:
- item_type: The type or value to get the name of.
-
- Returns:
- str: The name of the type.
- """
- # Handle UnionType
+
+def typeName(item_type):
if isinstance(item_type, UnionType):
return str(item_type).replace(" | ", "' or '")
-
- # Handle non-type objects (like Literal values)
+
if not isinstance(item_type, type):
if isinstance(item_type, str):
return f"'{item_type}'"
return str(item_type)
-
- # Handle generic types
+
origin = get_origin(item_type)
if origin:
args = get_args(item_type)
@@ -96,162 +67,103 @@ def typeName(item_type: Any) -> str:
arg_names = ', '.join([typeName(arg) for arg in args])
return f"{origin.__name__}[{arg_names}]"
return origin.__name__
-
- # Handle basic types
+
return item_type.__name__
-def addGlowEffect(input_path: StrPath, output_path: StrPath, output: bool) -> None:
- """
- Add a glow effect to a video.
-
- Args:
- input_path (StrPath): Path to the input video file.
- output_path (StrPath): Path to save the output video file.
- output (bool): Whether to display progress bars.
-
- Returns:
- None
- """
- # 内部帧处理函数
- def _frame_glow(t: np.ndarray):
- # 获取MoviePy的numpy帧并转为PIL图像
+
+def addGlowEffect(input_path, output_path, output):
+ def _frame_glow(t):
frame = t.astype(np.uint8)
pil_img = Image.fromarray(frame).convert("RGBA")
- # 提升基础亮度
brightness_enhancer = ImageEnhance.Brightness(pil_img)
pil_img = brightness_enhancer.enhance(1.2)
- # 创建模糊光晕层
glow = pil_img.filter(ImageFilter.GaussianBlur(radius=10))
- # 提升光晕的亮度和饱和度
glow_bright_enhancer = ImageEnhance.Brightness(glow)
glow = glow_bright_enhancer.enhance(1.6)
glow_color_enhancer = ImageEnhance.Color(glow)
glow = glow_color_enhancer.enhance(2)
- # 混合原图像与光晕层
soft_glow_img = Image.blend(glow, pil_img, 0.4)
glow_frame = np.array(soft_glow_img.convert("RGB")).astype(np.uint8)
return np.clip(glow_frame, 0, 255)
-
- glow_video: VideoFileClip = VideoFileClip(input_path).image_transform(_frame_glow)
- glow_video.write_videofile(output_path, codec='libx264', audio=True, logger=RichProgressBarLogger(output=output, title="Glow Effect", leave_bars=False))
-
-def findSpacePositions(string: str) -> List[List[int]]:
- """
- Find the 2D positions of all non-leading, non-trailing spaces in a string.
- Each position is represented as a list ``[row_index, column_index]``.
-
- Args:
- string (str): A string.
-
- Returns:
- List[List[int]]: A list of 2D positions of all non-leading, non-trailing spaces.
- Each position is represented as a list ``[row_index, column_index]``.
- """
- result = [] # 存储所有[行, 列]格式的空格位置
+
+ glow_video = VideoFileClip(input_path).image_transform(_frame_glow)
+ glow_video.write_videofile(
+ output_path,
+ codec='libx264',
+ audio=True,
+ preset=FFMPEG_PRESET,
+ threads=FFMPEG_THREADS,
+ ffmpeg_params=["-bufsize", FFMPEG_BUFSIZE],
+ logger=RichProgressBarLogger(output=output, title="Glow Effect", leave_bars=False),
+ )
+
+
+def findSpacePositions(string):
+ result = []
for row_idx, s in enumerate(string.splitlines()):
- # 找到第一个非空格字符的列索引
first_non_space = 0
while first_non_space < len(s) and s[first_non_space] == ' ':
first_non_space += 1
-
- # 找到最后一个非空格字符的列索引
+
last_non_space = len(s) - 1
while last_non_space >= 0 and s[last_non_space] == ' ':
last_non_space -= 1
-
- # 全空格/空字符串,跳过
+
if first_non_space > last_non_space:
- result.extend([[row_idx, col_idx] for col_idx in range(len(s))]) # 记录整行空格位置
+ result.extend([[row_idx, col_idx] for col_idx in range(len(s))])
continue
-
- # 遍历中间部分,收集[行, 列]格式的位置
+
for col_idx in range(first_non_space, last_non_space + 1):
if s[col_idx] == ' ':
result.append([row_idx, col_idx])
-
+
return result
-def findEmptyLinePositions(string: str) -> List[int]:
- """
- Find the line indices of all empty lines in a string.
-
- Args:
- string (str): A string.
-
- Returns:
- List[int]: A list of line indices of all empty lines.
- """
+
+def findEmptyLinePositions(string):
return [idx for idx, line in enumerate(string.splitlines()) if line.strip() == '']
-def replaceMiddleSpacesWithOccupyCharacter(string: str) -> str:
- """
- Replace all non-leading, non-trailing spaces in the input string with :data:`~.OCCUPY_CHARACTER`.
- Retain leading and trailing spaces.
-
- Args:
- string (str): Original string.
-
- Returns:
- str: Processed string with middle spaces replaced by :data:`~.OCCUPY_CHARACTER`.
- """
+
+def replaceMiddleSpacesWithOccupyCharacter(string):
result = []
for s in string.splitlines():
- # 处理非字符串元素,直接保留原元素
if not isinstance(s, str):
result.append(s)
continue
-
- # 空字符串直接保留
+
if len(s) == 0:
result.append(s)
continue
-
- # 转为列表方便修改字符
+
s_list = list(s)
-
- # 找到第一个非空格字符的索引
+
first_non_space = 0
while first_non_space < len(s_list) and s_list[first_non_space] == ' ':
first_non_space += 1
-
- # 找到最后一个非空格字符的索引
+
last_non_space = len(s_list) - 1
while last_non_space >= 0 and s_list[last_non_space] == ' ':
last_non_space -= 1
-
- # 全是空格的情况,直接保留原字符串
+
if first_non_space > last_non_space:
result.append(s.replace(' ', OCCUPY_CHARACTER))
continue
-
- # 遍历中间区域,替换空格为1
+
for idx in range(first_non_space, last_non_space + 1):
if s_list[idx] == ' ':
s_list[idx] = OCCUPY_CHARACTER
-
- # 转回字符串并加入结果
+
result.append(''.join(s_list))
-
+
return '\n'.join(result)
+
class DefaultProgressBar(Progress):
- """
- Default progress bar used during video rendering.
-
- This is a thin wrapper around ``rich.progress.Progress`` pre-configured with
- descriptive columns (task name, bar, completion count, percentage, time remaining,
- and transfer speed).
- """
- def __init__(self, output: bool):
- """
- Args:
- output (bool): If ``False``, the progress bar is attached to a ``None`` console
- so that no output is displayed.
- """
+ def __init__(self, output):
super().__init__(
TextColumn("[progress.description]{task.description}"),
BarColumn(),
@@ -262,18 +174,12 @@ def __init__(self, output: bool):
console=DEFAULT_OUTPUT_CONSOLE if output else None
)
-class RichProgressBarLogger(ProgressBarLogger):
- """
- A progress logger that uses Rich to display progress bars.
- This class bridges ``proglog`` (used by MoviePy) and ``rich`` progress bars,
- allowing the glow-effect post-processing step to show a native-looking progress
- indicator in the terminal.
- """
+class RichProgressBarLogger(ProgressBarLogger):
def __init__(
self,
- output: bool,
- title: str,
+ output,
+ title,
init_state=None,
bars=None,
leave_bars=True,
@@ -283,113 +189,80 @@ def __init__(
min_time_interval=0.1,
ignore_bars_under=0,
):
- """
- Args:
- output (bool): Whether to display the progress bar.
- title (str): Title shown at the top of the progress bar.
- init_state: Initial state forwarded to ``ProgressBarLogger``.
- bars: Bar definitions forwarded to ``ProgressBarLogger``.
- leave_bars (bool): If ``True``, finished bars remain visible.
- ignored_bars: Bars to ignore forwarded to ``ProgressBarLogger``.
- logged_bars: Bars to log forwarded to ``ProgressBarLogger``.
- print_messages (bool): Whether to print log messages.
- min_time_interval (float): Minimum update interval in seconds.
- ignore_bars_under (int): Ignore bars with fewer than this many items.
- """
- # 调用父类构造函数,初始化核心属性
super().__init__(
init_state=init_state,
bars=bars,
ignored_bars=ignored_bars,
logged_bars=logged_bars,
ignore_bars_under=ignore_bars_under,
- min_time_interval=min_time_interval, # type: ignore
+ min_time_interval=min_time_interval,
)
-
- # 初始化自定义属性
+
self.leave_bars = leave_bars
self.print_messages = print_messages
self.output = output
self.title = title
self.start_time = time.time()
-
- # 初始化 Rich 进度条
+
self.progress_bar = copy(DefaultProgressBar(self.output))
- self.rich_bars = OrderedDict() # 存储 {bar_name: task_id}
-
- # 启动 Rich 进度条
+ self.rich_bars = OrderedDict()
+
if self.progress_bar and not self.progress_bar.live.is_started:
self.progress_bar.start()
def new_tqdm_bar(self, bar):
- """
- Create a Rich progress bar task for the given bar.
- """
if not self.output or self.progress_bar is None:
return
-
- # 关闭已有进度条
+
if bar in self.rich_bars:
self.close_tqdm_bar(bar)
-
- # 获取父类维护的进度条信息
+
infos = self.bars[bar]
- # 创建 Rich 进度条任务
task_id = self.progress_bar.add_task(description=f"[yellow]{self.title}[/yellow]", total=infos["total"])
self.rich_bars[bar] = task_id
def close_tqdm_bar(self, bar):
- """
- Close the Rich progress bar task for the given bar.
- """
if not self.output or self.progress_bar is None:
return
-
+
if bar in self.rich_bars:
task_id = self.rich_bars[bar]
- # 若不需要保留,移除任务
if not self.leave_bars:
self.progress_bar.remove_task(task_id)
del self.rich_bars[bar]
def bars_callback(self, bar, attr, value, old_value):
- """
- Update the Rich progress bar task based on the attribute change.
- """
+ if not self.output or self.progress_bar is None:
+ return
if bar not in self.rich_bars:
self.new_tqdm_bar(bar)
-
+
task_id = self.rich_bars.get(bar)
+ if task_id is None:
+ return
if attr == "index":
- # 处理帧数更新(核心)
if value >= old_value:
total = self.bars[bar]["total"]
- # 计算处理速度
elapsed = time.time() - self.start_time
speed = value / elapsed if elapsed > 0 else 0.0
-
- # 更新 Rich 进度条
+
self.progress_bar.update(
- task_id, # type: ignore
+ task_id,
completed=value,
speed=speed
)
-
- # 完成后关闭(复刻原逻辑)
+
if total and (value >= total):
self.close_tqdm_bar(bar)
else:
- # 帧数回退:重置进度条
self.new_tqdm_bar(bar)
self.progress_bar.update(self.rich_bars[bar], completed=value)
def stop(self):
- """
- Stop the Rich progress bar.
- """
if self.progress_bar and self.progress_bar.live.is_started:
self.progress_bar.stop()
+
__all__ = [
"noManimOutput",
"stripEmptyLines",
@@ -400,4 +273,4 @@ def stop(self):
"replaceMiddleSpacesWithOccupyCharacter",
"DefaultProgressBar",
"RichProgressBarLogger"
-]
\ No newline at end of file
+]
diff --git a/CodeVideoRenderer/version.py b/CodeVideoRenderer/version.py
index 1d7908f..83b2d0a 100644
--- a/CodeVideoRenderer/version.py
+++ b/CodeVideoRenderer/version.py
@@ -1,3 +1,8 @@
-from importlib.metadata import version
-__version__ = version("CodeVideoRenderer")
-"""Version number of CodeVideoRenderer."""
\ No newline at end of file
+# Prefer the installed distribution's version; fall back to this when running from source.
+__version__ = "1.5.0"
+
+try:
+ from importlib.metadata import version as _pkg_version
+ __version__ = _pkg_version("codevideorenderer")
+except Exception: # pragma: no cover - only triggered when the distribution is not installed
+ pass
diff --git a/CodeVideoRenderer/vscode_theme.py b/CodeVideoRenderer/vscode_theme.py
new file mode 100644
index 0000000..a449361
--- /dev/null
+++ b/CodeVideoRenderer/vscode_theme.py
@@ -0,0 +1,135 @@
+"""VS Code Dark+ syntax-highlighting style and Python lexer."""
+from pygments.filter import Filter
+from pygments.style import Style
+from pygments.token import (
+ Token, Keyword, Name, Comment, String, Error, Number, Operator,
+ Punctuation, Literal, Generic, Whitespace,
+)
+from pygments.lexers.python import PythonLexer
+
+__all__ = [
+ "VSCodeDarkPlusStyle",
+ "VSCodePythonLexer",
+ "register_vscode",
+ "resolve_language",
+ "STYLE_NAME",
+ "LEXER_NAME",
+]
+
+STYLE_NAME = "vscode-dark-plus"
+LEXER_NAME = "python-vscode"
+
+PYTHON_LANGUAGES = {"python", "python3", "py", "py3", "python2", "py2"}
+
+
+def resolve_language(language):
+ return LEXER_NAME if language in PYTHON_LANGUAGES else language
+
+
+class VSCodeDarkPlusStyle(Style):
+ name = STYLE_NAME
+ background_color = "#1E1E1E"
+ styles = {
+ Token: "#D4D4D4",
+ Whitespace: "",
+ Comment: "#6A9955",
+ Comment.Preproc: "#C586C0",
+ # def/class/lambda blue; if/for/return… purple
+ Keyword: "#569CD6",
+ Keyword.Constant: "#569CD6",
+ Keyword.Declaration: "#569CD6",
+ Keyword.Reserved: "#C586C0",
+ Keyword.Namespace: "#569CD6",
+ Keyword.Type: "#4EC9B0",
+ Operator: "#D4D4D4",
+ Operator.Word: "#C586C0", # and/or/not/in/is
+ Name: "#D4D4D4",
+ Name.Builtin: "#DCDCAA", # print/len/range
+ Name.Builtin.Pseudo: "#9CDCFE", # self/cls
+ Name.Function: "#DCDCAA",
+ Name.Function.Magic: "#DCDCAA",
+ Name.Class: "#4EC9B0",
+ Name.Namespace: "#4EC9B0",
+ Name.Exception: "#4EC9B0",
+ Name.Decorator: "#DCDCAA",
+ Name.Variable: "#9CDCFE",
+ Name.Constant: "#9CDCFE",
+ Name.Attribute: "#9CDCFE",
+ Name.Tag: "#569CD6",
+ Name.Label: "#9CDCFE",
+ String: "#CE9178",
+ String.Doc: "#CE9178",
+ String.Interpol: "#CE9178",
+ String.Escape: "#CE9178",
+ String.Regex: "#CE9178",
+ String.Symbol: "#CE9178",
+ String.Other: "#CE9178",
+ Number: "#B5CEA8",
+ Punctuation: "#D4D4D4",
+ Literal: "#B5CEA8",
+ Generic: "#D4D4D4",
+ Error: "#F44747",
+ }
+
+
+# control-flow keywords stay purple; the rest become blue (def/class)
+_CONTROL_KEYWORDS = {
+ "if", "elif", "else", "for", "while", "return", "break", "continue",
+ "pass", "raise", "try", "except", "finally", "with", "assert",
+ "import", "from", "as", "global", "nonlocal", "yield",
+ "and", "or", "not", "is", "in", "del", "async", "await",
+}
+
+
+class _VSKeywordFilter(Filter):
+ def filter(self, lexer, stream):
+ for ttype, value in stream:
+ if ttype is Keyword:
+ if value in _CONTROL_KEYWORDS:
+ yield Keyword.Reserved, value
+ else:
+ yield Keyword.Declaration, value
+ else:
+ yield ttype, value
+
+
+class VSCodePythonLexer(PythonLexer):
+ name = "Python (VS Code)"
+ aliases = [LEXER_NAME]
+ filenames = ["*.py"]
+
+ def __init__(self, **options):
+ super().__init__(**options)
+ self.add_filter(_VSKeywordFilter())
+
+
+_registered = False
+
+
+def register_vscode():
+ global _registered
+ if _registered:
+ return
+ try:
+ from pygments.styles import _STYLE_NAME_TO_MODULE_MAP
+ _STYLE_NAME_TO_MODULE_MAP[STYLE_NAME] = (
+ "CodeVideoRenderer.vscode_theme",
+ "VSCodeDarkPlusStyle",
+ )
+
+ from pygments.lexers import LEXERS, _lexer_cache
+ LEXERS["VSCodePythonLexer"] = (
+ "CodeVideoRenderer.vscode_theme",
+ VSCodePythonLexer.name,
+ VSCodePythonLexer.aliases,
+ VSCodePythonLexer.filenames,
+ ("text/x-python",),
+ )
+ _lexer_cache[VSCodePythonLexer.name] = VSCodePythonLexer
+ _registered = True
+ except Exception:
+ # fall back to Pygments' built-in python lexer if registration fails
+ _registered = False
+
+
+register_vscode()
diff --git a/README.md b/README.md
index f609300..5f88416 100644
--- a/README.md
+++ b/README.md
@@ -19,8 +19,13 @@ CodeVideoRenderer is a Python animation library specifically designed for creati
- **🎬 Professional animation effects**: Based on Manim engine, providing high-quality animation rendering
- **📝 Multi-language support**: Syntax highlighting for various programming languages including Python, JavaScript, Java, and more
- **⚙️ Highly customizable**: Adjustable typing speed, line spacing, camera behavior, and other parameters
-- **🎨 Rich styling**: Multiple code highlighting styles (such as github-dark, monokai, etc.)
+- **🎨 Rich styling**: Multiple code highlighting styles, plus a built-in **VS Code Dark+** theme (`vscode-dark-plus`, the default)
- **🔧 Dual renderers**: Support for both Cairo and OpenGL rendering backends
+- **🧹 Clear-code animation**: Delete the code backspace-style (speed-adjustable) or fade it out after typing finishes (`clear_code=True`)
+- **💡 VS Code-style autocomplete**: IntelliSense-style popups with colored icons, labels, details and a selection highlight (`autocomplete=True`)
+- **🇨🇳 Chinese IME simulation**: Pinyin (hyphen-separated) + candidate box pops up when Chinese characters are typed (`chinese_ime=True`)
+- **🎨 Theme & layout**: Customisable background color and current-line highlight color
+- **🎞️ Video post-processing**: Strip/add subtitles, mix background music, concatenate clips, set quality/resolution/fps, add watermarks, title cards and cover images
## 🚀 Quick Installation
@@ -68,7 +73,7 @@ print(f"Fibonacci(10) = {result}")
video = CameraFollowCursorCV(
code=('string', code),
language='python',
- formatter_style='github-dark',
+ formatter_style='vscode-dark-plus',
video_name='FibonacciExample'
)
video.render()
@@ -86,6 +91,109 @@ video.render()
- **Smooth movement**: Camera smoothly follows cursor movement
- **Focus management**: Intelligently recognizes code structure to ensure important parts remain visible
+## 🎞️ Video Post-processing
+
+After rendering, use the built-in helpers to finish your video. Every function accepts an
+input path and an optional output path (a sensible name is derived if omitted), and returns
+the output path.
+
+```python
+from CodeVideoRenderer import (
+ remove_subtitles, add_subtitles, add_background_music, concat_videos,
+ set_quality, add_watermark, add_title_card, extract_cover,
+ extract_audio, remove_audio, trim_video, change_speed,
+)
+
+video = "FibonacciExample.mp4"
+
+remove_subtitles(video) # strip subtitle streams -> *_no_subs.mp4
+add_subtitles(video, "captions.srt") # burn subtitles in -> *_subbed.mp4
+add_subtitles(video, "captions.srt", soft=True) # switchable soft subs
+add_background_music(video, "bgm.mp3", volume=0.3) # mix music -> *_bgm.mp4
+concat_videos(["part1.mp4", "part2.mp4"], "full.mp4")
+set_quality(video, resolution="1080p", fps=60, crf=20) # re-encode -> *_quality.mp4
+trim_video(video, start=1.0, duration=5.0)
+change_speed(video, speed=2.0)
+add_watermark(video, text="MyChannel", position="bottom-right")
+add_watermark(video, image_path="logo.png", opacity=0.5)
+add_title_card(video, title="Hello World", subtitle="A code demo", duration=3.0)
+extract_cover(video, time=1.0, width=1280) # thumbnail -> *_cover.png
+extract_audio(video, format="mp3") # audio only -> *.mp3
+remove_audio(video) # silent video -> *_mute.mp4
+```
+
+> **FFmpeg**: these helpers shell out to `ffmpeg`. It is located automatically via the
+> `CODEVIDEORENDERER_FFMPEG` / `FFMPEG_BINARY` environment variables, `PATH`, common install
+> locations, or the binary bundled with `imageio-ffmpeg`.
+
+### Clear code after typing
+
+Pass `clear_code=True` to delete the code once typing completes. Two modes are available:
+
+```python
+video = CameraFollowCursorCV(
+ code=('string', code),
+ language='python',
+ video_name='FibonacciExample',
+
+ clear_code=True, # delete the code after typing
+ clear_code_mode='backspace', # 'backspace' (逐字符删除) or 'fade' (整块淡出)
+ clear_code_interval=0.03, # 每个字符的删除间隔(控制删除速度)
+ clear_code_run_time=1.0, # fade 模式的时长 / backspace 模式下收尾的时长
+)
+```
+
+### VS Code-style autocomplete
+
+Enable `autocomplete=True` to pop up a completion box (just like IntelliSense) whenever
+a keyword such as `def`, `import`, `class`, `for`, `return` … is typed. The popup mirrors
+VS Code's suggest widget: **colored symbol icons** (method `ƒ`, class `▣`, keyword `⚿`,
+snippet `➤`, …), labels, grey type details, and the blue selected-row highlight.
+
+```python
+video = CameraFollowCursorCV(
+ code=('string', code),
+ language='python',
+ video_name='AutocompleteDemo',
+ autocomplete=True, # 触发关键词时弹出补全提示
+ autocomplete_wait_time=0.6, # 提示框停留时长(秒)
+)
+```
+
+### VS Code Dark+ syntax highlighting
+
+The default `formatter_style` is `vscode-dark-plus`, a faithful port of the VS Code Dark+
+theme (keywords `#569CD6` / `#C586C0`, strings `#CE9178`, functions `#DCDCAA`, classes
+`#4EC9B0`, comments `#6A9955`, numbers `#B5CEA8`). Pass it explicitly, or use any other
+Pygments style such as `github-dark` or `monokai`.
+
+### Chinese IME simulation
+
+Set `chinese_ime=True` to pop up a pinyin + candidate box (like Microsoft Pinyin) whenever
+a Chinese character is typed — multi-character words are shown as **hyphen-separated
+pinyin** (`世界` → `shi-jie`):
+
+```python
+video = CameraFollowCursorCV(
+ code=('string', code),
+ language='python',
+ video_name='ChineseDemo',
+ chinese_ime=True, # 输入中文时弹出拼音候选框
+ ime_wait_time=0.6, # 候选框停留时长(秒)
+)
+```
+
+### Theme & layout
+
+```python
+video = CameraFollowCursorCV(
+ ...,
+ background_color="#1E1E1E", # 背景色
+ line_highlight_color="#2D2D2D", # 当前行高亮色
+ end_wait_time=2.0, # 结尾停留时长(秒)
+)
+```
+
## 🎯 Use Cases
- **Educational demonstrations**: Create code explanation videos for programming courses
diff --git a/docs/demo/01_vscode_dark_plus.png b/docs/demo/01_vscode_dark_plus.png
new file mode 100644
index 0000000..837b904
Binary files /dev/null and b/docs/demo/01_vscode_dark_plus.png differ
diff --git a/docs/demo/02_autocomplete.png b/docs/demo/02_autocomplete.png
new file mode 100644
index 0000000..b5e242e
Binary files /dev/null and b/docs/demo/02_autocomplete.png differ
diff --git a/docs/demo/03_chinese_ime.png b/docs/demo/03_chinese_ime.png
new file mode 100644
index 0000000..1e47003
Binary files /dev/null and b/docs/demo/03_chinese_ime.png differ
diff --git a/docs/demo/demo_showcase.gif b/docs/demo/demo_showcase.gif
new file mode 100644
index 0000000..69920ef
Binary files /dev/null and b/docs/demo/demo_showcase.gif differ
diff --git a/docs/demo/demo_showcase.mp4 b/docs/demo/demo_showcase.mp4
new file mode 100644
index 0000000..aac275a
Binary files /dev/null and b/docs/demo/demo_showcase.mp4 differ
diff --git a/editor.py b/editor.py
new file mode 100644
index 0000000..4f6945b
--- /dev/null
+++ b/editor.py
@@ -0,0 +1,1257 @@
+"""PyQt5 GUI for CodeVideoRenderer: write code, configure effects, render a typing video."""
+import os
+import re
+import sys
+import traceback
+from pathlib import Path
+
+from PyQt5.QtCore import Qt, QThread, QSize, QRect, QUrl, QRegularExpression, pyqtSignal
+from PyQt5.QtGui import (
+ QColor, QFont, QFontMetrics, QPainter, QSyntaxHighlighter, QTextCharFormat,
+ QTextFormat, QKeySequence, QDesktopServices, QFontDatabase,
+)
+from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
+from PyQt5.QtMultimediaWidgets import QVideoWidget
+from PyQt5.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
+ QPlainTextEdit, QComboBox, QLineEdit, QCheckBox, QPushButton, QLabel,
+ QFormLayout, QScrollArea, QSpinBox, QDoubleSpinBox, QTableWidget,
+ QTableWidgetItem, QFileDialog, QMessageBox, QTextBrowser, QAbstractItemView,
+ QHeaderView, QTextEdit, QColorDialog, QDialog, QSlider,
+)
+
+import CodeVideoRenderer
+from CodeVideoRenderer import (
+ CameraFollowCursorCV, add_background_music, add_background,
+ replace_background, add_sound, add_subtitles, add_lyrics, add_watermark,
+)
+
+# One Dark Pro Darker (matches the renderer's default output)
+EDITOR_BG = "#23272e"
+EDITOR_FG = "#abb2bf"
+EDITOR_SELECTION = "#3d4556"
+EDITOR_CURRENT_LINE = "#2c313c"
+EDITOR_GUTTER = "#23272e"
+EDITOR_GUTTER_FG = "#7f848e"
+
+# VS Code Dark+ syntax colors (matches the renderer's formatter_style="vscode-dark-plus")
+C_KEYWORD = "#C586C0"
+C_DECL = "#569CD6"
+C_STRING = "#CE9178"
+C_COMMENT = "#6A9955"
+C_NUMBER = "#B5CEA8"
+C_FUNC = "#DCDCAA"
+C_BUILTIN = "#9CDCFE"
+
+# CJK-capable monospace font bundled with the package (matches the rendered output)
+EDITOR_FONT_FAMILY = "CodeVideoRendererFont"
+EDITOR_FONT_SIZE = 15
+
+LANGUAGES = [
+ "python", "javascript", "typescript", "cpp", "c", "java", "go", "rust",
+ "html", "css", "json", "bash", "ruby", "php", "sql", "markdown", "lua",
+ "csharp", "kotlin", "swift", "r", "matlab", "yaml", "xml",
+]
+
+QUALITIES = ["low_quality", "medium_quality", "high_quality", "fourk_quality"]
+
+POSITIONS = [
+ "bottom-left", "bottom-center", "bottom-right",
+ "middle-left", "middle-center", "middle-right",
+ "top-left", "top-center", "top-right",
+]
+
+# Per-language highlighting specs. Keys:
+# line_comment: regex or None
+# block_comment: (start, end) or None
+# strings: list of regexes
+# kw: control keywords (purple)
+# decl: declaration/type keywords (blue)
+# builtin: builtins/types/constants (light blue)
+# func: keywords that introduce a function/class/type name (captured, yellow)
+LANG_SPECS = {
+ "python": {
+ "line_comment": r"#[^\n]*",
+ "block_comment": ('"""', '"""'),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["if", "elif", "else", "for", "while", "return", "break", "continue",
+ "pass", "try", "except", "finally", "raise", "with", "yield", "assert",
+ "in", "is", "not", "and", "or", "import", "from", "as", "lambda",
+ "global", "nonlocal", "del", "await", "async"],
+ "decl": ["def", "class"],
+ "builtin": ["print", "len", "range", "str", "int", "float", "bool", "list", "dict",
+ "set", "tuple", "type", "isinstance", "enumerate", "zip", "map", "filter",
+ "sum", "min", "max", "abs", "round", "open", "input", "super", "self",
+ "cls", "True", "False", "None"],
+ "func": ["def", "class"],
+ },
+ "javascript": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'", r"`[^`\\]*(?:\\.[^`\\]*)*`"],
+ "kw": ["break", "case", "catch", "class", "const", "continue", "debugger", "default",
+ "delete", "do", "else", "export", "extends", "finally", "for", "function",
+ "if", "import", "in", "instanceof", "let", "new", "return", "super", "switch",
+ "this", "throw", "try", "typeof", "var", "void", "while", "with", "yield",
+ "async", "await", "static", "get", "set", "of"],
+ "decl": [],
+ "builtin": ["console", "document", "window", "Math", "JSON", "Object", "Array",
+ "String", "Number", "Boolean", "Promise", "undefined", "null", "true",
+ "false", "NaN", "Infinity"],
+ "func": ["function", "class"],
+ },
+ "typescript": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'", r"`[^`\\]*(?:\\.[^`\\]*)*`"],
+ "kw": ["break", "case", "catch", "class", "const", "continue", "default", "delete",
+ "do", "else", "export", "extends", "finally", "for", "function", "if",
+ "import", "in", "instanceof", "let", "new", "return", "super", "switch",
+ "this", "throw", "try", "typeof", "var", "void", "while", "with", "yield",
+ "async", "await", "static", "get", "set", "of", "keyof", "infer", "namespace",
+ "declare", "readonly", "implements"],
+ "decl": ["interface", "type", "enum", "abstract"],
+ "builtin": ["string", "number", "boolean", "any", "void", "never", "unknown", "symbol",
+ "console", "document", "window", "Math", "JSON", "undefined", "null",
+ "true", "false"],
+ "func": ["function", "class", "interface", "enum"],
+ },
+ "cpp": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["auto", "break", "case", "const", "continue", "default", "do", "else", "enum",
+ "extern", "for", "goto", "if", "inline", "register", "return", "sizeof",
+ "static", "struct", "switch", "typedef", "union", "volatile", "while", "class",
+ "namespace", "template", "typename", "new", "delete", "this", "virtual",
+ "override", "final", "public", "private", "protected", "friend", "operator",
+ "using", "throw", "try", "catch", "nullptr", "constexpr", "noexcept"],
+ "decl": ["int", "float", "double", "char", "bool", "void", "long", "short", "unsigned",
+ "signed", "size_t", "wchar_t"],
+ "builtin": ["std", "cout", "cin", "endl", "printf", "scanf", "malloc", "free",
+ "NULL", "true", "false"],
+ "func": [],
+ },
+ "c": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["auto", "break", "case", "const", "continue", "default", "do", "else", "enum",
+ "extern", "for", "goto", "if", "inline", "register", "return", "sizeof",
+ "static", "struct", "switch", "typedef", "union", "volatile", "while"],
+ "decl": ["int", "float", "double", "char", "void", "long", "short", "unsigned",
+ "signed", "size_t"],
+ "builtin": ["printf", "scanf", "malloc", "free", "NULL", "FILE", "EOF"],
+ "func": [],
+ },
+ "java": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["abstract", "assert", "break", "case", "catch", "class", "continue", "default",
+ "do", "else", "enum", "extends", "final", "finally", "for", "if", "implements",
+ "import", "instanceof", "interface", "native", "new", "package", "private",
+ "protected", "public", "return", "static", "super", "switch", "synchronized",
+ "this", "throw", "throws", "try", "volatile", "while", "record", "var", "yield"],
+ "decl": ["int", "float", "double", "char", "boolean", "byte", "short", "long", "void"],
+ "builtin": ["String", "Object", "Integer", "List", "Map", "System", "out", "true",
+ "false", "null"],
+ "func": ["class", "interface", "enum", "record"],
+ },
+ "go": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'", r"`[^`]*`"],
+ "kw": ["break", "case", "chan", "const", "continue", "default", "defer", "else",
+ "fallthrough", "for", "func", "go", "goto", "if", "import", "interface", "map",
+ "package", "range", "return", "select", "struct", "switch", "type", "var"],
+ "decl": ["string", "int", "float64", "float32", "bool", "byte", "rune", "error"],
+ "builtin": ["append", "cap", "close", "complex", "copy", "delete", "imag", "len",
+ "make", "new", "panic", "print", "println", "real", "recover", "nil",
+ "true", "false"],
+ "func": ["func", "type"],
+ },
+ "rust": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["as", "break", "const", "continue", "crate", "else", "enum", "extern", "for",
+ "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
+ "return", "self", "Self", "static", "struct", "super", "trait", "type", "unsafe",
+ "use", "where", "while", "async", "await", "dyn", "union", "fn"],
+ "decl": ["i32", "i64", "u32", "u64", "f32", "f64", "bool", "char", "str", "usize",
+ "isize"],
+ "builtin": ["println", "print", "vec", "Some", "None", "Ok", "Err", "String", "Vec",
+ "Option", "Result", "Box", "true", "false"],
+ "func": ["fn", "struct", "enum", "trait", "impl"],
+ },
+ "html": {
+ "line_comment": None,
+ "block_comment": (""),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["html", "head", "body", "div", "span", "p", "a", "img", "ul", "ol", "li",
+ "table", "tr", "td", "th", "form", "input", "button", "select", "option",
+ "textarea", "label", "h1", "h2", "h3", "h4", "h5", "h6", "script", "style",
+ "link", "meta", "title", "header", "footer", "nav", "main", "section",
+ "article", "aside", "iframe", "video", "audio", "canvas", "br", "hr", "strong",
+ "em", "code", "pre"],
+ "decl": [],
+ "builtin": [],
+ "func": [],
+ },
+ "css": {
+ "line_comment": None,
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["color", "background", "background-color", "margin", "padding", "border",
+ "width", "height", "display", "position", "top", "left", "right", "bottom",
+ "font", "font-size", "font-family", "font-weight", "text-align", "flex",
+ "grid", "gap", "justify-content", "align-items", "overflow", "z-index",
+ "opacity", "cursor", "transition", "transform", "animation", "border-radius",
+ "box-shadow", "line-height", "letter-spacing", "float", "clear", "content",
+ "visibility"],
+ "decl": [],
+ "builtin": ["none", "block", "inline", "flex", "grid", "absolute", "relative", "fixed",
+ "sticky", "center", "auto", "inherit", "transparent"],
+ "func": [],
+ },
+ "json": {
+ "line_comment": None,
+ "block_comment": None,
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"'],
+ "kw": [],
+ "decl": [],
+ "builtin": ["true", "false", "null"],
+ "func": [],
+ },
+ "bash": {
+ "line_comment": r"#[^\n]*",
+ "block_comment": None,
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^']*'"],
+ "kw": ["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case",
+ "esac", "function", "in", "select", "until", "time", "coproc"],
+ "decl": [],
+ "builtin": ["echo", "cd", "ls", "pwd", "cat", "grep", "sed", "awk", "export", "source",
+ "exit", "return", "read", "printf", "test", "set", "unset", "shift", "local",
+ "sudo", "mkdir", "rm", "cp", "mv", "chmod", "chown"],
+ "func": ["function"],
+ },
+ "ruby": {
+ "line_comment": r"#[^\n]*",
+ "block_comment": None,
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^']*'"],
+ "kw": ["if", "elsif", "else", "unless", "while", "until", "for", "do", "end", "begin",
+ "rescue", "ensure", "case", "when", "then", "return", "yield", "require",
+ "require_relative", "include", "extend", "and", "or", "not", "next", "break",
+ "redo", "retry", "alias", "defined"],
+ "decl": ["def", "class", "module"],
+ "builtin": ["self", "super", "nil", "true", "false", "puts", "print", "gets", "raise",
+ "lambda", "proc", "attr_accessor", "attr_reader", "attr_writer"],
+ "func": ["def", "class", "module"],
+ },
+ "php": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["if", "else", "elseif", "for", "foreach", "while", "do", "switch", "case",
+ "break", "continue", "return", "function", "class", "interface", "trait",
+ "extends", "implements", "public", "private", "protected", "static", "final",
+ "abstract", "new", "echo", "print", "include", "require", "namespace", "use",
+ "as", "global", "const", "try", "catch", "finally", "throw", "instanceof",
+ "isset", "unset", "empty"],
+ "decl": [],
+ "builtin": ["true", "false", "null", "array", "self", "parent", "$this"],
+ "func": ["function", "class", "interface", "trait"],
+ },
+ "sql": {
+ "line_comment": r"--[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r"'[^']*'"],
+ "kw": ["select", "from", "where", "insert", "update", "delete", "create", "drop",
+ "alter", "table", "index", "view", "join", "inner", "outer", "left", "right",
+ "full", "on", "group", "by", "order", "having", "limit", "offset", "and", "or",
+ "not", "null", "in", "like", "between", "exists", "case", "when", "then",
+ "else", "end", "as", "distinct", "union", "all", "into", "values", "set",
+ "primary", "key", "foreign", "references", "default"],
+ "decl": [],
+ "builtin": ["count", "sum", "avg", "min", "max", "now", "concat", "substring"],
+ "func": [],
+ },
+ "markdown": {
+ "line_comment": None,
+ "block_comment": None,
+ "strings": [r"`[^`]*`"],
+ "kw": [],
+ "decl": [],
+ "builtin": [],
+ "func": [],
+ },
+ "lua": {
+ "line_comment": r"--[^\n]*",
+ "block_comment": ("--[[", "]]"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["and", "break", "do", "else", "elseif", "end", "false", "for", "function",
+ "goto", "if", "in", "local", "nil", "not", "or", "repeat", "return", "then",
+ "true", "until", "while"],
+ "decl": [],
+ "builtin": ["print", "pairs", "ipairs", "table", "string", "math", "os", "io", "type",
+ "tostring", "tonumber", "pcall", "xpcall", "error", "assert", "select",
+ "next", "rawget", "rawset"],
+ "func": ["function"],
+ },
+ "csharp": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["abstract", "as", "base", "break", "case", "catch", "checked", "class", "const",
+ "continue", "default", "delegate", "do", "else", "enum", "event", "explicit",
+ "extern", "finally", "fixed", "for", "foreach", "goto", "if", "implicit", "in",
+ "interface", "internal", "is", "lock", "namespace", "new", "null", "operator",
+ "out", "override", "params", "private", "protected", "public", "readonly",
+ "ref", "return", "sealed", "sizeof", "stackalloc", "static", "struct", "switch",
+ "this", "throw", "try", "typeof", "unchecked", "unsafe", "using", "virtual",
+ "volatile", "while", "var", "async", "await", "yield", "record"],
+ "decl": ["int", "float", "double", "char", "bool", "byte", "short", "long", "string",
+ "object", "void", "decimal", "uint", "ulong", "ushort", "sbyte"],
+ "builtin": ["true", "false", "null", "nameof", "sizeof", "typeof"],
+ "func": ["class", "interface", "struct", "enum", "record"],
+ },
+ "kotlin": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if",
+ "in", "interface", "is", "null", "object", "package", "return", "super", "this",
+ "throw", "true", "try", "typealias", "typeof", "val", "var", "when", "while",
+ "by", "catch", "constructor", "finally", "get", "import", "init", "param",
+ "set", "where", "actual", "abstract", "annotation", "companion", "const",
+ "crossinline", "data", "enum", "expect", "external", "final", "infix", "inline",
+ "inner", "internal", "lateinit", "noinline", "open", "operator", "out",
+ "override", "private", "protected", "public", "reified", "sealed", "suspend",
+ "tailrec", "vararg"],
+ "decl": ["Int", "Float", "Double", "Boolean", "Char", "String", "Unit", "Any", "Nothing",
+ "Long", "Short", "Byte", "List", "Map", "Set"],
+ "builtin": ["println", "print", "null", "true", "false"],
+ "func": ["fun", "class", "interface", "object", "data"],
+ },
+ "swift": {
+ "line_comment": r"//[^\n]*",
+ "block_comment": ("/*", "*/"),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["if", "else", "for", "while", "repeat", "switch", "case", "break", "continue",
+ "return", "guard", "defer", "do", "try", "catch", "throw", "throws", "import",
+ "init", "deinit", "subscript", "typealias", "associatedtype", "inout", "lazy",
+ "weak", "unowned", "static", "final", "override", "public", "private",
+ "internal", "open", "fileprivate", "where", "as", "is", "in", "await"],
+ "decl": ["class", "struct", "enum", "protocol", "extension", "func", "var", "let"],
+ "builtin": ["Int", "Float", "Double", "String", "Bool", "Array", "Dictionary", "Set",
+ "Optional", "self", "super", "true", "false", "nil"],
+ "func": ["func", "class", "struct", "enum", "protocol"],
+ },
+ "r": {
+ "line_comment": r"#[^\n]*",
+ "block_comment": None,
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": ["if", "else", "repeat", "while", "function", "for", "in", "next", "break"],
+ "decl": [],
+ "builtin": ["TRUE", "FALSE", "NULL", "Inf", "NaN", "NA", "print", "c", "list",
+ "data.frame", "matrix", "length", "names"],
+ "func": ["function"],
+ },
+ "matlab": {
+ "line_comment": r"%[^\n]*",
+ "block_comment": ("%{", "%}"),
+ "strings": [r"'[^']*'"],
+ "kw": ["if", "else", "elseif", "for", "while", "end", "function", "switch", "case",
+ "otherwise", "try", "catch", "return", "break", "continue", "global", "persistent",
+ "classdef", "properties", "methods", "events"],
+ "decl": [],
+ "builtin": ["true", "false", "inf", "nan", "pi", "zeros", "ones", "eye", "size",
+ "length", "disp", "plot", "figure", "sqrt", "abs"],
+ "func": ["function"],
+ },
+ "yaml": {
+ "line_comment": r"#[^\n]*",
+ "block_comment": None,
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^']*'"],
+ "kw": [],
+ "decl": [],
+ "builtin": ["true", "false", "null", "yes", "no", "on", "off"],
+ "func": [],
+ },
+ "xml": {
+ "line_comment": None,
+ "block_comment": (""),
+ "strings": [r'"[^"\\]*(?:\\.[^"\\]*)*"', r"'[^'\\]*(?:\\.[^'\\]*)*'"],
+ "kw": [],
+ "decl": [],
+ "builtin": [],
+ "func": [],
+ },
+}
+
+
+class SyntaxHighlighter(QSyntaxHighlighter):
+ """Language-aware syntax highlighter using VS Code Dark+ colors."""
+
+ def __init__(self, document, language="python"):
+ super().__init__(document)
+ self._fmt = {
+ "kw": self._fmt_color(C_KEYWORD),
+ "decl": self._fmt_color(C_DECL),
+ "string": self._fmt_color(C_STRING),
+ "comment": self._fmt_color(C_COMMENT),
+ "number": self._fmt_color(C_NUMBER),
+ "func": self._fmt_color(C_FUNC),
+ "builtin": self._fmt_color(C_BUILTIN),
+ }
+ self.block_comment = None
+ self.set_language(language)
+
+ @staticmethod
+ def _fmt_color(color):
+ fmt = QTextCharFormat()
+ fmt.setForeground(QColor(color))
+ return fmt
+
+ def set_language(self, language):
+ spec = LANG_SPECS.get(language) or {}
+ self.block_comment = spec.get("block_comment")
+ rules = []
+
+ def add(pattern, fmt, group=0):
+ rules.append((QRegularExpression(pattern), fmt, group))
+
+ if spec.get("line_comment"):
+ add(spec["line_comment"], self._fmt["comment"])
+ for s in spec.get("strings", []):
+ add(s, self._fmt["string"])
+ add(r"\b[0-9]+(\.[0-9]+)?\b", self._fmt["number"])
+ if spec.get("kw"):
+ add(r"\b(" + "|".join(re.escape(k) for k in spec["kw"]) + r")\b", self._fmt["kw"])
+ if spec.get("decl"):
+ add(r"\b(" + "|".join(re.escape(k) for k in spec["decl"]) + r")\b", self._fmt["decl"])
+ if spec.get("builtin"):
+ add(r"\b(" + "|".join(re.escape(k) for k in spec["builtin"]) + r")\b", self._fmt["builtin"])
+ for kw in spec.get("func", []):
+ add(r"\b" + re.escape(kw) + r"\s+([A-Za-z_]\w*)\b", self._fmt["func"], 1)
+
+ self.rules = rules
+ self.rehighlight()
+
+ def highlightBlock(self, text):
+ for expr, fmt, group in self.rules:
+ it = expr.globalMatch(text)
+ while it.hasNext():
+ m = it.next()
+ if group:
+ start = m.capturedStart(group)
+ length = m.capturedLength(group)
+ else:
+ start = m.capturedStart()
+ length = m.capturedLength()
+ if length > 0:
+ self.setFormat(start, length, fmt)
+
+ if self.block_comment:
+ self._apply_block_comment(text)
+ else:
+ self.setCurrentBlockState(0)
+
+ def _apply_block_comment(self, text):
+ start_marker, end_marker = self.block_comment
+ length = len(text)
+ if self.previousBlockState() == 1:
+ end_idx = text.find(end_marker)
+ if end_idx == -1:
+ self.setFormat(0, length, self._fmt["comment"])
+ self.setCurrentBlockState(1)
+ return
+ self.setFormat(0, end_idx + len(end_marker), self._fmt["comment"])
+ rest = end_idx + len(end_marker)
+ else:
+ rest = 0
+
+ idx = text.find(start_marker, rest)
+ while idx != -1:
+ end_idx = text.find(end_marker, idx + len(start_marker))
+ if end_idx == -1:
+ self.setFormat(idx, length - idx, self._fmt["comment"])
+ self.setCurrentBlockState(1)
+ return
+ self.setFormat(idx, end_idx + len(end_marker) - idx, self._fmt["comment"])
+ idx = text.find(start_marker, end_idx + len(end_marker))
+ self.setCurrentBlockState(0)
+
+
+class CodeEditor(QPlainTextEdit):
+ def __init__(self):
+ super().__init__()
+ self.setFont(QFont(EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE))
+ self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" "))
+ self.setStyleSheet(f"""
+ QPlainTextEdit {{
+ background-color: {EDITOR_BG};
+ color: {EDITOR_FG};
+ border: none;
+ selection-background-color: {EDITOR_SELECTION};
+ }}
+ """)
+ self.highlighter = SyntaxHighlighter(self.document(), "python")
+ self.setLineWrapMode(QPlainTextEdit.NoWrap)
+
+ self.line_number_area = LineNumberArea(self)
+ self.blockCountChanged.connect(self._update_width)
+ self.updateRequest.connect(self._update_area)
+ self.cursorPositionChanged.connect(self._highlight_current)
+ self._update_width()
+ self._highlight_current()
+
+ def set_language(self, language):
+ self.highlighter.set_language(language)
+
+ def _update_width(self):
+ self.setViewportMargins(self._area_width(), 0, 0, 0)
+
+ def _area_width(self):
+ digits = len(str(max(1, self.blockCount())))
+ return 16 + self.fontMetrics().horizontalAdvance("9") * digits
+
+ def _update_area(self, rect, dy):
+ if dy:
+ self.line_number_area.scroll(0, dy)
+ else:
+ self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
+ if rect.contains(self.viewport().rect()):
+ self._update_width()
+
+ def _highlight_current(self):
+ extra = []
+ if not self.isReadOnly():
+ selection = QTextEdit.ExtraSelection()
+ selection.format.setBackground(QColor(EDITOR_CURRENT_LINE))
+ selection.format.setProperty(QTextFormat.FullWidthSelection, True)
+ selection.cursor = self.textCursor()
+ selection.cursor.clearSelection()
+ extra.append(selection)
+ self.setExtraSelections(extra)
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ cr = self.contentsRect()
+ self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self._area_width(), cr.height()))
+
+ def line_number_area_paint(self, event):
+ painter = QPainter(self.line_number_area)
+ painter.fillRect(event.rect(), QColor(EDITOR_GUTTER))
+ block = self.firstVisibleBlock()
+ block_number = block.blockNumber()
+ top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
+ bottom = top + round(self.blockBoundingRect(block).height())
+ while block.isValid() and top <= event.rect().bottom():
+ if block.isVisible() and bottom >= event.rect().top():
+ painter.setPen(QColor(EDITOR_GUTTER_FG))
+ painter.drawText(0, top, self.line_number_area.width() - 6, self.fontMetrics().height(),
+ Qt.AlignRight, str(block_number + 1))
+ block = block.next()
+ top = bottom
+ bottom = top + round(self.blockBoundingRect(block).height())
+ block_number += 1
+
+
+class LineNumberArea(QWidget):
+ def __init__(self, editor):
+ super().__init__(editor)
+ self.editor = editor
+
+ def sizeHint(self):
+ return QSize(self.editor._area_width(), 0)
+
+ def paintEvent(self, event):
+ self.editor.line_number_area_paint(event)
+
+
+class RenderThread(QThread):
+ done = pyqtSignal(str)
+ failed = pyqtSignal(str)
+ status = pyqtSignal(str)
+
+ def __init__(self, cfg, parent=None):
+ super().__init__(parent)
+ self.cfg = cfg
+
+ def _find_output(self, filename):
+ cwd = Path.cwd()
+ candidates = sorted(cwd.rglob(filename), key=lambda p: p.stat().st_mtime, reverse=True)
+ if candidates:
+ return str(candidates[0])
+ raise FileNotFoundError(
+ f"Rendered output '{filename}' was not found under {cwd}. "
+ "Check the Manim media directory."
+ )
+
+ def run(self):
+ try:
+ c = self.cfg
+ self.status.emit("Rendering typing animation (Manim)...")
+ renderer = CameraFollowCursorCV(
+ code=("string", c["code"]),
+ language=c["language"],
+ formatter_style="vscode-dark-plus",
+ video_name=c["video_name"],
+ background_color=c["background_color"],
+ line_highlight_color=c["line_highlight_color"],
+ interval_range=(c["interval_min"], c["interval_max"]),
+ camera_scale=c["camera_scale"],
+ autocomplete=c["autocomplete"],
+ autocomplete_wait_time=c["autocomplete_wait_time"],
+ chinese_ime=c["chinese_ime"],
+ ime_wait_time=c["ime_wait_time"],
+ clear_code=c["clear_code"],
+ clear_code_mode=c["clear_code_mode"],
+ clear_code_run_time=c["clear_code_run_time"],
+ end_wait_time=c["end_wait_time"],
+ quality=c.get("quality"),
+ frame_rate=c.get("frame_rate"),
+ )
+ renderer.render(output=False)
+
+ video = self._find_output(c["video_name"] + ".mp4")
+
+ if c.get("music"):
+ self.status.emit("Mixing background music...")
+ video = add_background_music(
+ video, c["music"], volume=c["music_volume"],
+ loop=c["music_loop"], mix_original=c["music_mix"],
+ )
+
+ if c.get("background_image"):
+ self.status.emit("Compositing background...")
+ if c.get("background_mode") == "replace":
+ video = replace_background(
+ video, c["background_image"], key_color=c["key_color"],
+ similarity=c["similarity"], blend=c["blend"], blur=c["blur"],
+ )
+ else:
+ video = add_background(video, image=c["background_image"], blur=c["blur"])
+
+ if c.get("sound"):
+ self.status.emit("Adding sound effect...")
+ video = add_sound(video, c["sound"], at=c["sound_at"], volume=c["sound_volume"])
+
+ if c.get("subtitle_file"):
+ self.status.emit("Burning subtitles...")
+ video = add_subtitles(
+ video, c["subtitle_file"],
+ font=c.get("sub_font") or None,
+ fontsize=c.get("sub_fontsize") or None,
+ color=c.get("sub_color") or None,
+ position=c.get("sub_position") or None,
+ )
+
+ if c.get("lyrics"):
+ self.status.emit("Burning lyrics...")
+ video = add_lyrics(
+ video, c["lyrics"],
+ font=c.get("lyrics_font") or "",
+ fontsize=c.get("lyrics_fontsize") or 48,
+ color=c.get("lyrics_color") or "white",
+ highlight_color=c.get("lyrics_highlight") or "yellow",
+ position=c.get("lyrics_position") or "bottom-center",
+ margin=c.get("lyrics_margin") or 80,
+ karaoke=c.get("karaoke", True),
+ )
+
+ if c.get("watermark_text") or c.get("watermark_image"):
+ self.status.emit("Adding watermark...")
+ video = add_watermark(
+ video,
+ text=c.get("watermark_text") or None,
+ image_path=c.get("watermark_image") or None,
+ position=c.get("watermark_position") or "bottom-right",
+ fontsize=c.get("watermark_fontsize") or 24,
+ opacity=c.get("watermark_opacity") or 0.6,
+ color=c.get("watermark_color") or "white",
+ )
+
+ self.status.emit("Done.")
+ self.done.emit(video)
+ except Exception:
+ self.failed.emit(traceback.format_exc())
+
+
+class MainWindow(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("CodeVideoRenderer Editor")
+ self.resize(1280, 800)
+
+ self.editor = CodeEditor()
+ self.editor.setPlainText(
+ 'def fibonacci(n):\n'
+ ' """Return the n-th Fibonacci number."""\n'
+ ' a, b = 0, 1\n'
+ ' for _ in range(n):\n'
+ ' a, b = b, a + b\n'
+ ' return a\n'
+ '\n'
+ 'print(fibonacci(10))\n'
+ )
+
+ self.settings = self._build_settings()
+
+ splitter = QSplitter(Qt.Horizontal)
+ splitter.addWidget(self.editor)
+ splitter.addWidget(self.settings)
+ splitter.setSizes([760, 520])
+
+ self.status = QTextBrowser()
+ self.status.setMaximumHeight(120)
+ self.status.setStyleSheet(f"background-color: {EDITOR_CURRENT_LINE}; color: {EDITOR_FG}; border: none;")
+
+ self.render_btn = QPushButton("Render Video")
+ self.render_btn.setMinimumHeight(36)
+ self.render_btn.clicked.connect(self._start_render)
+
+ self.preview_btn = QPushButton("Quick Preview")
+ self.preview_btn.setMinimumHeight(36)
+ self.preview_btn.clicked.connect(self._start_preview)
+
+ self.play_btn = QPushButton("Play Video")
+ self.play_btn.setMinimumHeight(36)
+ self.play_btn.setEnabled(False)
+ self.play_btn.clicked.connect(self._play_video)
+
+ self.open_btn = QPushButton("Open Folder")
+ self.open_btn.setMinimumHeight(36)
+ self.open_btn.clicked.connect(self._open_output_folder)
+
+ self.result_label = QLabel("")
+ self.result_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ self.result_label.setStyleSheet(f"color: #9CDCFE;")
+
+ central = QWidget()
+ layout = QVBoxLayout(central)
+ layout.addWidget(splitter)
+ layout.addWidget(self.status)
+ bottom = QHBoxLayout()
+ bottom.addWidget(self.render_btn, 1)
+ bottom.addWidget(self.preview_btn, 1)
+ bottom.addWidget(self.play_btn, 1)
+ bottom.addWidget(self.open_btn, 1)
+ bottom.addWidget(self.result_label, 3)
+ layout.addLayout(bottom)
+ self.setCentralWidget(central)
+
+ self.thread = None
+ self._last_output = None
+
+ def _add_row(self, form, label, widget):
+ form.addRow(label, widget)
+ return widget
+
+ def _build_settings(self):
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setMinimumWidth(380)
+ panel = QWidget()
+ form = QFormLayout(panel)
+ form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+
+ self.language = QComboBox()
+ self.language.addItems(LANGUAGES)
+ self.language.currentTextChanged.connect(self.editor.set_language)
+ form.addRow("Language", self.language)
+
+ self.video_name = QLineEdit("code_video")
+ form.addRow("Video name", self.video_name)
+
+ self.quality = QComboBox()
+ self.quality.addItems(QUALITIES)
+ self.quality.setCurrentText("medium_quality")
+ form.addRow("Quality", self.quality)
+
+ self.frame_rate = QSpinBox()
+ self.frame_rate.setRange(15, 60)
+ self.frame_rate.setValue(30)
+ form.addRow("Frame rate", self.frame_rate)
+
+ self.background_color = QLineEdit("#23272e")
+ bg_color_btn = QPushButton("Pick")
+ bg_color_btn.clicked.connect(self._pick_background_color)
+ bg_row = QWidget()
+ bg_lay = QHBoxLayout(bg_row)
+ bg_lay.setContentsMargins(0, 0, 0, 0)
+ bg_lay.addWidget(self.background_color)
+ bg_lay.addWidget(bg_color_btn)
+ form.addRow("Background", bg_row)
+
+ self.line_highlight_color = QLineEdit("#2c313c")
+ hl_color_btn = QPushButton("Pick")
+ hl_color_btn.clicked.connect(self._pick_line_highlight_color)
+ hl_row = QWidget()
+ hl_lay = QHBoxLayout(hl_row)
+ hl_lay.setContentsMargins(0, 0, 0, 0)
+ hl_lay.addWidget(self.line_highlight_color)
+ hl_lay.addWidget(hl_color_btn)
+ form.addRow("Line highlight", hl_row)
+
+ self.interval_min = QDoubleSpinBox()
+ self.interval_min.setRange(0.01, 2.0)
+ self.interval_min.setSingleStep(0.01)
+ self.interval_min.setValue(0.03)
+ self.interval_max = QDoubleSpinBox()
+ self.interval_max.setRange(0.01, 2.0)
+ self.interval_max.setSingleStep(0.01)
+ self.interval_max.setValue(0.06)
+ interval_row = QWidget()
+ interval_lay = QHBoxLayout(interval_row)
+ interval_lay.setContentsMargins(0, 0, 0, 0)
+ interval_lay.addWidget(self.interval_min)
+ interval_lay.addWidget(QLabel("-"))
+ interval_lay.addWidget(self.interval_max)
+ form.addRow("Type interval (s)", interval_row)
+
+ self.camera_scale = QDoubleSpinBox()
+ self.camera_scale.setRange(0.1, 2.0)
+ self.camera_scale.setSingleStep(0.05)
+ self.camera_scale.setValue(0.5)
+ form.addRow("Camera scale", self.camera_scale)
+
+ self.autocomplete = QCheckBox()
+ self.autocomplete.setChecked(True)
+ self.autocomplete_wait_time = QDoubleSpinBox()
+ self.autocomplete_wait_time.setRange(0.1, 5.0)
+ self.autocomplete_wait_time.setValue(0.6)
+ form.addRow("Autocomplete", self.autocomplete)
+ form.addRow("Autocomplete wait", self.autocomplete_wait_time)
+
+ self.chinese_ime = QCheckBox()
+ self.chinese_ime.setChecked(True)
+ self.ime_wait_time = QDoubleSpinBox()
+ self.ime_wait_time.setRange(0.1, 5.0)
+ self.ime_wait_time.setValue(0.6)
+ form.addRow("Chinese IME", self.chinese_ime)
+ form.addRow("IME wait", self.ime_wait_time)
+
+ self.clear_code = QCheckBox()
+ self.clear_code_mode = QComboBox()
+ self.clear_code_mode.addItems(["backspace", "fade"])
+ self.clear_code_run_time = QDoubleSpinBox()
+ self.clear_code_run_time.setRange(0.1, 10.0)
+ self.clear_code_run_time.setValue(1.0)
+ form.addRow("Clear code", self.clear_code)
+ form.addRow("Clear mode", self.clear_code_mode)
+ form.addRow("Clear time", self.clear_code_run_time)
+
+ self.end_wait_time = QDoubleSpinBox()
+ self.end_wait_time.setRange(0.0, 10.0)
+ self.end_wait_time.setValue(1.0)
+ form.addRow("End wait (s)", self.end_wait_time)
+
+ form.addRow(QLabel("Music"), QWidget())
+
+ self.music = QLineEdit()
+ music_btn = QPushButton("Browse")
+ music_btn.clicked.connect(lambda: self._browse_file(self.music, "Audio (*.mp3 *.wav *.m4a *.aac)"))
+ music_row = self._file_row(self.music, music_btn)
+ form.addRow("Music file", music_row)
+
+ self.music_volume = QDoubleSpinBox()
+ self.music_volume.setRange(0.0, 2.0)
+ self.music_volume.setSingleStep(0.05)
+ self.music_volume.setValue(0.3)
+ form.addRow("Music volume", self.music_volume)
+ self.music_loop = QCheckBox()
+ self.music_loop.setChecked(True)
+ self.music_mix = QCheckBox()
+ self.music_mix.setChecked(True)
+ form.addRow("Loop music", self.music_loop)
+ form.addRow("Mix with video audio", self.music_mix)
+
+ form.addRow(QLabel("Background"), QWidget())
+
+ self.background_image = QLineEdit()
+ bg_img_btn = QPushButton("Browse")
+ bg_img_btn.clicked.connect(lambda: self._browse_file(self.background_image, "Images (*.png *.jpg *.jpeg *.webp)"))
+ form.addRow("Background image", self._file_row(self.background_image, bg_img_btn))
+
+ self.background_mode = QComboBox()
+ self.background_mode.addItems(["overlay (transparent code)", "replace (chroma key)"])
+ form.addRow("Mode", self.background_mode)
+
+ self.key_color = QLineEdit("0x00FF00")
+ form.addRow("Key color", self.key_color)
+ self.similarity = QDoubleSpinBox()
+ self.similarity.setRange(0.0, 1.0)
+ self.similarity.setSingleStep(0.01)
+ self.similarity.setValue(0.3)
+ form.addRow("Similarity", self.similarity)
+ self.blend = QDoubleSpinBox()
+ self.blend.setRange(0.0, 1.0)
+ self.blend.setSingleStep(0.01)
+ self.blend.setValue(0.1)
+ form.addRow("Blend", self.blend)
+ self.blur = QDoubleSpinBox()
+ self.blur.setRange(0.0, 50.0)
+ self.blur.setValue(0.0)
+ form.addRow("Blur background", self.blur)
+
+ form.addRow(QLabel("Sound effect"), QWidget())
+
+ self.sound = QLineEdit()
+ sound_btn = QPushButton("Browse")
+ sound_btn.clicked.connect(lambda: self._browse_file(self.sound, "Audio (*.mp3 *.wav *.m4a *.aac)"))
+ form.addRow("Sound file", self._file_row(self.sound, sound_btn))
+ self.sound_at = QDoubleSpinBox()
+ self.sound_at.setRange(0.0, 3600.0)
+ self.sound_at.setValue(0.0)
+ form.addRow("Sound at (s)", self.sound_at)
+ self.sound_volume = QDoubleSpinBox()
+ self.sound_volume.setRange(0.0, 2.0)
+ self.sound_volume.setSingleStep(0.05)
+ self.sound_volume.setValue(1.0)
+ form.addRow("Sound volume", self.sound_volume)
+
+ form.addRow(QLabel("Subtitle file"), QWidget())
+
+ self.subtitle_file = QLineEdit()
+ sub_btn = QPushButton("Browse")
+ sub_btn.clicked.connect(lambda: self._browse_file(self.subtitle_file, "Subtitles (*.srt *.ass *.vtt)"))
+ form.addRow("Subtitle file", self._file_row(self.subtitle_file, sub_btn))
+ self.sub_font = QLineEdit("")
+ form.addRow("Subtitle font", self.sub_font)
+ self.sub_fontsize = QSpinBox()
+ self.sub_fontsize.setRange(1, 200)
+ self.sub_fontsize.setValue(24)
+ form.addRow("Subtitle size", self.sub_fontsize)
+ self.sub_color = QLineEdit("white")
+ form.addRow("Subtitle color", self.sub_color)
+ self.sub_position = QComboBox()
+ self.sub_position.addItems(POSITIONS)
+ self.sub_position.setCurrentText("bottom-center")
+ form.addRow("Subtitle position", self.sub_position)
+
+ form.addRow(QLabel("Lyrics / music subtitles"), QWidget())
+
+ self.lyrics_font = QLineEdit("")
+ form.addRow("Lyrics font", self.lyrics_font)
+ self.lyrics_fontsize = QSpinBox()
+ self.lyrics_fontsize.setRange(1, 300)
+ self.lyrics_fontsize.setValue(48)
+ form.addRow("Lyrics size", self.lyrics_fontsize)
+ self.lyrics_color = QLineEdit("white")
+ form.addRow("Lyrics color", self.lyrics_color)
+ self.lyrics_highlight = QLineEdit("yellow")
+ form.addRow("Highlight color", self.lyrics_highlight)
+ self.lyrics_position = QComboBox()
+ self.lyrics_position.addItems(POSITIONS)
+ self.lyrics_position.setCurrentText("bottom-center")
+ form.addRow("Lyrics position", self.lyrics_position)
+ self.karaoke = QCheckBox()
+ self.karaoke.setChecked(True)
+ form.addRow("Karaoke highlight", self.karaoke)
+
+ self.lyrics_table = QTableWidget(0, 3)
+ self.lyrics_table.setHorizontalHeaderLabels(["Start (s)", "End (s)", "Text"])
+ self.lyrics_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
+ self.lyrics_table.setMinimumHeight(120)
+ form.addRow("Lyrics", self.lyrics_table)
+
+ lyrics_btn_row = QWidget()
+ lyrics_btn_lay = QHBoxLayout(lyrics_btn_row)
+ lyrics_btn_lay.setContentsMargins(0, 0, 0, 0)
+ add_lyrics_btn = QPushButton("+ Row")
+ add_lyrics_btn.clicked.connect(self._add_lyric_row)
+ del_lyrics_btn = QPushButton("- Row")
+ del_lyrics_btn.clicked.connect(self._del_lyric_row)
+ lyrics_btn_lay.addWidget(add_lyrics_btn)
+ lyrics_btn_lay.addWidget(del_lyrics_btn)
+ lyrics_btn_lay.addStretch()
+ form.addRow("", lyrics_btn_row)
+
+ form.addRow(QLabel("Watermark"), QWidget())
+
+ self.watermark_text = QLineEdit("")
+ form.addRow("Watermark text", self.watermark_text)
+ self.watermark_image = QLineEdit()
+ wm_btn = QPushButton("Browse")
+ wm_btn.clicked.connect(lambda: self._browse_file(self.watermark_image, "Images (*.png *.jpg *.jpeg *.webp)"))
+ form.addRow("Watermark image", self._file_row(self.watermark_image, wm_btn))
+ self.watermark_position = QComboBox()
+ self.watermark_position.addItems(POSITIONS)
+ self.watermark_position.setCurrentText("bottom-right")
+ form.addRow("Watermark position", self.watermark_position)
+ self.watermark_fontsize = QSpinBox()
+ self.watermark_fontsize.setRange(1, 200)
+ self.watermark_fontsize.setValue(24)
+ form.addRow("Watermark size", self.watermark_fontsize)
+ self.watermark_opacity = QDoubleSpinBox()
+ self.watermark_opacity.setRange(0.05, 1.0)
+ self.watermark_opacity.setSingleStep(0.05)
+ self.watermark_opacity.setValue(0.6)
+ form.addRow("Watermark opacity", self.watermark_opacity)
+ self.watermark_color = QLineEdit("white")
+ form.addRow("Watermark color", self.watermark_color)
+
+ scroll.setWidget(panel)
+ return scroll
+
+ def _file_row(self, line_edit, browse_btn):
+ w = QWidget()
+ lay = QHBoxLayout(w)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.addWidget(line_edit)
+ lay.addWidget(browse_btn)
+ return w
+
+ def _browse_file(self, line_edit, pattern):
+ path, _ = QFileDialog.getOpenFileName(self, "Select file", "", pattern)
+ if path:
+ line_edit.setText(path)
+
+ def _pick_background_color(self):
+ color = QColorDialog.getColor(QColor(self.background_color.text()), self, "Background color")
+ if color.isValid():
+ self.background_color.setText(color.name())
+
+ def _pick_line_highlight_color(self):
+ color = QColorDialog.getColor(QColor(self.line_highlight_color.text()), self, "Line highlight color")
+ if color.isValid():
+ self.line_highlight_color.setText(color.name())
+
+ def _add_lyric_row(self):
+ row = self.lyrics_table.rowCount()
+ self.lyrics_table.insertRow(row)
+ self.lyrics_table.setItem(row, 0, QTableWidgetItem("0.0"))
+ self.lyrics_table.setItem(row, 1, QTableWidgetItem("3.0"))
+ self.lyrics_table.setItem(row, 2, QTableWidgetItem(""))
+
+ def _del_lyric_row(self):
+ row = self.lyrics_table.currentRow()
+ if row >= 0:
+ self.lyrics_table.removeRow(row)
+
+ def _collect_lyrics(self):
+ lyrics = []
+ for row in range(self.lyrics_table.rowCount()):
+ try:
+ start = float(self.lyrics_table.item(row, 0).text())
+ end = float(self.lyrics_table.item(row, 1).text())
+ except (ValueError, AttributeError):
+ continue
+ text_item = self.lyrics_table.item(row, 2)
+ text = text_item.text().strip() if text_item else ""
+ if text and end > start:
+ lyrics.append((start, end, text))
+ return lyrics
+
+ def _collect_cfg(self):
+ video_name = self.video_name.text().strip() or "code_video"
+ return {
+ "code": self.editor.toPlainText(),
+ "language": self.language.currentText(),
+ "video_name": video_name,
+ "quality": self.quality.currentText(),
+ "frame_rate": self.frame_rate.value(),
+ "background_color": self.background_color.text().strip() or "#23272e",
+ "line_highlight_color": self.line_highlight_color.text().strip() or "#2c313c",
+ "interval_min": self.interval_min.value(),
+ "interval_max": self.interval_max.value(),
+ "camera_scale": self.camera_scale.value(),
+ "autocomplete": self.autocomplete.isChecked(),
+ "autocomplete_wait_time": self.autocomplete_wait_time.value(),
+ "chinese_ime": self.chinese_ime.isChecked(),
+ "ime_wait_time": self.ime_wait_time.value(),
+ "clear_code": self.clear_code.isChecked(),
+ "clear_code_mode": self.clear_code_mode.currentText(),
+ "clear_code_run_time": self.clear_code_run_time.value(),
+ "end_wait_time": self.end_wait_time.value(),
+ "music": self.music.text().strip() or None,
+ "music_volume": self.music_volume.value(),
+ "music_loop": self.music_loop.isChecked(),
+ "music_mix": self.music_mix.isChecked(),
+ "background_image": self.background_image.text().strip() or None,
+ "background_mode": "replace" if self.background_mode.currentIndex() == 1 else "overlay",
+ "key_color": self.key_color.text().strip() or "0x00FF00",
+ "similarity": self.similarity.value(),
+ "blend": self.blend.value(),
+ "blur": self.blur.value(),
+ "sound": self.sound.text().strip() or None,
+ "sound_at": self.sound_at.value(),
+ "sound_volume": self.sound_volume.value(),
+ "subtitle_file": self.subtitle_file.text().strip() or None,
+ "sub_font": self.sub_font.text().strip() or None,
+ "sub_fontsize": self.sub_fontsize.value(),
+ "sub_color": self.sub_color.text().strip() or None,
+ "sub_position": self.sub_position.currentText(),
+ "lyrics_font": self.lyrics_font.text().strip() or None,
+ "lyrics_fontsize": self.lyrics_fontsize.value(),
+ "lyrics_color": self.lyrics_color.text().strip() or "white",
+ "lyrics_highlight": self.lyrics_highlight.text().strip() or "yellow",
+ "lyrics_position": self.lyrics_position.currentText(),
+ "karaoke": self.karaoke.isChecked(),
+ "lyrics": self._collect_lyrics(),
+ "watermark_text": self.watermark_text.text().strip() or None,
+ "watermark_image": self.watermark_image.text().strip() or None,
+ "watermark_position": self.watermark_position.currentText(),
+ "watermark_fontsize": self.watermark_fontsize.value(),
+ "watermark_opacity": self.watermark_opacity.value(),
+ "watermark_color": self.watermark_color.text().strip() or "white",
+ }
+
+ def _start_render(self):
+ self._start(False)
+
+ def _start_preview(self):
+ self._start(True)
+
+ def _start(self, preview):
+ if self.thread is not None and self.thread.isRunning():
+ return
+ code = self.editor.toPlainText()
+ if not code.strip():
+ QMessageBox.warning(self, "Empty code", "Write some code first.")
+ return
+
+ cfg = self._collect_cfg()
+ if preview:
+ cfg["video_name"] = (cfg["video_name"] or "code_video") + "_preview"
+ cfg["quality"] = "low_quality"
+ cfg["frame_rate"] = 15
+ cfg["end_wait_time"] = 0.2
+ cfg["clear_code"] = False
+
+ self.render_btn.setEnabled(False)
+ self.preview_btn.setEnabled(False)
+ self.status.clear()
+ self.result_label.setText("")
+ self.thread = RenderThread(cfg)
+ self.thread.status.connect(lambda msg: self.status.append(msg))
+ self.thread.done.connect(self._on_done)
+ self.thread.failed.connect(self._on_failed)
+ self.thread.start()
+
+ def _on_done(self, path):
+ self.render_btn.setEnabled(True)
+ self.preview_btn.setEnabled(True)
+ self.play_btn.setEnabled(True)
+ self._last_output = path
+ self.result_label.setText(f"Ready: {path}")
+ self._play_video()
+
+ def _on_failed(self, err):
+ self.render_btn.setEnabled(True)
+ self.preview_btn.setEnabled(True)
+ self.status.append("ERROR:\n" + err)
+
+ def _open_output_folder(self):
+ folder = Path(self._last_output).parent if self._last_output else Path.cwd() / "media" / "videos"
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(folder)))
+
+ def _play_video(self):
+ if not self._last_output or not os.path.isfile(self._last_output):
+ return
+ self._player_dialog = VideoPlayerDialog(self._last_output, self)
+ self._player_dialog.show()
+
+
+class VideoPlayerDialog(QDialog):
+ def __init__(self, video_path, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Video Preview")
+ self.resize(960, 620)
+
+ layout = QVBoxLayout(self)
+ self.video_widget = QVideoWidget()
+ layout.addWidget(self.video_widget, 1)
+
+ self.player = QMediaPlayer(self)
+ self.player.setVideoOutput(self.video_widget)
+ self.player.setMedia(QMediaContent(QUrl.fromLocalFile(video_path)))
+
+ controls = QHBoxLayout()
+ self.play_pause_btn = QPushButton("Pause")
+ self.play_pause_btn.clicked.connect(self._toggle)
+ controls.addWidget(self.play_pause_btn)
+ self.slider = QSlider(Qt.Horizontal)
+ self.slider.setRange(0, 0)
+ self.slider.sliderMoved.connect(self.player.setPosition)
+ controls.addWidget(self.slider, 1)
+ self.time_label = QLabel("00:00 / 00:00")
+ controls.addWidget(self.time_label)
+ layout.addLayout(controls)
+
+ self.player.positionChanged.connect(self._on_position)
+ self.player.durationChanged.connect(self._on_duration)
+ self.player.stateChanged.connect(self._on_state)
+ self.player.error.connect(self._on_error)
+ self.player.play()
+
+ def _toggle(self):
+ if self.player.state() == QMediaPlayer.PlayingState:
+ self.player.pause()
+ else:
+ self.player.play()
+
+ def _on_duration(self, ms):
+ self.slider.setRange(0, ms)
+ self._update_time()
+
+ def _on_position(self, ms):
+ self.slider.setValue(ms)
+ self._update_time()
+
+ def _update_time(self):
+ self.time_label.setText(f"{self._fmt(self.player.position())} / {self._fmt(self.player.duration())}")
+
+ def _on_state(self, state):
+ self.play_pause_btn.setText("Pause" if state == QMediaPlayer.PlayingState else "Play")
+
+ def _on_error(self, error):
+ self.time_label.setText(f"Playback error: {self.player.errorString()}")
+
+ @staticmethod
+ def _fmt(ms):
+ s = ms // 1000
+ return f"{s // 60:02d}:{s % 60:02d}"
+
+ def closeEvent(self, event):
+ self.player.stop()
+ super().closeEvent(event)
+
+
+def main():
+ QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
+ QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
+ app = QApplication(sys.argv)
+ app.setStyle("Fusion")
+
+ font_path = os.path.join(os.path.dirname(CodeVideoRenderer.__file__), "fonts", "CodeVideoRendererFont.ttf")
+ if os.path.isfile(font_path):
+ QFontDatabase.addApplicationFont(font_path)
+
+ app.setStyleSheet(f"""
+ QWidget {{ background-color: {EDITOR_BG}; color: {EDITOR_FG}; font-size: 14px; }}
+ QLabel {{ background: transparent; }}
+ QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QTableWidget {{
+ background-color: {EDITOR_CURRENT_LINE}; color: {EDITOR_FG};
+ border: 1px solid #3d4556; border-radius: 3px; padding: 3px;
+ }}
+ QPushButton {{
+ background-color: #0e639c; color: white; border: none;
+ border-radius: 3px; padding: 6px 12px;
+ }}
+ QPushButton:hover {{ background-color: #1177bb; }}
+ QPushButton:disabled {{ background-color: #3c3c3c; color: #808080; }}
+ QCheckBox {{ background: transparent; }}
+ QScrollArea {{ border: none; }}
+ QHeaderView::section {{ background-color: {EDITOR_CURRENT_LINE}; color: {EDITOR_FG}; border: 1px solid #3d4556; }}
+ """)
+ win = MainWindow()
+ win.show()
+ sys.exit(app.exec_())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index 481ad4a..505d419 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ CodeVideoRenderer = ["**/*.py", "**/*.ttf"]
[project]
name = "codevideorenderer"
-version = "1.2.3"
+version = "1.5.0"
description = "A Python library for rendering code videos"
readme = "README.md"
requires-python = ">=3.8"
@@ -38,6 +38,7 @@ dependencies = [
"typing-extensions>=4.0.0",
"imageio-ffmpeg>=0.4.0",
"typeguard>=3.0",
+ "pypinyin>=0.49.0",
]
license = {text = "MIT"}
classifiers = [
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 9572a2e..ad0dc79 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -299,6 +299,127 @@ def test_special_characters():
)
assert video is not None
+# --- Post-processing helpers -------------------------------------------------
+
+def test_postprocess_functions_imported():
+ """All post-processing helpers should be importable and callable."""
+ from CodeVideoRenderer import (
+ find_ffmpeg, remove_subtitles, add_subtitles, add_background_music,
+ concat_videos, set_quality, add_watermark, add_title_card,
+ extract_cover, extract_audio, remove_audio, trim_video, change_speed,
+ )
+ for fn in (
+ find_ffmpeg, remove_subtitles, add_subtitles, add_background_music,
+ concat_videos, set_quality, add_watermark, add_title_card,
+ extract_cover, extract_audio, remove_audio, trim_video, change_speed,
+ ):
+ assert callable(fn)
+
+
+def test_find_ffmpeg_returns_path():
+ """find_ffmpeg should resolve to an existing executable."""
+ import os
+ from CodeVideoRenderer import find_ffmpeg
+ path = find_ffmpeg()
+ assert isinstance(path, str) and os.path.exists(path)
+
+
+def test_postprocess_validation_errors():
+ """Invalid arguments should raise ValueError early."""
+ import pytest
+ from CodeVideoRenderer import add_watermark, concat_videos, set_quality, change_speed, extract_audio
+
+ with pytest.raises(ValueError):
+ add_watermark("x.mp4") # neither text nor image
+
+ with pytest.raises(ValueError):
+ concat_videos(["only_one.mp4"], "out.mp4") # fewer than two inputs
+
+ with pytest.raises(ValueError):
+ set_quality("x.mp4", resolution="not-a-preset")
+
+ with pytest.raises(ValueError):
+ change_speed("x.mp4", speed=0)
+
+ with pytest.raises(ValueError):
+ extract_audio("x.mp4", format="flac")
+
+
+def test_clear_code_parameter_accepted():
+ """CameraFollowCursorCV should accept the new clear_code parameter."""
+ video = CameraFollowCursorCV(
+ code=('string', 'print("hi")'),
+ language='python',
+ video_name='test_clear_code',
+ clear_code=True,
+ clear_code_run_time=0.5,
+ )
+ assert video is not None
+
+
+def test_new_render_parameters_accepted():
+ """Backspace-delete / autocomplete / theme parameters should be accepted."""
+ video = CameraFollowCursorCV(
+ code=('string', 'def foo():\n return 1'),
+ language='python',
+ video_name='test_new_params',
+ clear_code=True,
+ clear_code_mode='backspace',
+ clear_code_interval=0.02,
+ autocomplete=True,
+ autocomplete_wait_time=0.4,
+ background_color='#1E1E1E',
+ line_highlight_color='#2D2D2D',
+ end_wait_time=0.5,
+ )
+ assert video is not None
+
+
+def test_clear_code_interval_validation():
+ """clear_code_interval must be positive."""
+ with pytest.raises(ValueError):
+ CameraFollowCursorCV(
+ code=('string', 'x = 1'),
+ language='python',
+ video_name='test_bad_interval',
+ clear_code_interval=0,
+ )
+
+
+def test_chinese_ime_parameters_accepted():
+ """The Chinese IME parameters should be accepted."""
+ video = CameraFollowCursorCV(
+ code=('string', '# 注释\nprint("你好世界")'),
+ language='python',
+ video_name='test_ime',
+ autocomplete=True,
+ chinese_ime=True,
+ ime_wait_time=0.3,
+ formatter_style='vscode-dark-plus',
+ )
+ assert video is not None
+
+
+def test_vscode_theme_registered():
+ """The VS Code style and lexer should be registered with Pygments."""
+ from pygments.styles import get_style_by_name
+ from pygments.lexers import get_lexer_by_name
+ assert get_style_by_name("vscode-dark-plus") is not None
+ lexer = get_lexer_by_name("python-vscode")
+ assert lexer is not None
+
+
+def test_ime_helpers():
+ """IME helpers should detect CJK runs and produce hyphenated pinyin."""
+ from CodeVideoRenderer import is_cjk, cjk_run_at, get_ime
+ assert is_cjk("世") is True
+ assert is_cjk("a") is False
+ assert cjk_run_at("# 世界", 3) == (2, 4)
+ pinyin, cands = get_ime("print('世界')", 8)
+ assert pinyin == "shi-jie"
+ assert cands and cands[0] == "世界"
+
+
# Note: The actual render test is commented out to avoid creating video files during testing
# def test_render_functionality():
# """Test actual rendering functionality (creates video file)"""