|
|
|
""" |
|
png2gif.py โย Turn a directory of .png frames into an animated .gif |
|
Usage: |
|
python png2gif.py --src ./frames --out animation.gif --fps 12 |
|
""" |
|
import argparse |
|
from pathlib import Path |
|
from PIL import Image |
|
import re |
|
import gradio as gr |
|
import os |
|
import tempfile |
|
import zipfile |
|
import shutil |
|
import datetime |
|
|
|
def natural_key(s): |
|
"""Sort 'frame_2.png' before 'frame_10.png'.""" |
|
return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', s.name)] |
|
|
|
def gather_frames(src_dir): |
|
pngs = sorted(Path(src_dir).glob("*.png"), key=natural_key) |
|
if not pngs: |
|
return [] |
|
return pngs |
|
|
|
def build_gif(pngs, out_file, fps, loop): |
|
ms_per_frame = int(1000 / fps) |
|
frames = [Image.open(png).convert("RGBA") for png in pngs] |
|
|
|
|
|
w, h = frames[0].size |
|
if any(im.size != (w, h) for im in frames): |
|
print("โ ๏ธ Frame dimensions differ; resizing to first frameโs size") |
|
frames = [im.resize((w, h), Image.LANCZOS) for im in frames] |
|
|
|
frames[0].save( |
|
out_file, |
|
save_all=True, |
|
append_images=frames[1:], |
|
duration=ms_per_frame, |
|
loop=loop, |
|
disposal=2, |
|
optimize=True, |
|
) |
|
print(f"โ
GIF saved to {out_file}") |
|
|
|
def process_batch_directories(parent_dir, fps, loop_count): |
|
parent_path = Path(parent_dir) |
|
if not parent_path.exists(): |
|
print(f"โ Parent directory not found: {parent_dir}") |
|
return |
|
|
|
if not parent_path.is_dir(): |
|
print(f"โ Path is not a directory: {parent_dir}") |
|
return |
|
|
|
|
|
subdirs = [d for d in parent_path.iterdir() if d.is_dir()] |
|
|
|
if not subdirs: |
|
print(f"โ No subdirectories found in {parent_dir}") |
|
return |
|
|
|
print(f"๐ Found {len(subdirs)} subdirectories to process") |
|
|
|
successful = 0 |
|
failed = 0 |
|
|
|
for subdir in subdirs: |
|
print(f"\n๐ Processing: {subdir.name}") |
|
|
|
|
|
pngs = gather_frames(subdir) |
|
|
|
if not pngs: |
|
print(f"โ ๏ธ No PNG files found in {subdir.name}, skipping") |
|
continue |
|
|
|
|
|
gif_name = f"{subdir.name}.gif" |
|
output_path = subdir / gif_name |
|
|
|
try: |
|
build_gif(pngs, output_path, fps, loop_count) |
|
print(f"โ
Created {gif_name} with {len(pngs)} frames") |
|
successful += 1 |
|
except Exception as e: |
|
print(f"โ Failed to create GIF for {subdir.name}: {e}") |
|
failed += 1 |
|
|
|
|
|
print(f"\n๐ Batch processing complete:") |
|
print(f" โ
Successful: {successful}") |
|
print(f" โ Failed: {failed}") |
|
print(f" ๐ Total processed: {successful + failed}") |
|
|
|
def process_uploaded_files(files, fps, loop_count): |
|
if not files: |
|
return None, None, "Please upload PNG files" |
|
|
|
with tempfile.TemporaryDirectory() as temp_dir: |
|
png_files = [] |
|
|
|
for file in files: |
|
if not file.name.lower().endswith('.png'): |
|
continue |
|
|
|
|
|
source_path = Path(file.name) |
|
dest_path = Path(temp_dir) / source_path.name |
|
|
|
|
|
shutil.copy2(source_path, dest_path) |
|
png_files.append(dest_path) |
|
|
|
if not png_files: |
|
return None, None, "No valid PNG files found" |
|
|
|
png_files = sorted(png_files, key=natural_key) |
|
|
|
temp_output = Path(temp_dir) / "animation.gif" |
|
try: |
|
build_gif(png_files, temp_output, fps, loop_count) |
|
|
|
|
|
persistent_output = tempfile.NamedTemporaryFile(suffix=".gif", delete=False) |
|
persistent_output.close() |
|
|
|
|
|
shutil.copy2(temp_output, persistent_output.name) |
|
|
|
|
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") |
|
filename_for_display = f"animation_{timestamp}.gif" |
|
|
|
return persistent_output.name, persistent_output.name, f"โ
GIF created successfully with {len(png_files)} frames\n๐ Filename: {filename_for_display}" |
|
except Exception as e: |
|
return None, None, f"โ Error creating GIF: {str(e)}" |
|
|
|
def process_batch_zip(zip_file, fps, loop_count): |
|
if not zip_file: |
|
return "No ZIP file uploaded", [], None |
|
|
|
with tempfile.TemporaryDirectory() as temp_dir: |
|
try: |
|
|
|
zip_path = Path(zip_file.name) |
|
extract_dir = Path(temp_dir) / "extracted" |
|
|
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref: |
|
zip_ref.extractall(extract_dir) |
|
|
|
|
|
subdirs = [] |
|
for item in extract_dir.rglob("*"): |
|
if item.is_dir(): |
|
pngs = list(item.glob("*.png")) |
|
if pngs: |
|
subdirs.append(item) |
|
|
|
if not subdirs: |
|
return "โ No subdirectories with PNG files found in ZIP", [], None |
|
|
|
progress_msg = f"๐ Found {len(subdirs)} directories to process" |
|
generated_gifs = [] |
|
results_dir = Path(temp_dir) / "results" |
|
results_dir.mkdir() |
|
|
|
successful = 0 |
|
failed = 0 |
|
|
|
for i, subdir in enumerate(subdirs, 1): |
|
progress_msg += f"\n๐ Processing {i}/{len(subdirs)}: {subdir.name}" |
|
|
|
|
|
pngs = gather_frames(subdir) |
|
if not pngs: |
|
progress_msg += f"\nโ ๏ธ No PNG files found in {subdir.name}, skipping" |
|
continue |
|
|
|
|
|
gif_name = f"{subdir.name}.gif" |
|
gif_path = results_dir / gif_name |
|
|
|
try: |
|
build_gif(pngs, gif_path, fps, loop_count) |
|
|
|
|
|
persistent_gif = tempfile.NamedTemporaryFile(suffix=".gif", delete=False) |
|
persistent_gif.close() |
|
shutil.copy2(gif_path, persistent_gif.name) |
|
generated_gifs.append(persistent_gif.name) |
|
|
|
progress_msg += f"\nโ
Created {gif_name} with {len(pngs)} frames" |
|
successful += 1 |
|
except Exception as e: |
|
progress_msg += f"\nโ Failed to create GIF for {subdir.name}: {e}" |
|
failed += 1 |
|
|
|
|
|
if generated_gifs: |
|
results_zip = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) |
|
results_zip.close() |
|
|
|
with zipfile.ZipFile(results_zip.name, 'w') as zip_out: |
|
for gif_path in results_dir.glob("*.gif"): |
|
zip_out.write(gif_path, gif_path.name) |
|
|
|
progress_msg += f"\n\n๐ Batch processing complete:" |
|
progress_msg += f"\n โ
Successful: {successful}" |
|
progress_msg += f"\n โ Failed: {failed}" |
|
progress_msg += f"\n ๐ฆ Results ZIP created with {len(generated_gifs)} GIFs" |
|
|
|
return progress_msg, generated_gifs, results_zip.name |
|
else: |
|
return "โ No GIFs were successfully created", [], None |
|
|
|
except Exception as e: |
|
return f"โ Error processing ZIP file: {str(e)}", [], None |
|
|
|
def toggle_mode(mode): |
|
if mode == "Single Sequence": |
|
return ( |
|
gr.update(visible=True), |
|
gr.update(visible=False), |
|
gr.update(visible=True), |
|
gr.update(visible=False) |
|
) |
|
else: |
|
return ( |
|
gr.update(visible=False), |
|
gr.update(visible=True), |
|
gr.update(visible=False), |
|
gr.update(visible=True) |
|
) |
|
|
|
def process_conversion(mode, files, zip_file, fps, loop_count): |
|
if mode == "Single Sequence": |
|
if files: |
|
preview, download, status = process_uploaded_files(files, fps, loop_count) |
|
return preview, download, status, "", [], None |
|
else: |
|
return None, None, "Please upload PNG files", "", [], None |
|
else: |
|
if zip_file: |
|
progress, gallery, zip_download = process_batch_zip(zip_file, fps, loop_count) |
|
return None, None, "", progress, gallery, zip_download |
|
else: |
|
return None, None, "", "Please upload a ZIP file", [], None |
|
|
|
def create_gradio_interface(): |
|
with gr.Blocks(title="PNG to GIF Converter") as demo: |
|
gr.Markdown("# PNG to GIF Converter") |
|
gr.Markdown("Create animated GIFs from PNG sequences") |
|
|
|
|
|
mode_selector = gr.Radio( |
|
choices=["Single Sequence", "Batch Processing"], |
|
value="Single Sequence", |
|
label="Processing Mode" |
|
) |
|
gr.Markdown("**Single**: Upload individual PNG files | **Batch**: Upload ZIP with subdirectories") |
|
|
|
with gr.Row(): |
|
with gr.Column(): |
|
|
|
with gr.Group(visible=True) as single_group: |
|
files_input = gr.File( |
|
label="Upload PNG Files", |
|
file_count="multiple", |
|
file_types=[".png"] |
|
) |
|
|
|
|
|
with gr.Group(visible=False) as batch_group: |
|
gr.Markdown("ZIP should contain subdirectories, each with PNG files") |
|
zip_input = gr.File( |
|
label="Upload ZIP File", |
|
file_count="single", |
|
file_types=[".zip"] |
|
) |
|
|
|
|
|
with gr.Row(): |
|
fps_input = gr.Slider( |
|
minimum=1, |
|
maximum=60, |
|
value=12, |
|
step=1, |
|
label="Frames Per Second (FPS)" |
|
) |
|
|
|
loop_input = gr.Slider( |
|
minimum=0, |
|
maximum=10, |
|
value=0, |
|
step=1, |
|
label="Loop Count (0 = infinite)" |
|
) |
|
|
|
convert_btn = gr.Button("Convert to GIF", variant="primary") |
|
|
|
with gr.Column(): |
|
|
|
with gr.Group(visible=True) as single_output: |
|
gif_preview = gr.Image(label="GIF Preview", type="filepath") |
|
output_file = gr.File(label="Download GIF") |
|
|
|
|
|
with gr.Group(visible=False) as batch_output: |
|
batch_progress = gr.Textbox(label="Progress", interactive=False) |
|
gif_gallery = gr.Gallery(label="Generated GIFs", columns=3, rows=2) |
|
batch_download = gr.File(label="Download All GIFs (ZIP)") |
|
|
|
status_text = gr.Textbox(label="Status", interactive=False) |
|
|
|
|
|
mode_selector.change( |
|
fn=toggle_mode, |
|
inputs=[mode_selector], |
|
outputs=[single_group, batch_group, single_output, batch_output] |
|
) |
|
|
|
|
|
convert_btn.click( |
|
fn=process_conversion, |
|
inputs=[mode_selector, files_input, zip_input, fps_input, loop_input], |
|
outputs=[gif_preview, output_file, status_text, batch_progress, gif_gallery, batch_download] |
|
) |
|
|
|
return demo |
|
|
|
def main(): |
|
|
|
if os.getenv("SYSTEM") == "spaces": |
|
demo = create_gradio_interface() |
|
demo.launch() |
|
return |
|
|
|
ap = argparse.ArgumentParser() |
|
ap.add_argument("--src", default=".", help="Folder with PNG frames or parent directory for batch mode") |
|
ap.add_argument("--out", default="animation.gif", help="Output GIF file") |
|
ap.add_argument("--fps", type=int, default=12, help="Frames per second") |
|
ap.add_argument("--loop", type=int, default=0, help="Loop count (0 = infinite)") |
|
ap.add_argument("--web", action="store_true", help="Launch web interface") |
|
ap.add_argument("--batch", action="store_true", help="Batch process subdirectories") |
|
args = ap.parse_args() |
|
|
|
if args.web: |
|
demo = create_gradio_interface() |
|
demo.launch(share=True) |
|
elif args.batch: |
|
process_batch_directories(args.src, args.fps, args.loop) |
|
else: |
|
pngs = gather_frames(args.src) |
|
if not pngs: |
|
print(f"โ No PNG files found in {args.src}") |
|
return |
|
build_gif(pngs, args.out, args.fps, args.loop) |
|
|
|
if __name__ == "__main__": |
|
main() |
|
|