代码演示视频是怎么用python制作,我的解决思路

在半年前,我曾想过出一些关于编程、图像处理的视频教程。

在往常附上代码的以文本的形式,那如果要制作视频,那应该怎么制作呢?

我有认真思考过这个问题。

视频的本质就是多帧的图片叠加起来。

大致就是周星驰电影《苏乞儿》里那本武功秘籍,把书快速翻动,里面的小人就打一套功夫。

所以用python制作 一个类似 代码演示的视频,这是一件不难的事情。

写完了之后,我和朋友聊起这件事。

image-20251124140357266

整体流程逻辑(大框架)

代码主要分 4 个阶段:


① 初始化环境与加载字体

  • 清空输出目录
  • 找字体 → 成功用之,不成功就换 → 最后用默认
  • 根据 Pygments 配色方案(dracula)获取各种 token 的颜色

② 按字符绘制代码,模拟打字动画

核心逻辑:

  1. 代码内容 CODE_TO_ANIMATE → 清洗成干净的字符串
  2. for char in clean_code
    每多一个字符,就重新画一张“当前代码”的截图
  3. 每生成一帧,文本要
    • 用 PIL 绘制
    • 按 token 上色(和 Python 语法高亮一样)
    • 计算光标位置(像打字机一样挪动)
  4. 遇到换行 \n,会停顿一些帧(让动画更自然)
  5. 每一帧保存为一张 PNG

③ 结尾做光标闪烁

结束后停几秒,用如下方式制造光标闪烁:

显示光标 → 隐藏光标 → 显示光标 → ...

同样是重新绘制帧。


④ 最后用 FFmpeg 把所有 PNG 合成视频

关键命令类似:

ffmpeg -framerate 60 -i frame_%05d.png ... typing_animation.mp4

最后产出

一堆 PNG 帧
一个“代码逐字打出来”的高品质 MP4 动画


核心解决思路总结

用 PIL 逐帧渲染代码(带语法高亮 + 光标),把每一次字符变化都画成一张图片,然后再用 FFmpeg 把所有图片拼成视频。

原理讲明白了,code2video的完整代码如下:

import os
import shutil
import subprocess
from PIL import Image, ImageDraw, ImageFont
from pygments.lexers import PythonLexer
from pygments.token import Token
from pygments.styles import get_style_by_name

# --- 1. 配置中心 (所有参数都在这里修改) ---
CONFIG = {
    # 代码内容
    "CODE_TO_ANIMATE": """import numpy as np

def calculate_pi(n_terms: int) -> float:
    numerator = 4.0
    denominator = 1.0
    operator = 1.0
    pi = 0.0
    for _ in range(n_terms):
        pi += operator * (numerator / denominator)
        denominator += 2.0
        operator *= -1.0

    return pi

if __name__ == "__main__":
    pi_estimate = calculate_pi(100000)
    print(f"Pi approximation: {pi_estimate}")
""",
    # 视频参数
    "VIDEO_WIDTH": 1920,
    "VIDEO_HEIGHT": 1080,
    "FPS": 60,
    # 视觉样式
    "BACKGROUND_COLOR": (14, 18, 25),
    "FONT_SIZE": 38,
    "LINE_SPACING": 20,
    "PADDING": 90,
    "FONT_PATHS_TO_TRY": ["Consolas", "Menlo", "DejaVuSansMono.ttf", "DejaVu Sans Mono"],
    "PYGMENTS_STYLE": 'dracula',
    "CURSOR_COLOR": (248, 248, 242),
    "CURSOR_WIDTH": 3,
    # 动画参数
    "LINE_PAUSE_FRAMES": 20,
    "END_PAUSE_SECONDS": 5,
    "SUPER_SAMPLING_SCALE": 2,
    # 输出设置
    "OUTPUT_DIR": "frames_manim_style",
    "OUTPUT_FILENAME": "typing_animation_manim_style.mp4"
}


def setup_environment():
    output_dir = CONFIG["OUTPUT_DIR"]
    if os.path.exists(output_dir):
        shutil.rmtree(output_dir)
    os.makedirs(output_dir)
    return output_dir


def load_font():
    font_size = CONFIG["FONT_SIZE"] * CONFIG["SUPER_SAMPLING_SCALE"]
    for font_path in CONFIG["FONT_PATHS_TO_TRY"]:
        try:
            font = ImageFont.truetype(font_path, font_size)
            print(f"✅ 字体加载成功: {font_path}")
            return font
        except IOError:
            print(f"🟡 提示: 字体 '{font_path}' 未找到, 尝试下一个...")
    print("⚠️ 警告: 所有指定字体均未找到, 使用Pillow默认字体。")
    return ImageFont.load_default()


def get_token_colors():
    style = get_style_by_name(CONFIG["PYGMENTS_STYLE"])
    colors = {}
    for token, s in style:
        if s['color']:
            colors[token] = tuple(int(s['color'][i:i + 2], 16) for i in (0, 2, 4))
    default_text_color = (248, 248, 242)
    if Token.Text in colors:
        default_text_color = colors[Token.Text]
    return colors, default_text_color


def draw_code_frame(code_to_draw, show_cursor, font, token_colors, default_color):
    scale = CONFIG["SUPER_SAMPLING_SCALE"]
    img_size = (CONFIG["VIDEO_WIDTH"] * scale, CONFIG["VIDEO_HEIGHT"] * scale)
    img = Image.new('RGB', img_size, color=CONFIG["BACKGROUND_COLOR"])
    draw = ImageDraw.Draw(img)

    padding = CONFIG["PADDING"] * scale
    line_height = (CONFIG["FONT_SIZE"] + CONFIG["LINE_SPACING"]) * scale

    lexer = PythonLexer()
    tokens = list(lexer.get_tokens(code_to_draw))  # Convert to list for easier handling

    x, y = padding, padding
    cursor_x, cursor_y = x, y  # Initialize cursor position
    char_index = 0  # Track position in code_to_draw string

    # Iterate through tokens for rendering text with correct colors
    for token_type, token_text in tokens:
        color = default_color
        current_token = token_type
        while current_token not in token_colors and current_token.parent:
            current_token = current_token.parent
        if current_token in token_colors:
            color = token_colors[current_token]

        # Process each character in the token
        for char in token_text:
            if char_index < len(code_to_draw):
                # Use the actual character from code_to_draw to determine cursor movement
                actual_char = code_to_draw[char_index]
                if actual_char == '\n':
                    x = padding
                    y += line_height
                    cursor_x, cursor_y = x, y  # Move cursor to start of next line
                elif actual_char == '\t':
                    x += font.getlength('    ')
                    cursor_x, cursor_y = x, y
                else:
                    draw.text((x, y), char, font=font, fill=color)
                    x += font.getlength(char)
                    cursor_x, cursor_y = x, y  # Update cursor after character
                char_index += 1

    # Draw cursor at the final position
    if show_cursor:
        cursor_height = CONFIG["FONT_SIZE"] * scale * 1.1
        cursor_width = CONFIG["CURSOR_WIDTH"] * scale
        draw.rectangle(
            [cursor_x, cursor_y, cursor_x + cursor_width, cursor_y + cursor_height],
            fill=CONFIG["CURSOR_COLOR"]
        )

    final_img = img.resize((CONFIG["VIDEO_WIDTH"], CONFIG["VIDEO_HEIGHT"]), Image.Resampling.LANCZOS)
    return final_img


def main():
    output_dir = setup_environment()
    font = load_font()
    token_colors, default_color = get_token_colors()

    clean_code = CONFIG["CODE_TO_ANIMATE"].strip().replace('\u00a0', ' ')

    frames = []
    code_to_display = ""

    print("🎬 开始生成动画帧...")

    for char in clean_code:
        code_to_display += char
        frame = draw_code_frame(code_to_display, True, font, token_colors, default_color)
        frames.append(frame)

        if char == '\n':
            for _ in range(CONFIG["LINE_PAUSE_FRAMES"]):
                frames.append(frame)

    total_end_frames = CONFIG["END_PAUSE_SECONDS"] * CONFIG["FPS"]
    blink_interval = CONFIG["FPS"] // 2

    for i in range(total_end_frames):
        show_cursor = (i // blink_interval) % 2 == 0
        frame = draw_code_frame(code_to_display, show_cursor, font, token_colors, default_color)
        frames.append(frame)

    print(f"🖼️ 动画帧生成完毕,总共 {len(frames)} 帧。开始保存图片...")

    for i, frame in enumerate(frames):
        filename = os.path.join(output_dir, f"frame_{i:05d}.png")
        frame.save(filename)
        print(f"正在保存第 {i + 1}/{len(frames)} 帧", end='\r', flush=True)

    print("\n✅ 所有帧已保存。开始使用 FFmpeg 合成视频...")

    ffmpeg_command = [
        'ffmpeg', '-y',
        '-framerate', str(CONFIG["FPS"]),
        '-i', os.path.join(output_dir, 'frame_%05d.png'),
        '-c:v', 'libx264',
        '-pix_fmt', 'yuv420p',
        '-crf', '18',
        '-preset', 'slow',
        CONFIG["OUTPUT_FILENAME"]
    ]

    try:
        subprocess.run(ffmpeg_command, check=True, capture_output=True, text=True)
        print(f"🎉 视频生成成功!文件名为: {CONFIG['OUTPUT_FILENAME']}")
    except FileNotFoundError:
        print("\n❌ 错误: FFmpeg 未找到。请确保它已安装并已添加到系统 PATH。")
    except subprocess.CalledProcessError as e:
        print(f"\n❌ FFmpeg 合成视频时出错: {e}")
        print(f"FFmpeg 输出:\n{e.stderr}")


if __name__ == '__main__':
    main()

image-20251124180014574

写代码是不难的,难的是解决问题的思路。你有思路了,你可以在ai辅助下完成很多有意思的事情。

保持热爱,开心每一天。

万分感谢大家的支持和厚爱,我们明天见。