#!/usr/bin/env python3 import os import glob import shutil import subprocess import yaml import sys from PIL import Image # --- 1. Load Configuration --- def load_config(config_path="config.yml"): if not os.path.exists(config_path): print(f"āŒ Error: Configuration file '{config_path}' not found.") sys.exit(1) with open(config_path, 'r') as file: return yaml.safe_load(file) # --- 2. System Checks --- def check_dependencies(): tools = ["ffmpeg", "pdftoppm"] missing = [] for tool in tools: if not shutil.which(tool): missing.append(tool) if missing: print(f"āŒ Error: Missing required tools: {', '.join(missing)}") print(" Please install them: apt install ffmpeg poppler-utils") sys.exit(1) def main(): print("šŸš€ Starting PDF to Scroll Video Converter...") # Load settings cfg = load_config() INPUT_DIR = cfg['input_folder'] CACHE_DIR = cfg['cache_folder'] OUTPUT_FILE = cfg['output_filename'] WIDTH = cfg['resolution']['width'] HEIGHT = cfg['resolution']['height'] SEC_PER_SLIDE = cfg['timing']['seconds_per_slide'] FPS = cfg['timing']['fps'] check_dependencies() # Clean and create directories if os.path.exists(CACHE_DIR): shutil.rmtree(CACHE_DIR) os.makedirs(CACHE_DIR) # Find PDFs pdf_files = sorted(glob.glob(os.path.join(INPUT_DIR, "*.pdf"))) if not pdf_files: print("āŒ No PDF files found in the input folder.") sys.exit(1) print(f"šŸ“‚ Found {len(pdf_files)} PDF(s). Processing...") # --- Step 1: Convert PDFs to Images --- global_counter = 0 for pdf in pdf_files: print(f" šŸ“„ Processing: {os.path.basename(pdf)}") cmd = f"pdftoppm -png -r 150 \"{pdf}\" \"{CACHE_DIR}/temp\"" subprocess.run(cmd, shell=True, check=True, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL) temp_files = sorted(glob.glob(os.path.join(CACHE_DIR, "temp-*.png"))) for temp_img in temp_files: new_name = os.path.join(CACHE_DIR, f"slide_{global_counter:05d}.png") os.rename(temp_img, new_name) global_counter += 1 # --- Step 2: Process Images --- print("šŸ“ Processing images...") all_images = sorted(glob.glob(os.path.join(CACHE_DIR, "slide_*.png"))) processed_dir = os.path.join(CACHE_DIR, "processed") os.makedirs(processed_dir, exist_ok=True) # Resize all images to target width processed_images = [] total_height = 0 for i, img_path in enumerate(all_images): img = Image.open(img_path) # Resize to target width while maintaining aspect ratio aspect_ratio = img.height / img.width new_height = int(WIDTH * aspect_ratio) img_resized = img.resize((WIDTH, new_height), Image.LANCZOS) output_path = os.path.join(processed_dir, f"frame_{i:05d}.png") img_resized.save(output_path, "PNG", optimize=True) processed_images.append(output_path) total_height += new_height img.close() if (i + 1) % 10 == 0: print(f" ... Processed {i + 1}/{len(all_images)} images") total_slides = len(processed_images) total_duration = total_slides * SEC_PER_SLIDE print(f"\nšŸ“Š Statistics:") print(f" Total Slides: {total_slides}") print(f" Combined Height: {total_height:,}px") print(f" Video Duration: {total_duration} seconds") print(f" Viewport: {WIDTH}x{HEIGHT}px") # --- Step 3: Create Stitched Image in Chunks --- print("\n🧩 Creating scrollable canvas...") # For very long documents, we need to split into manageable chunks MAX_CHUNK_HEIGHT = 65000 # Safe limit for PNG chunk_images = [] current_chunk = [] current_chunk_height = 0 chunk_num = 0 for img_path in processed_images: with Image.open(img_path) as img: img_height = img.height # Check if adding this image would exceed chunk limit if current_chunk_height + img_height > MAX_CHUNK_HEIGHT and current_chunk: # Save current chunk chunk_path = os.path.join(CACHE_DIR, f"chunk_{chunk_num:03d}.png") # Stitch current chunk total_h = sum(Image.open(p).height for p in current_chunk) canvas = Image.new('RGB', (WIDTH, total_h), (255, 255, 255)) y_offset = 0 for chunk_img_path in current_chunk: with Image.open(chunk_img_path) as ci: canvas.paste(ci, (0, y_offset)) y_offset += ci.height canvas.save(chunk_path, "PNG", optimize=True) canvas.close() chunk_images.append(chunk_path) print(f" ... Created chunk {chunk_num + 1} ({current_chunk_height:,}px)") # Reset for next chunk current_chunk = [] current_chunk_height = 0 chunk_num += 1 current_chunk.append(img_path) current_chunk_height += img_height # Save final chunk if current_chunk: chunk_path = os.path.join(CACHE_DIR, f"chunk_{chunk_num:03d}.png") total_h = sum(Image.open(p).height for p in current_chunk) canvas = Image.new('RGB', (WIDTH, total_h), (255, 255, 255)) y_offset = 0 for chunk_img_path in current_chunk: with Image.open(chunk_img_path) as ci: canvas.paste(ci, (0, y_offset)) y_offset += ci.height canvas.save(chunk_path, "PNG", optimize=True) canvas.close() chunk_images.append(chunk_path) print(f" ... Created chunk {chunk_num + 1} ({current_chunk_height:,}px)") # --- Step 4: Create Video from Chunks --- print("\nšŸŽ„ Encoding video with smooth scroll effect...") if len(chunk_images) == 1: # Simple case: one chunk big_image = chunk_images[0] ffmpeg_cmd = ( f"ffmpeg -y -v error -stats " f"-loop 1 -i \"{big_image}\" " f"-vf \"crop={WIDTH}:{HEIGHT}:0:'min(ih-oh,(ih-oh)*t/{total_duration})',format=yuv420p\" " f"-t {total_duration} " f"-c:v libx264 -preset medium -crf 23 -r {FPS} " f"\"{OUTPUT_FILE}\"" ) result = os.system(ffmpeg_cmd) else: # Multiple chunks: process each and concatenate print(f" Processing {len(chunk_images)} chunks...") chunk_videos = [] time_per_chunk = total_duration / len(chunk_images) for i, chunk_path in enumerate(chunk_images): chunk_video = os.path.join(CACHE_DIR, f"chunk_video_{i:03d}.mp4") ffmpeg_cmd = ( f"ffmpeg -y -v error -stats " f"-loop 1 -i \"{chunk_path}\" " f"-vf \"crop={WIDTH}:{HEIGHT}:0:'min(ih-oh,(ih-oh)*t/{time_per_chunk})',format=yuv420p\" " f"-t {time_per_chunk} " f"-c:v libx264 -preset medium -crf 23 -r {FPS} " f"\"{chunk_video}\"" ) print(f" ... Encoding chunk {i + 1}/{len(chunk_images)}") result = os.system(ffmpeg_cmd) if result != 0: print(f"āŒ Error encoding chunk {i + 1}") sys.exit(1) chunk_videos.append(chunk_video) # Concatenate chunks print(" ... Concatenating chunks...") concat_file = os.path.join(CACHE_DIR, "concat_list.txt") with open(concat_file, 'w') as f: for cv in chunk_videos: f.write(f"file '{os.path.abspath(cv)}'\n") ffmpeg_cmd = ( f"ffmpeg -y -v error -stats " f"-f concat -safe 0 -i \"{concat_file}\" " f"-c copy " f"\"{OUTPUT_FILE}\"" ) result = os.system(ffmpeg_cmd) if result != 0: print("āŒ Error during ffmpeg encoding.") sys.exit(1) print("\n" + "="*60) print(f"āœ… SUCCESS! Video saved as: {OUTPUT_FILE}") print(f" Duration: {total_duration}s | Resolution: {WIDTH}x{HEIGHT}") print(f" Frames: {total_duration * FPS:,.0f} | Size: {os.path.getsize(OUTPUT_FILE) / 1024 / 1024:.1f} MB") print("="*60) # Optional: cleanup print("\nšŸ’” Tip: Delete the './tmp' folder to free up disk space") if __name__ == "__main__": main()