254 lines
8.7 KiB
Python
254 lines
8.7 KiB
Python
#!/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()
|