[Python] How to achieve high FPS screen recording of HTML animations using Playwright?

Discussão em 'Python' iniciado por Stack, Fevereiro 15, 2025.

  1. Stack

    Stack Membro Participativo

    I'm working on a Python script to record HTML animations using Playwright. The script takes screenshots of animated HTML pages and combines them into a video using FFmpeg. However, I'm encountering issues with frame rate and video quality.

    Here's the relevant part of my code:

    async def record_page(self, browser: Browser, html_path: str, index: int):
    page = await browser.new_page()
    # ... setup code ...

    frame_start = time.time()
    while (time.time() - segment_start) < total_duration:
    screenshot = await page.screenshot(type='jpeg', quality=100)
    frames_captured += 1

    # Send frame to FFmpeg
    if ffmpeg_process is not None:
    ffmpeg_process.stdin.write(screenshot)
    ffmpeg_process.stdin.flush()

    # Dynamic sleep time based on target FPS
    elapsed = time.time() - frame_start
    sleep_time = max(0, (1 / self.video_config.target_fps) - elapsed)
    await asyncio.sleep(sleep_time)
    frame_start = time.time()


    Current Issues:

    • Despite setting target_fps = 90, I'm getting much lower actual FPS (around 16-40 FPS based on logs)
    • The final video duration is shorter than expected (e.g., a 50-second animation might result in a 20-second video)
    • The animation appears jumpy and not smooth

    What I've Tried:

    • Using page.pause() (brings up debug window, not what I need)
    • Implementing dynamic sleep time between frames
    • Setting high JPEG quality for screenshots

    What I Need:

    • Is it possible to achieve 90 FPS recording with Playwright?
    • How can I properly synchronize the animation frames with screenshot capture?
    • Is there a way to pause/control animation execution between frames without using page.pause()?

    My goal is to create high-quality recordings of HTML animations that maintain both smooth playback and correct timing. Any suggestions for improving the frame capture process or alternative approaches would be greatly appreciated.

    Environment: -Python 3.x -Playwright -FFmpeg for video encoding -Windows 10

    Full code:

    import os
    import asyncio
    from pathlib import Path
    import random
    from typing import List, Optional
    from dataclasses import dataclass
    from playwright.async_api import async_playwright, Page, Browser
    import logging
    import subprocess
    import tempfile
    import shutil
    import time
    from datetime import datetime

    # Configure logging
    logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
    )
    logger = logging.getLogger(__name__)

    @dataclass
    class VideoConfig:
    width: int = 500 #3840 # 4K resolution
    height: int = 500 #2160
    target_fps: int = 30 # Reduced to more achievable framerate
    actual_fps: float = 0 # Track actual achieved FPS
    quality: str = "ultra"

    @property
    def ffmpeg_quality_settings(self) -> dict:
    quality_presets = {
    "hd": {"crf": "20", "preset": "medium"},
    "ultra": {"crf": "15", "preset": "slow"},
    "lossless": {"crf": "0", "preset": "veryslow"}
    }
    return quality_presets[self.quality]

    class RandomTimeGenerator:
    def __init__(self, num_segments: int, min_duration: float = 45.0, max_duration: float = 120.0):
    self.num_segments = num_segments
    self.min_duration = min_duration
    self.max_duration = max_duration

    def generate_timings(self) -> List[float]:
    return [random.uniform(self.min_duration, self.max_duration)
    for _ in range(self.num_segments)]

    class HTMLLoader:
    def __init__(self, directory: str):
    self.directory = Path(directory)

    def load_html_files(self) -> List[str]:
    if not self.directory.exists():
    raise FileNotFoundError(f"Directory {self.directory} does not exist")

    html_files = []
    for file_path in self.directory.glob("**/*.html"):
    html_files.append(str(file_path.absolute()))

    logger.info(f"Found {len(html_files)} HTML files")
    return html_files

    class PageRecorder:
    def __init__(
    self,
    html_files: List[str],
    output_path: str,
    timings: List[float],
    video_config: Optional[VideoConfig] = None
    ):
    self.html_files = html_files
    self.output_path = Path(output_path)
    self.timings = timings
    self.video_config = video_config or VideoConfig()
    self.output_path.mkdir(parents=True, exist_ok=True)
    self.temp_dir = Path(tempfile.mkdtemp())
    self.status_check_interval = 5 # Seconds between status updates

    if len(html_files) != len(timings):
    raise ValueError("Number of HTML files must match number of timings")

    async def verify_page_loaded(self, page: Page):
    try:
    await page.wait_for_load_state("networkidle", timeout=15000)
    await page.wait_for_function("document.readyState === 'complete'", timeout=10000)
    except Exception as e:
    logger.warning(f"Page load warning: {e}")

    def create_ffmpeg_process(self, output_path: str, actual_fps: float):
    ffmpeg_cmd = [
    "ffmpeg",
    "-y",
    "-f", "image2pipe",
    "-framerate", str(actual_fps), # Use actual achieved FPS
    "-c:v", "mjpeg",
    "-i", "-",
    "-c:v", "libx264",
    "-crf", self.video_config.ffmpeg_quality_settings["crf"],
    "-preset", self.video_config.ffmpeg_quality_settings["preset"],
    "-pix_fmt", "yuv420p",
    "-vf", f"scale={self.video_config.width}:{self.video_config.height}:force_original_aspect_ratio=decrease,pad={self.video_config.width}:{self.video_config.height}:(ow-iw)/2:(oh-ih)/2",
    "-loglevel", "error",
    output_path
    ]

    return subprocess.Popen(
    ffmpeg_cmd,
    stdin=subprocess.PIPE,
    stdout=subprocess.DEVNULL,
    stderr=subprocess.PIPE,
    bufsize=10**8
    )

    def _get_memory_usage(self):
    try:
    import psutil
    process = psutil.Process(os.getpid())
    return f"{process.memory_info().rss / 1024 ** 2:.1f}"
    except ImportError:
    return "N/A"

    async def _log_status(self, page: Page, start_time: float, current_duration: float, total_duration: float):
    elapsed = time.time() - start_time
    mem_usage = self._get_memory_usage()
    logger.info(
    f"Recording status | "
    f"Elapsed: {elapsed:.1f}s | "
    f"Segment: {current_duration:.1f}/{total_duration:.1f}s | "
    f"Memory: {mem_usage} MB | "
    f"Target FPS: {self.video_config.target_fps}"
    )

    async def record_page(self, browser: Browser, html_path: str, index: int) -> Optional[str]:
    page = None
    ffmpeg_process = None
    temp_output = self.temp_dir / f"temp_{index}.mp4"
    total_duration = self.timings[index]
    start_time = time.time()
    last_status = start_time
    frames_captured = 0
    frame_timestamps = []

    try:
    page = await browser.new_page()
    await page.set_viewport_size({
    "width": self.video_config.width,
    "height": self.video_config.height
    })

    logger.info(f"Loading page: {html_path}")
    await page.goto(f"file://{html_path}")
    await self.verify_page_loaded(page)

    try:
    await page.evaluate("window.RunAnimation()")
    except Exception as e:
    logger.warning(f"Animation not started: {e}")

    segment_start = time.time()
    frame_start = time.time()

    while (time.time() - segment_start) < total_duration:

    if (time.time() - last_status) >= self.status_check_interval:
    await self._log_status(
    page,
    start_time=segment_start,
    current_duration=time.time() - segment_start,
    total_duration=total_duration
    )
    last_status = time.time()

    # Capture frame and record timestamp
    screenshot = await page.screenshot(type='jpeg', quality=100)
    frame_timestamps.append(time.time() - segment_start)

    try:
    frames_captured += 1
    if ffmpeg_process is None:
    # Calculate actual FPS from the first few frames
    if frames_captured >= 10:
    actual_fps = frames_captured / (time.time() - frame_start)
    ffmpeg_process = self.create_ffmpeg_process(str(temp_output), actual_fps)
    logger.info(f"Actual FPS calculated: {actual_fps:.1f}")
    continue

    ffmpeg_process.stdin.write(screenshot)
    ffmpeg_process.stdin.flush()
    except BrokenPipeError as e:
    logger.error(f"FFmpeg pipe broken: {e}")
    break

    # Dynamic sleep time based on target FPS and processing time
    elapsed = time.time() - frame_start
    sleep_time = max(0, (1 / self.video_config.target_fps) - elapsed)
    await asyncio.sleep(sleep_time)
    frame_start = time.time()

    actual_duration = time.time() - segment_start
    actual_fps = frames_captured / actual_duration if actual_duration > 0 else 0
    logger.info(f"Segment {index} completed | "
    f"Duration: {actual_duration:.1f}s | "
    f"Frames: {frames_captured} | "
    f"Actual FPS: {actual_fps:.1f}")

    if ffmpeg_process:
    ffmpeg_process.stdin.close()
    _, stderr = ffmpeg_process.communicate()

    if ffmpeg_process.returncode == 0 and temp_output.exists():
    return str(temp_output)
    else:
    logger.error(f"FFmpeg error {index}: {ffmpeg_process.returncode}")
    logger.error(f"FFmpeg stderr: {stderr.decode()}")
    return None

    except Exception as e:
    logger.error(f"Error recording {index}: {str(e)}", exc_info=True)
    return None

    finally:
    if ffmpeg_process and ffmpeg_process.poll() is None:
    ffmpeg_process.kill()
    if page:
    await page.close()

    def concatenate_videos(self, video_files: List[str], output_path: str):
    if not video_files:
    raise ValueError("No video files to concatenate")

    list_file = self.temp_dir / "file_list.txt"
    with open(list_file, "w") as f:
    for video_file in video_files:
    if Path(video_file).exists():
    f.write(f"file '{video_file}'\n")

    concat_cmd = [
    "ffmpeg",
    "-y",
    "-f", "concat",
    "-safe", "0",
    "-i", str(list_file),
    "-c", "copy",
    output_path
    ]

    result = subprocess.run(concat_cmd, capture_output=True, text=True)
    if result.returncode != 0:
    logger.error(f"Concatenation failed: {result.stderr}")
    raise Exception("Failed to concatenate videos")
    else:
    logger.info(f"Created final video: {output_path}")

    async def record_all(self):
    try:
    async with async_playwright() as p:
    browser = await p.chromium.launch(
    headless=False,
    args=[
    "--disable-gpu",
    "--disable-dev-shm-usage",
    "--disable-setuid-sandbox",
    "--no-sandbox"
    ]
    )

    temp_video_files = []
    for idx, html_file in enumerate(self.html_files):
    logger.info(f"Starting segment {idx}/{len(self.html_files)-1}")
    result = await self.record_page(browser, html_file, idx)
    if result:
    temp_video_files.append(result)

    await browser.close()

    if temp_video_files:
    output_file = str(self.output_path / "final_video.mp4")
    self.concatenate_videos(temp_video_files, output_file)
    else:
    raise Exception("No successful recordings")

    finally:
    shutil.rmtree(self.temp_dir, ignore_errors=True)
    logger.info("Temporary files cleaned up")

    def main():
    html_dir = r"C:\Users\...\html"
    output_dir = r"C:\Users\...\output"

    loader = HTMLLoader(html_dir)
    html_files = loader.load_html_files()

    time_generator = RandomTimeGenerator(
    num_segments=len(html_files),
    min_duration=10.0,
    max_duration=20.0
    )
    timings = time_generator.generate_timings()
    logger.info(f"Generated timings: {[f'{t:.1f}s' for t in timings]}")

    recorder = PageRecorder(
    html_files=html_files,
    output_path=output_dir,
    timings=timings,
    video_config=VideoConfig(quality="ultra", target_fps=90) # Reduced target FPS
    )

    asyncio.run(recorder.record_all())

    if __name__ == "__main__":
    main()


    Page1:

    <!DOCTYPE html>
    <html>
    <head>
    <title>Particle Animation</title>
    <style>
    body {
    margin: 0;
    overflow: hidden;
    background: #1a1a1a;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    }
    canvas {
    border: 2px solid #333;
    box-shadow: 0 0 20px rgba(0,0,0,0.5);
    }
    .message {
    position: fixed;
    top: 20px;
    color: white;
    font-family: Arial, sans-serif;
    text-align: center;
    width: 100%;
    }
    </style>
    </head>
    <body>
    <div class="message">Open console and run window.RunAnimation() to start</div>
    <canvas id="animationCanvas"></canvas>

    <script>
    const canvas = document.getElementById('animationCanvas');
    const ctx = canvas.getContext('2d');
    let animationId;
    let particles = [];

    // Set canvas size
    function resizeCanvas() {
    canvas.width = Math.min(800, window.innerWidth - 40);
    canvas.height = Math.min(600, window.innerHeight - 40);
    }
    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);

    class Particle {
    constructor() {
    this.reset();
    }

    reset() {
    this.x = Math.random() * canvas.width;
    this.y = Math.random() * canvas.height;
    this.size = Math.random() * 5 + 2;
    this.speedX = Math.random() * 3 - 1.5;
    this.speedY = Math.random() * 3 - 1.5;
    this.color = `hsl(${Math.random() * 360}, 70%, 50%)`;
    this.life = 1;
    this.decay = 0.003 + Math.random() * 0.002;
    }

    update() {
    this.x += this.speedX;
    this.y += this.speedY;

    // Bounce off walls
    if (this.x < 0 || this.x > canvas.width) this.speedX *= -1;
    if (this.y < 0 || this.y > canvas.height) this.speedY *= -1;

    // Particle life and regeneration
    this.life -= this.decay;
    if (this.life <= 0) this.reset();
    }

    draw() {
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
    ctx.fillStyle = this.color;
    ctx.globalAlpha = this.life;
    ctx.fill();
    ctx.globalAlpha = 1;
    }
    }

    function createParticles() {
    for (let i = 0; i < 100; i++) {
    particles.push(new Particle());
    }
    }

    function animate() {
    ctx.fillStyle = 'rgba(26, 26, 26, 0.1)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    particles.forEach(particle => {
    particle.update();
    particle.draw();
    });

    // Draw connections between nearby particles
    particles.forEach((p1, i) => {
    particles.slice(i + 1).forEach(p2 => {
    const dx = p1.x - p2.x;
    const dy = p1.y - p2.y;
    const distance = Math.sqrt(dx * dx + dy * dy);

    if (distance < 100) {
    ctx.beginPath();
    ctx.moveTo(p1.x, p1.y);
    ctx.lineTo(p2.x, p2.y);
    ctx.strokeStyle = `rgba(255, 255, 255, ${0.2 * (1 - distance/100)})`;
    ctx.stroke();
    }
    });
    });

    animationId = requestAnimationFrame(animate);
    }

    window.RunAnimation = function() {
    if (animationId) {
    cancelAnimationFrame(animationId);
    }
    particles = [];
    createParticles();
    animate();
    console.log('Animation started! Watch the particles dance...');
    };
    </script>
    </body>
    </html>


    Page 2

    <!DOCTYPE html>
    <html>
    <head>
    <title>Hypnotic Spiral</title>
    <style>
    body {
    margin: 0;
    overflow: hidden;
    background: #000;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    }
    canvas {
    border: 2px solid #333;
    box-shadow: 0 0 20px rgba(0,0,0,0.5);
    }
    .message {
    position: fixed;
    top: 20px;
    color: white;
    font-family: Arial, sans-serif;
    text-align: center;
    width: 100%;
    }
    </style>
    </head>
    <body>
    <div class="message">Open console and run window.RunAnimation() to start</div>
    <canvas id="animationCanvas"></canvas>

    <script>
    const canvas = document.getElementById('animationCanvas');
    const ctx = canvas.getContext('2d');
    let animationId;
    let rotation = 0;
    let hue = 0;

    // Set canvas size
    function resizeCanvas() {
    canvas.width = Math.min(800, window.innerWidth - 40);
    canvas.height = Math.min(800, window.innerHeight - 40);
    }
    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);

    function drawSpiral(time) {
    const centerX = canvas.width / 2;
    const centerY = canvas.height / 2;
    const maxRadius = Math.min(canvas.width, canvas.height) * 0.45;

    ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // Create multiple spiral arms
    for (let arm = 0; arm < 6; arm++) {
    const armOffset = (Math.PI * 2 / 6) * arm;

    ctx.beginPath();
    for (let angle = 0; angle < Math.PI * 8; angle += 0.1) {
    const radius = (angle / (Math.PI * 8)) * maxRadius;
    const x = centerX + radius * Math.cos(angle * 2 + rotation + armOffset);
    const y = centerY + radius * Math.sin(angle * 2 + rotation + armOffset);

    if (angle === 0) {
    ctx.moveTo(x, y);
    } else {
    ctx.lineTo(x, y);
    }
    }

    // Create gradient for the spiral arm
    const gradient = ctx.createLinearGradient(
    centerX - maxRadius, centerY - maxRadius,
    centerX + maxRadius, centerY + maxRadius
    );

    const hueOffset = (hue + (60 * arm)) % 360;
    gradient.addColorStop(0, `hsla(${hueOffset}, 100%, 50%, 0.5)`);
    gradient.addColorStop(0.5, `hsla(${(hueOffset + 30) % 360}, 100%, 50%, 0.5)`);
    gradient.addColorStop(1, `hsla(${(hueOffset + 60) % 360}, 100%, 50%, 0.5)`);

    ctx.strokeStyle = gradient;
    ctx.lineWidth = 3;
    ctx.stroke();
    }

    // Update animation parameters
    rotation += 0.02;
    hue = (hue + 0.5) % 360;

    animationId = requestAnimationFrame(drawSpiral);
    }

    window.RunAnimation = function() {
    if (animationId) {
    cancelAnimationFrame(animationId);
    }
    rotation = 0;
    hue = 0;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawSpiral();
    console.log('Spiral animation started! Watch the hypnotic pattern...');
    };
    </script>
    </body>
    </html>

    Continue reading...

Compartilhe esta Página