From b24918da413bad94821ae306912b61ba9ddc5f4f Mon Sep 17 00:00:00 2001 From: syk1411119 <19156238772@163.com> Date: Sat, 19 Sep 2026 21:36:54 +0800 Subject: [PATCH] Add VS Code Dark+ theme, Chinese IME, autocomplete, and video post-processing - New `vscode-dark-plus` Pygments style + Python lexer matching VS Code Dark+ colors - VS Code-style autocomplete popup with colored Codicon-style icons, details and selection highlight - Chinese IME simulation: hyphen-separated pinyin + candidate box when typing CJK - Backspace-style code deletion (`clear_code` / `clear_code_mode`) with camera reframe - Video post-processing helpers: subtitles, background music, concat, quality/resolution/fps, watermark, title card, cover - New `chinese_ime` / `ime_wait_time` parameters; default `formatter_style` is now `vscode-dark-plus` - Add `pypinyin` dependency; document new features in README; add tests --- CodeVideoRenderer/__init__.py | 6 + CodeVideoRenderer/ime.py | 110 +++++ CodeVideoRenderer/postprocess.py | 774 ++++++++++++++++++++++++++++++ CodeVideoRenderer/renderer.py | 295 +++++++++++- CodeVideoRenderer/typing.py | 2 +- CodeVideoRenderer/utils.py | 6 +- CodeVideoRenderer/version.py | 14 +- CodeVideoRenderer/vscode_theme.py | 157 ++++++ README.md | 112 ++++- pyproject.toml | 3 +- tests/test_basic.py | 121 +++++ 11 files changed, 1582 insertions(+), 18 deletions(-) create mode 100644 CodeVideoRenderer/ime.py create mode 100644 CodeVideoRenderer/postprocess.py create mode 100644 CodeVideoRenderer/vscode_theme.py diff --git a/CodeVideoRenderer/__init__.py b/CodeVideoRenderer/__init__.py index 67ed6d7..c49451f 100644 --- a/CodeVideoRenderer/__init__.py +++ b/CodeVideoRenderer/__init__.py @@ -19,6 +19,9 @@ ------- * :mod:`~.renderer` — Core rendering engine (:class:`~.CameraFollowCursorCV`). +* :mod:`~.postprocess` — Video post-processing helpers (subtitles, audio, quality, watermarks, …). +* :mod:`~.vscode_theme` — VS Code Dark+ 语法高亮主题与词法器. +* :mod:`~.ime` — 中文输入法(拼音 + 候选词)模拟. * :mod:`~.config` — Default constants and configuration values. * :mod:`~.typing` — Type aliases used across the library. * :mod:`~.utils` — Internal utility functions and helpers. @@ -28,4 +31,7 @@ from .config import * from .typing import * from .utils import * +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__ \ No newline at end of file diff --git a/CodeVideoRenderer/ime.py b/CodeVideoRenderer/ime.py new file mode 100644 index 0000000..91b937c --- /dev/null +++ b/CodeVideoRenderer/ime.py @@ -0,0 +1,110 @@ +"""中文输入法(IME)候选框模拟:拼音 + 候选词。 + +渲染代码时遇到汉字,弹出类似输入法的候选框:上面是带横杠分隔的拼音, +下面用一条横线隔开,再列出候选汉字/词语,尽量贴近真实的 IME。 +""" +from __future__ import annotations + +from typing import List, Tuple + +try: + from pypinyin import pinyin as _py, Style as _Style + _HAS_PYPINYIN = True +except ImportError: # pragma: no cover - 未安装 pypinyin 时退化为原字 + _HAS_PYPINYIN = False + +__all__ = ["is_cjk", "cjk_run_at", "get_ime"] + +# 常见单字/词语的同音候选,让候选框看起来真实(拼音 -> 候选列表) +_CANDIDATES = { + # 单字 + "ni": ["你", "泥", "拟", "尼", "逆"], + "hao": ["好", "号", "浩", "耗", "毫"], + "shi": ["世", "是", "事", "试", "市"], + "jie": ["界", "借", "接", "街", "解"], + "da": ["打", "大", "答", "达", "搭"], + "wan": ["完", "玩", "碗", "晚", "万"], + "dai": ["代", "带", "待", "戴", "贷"], + "ma": ["码", "马", "妈", "吗", "麻"], + "zi": ["自", "字", "资", "紫", "子"], + "dong": ["动", "东", "懂", "冬", "洞"], + "qing": ["清", "请", "情", "轻", "青"], + "ping": ["屏", "平", "评", "苹", "凭"], + "zhong": ["中", "种", "重", "终", "众"], + "wen": ["文", "问", "闻", "温", "稳"], + "yan": ["演", "眼", "盐", "严", "言"], + "hou": ["后", "候", "厚", "喉", "猴"], + "hui": ["会", "回", "惠", "汇", "灰"], + "qi": ["契", "气", "起", "器", "期"], + "shu": ["数", "书", "树", "输", "术"], + "fei": ["斐", "飞", "非", "肥", "费"], + "bo": ["波", "播", "拨", "玻", "伯"], + "na": ["那", "哪", "拿", "纳", "呐"], + "di": ["第", "地", "的", "底", "弟"], + "ge": ["个", "各", "歌", "格", "哥"], + # 双字词(拼接后的全拼) + "nihao": ["你好", "你", "泥", "拟", "尼"], + "shijie": ["世界", "试解", "时间", "四季", "师姐"], + "daima": ["代码", "大马", "打码", "大妈"], + "dawan": ["打完", "大碗", "答万"], + "yanshi": ["演示", "严实", "延时", "验尸"], + "zhongwen": ["中文", "种闻", "重文"], + "zidong": ["自动", "字动", "资东"], + "qingping": ["清屏", "轻评"], + "houqi": ["后期", "厚起"], + "chuli": ["处理", "除理", "矗立"], + "feibo": ["斐波", "飞播"], + "naqi": ["那契", "纳气"], + "shuru": ["输入", "书入", "数如"], + "hanzi": ["汉字", "含字"], + "wancheng": ["完成", "碗城"], + "chengxu": ["程序", "成序", "城需"], +} + + +def is_cjk(ch: str) -> bool: + """是否为 CJK 汉字。""" + return len(ch) == 1 and "一" <= ch <= "鿿" + + +def cjk_run_at(text: str, index: int) -> Tuple[int, int]: + """返回包含 ``index`` 的连续汉字区间的 ``(start, end)``(end 不含)。""" + 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: str) -> str: + 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: str, index: int) -> Tuple[str, List[str]]: + """返回 ``(拼音, 候选词列表)``。 + + 拼音取该汉字串前两个字(不足两个则取全部),音节之间用 ``-``(横杠)连接; + 候选词从内置同音表查找,查不到时退化为原字本身。 + """ + 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..5847c29 --- /dev/null +++ b/CodeVideoRenderer/postprocess.py @@ -0,0 +1,774 @@ +"""Video post-processing utilities for CodeVideoRenderer. + +This module provides a collection of standalone functions that operate on +already-rendered (or any) video files. They are thin, well-documented wrappers +around ``ffmpeg`` and are designed to be called *after* a video has been +generated, e.g.: + +.. code-block:: python + + from CodeVideoRenderer import remove_subtitles, add_background_music + + remove_subtitles("my_video.mp4") # -> my_video_no_subs.mp4 + add_background_music("my_video.mp4", "bgm.mp3") + +All functions accept ``input_path`` (a ``str`` or ``os.PathLike``) and an +optional ``output_path``. When ``output_path`` is omitted, a sensible name is +derived from the input file. Every function returns the resolved output path. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +from fractions import Fraction +from pathlib import Path +from typing import Iterable, List, Optional, Sequence, Tuple, Union + +from .typing import StrPath + +# --------------------------------------------------------------------------- +# ffmpeg discovery +# --------------------------------------------------------------------------- + +_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() -> str: + """Locate the ``ffmpeg`` executable. + + Resolution order: + + 1. The ``CODEVIDEORENDERER_FFMPEG`` / ``FFMPEG_BINARY`` environment variables. + 2. ``ffmpeg`` on ``PATH``. + 3. A handful of common install locations. + 4. The binary bundled with ``imageio-ffmpeg`` (a dependency of this library). + + Returns: + str: The absolute path to ``ffmpeg``. + + Raises: + FileNotFoundError: If no ``ffmpeg`` binary could be found. + """ + 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 # type: ignore + + 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() -> str: + """Locate ``ffprobe`` next to :func:`find_ffmpeg`. + + Returns: + str: The absolute path to ``ffprobe``. + + Raises: + FileNotFoundError: If ``ffprobe`` cannot be found. + """ + 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: Sequence[str]) -> None: + """Run ffmpeg, raising a descriptive error on failure.""" + cmd = [find_ffmpeg(), "-hide_banner", "-loglevel", "error", "-y", *args] + 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: Sequence[str]) -> str: + """Run ffprobe and return its stdout.""" + 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: StrPath, tag: str) -> str: + """Build a default output path by inserting ``tag`` before the extension.""" + p = Path(input_path) + return str(p.with_name(f"{p.stem}_{tag}{p.suffix}")) + + +def _ffmpeg_filter_path(path: StrPath) -> str: + """Escape a path so it can be embedded in an ffmpeg filter argument.""" + p = str(path).replace("\\", "/") + if os.name == "nt": + p = p.replace(":", r"\:") + return p + + +def _drawtext_escape(text: str) -> str: + """Escape special characters for use inside an ffmpeg ``drawtext`` filter.""" + return ( + text.replace("\\", "\\\\") + .replace(":", "\\:") + .replace("'", "\\'") + .replace("%", "\\%") + ) + + +def _default_font() -> str: + """Return a font file that supports CJK when available, else empty (ffmpeg default).""" + candidates = [ + r"C:\Windows\Fonts\msyh.ttc", # 微软雅黑 + 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: StrPath) -> bool: + """Return ``True`` if the input contains at least one audio stream.""" + 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: StrPath) -> Tuple[int, int, float]: + """Return ``(width, height, fps)`` of the first video stream.""" + 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) -> Tuple[int, int]: + """Normalise a resolution specification to a ``(width, height)`` tuple. + + ``-2`` as a width means "keep 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." + ) + + +# --------------------------------------------------------------------------- +# Subtitles +# --------------------------------------------------------------------------- + +def remove_subtitles(input_path: StrPath, output_path: Optional[StrPath] = None) -> str: + """Strip all embedded subtitle streams from a video. + + Args: + input_path: Path to the input video. + output_path: Optional output path. Defaults to ``_no_subs.``. + + Returns: + str: The output file path. + """ + 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: StrPath, + subtitle_path: StrPath, + output_path: Optional[StrPath] = None, + soft: bool = False, +) -> str: + """Add subtitles to a video. + + Args: + input_path: Path to the input video. + subtitle_path: Path to a subtitle file (``.srt`` / ``.ass`` / ``.vtt``). + output_path: Optional output path. Defaults to ``_subbed.``. + soft: If ``True``, embed the subtitle as a switchable (soft) track + without re-encoding video/audio. If ``False`` (default), the + subtitles are *burned* into the picture (always visible). + + Returns: + str: The output file path. + """ + 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)}'" + _run_ffmpeg( + [ + "-i", str(input_path), + "-vf", vf, + "-c:v", "libx264", "-c:a", "copy", + str(output_path), + ] + ) + return str(output_path) + + +# --------------------------------------------------------------------------- +# Audio +# --------------------------------------------------------------------------- + +def add_background_music( + input_path: StrPath, + music_path: StrPath, + output_path: Optional[StrPath] = None, + volume: float = 0.3, + loop: bool = True, + mix_original: bool = True, +) -> str: + """Mix a background-music track into a video. + + Args: + input_path: Path to the input video. + music_path: Path to the audio file (``.mp3`` / ``.wav`` / ``.m4a`` …). + output_path: Optional output path. Defaults to ``_bgm.``. + volume: Volume multiplier applied to the music (0.0–1.0+). Defaults to 0.3. + loop: Loop the music to cover the whole video. Defaults to ``True``. + mix_original: If the video already has audio, mix it with the music. + If the video has no audio, the music simply becomes the audio track. + + Returns: + str: The output file path. + """ + output_path = output_path or _default_output(input_path, "bgm") + loop_args: List[str] = ["-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: StrPath, output_path: Optional[StrPath] = None, format: str = "mp3") -> str: + """Extract the audio track of a video into a standalone audio file. + + Args: + input_path: Path to the input video. + output_path: Optional output path. Defaults to ``.``. + format: Output audio format (``mp3`` / ``wav`` / ``m4a`` / ``aac``). + + Returns: + str: The output audio path. + """ + 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: StrPath, output_path: Optional[StrPath] = None) -> str: + """Remove the audio track from a video (producing a silent video). + + Returns: + str: The output file path. + """ + 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) + + +# --------------------------------------------------------------------------- +# Editing / quality +# --------------------------------------------------------------------------- + +def concat_videos( + input_paths: Iterable[StrPath], + output_path: StrPath, + reencode: bool = False, +) -> str: + """Concatenate multiple videos into one. + + Args: + input_paths: An iterable of input video paths, in the desired order. + output_path: The output path (required). + reencode: If ``False`` (default), streams are copied without re-encoding + — fast, but all inputs must share the same codec/resolution. Set to + ``True`` to re-encode (handles codec differences; inputs must still + have matching resolution for the concat filter). + + Returns: + str: The output file path. + """ + 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: List[str] = [] + for p in paths: + inputs += ["-i", p] + n = len(paths) + has_audio_flags = [_has_audio_stream(p) for p in paths] + + # 视频流统一成 yuv420p,避免不同像素格式导致 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: StrPath, + output_path: Optional[StrPath] = None, + resolution: Optional[Union[str, int, Tuple[int, int]]] = None, + fps: Optional[Union[int, float]] = None, + bitrate: Optional[str] = None, + crf: Optional[int] = None, +) -> str: + """Re-encode a video with a chosen resolution, frame rate, bitrate or CRF. + + Args: + input_path: Path to the input video. + output_path: Optional output path. Defaults to ``_quality.``. + resolution: A preset string (``"1080p"``, ``"4k"`` …), a target height + (``1080``), or a ``(width, height)`` tuple. + fps: Output frame rate. + bitrate: Video bitrate, e.g. ``"5M"``. + crf: Constant Rate Factor (lower = higher quality, 18–28 typical). + + Returns: + str: The output file path. + """ + output_path = output_path or _default_output(input_path, "quality") + args: List[str] = ["-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: StrPath, + output_path: Optional[StrPath] = None, + start: float = 0.0, + end: Optional[float] = None, + duration: Optional[float] = None, +) -> str: + """Cut a segment out of a video. + + Args: + input_path: Path to the input video. + output_path: Optional output path. Defaults to ``_trim.``. + start: Start time in seconds. + end: End time in seconds (alternative to ``duration``). + duration: Segment duration in seconds. + + Returns: + str: The output file path. + """ + output_path = output_path or _default_output(input_path, "trim") + args: List[str] = ["-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: StrPath, + output_path: Optional[StrPath] = None, + speed: float = 1.0, +) -> str: + """Speed up or slow down a video while keeping audio in sync. + + Args: + input_path: Path to the input video. + output_path: Optional output path. Defaults to ``_speed.``. + speed: Playback speed multiplier (e.g. 2.0 = twice as fast). + + Returns: + str: The output file path. + """ + 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) + + +# --------------------------------------------------------------------------- +# Overlays (watermark / title / cover) +# --------------------------------------------------------------------------- + +def _overlay_position(position: str, margin: int) -> Tuple[str, str]: + """Return ffmpeg overlay ``(x, y)`` expressions for a named position.""" + 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: str, margin: int) -> Tuple[str, str]: + """Return drawtext ``(x, y)`` expressions for a named position.""" + 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: StrPath, + output_path: Optional[StrPath] = None, + text: Optional[str] = None, + image_path: Optional[StrPath] = None, + position: str = "bottom-right", + fontsize: int = 24, + opacity: float = 0.6, + color: str = "white", + margin: int = 20, +) -> str: + """Overlay a text or image watermark onto a video. + + Args: + input_path: Path to the input video. + output_path: Optional output path. Defaults to ``_wm.``. + text: Watermark text (used when ``image_path`` is ``None``). + image_path: Path to a watermark image (with transparency recommended). + position: One of ``"top-left"``, ``"top-right"``, ``"bottom-left"``, + ``"bottom-right"``, ``"center"``. + fontsize: Font size for text watermarks. + opacity: Watermark opacity (0.0–1.0). + color: Text color for text watermarks. + margin: Distance from the edge, in pixels. + + Returns: + str: The output file path. + """ + 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: StrPath, + output_path: Optional[StrPath] = None, + title: Optional[str] = None, + subtitle: Optional[str] = None, + duration: float = 3.0, + background_color: str = "black", + title_color: str = "white", + subtitle_color: str = "0xCCCCCC", + fontsize: int = 64, +) -> str: + """Prepend a title screen to the beginning of a video. + + Args: + input_path: Path to the input video. + output_path: Optional output path. Defaults to ``_titled.``. + title: Main title text. Defaults to the input file stem. + subtitle: Optional secondary line shown under the title. + duration: Length of the title screen in seconds. + background_color: Background color of the title screen. + title_color: Title text color. + subtitle_color: Subtitle text color. + fontsize: Title font size. + + Returns: + str: The output file path. + """ + 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) + + # 片头音轨状态与原视频保持一致,确保 concat(reencode) 能顺利拼接 + title_args: List[str] = [ + "-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: StrPath, + output_path: Optional[StrPath] = None, + time: float = 0.5, + width: Optional[int] = None, +) -> str: + """Extract a still frame from a video to use as a cover/thumbnail. + + Args: + input_path: Path to the input video. + output_path: Optional output path. Defaults to ``_cover.png``. + time: Timestamp (in seconds) of the frame to capture. + width: Optional output width in pixels (height is scaled proportionally). + + Returns: + str: The output image path. + """ + if output_path is None: + output_path = str(Path(input_path).with_name(f"{Path(input_path).stem}_cover.png")) + args: List[str] = ["-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) + + +__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", +] diff --git a/CodeVideoRenderer/renderer.py b/CodeVideoRenderer/renderer.py index 79d15fe..c4ff00d 100644 --- a/CodeVideoRenderer/renderer.py +++ b/CodeVideoRenderer/renderer.py @@ -1,5 +1,5 @@ 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 import VGroup, Code, SurroundingRectangle, RoundedRectangle, Rectangle, Line, MovingCameraScene, rate_functions, RendererType, config, WHITE, GREY, UP, DOWN, LEFT, RIGHT, register_font, FadeOut, FadeIn, Text from manim.typing import Point3D from pathlib import Path from copy import copy @@ -14,8 +14,93 @@ 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() + +# VS Code 风格的代码补全:触发关键词 -> 候选建议 (标签, 种类, 详情) +AUTOCOMPLETE_SUGGESTIONS: Dict[str, List[Tuple[str, str, str]]] = { + "def": [ + ("def", "keyword", "keyword"), + ("def name():", "snippet", "function"), + ("def name(args):", "snippet", "function"), + ("def __init__(self):", "method", "method"), + ], + "class": [ + ("class", "keyword", "keyword"), + ("class Name:", "snippet", "class"), + ("class Name(Base):", "snippet", "class"), + ("class Meta:", "snippet", "class"), + ], + "import": [ + ("import os", "module", "module"), + ("import sys", "module", "module"), + ("import numpy as np", "module", "module"), + ("import re", "module", "module"), + ], + "from": [ + ("from module import name", "snippet", "import"), + ("from . import name", "module", "import"), + ("from typing import List", "module", "module"), + ], + "for": [ + ("for i in range(n):", "snippet", "loop"), + ("for item in iterable:", "snippet", "loop"), + ("for k, v in d.items():", "snippet", "loop"), + ], + "if": [ + ("if condition:", "snippet", "conditional"), + ("if x is None:", "snippet", "conditional"), + ("if __name__ == '__main__':", "snippet", "main"), + ], + "return": [ + ("return", "keyword", "keyword"), + ("return value", "snippet", "statement"), + ("return None", "snippet", "statement"), + ("return self", "snippet", "statement"), + ], + "print": [ + ("print", "function", "built-in"), + ("print(*args)", "function", "built-in"), + ("print(f'...')", "function", "built-in"), + ], + "while": [ + ("while condition:", "snippet", "loop"), + ("while True:", "snippet", "loop"), + ], + "try": [ + ("try:", "snippet", "exception"), + ("try: ... except Exception as e:", "snippet", "exception"), + ], + "with": [ + ("with open(...) as f:", "snippet", "context manager"), + ("with contextlib.suppress(...):", "snippet", "context manager"), + ], +} + +# VS Code 补全图标:种类 -> (字形, 颜色)。字形/颜色对应 VS Code 的 Codicon + symbolIcon 配色 +AUTOCOMPLETE_KIND_STYLE: Dict[str, Tuple[str, str]] = { + "keyword": ("⚿", "#569CD6"), # 蓝色 key 图标 + "function": ("ƒ", "#B180D7"), # 紫色 ƒ + "method": ("ƒ", "#B180D7"), # 紫色 ƒ + "class": ("▣", "#EE9D28"), # 橙色方块 + "module": ("▣", "#75BEFF"), # 蓝色方块 + "snippet": ("➤", "#75BEFF"), # 蓝色箭头 + "variable": ("●", "#75BEFF"), # 蓝色圆点 + "string": ("§", "#CE9178"), # 橙色 § + "number": ("≡", "#B5CEA8"), # 绿色 ≡ + "constant": ("≡", "#B5CEA8"), + "property": ("●", "#75BEFF"), +} + +# VS Code 深色主题的补全框配色(与 Dark+ 一致) +_SUGGEST_BG = "#252526" +_SUGGEST_BORDER = "#454545" +_SUGGEST_FG = "#D4D4D4" +_SUGGEST_DETAIL = "#808080" +_SUGGEST_SELECTED_BG = "#04395E" class CameraFollowCursorCV: """ @@ -25,12 +110,23 @@ class CameraFollowCursorCV: 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"``. + formatter_style (PygmentsFormatterStyle): The style for syntax highlighting. Defaults to ``"vscode-dark-plus"`` (VS Code Dark+ 配色). 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'``. + clear_code (bool): Whether to clear the code off screen after the typing animation finishes. Defaults to ``False``. + clear_code_mode (Literal['fade', 'backspace']): How to clear the code. ``'backspace'`` deletes character by character (like pressing backspace); ``'fade'`` fades the whole block out at once. Defaults to ``'backspace'``. + clear_code_run_time (float): Duration (seconds) of the whole-block fade (``clear_code_mode='fade'``) or the final fade of line numbers/cursor (backspace mode). Defaults to 1.0. + clear_code_interval (float): Time between deleting each character in backspace mode — controls the deletion speed. Defaults to 0.03. + autocomplete (bool): Whether to show VS Code-style completion popups when a keyword (``def``, ``import``, ``class``, …) is typed. Defaults to ``False``. + autocomplete_wait_time (float): How long each completion popup stays on screen (seconds). Defaults to 0.6. + chinese_ime (bool): Whether to show a Chinese IME-style candidate box (拼音 + 候选词) when Chinese characters are typed. Defaults to ``False``. + ime_wait_time (float): How long each IME candidate box stays on screen (seconds). Defaults to 0.6. + background_color (str): The scene background color. Defaults to ``"#000000"``. + line_highlight_color (str): Fill color of the rectangle highlighting the line being typed. Defaults to ``"#333333"``. + end_wait_time (float): How long to pause on the final frame after typing (seconds). Defaults to 1.0. """ __all__ = ["render"] @@ -38,12 +134,23 @@ class CameraFollowCursorCV: def __init__(self, code: Union[Tuple[Literal['string'], str], Tuple[Literal['file'], StrPath]], language: PygmentsLanguage, - formatter_style: PygmentsFormatterStyle = "material", + formatter_style: PygmentsFormatterStyle = "vscode-dark-plus", 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', + clear_code: bool = False, + clear_code_mode: Literal['fade', 'backspace'] = 'backspace', + clear_code_run_time: float = 1.0, + clear_code_interval: float = 0.03, + autocomplete: bool = False, + autocomplete_wait_time: float = 0.6, + chinese_ime: bool = False, + ime_wait_time: float = 0.6, + background_color: str = "#000000", + line_highlight_color: str = "#333333", + end_wait_time: float = 1.0, ): # ----- 视频名称 ----- if not video_name: @@ -74,6 +181,10 @@ def __init__(self, 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 @@ -86,6 +197,17 @@ class Parameters: camera_scale: Union[float, int] video_name: str renderer: Literal['cairo', 'opengl'] + clear_code: bool + clear_code_mode: Literal['fade', 'backspace'] + clear_code_run_time: float + clear_code_interval: float + autocomplete: bool + autocomplete_wait_time: float + chinese_ime: bool + ime_wait_time: float + background_color: str + line_highlight_color: str + end_wait_time: float Parameters.code = code Parameters.language = language Parameters.formatter_style = formatter_style @@ -94,6 +216,17 @@ 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) @@ -103,10 +236,12 @@ class Parameters: 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 } config.disable_caching = True config.renderer = renderer + config.background_color = background_color self.scene = self._create_scene() def _create_scene(self): @@ -130,8 +265,8 @@ def construct(scene): 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 @@ -154,7 +289,7 @@ def construct(scene): # 创建代码行矩形框 code_line_rectangle = SurroundingRectangle( VGroup(code_mobject[-1], line_number_mobject[-1]), # type: ignore - color="#333333", + color=Parameters.line_highlight_color, fill_opacity=1, stroke_width=0 ).set_y(code_mobject[0].get_y()) @@ -208,6 +343,78 @@ def playAnimation(**kwargs): scene.Animation_list.clear() del cameraAnimation + # 记录所有已打出的字符,供退格删除使用 + typed_mobjects: List = [] + + font_path = os.path.join(os.path.dirname(__file__), 'fonts/CodeVideoRendererFont.ttf') + + def showAutocomplete(keyword: str): + """弹出仿 VS Code 的 IntelliSense 补全框:图标 + 标签 + 详情 + 选中高亮。""" + suggestions = AUTOCOMPLETE_SUGGESTIONS.get(keyword, [])[:5] + if not suggestions: + return + with register_font(font_path): + rows = [] + for label, kind, detail in suggestions: + glyph, color = AUTOCOMPLETE_KIND_STYLE.get(kind, ("▣", "#75BEFF")) + icon = Text(glyph, font="CodeVideoRendererFont", font_size=20, color=color) + label_t = Text(label, font="CodeVideoRendererFont", font_size=30, color=_SUGGEST_FG) + detail_t = Text(detail, font="CodeVideoRendererFont", font_size=22, color=_SUGGEST_DETAIL) + rows.append((icon, label_t, detail_t)) + + left_parts, detail_parts = [], [] + for icon, label_t, detail_t in rows: + left_parts.append(VGroup(icon, label_t).arrange(RIGHT, aligned_edge=DOWN, buff=0.18)) + detail_parts.append(detail_t) + left_col = VGroup(*left_parts).arrange(DOWN, aligned_edge=LEFT, buff=0.16) + detail_col = VGroup(*detail_parts).arrange(DOWN, aligned_edge=RIGHT, buff=0.16) + detail_col.next_to(left_col, RIGHT, buff=1.2) + + content = VGroup(left_col, detail_col) + box = SurroundingRectangle( + content, color=_SUGGEST_BORDER, fill_color=_SUGGEST_BG, + fill_opacity=1, stroke_width=1, buff=0.3, corner_radius=0.08, + ) + # 第一项(选中项)整行高亮 + first = left_col[0] + sel = Rectangle( + width=box.get_width() - 0.55, height=first.get_height() + 0.16, + color=_SUGGEST_SELECTED_BG, fill_opacity=1, stroke_width=0, + ).move_to([box.get_x(), first.get_y(), 0]) + + popup = VGroup(box, sel, left_col, detail_col) + popup.next_to(cursor, DOWN, buff=0.4).shift(RIGHT * 0.5) + + 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: str, candidates): + """弹出中文输入法候选框:拼音(带横杠)+ 横线分隔 + 候选词。""" + if not candidates: + return + with register_font(font_path): + py_t = Text(pinyin, font="CodeVideoRendererFont", font_size=24, color="#9CDCFE") + cands = [Text(c, font="CodeVideoRendererFont", font_size=30, color="#808080") for c in candidates] + cands[0].set_color(_SUGGEST_FG) + cand_row = VGroup(*cands).arrange(RIGHT, aligned_edge=UP, buff=0.35) + 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.14) + first_hl = SurroundingRectangle(cands[0], color=_SUGGEST_SELECTED_BG, fill_opacity=1, stroke_width=0, buff=0.08) + box = SurroundingRectangle( + content, color=_SUGGEST_BORDER, fill_color=_SUGGEST_BG, + fill_opacity=1, stroke_width=1, buff=0.3, corner_radius=0.08, + ) + popup = VGroup(box, first_hl, py_t, bar, cand_row) + popup.next_to(cursor, DOWN, buff=0.4).shift(RIGHT * 0.5) + + 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) @@ -240,6 +447,19 @@ def playAnimation(**kwargs): first_non_space_index = len(self.code_str_lines[line]) - len(self.code_str_lines[line].lstrip()) total_typing_chars = char_num # 当前行实际要打的字数 + # 计算该行补全提示的触发点(关键词打完整的那一刻) + trigger_column = None + trigger_keyword = None + if Parameters.autocomplete: + stripped = self.code_str_lines[line].lstrip() + for kw in AUTOCOMPLETE_SUGGESTIONS: + if stripped.startswith(kw): + rest = stripped[len(kw):] + if rest == "" or not (rest[0].isalnum() or rest[0] == "_"): + trigger_keyword = kw + trigger_column = first_non_space_index + len(kw) - 1 + break + # 遍历当前行的每个字符 submobjects_char_index = 0 for column in range(first_non_space_index, char_num + first_non_space_index): @@ -247,6 +467,7 @@ def playAnimation(**kwargs): 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]) + typed_mobjects.append(code_mobject[line][submobjects_char_index]) submobjects_char_index += 1 cursor.next_to( code_mobject[line][submobjects_char_index-1], @@ -296,10 +517,63 @@ def playAnimation(**kwargs): progress.advance(total_progress, advance=1) progress.advance(current_line_progress, advance=1) + # 关键词打完,弹出补全提示 + if trigger_column is not None and column == trigger_column: + showAutocomplete(trigger_keyword) + + # 汉字打出,弹出输入法候选框(每个连续汉字串的首字触发) + 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: + # 删除前先把镜头拉回整段代码的全貌并固定, + # 否则退格删除时镜头还停在最后一个字符处,看起来像"跟着镜头一起删" + 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": + # 像按退格一样,逐字符反向删除 + 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.""" @@ -322,6 +596,7 @@ def render(scene): # 恢复配置 config.disable_caching = self.origin_config['disable_caching'] config.renderer = self.origin_config['renderer'] + config.background_color = self.origin_config['background_color'] if self.output: DEFAULT_OUTPUT_CONSOLE.log("Manim's config has been restored.") del self.origin_config @@ -329,8 +604,8 @@ def render(scene): 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)[/]") diff --git a/CodeVideoRenderer/typing.py b/CodeVideoRenderer/typing.py index 9f3c105..7f48207 100644 --- a/CodeVideoRenderer/typing.py +++ b/CodeVideoRenderer/typing.py @@ -28,7 +28,7 @@ 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'] +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', 'vscode-dark-plus'] """ 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 diff --git a/CodeVideoRenderer/utils.py b/CodeVideoRenderer/utils.py index 7359080..2a2cb8e 100644 --- a/CodeVideoRenderer/utils.py +++ b/CodeVideoRenderer/utils.py @@ -356,10 +356,14 @@ 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: diff --git a/CodeVideoRenderer/version.py b/CodeVideoRenderer/version.py index 1d7908f..6f20abd 100644 --- a/CodeVideoRenderer/version.py +++ b/CodeVideoRenderer/version.py @@ -1,3 +1,11 @@ -from importlib.metadata import version -__version__ = version("CodeVideoRenderer") -"""Version number of CodeVideoRenderer.""" \ No newline at end of file +from __future__ import annotations + +# 包版本号。优先读取已安装发行版的版本,未安装(直接运行源码)时回退到此硬编码值。 +__version__ = "1.5.0" + +try: + from importlib.metadata import version as _pkg_version + + __version__ = _pkg_version("codevideorenderer") +except Exception: # pragma: no cover - 仅在未安装发行版时触发 + pass diff --git a/CodeVideoRenderer/vscode_theme.py b/CodeVideoRenderer/vscode_theme.py new file mode 100644 index 0000000..990da42 --- /dev/null +++ b/CodeVideoRenderer/vscode_theme.py @@ -0,0 +1,157 @@ +"""VS Code 视觉风格:语法高亮主题 + Python 关键词拆分词法器。 + +提供与 VS Code "Dark+" 主题一致的语法高亮配色,以及一个把 Python 关键词 +拆分成 ``Keyword.Declaration``(def/class,蓝色)与 ``Keyword.Reserved`` +(if/for/return…,紫色)的词法器,使渲染结果尽量贴近真实的 VS Code。 +""" +from __future__ import annotations + +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" + +# 需要翻译成 VS Code 词法器的 Python 语言别名 +PYTHON_LANGUAGES = {"python", "python3", "py", "py3", "python2", "py2"} + + +def resolve_language(language: str) -> str: + """把标准 Python 语言名翻译成 VS Code 词法器名,其它语言原样返回。""" + return LEXER_NAME if language in PYTHON_LANGUAGES else language + + +class VSCodeDarkPlusStyle(Style): + """Pygments 风格,配色与 VS Code 默认 Dark+ 主题一致。""" + + name = STYLE_NAME + background_color = "#1E1E1E" + styles = { + Token: "#D4D4D4", + Whitespace: "", + # 注释 + Comment: "#6A9955", + Comment.Preproc: "#C586C0", + # 关键词:def/class/lambda 蓝,控制流(if/for/return…)紫 + Keyword: "#569CD6", + Keyword.Constant: "#569CD6", # None / True / False + Keyword.Declaration: "#569CD6", # def / class / lambda + Keyword.Reserved: "#C586C0", # if / for / return / import … + 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", # __init__ 等 + Name.Class: "#4EC9B0", # 类名 + Name.Namespace: "#4EC9B0", + Name.Exception: "#4EC9B0", + Name.Decorator: "#DCDCAA", # @decorator + 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", + } + + +# VS Code 中呈现为紫色(control)的关键词;其余普通关键词为蓝色(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): + """把纯 ``Keyword`` token 拆成 Declaration(蓝)与 Reserved(紫)。""" + + 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): + """Python 词法器:额外区分 def/class(蓝)与控制流关键词(紫)。""" + + 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(): + """把自定义风格与词法器注册进 Pygments(幂等,失败时静默降级)。""" + 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: + # 注册失败不影响库本身,只是退回到 Pygments 自带的 python 词法器 + _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/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)"""