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)"""