Как я гифку с помощью ИИ сжимал

import os import subprocess from pathlib import Path import json import math import tkinter as tk from tkinter import ttk, messagebox from PIL import Image, ImageTk SETTINGS_FILE = "gif_settings.json" INPUT_FOLDER = "input" DEFAULT_SETTINGS = { "FPS": "10", "MAX_COLORS": "64", "DITHER": "floyd_steinberg", "SCALE": "316:-1", "PRESET": "medium" } DITHER_INFO = { "floyd_steinberg": "Баланс качества и размера, подходит для большинства случаев.", "bayer": "Структурированный дизеринг, может подойти для пиксельной графики.", "none": "Без дизеринга. Может уменьшить размер, но понизит плавность переходов.", "sierra2": "Альтернативный дизеринг, стоит попробовать, если другие не подходят.", "sierra2_4a": "Ещё один вариант экспериментального дизеринга." } PRESET_INFO = { "ultrafast": "Очень быстрая обработка, но может быть снижение качества.", "fast": "Быстрее среднего, чуть лучше качество чем ultrafast.", "medium": "Сбалансированный вариант по скорости и качеству.", "slow": "Медленнее, но может дать немного лучшее качество.", "veryslow": "Очень медленная обработка, чуть лучшее качество, но долго." } SCALE_HELP = """\ Введите разрешение в формате WxH. Например: - 320:240 для точного масштаба. - 316:-1 чтобы ширина была 316, а высота подобралась автоматически для сохранения пропорций. Если указать -1 для одной из сторон, мы вычислим итоговое разрешение, исходя из оригинальных пропорций видео. """ def load_settings(): if os.path.exists(SETTINGS_FILE): with open(SETTINGS_FILE, "r", encoding="utf-8") as f: loaded = json.load(f) return {**DEFAULT_SETTINGS, **loaded} return DEFAULT_SETTINGS.copy() def save_settings(settings): with open(SETTINGS_FILE, "w", encoding="utf-8") as f: json.dump(settings, f, ensure_ascii=False, indent=4) def get_file_size(file_path): return round(file_path.stat().st_size / 1024, 2) if file_path.exists() else 0 def ffprobe_json(file_path): if not file_path.exists(): return None cmd = ["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(file_path)] proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) return json.loads(proc.stdout) if proc.returncode == 0 else None def get_video_info(file_path): data = ffprobe_json(file_path) if not data: return None fmt = data.get("format", {}) video_stream = next((s for s in data.get("streams", []) if s.get("codec_type") == "video"), None) if not video_stream: return None frame_rate_str = video_stream.get("avg_frame_rate", "0/0") frame_rate = float(frame_rate_str.split('/')[0]) / float(frame_rate_str.split('/')[1]) if frame_rate_str != "0/0" and frame_rate_str.split('/')[1] != '0' else 0.0 return { "duration": float(fmt.get("duration", 0.0)), "width": video_stream.get("width", 0), "height": video_stream.get("height", 0), "codec": video_stream.get("codec_name", "unknown"), "frame_rate": frame_rate, "file_size_mb": float(fmt.get("size", 0.0)) / (1024*1024), "creation_date": fmt.get("tags", {}).get("creation_time", "Неизвестно") } def parse_scale(scale_str): parts = scale_str.split(":") if len(parts) != 2: return -1, -1 try: return int(parts[0]), int(parts[1]) except ValueError: return -1, -1 class GifConverterApp: def __init__(self, master): self.master = master master.title("GIF Конвертер") self.settings = load_settings() self.output_folder = Path.cwd() / "converted_gifs" self.output_folder.mkdir(exist_ok=True) self.input_path = Path.cwd() / INPUT_FOLDER self.input_path.mkdir(exist_ok=True) self.files = self._load_files() self.selected_files = [] self.current_duration = 0.0 self.current_video_info = None self.last_selected_indices = () self.preview_image = None self.style = ttk.Style(master) self.style.theme_use('clam') self.style.configure('TLabelFrame.Label', font=('Segoe UI', 10, 'bold')) self.style.configure('TButton', padding=5) self.style.configure('TCombobox', padding=5) self.style.configure('TScale', background='#f0f0f0') left_frame = ttk.Frame(master, padding=10) left_frame.pack(side=tk.LEFT, fill=tk.Y) self.refresh_button = ttk.Button(left_frame, text="Обновить", command=self._update_file_list) self.refresh_button.pack(anchor="w", pady=(0, 5)) ttk.Label(left_frame, text="Список файлов:", font=('Segoe UI', 9)).pack(anchor="w") self.file_listbox = tk.Listbox(left_frame, height=10, selectmode=tk.EXTENDED, font=('Segoe UI', 9), borderwidth=1, relief="solid") self.file_listbox.pack(fill=tk.BOTH, expand=True) for f in self.files: self.file_listbox.insert(tk.END, f.name) self.file_listbox.bind("<<ListboxSelect>>", self._on_file_select) self.preview_frame = ttk.Frame(left_frame, borderwidth=1, relief="solid", padding=5) self.preview_frame.pack(fill=tk.X, pady=5) ttk.Label(self.preview_frame, text="Предпросмотр:", font=('Segoe UI', 9)).pack(anchor="w") self.preview_label = ttk.Label(self.preview_frame) self.preview_label.pack() right_frame = ttk.Frame(master, padding=10) right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) settings_frame = ttk.LabelFrame(right_frame, text="Настройки конвертации", padding=10) settings_frame.pack(fill=tk.X, pady=10) ttk.Label(settings_frame, text="FPS (1-30):", font=('Segoe UI', 9)).grid(row=0, column=0, sticky="w", padx=5, pady=5) fps_frame = ttk.Frame(settings_frame) fps_frame.grid(row=0, column=1, padx=5, pady=5, sticky="ew") self.fps_entry = ttk.Spinbox(fps_frame, from_=1, to=30, width=5, command=self._on_fps_entry_change) self.fps_entry.insert(0, self.settings["FPS"]) self.fps_entry.pack(side=tk.LEFT, padx=(0, 5)) self.fps_slider = ttk.Scale(fps_frame, from_=1, to=30, orient="horizontal", command=self._on_fps_change) self.fps_slider.set(int(self.settings["FPS"])) self.fps_slider.pack(side=tk.LEFT, fill="x", expand=True) self.fps_info = ttk.Label(settings_frame, text="FPS: Количество кадров в секунду. Чем выше FPS, тем плавнее анимация, но больше размер файла.", wraplength=300, justify="left", anchor="nw", font=('Segoe UI', 9)) self.fps_info.grid(row=1, column=0, columnspan=2, sticky="ew", padx=5, pady=5) ttk.Label(settings_frame, text="MAX COLORS (2-256):", font=('Segoe UI', 9)).grid(row=2, column=0, sticky="w", padx=5, pady=5) colors_frame = ttk.Frame(settings_frame) colors_frame.grid(row=2, column=1, padx=5, pady=5, sticky="ew") self.colors_entry = ttk.Spinbox(colors_frame, from_=2, to=256, width=5, command=self._on_colors_entry_change) self.colors_entry.insert(0, self.settings["MAX_COLORS"]) self.colors_entry.pack(side=tk.LEFT, padx=(0, 5)) self.colors_slider = ttk.Scale(colors_frame, from_=2, to=256, orient="horizontal", command=self._on_colors_change) self.colors_slider.set(int(self.settings["MAX_COLORS"])) self.colors_slider.pack(side=tk.LEFT, fill="x", expand=True) self.colors_info = ttk.Label(settings_frame, text="MAX COLORS: Чем больше цветов, тем лучше качество, но больше размер файла.", wraplength=300, justify="left", anchor="nw", font=('Segoe UI', 9)) self.colors_info.grid(row=3, column=0, columnspan=2, sticky="ew", padx=5, pady=5) ttk.Label(settings_frame, text="DITHER:", font=('Segoe UI', 9)).grid(row=4, column=0, sticky="w", padx=5, pady=5) self.dither_dropdown = ttk.Combobox(settings_frame, values=list(DITHER_INFO.keys())) self.dither_dropdown.grid(row=4, column=1, padx=5, pady=5, sticky="ew") self.dither_dropdown.set(self.settings["DITHER"]) self.dither_dropdown.bind("<<ComboboxSelected>>", self._on_dither_change) self.dither_info = ttk.Label(settings_frame, text=DITHER_INFO[self.settings["DITHER"]], wraplength=300, justify="left", anchor="nw", font=('Segoe UI', 9)) self.dither_info.grid(row=5, column=0, columnspan=2, sticky="ew", padx=5, pady=5) ttk.Label(settings_frame, text="SCALE (WxH):", font=('Segoe UI', 9)).grid(row=6, column=0, sticky="w", padx=5, pady=5) scale_frame = ttk.Frame(settings_frame) scale_frame.grid(row=6, column=1, padx=5, pady=5, sticky="ew") self.scale_entry = ttk.Entry(scale_frame) self.scale_entry.insert(0, self.settings["SCALE"]) self.scale_entry.pack(side=tk.LEFT, fill="x", expand=True, padx=(0, 5)) self.scale_entry.bind("<FocusIn>", self._preserve_selection) self.scale_entry.bind("<KeyRelease>", self._update_estimate) self.resolution_button = ttk.Button(scale_frame, text="Разрешение", command=self._apply_selected_resolution) self.resolution_button.pack(side=tk.LEFT, padx=(0, 5)) help_btn = ttk.Button(scale_frame, text="?", command=self._show_scale_help) help_btn.pack(side=tk.LEFT) self.scale_info = ttk.Label(settings_frame, text="SCALE: Например, 316:-1 — ширина 316, высота будет рассчитана пропорционально.", wraplength=300, justify="left", anchor="nw", font=('Segoe UI', 9)) self.scale_info.grid(row=7, column=0, columnspan=2, sticky="ew", padx=5, pady=5) ttk.Label(settings_frame, text="PRESET:", font=('Segoe UI', 9)).grid(row=8, column=0, sticky="w", padx=5, pady=5) self.preset_dropdown = ttk.Combobox(settings_frame, values=list(PRESET_INFO.keys())) self.preset_dropdown.grid(row=8, column=1, padx=5, pady=5, sticky="ew") self.preset_dropdown.set(self.settings["PRESET"]) self.preset_dropdown.bind("<<ComboboxSelected>>", self._on_preset_change) self.preset_info_label = ttk.Label(settings_frame, text=PRESET_INFO[self.settings["PRESET"]], wraplength=300, justify="left", anchor="nw", font=('Segoe UI', 9)) self.preset_info_label.grid(row=9, column=0, columnspan=2, sticky="ew", padx=5, pady=5) save_btn = ttk.Button(settings_frame, text="Сохранить настройки", command=self._save_current_settings) save_btn.grid(row=10, column=0, columnspan=2, padx=5, pady=5, sticky="ew") convert_btn = ttk.Button(right_frame, text="Конвертировать выбранные файлы", command=self._convert_selected_files) convert_btn.pack(pady=10, fill="x") self.results_text = tk.Text(right_frame, height=10, wrap="word", font=('Segoe UI', 9), borderwidth=1, relief="solid") self.results_text.pack(fill=tk.BOTH, expand=True, pady=5) self.results_text.config(state=tk.DISABLED) self.results_text.tag_configure("red", foreground="red") self.results_text.tag_configure("green", foreground="green") self.file_info_label = ttk.Label(right_frame, text="Нет выбранного файла", justify="left", anchor="nw", font=('Segoe UI', 9)) self.file_info_label.pack(anchor="w", padx=5, pady=5) self.estimate_label = ttk.Label(right_frame, text="Примерная оценка размера GIF: недостаточно данных", wraplength=300, justify="left", anchor="nw", foreground="blue", font=('Segoe UI', 9, 'italic')) self.estimate_label.pack(anchor="w", padx=5, pady=5) self._update_estimate() def _load_files(self): return list(self.input_path.glob("*.mp4")) def _update_file_list(self): self._preserve_selection() self.file_listbox.delete(0, tk.END) self.files = self._load_files() for f in self.files: self.file_listbox.insert(tk.END, f.name) self._restore_selection() def _apply_selected_resolution(self): if self.current_video_info: resolution = f"{self.current_video_info['width']}:{self.current_video_info['height']}" self.scale_entry.delete(0, tk.END) self.scale_entry.insert(0, resolution) self._update_estimate() def _show_scale_help(self): messagebox.showinfo("Справка по SCALE", SCALE_HELP) def _on_file_select(self, event=None): selection = self.file_listbox.curselection() self.selected_files = [self.files[i] for i in selection] self.last_selected_indices = selection if self.selected_files: selected_file_path = self.selected_files[0] info = get_video_info(selected_file_path) if info: self.current_video_info = info self.current_duration = info["duration"] file_info_text = (f"Файл: {selected_file_path.name}\n" f"Разрешение: {info['width']}x{info['height']}\n" f"Продолжительность: {round(info['duration'],2)} сек\n" f"Размер: {round(info['file_size_mb'],2)} МБ\n" f"Кодек: {info['codec']}\n" f"Частота кадров: {round(info['frame_rate'],2)} к/с\n" f"Дата создания: {info['creation_date']}") self.file_info_label.config(text=file_info_text) self._generate_preview(selected_file_path) self.resolution_button.config(text=f"{info['width']}:{info['height']}") else: self._clear_preview() self.file_info_label.config(text="Не удалось получить информацию о файле") self.resolution_button.config(text="Разрешение") else: self._clear_preview() self.file_info_label.config(text="Нет выбранного файла") self.resolution_button.config(text="Разрешение") self._update_estimate() def _clear_preview(self): self.preview_label.config(image='') self.preview_image = None def _generate_preview(self, video_path): self._clear_preview() info = get_video_info(video_path) if not info or info['duration'] <= 0: return preview_time = info['duration'] / 2 temp_filename = "preview.png" cmd = ["ffmpeg", "-i", str(video_path), "-ss", str(preview_time), "-vframes", "1", "-vf", "scale=200:-1", temp_filename] try: subprocess.run(cmd, check=True, capture_output=True) img = Image.open(temp_filename) self.preview_image = ImageTk.PhotoImage(img) self.preview_label.config(image=self.preview_image) except FileNotFoundError: messagebox.showerror("Ошибка", "FFmpeg не найден.") except subprocess.CalledProcessError as e: messagebox.showerror("Ошибка", f"Ошибка предпросмотра: {e.stderr.decode()}") except Exception as e: messagebox.showerror("Ошибка", f"Ошибка предпросмотра: {e}") finally: if os.path.exists(temp_filename): os.remove(temp_filename) def _on_fps_change(self, val): self.fps_entry.delete(0, tk.END) self.fps_entry.insert(0, str(int(float(val)))) self._update_estimate() def _on_fps_entry_change(self): try: val = int(self.fps_entry.get()) if 1 <= val <= 30: self.fps_slider.set(val) self._update_estimate() except ValueError: pass def _on_colors_change(self, val): self.colors_entry.delete(0, tk.END) self.colors_entry.insert(0, str(int(float(val)))) self._update_estimate() def _on_colors_entry_change(self): try: val = int(self.colors_entry.get()) if 2 <= val <= 256: self.colors_slider.set(val) self._update_estimate() except ValueError: pass def _on_dither_change(self, event=None): self.dither_info.config(text=DITHER_INFO[self.dither_dropdown.get()]) self._update_estimate() def _on_preset_change(self, event=None): self.preset_info_label.config(text=PRESET_INFO[self.preset_dropdown.get()]) self._update_estimate() def _save_current_settings(self): self.settings["FPS"] = str(int(float(self.fps_slider.get()))) self.settings["MAX_COLORS"] = str(int(float(self.colors_slider.get()))) self.settings["DITHER"] = self.dither_dropdown.get().strip() self.settings["SCALE"] = self.scale_entry.get().strip() self.settings["PRESET"] = self.preset_dropdown.get().strip() save_settings(self.settings) def _convert_selected_files(self): if not self.selected_files: self._append_result("Нет выбранных файлов для конвертации.", "red") return self._save_current_settings() fps = self.settings["FPS"] max_colors = self.settings["MAX_COLORS"] dither = self.settings["DITHER"] scale = self.settings["SCALE"] preset = self.settings["PRESET"] for input_file in self.selected_files: output_gif = self.output_folder / (input_file.stem + ".gif") palette_file = self.output_folder / (input_file.stem + "_palette.png") cmd_palette = ["ffmpeg", "-i", str(input_file), "-vf", f"fps={fps},scale={scale}:flags=lanczos,palettegen=max_colors={max_colors}", "-y", str(palette_file)] cmd_gif = ["ffmpeg", "-i", str(input_file), "-i", str(palette_file), "-lavfi", f"fps={fps},scale={scale}:flags=lanczos[x];[x][1:v]paletteuse=dither={dither}", "-preset", preset, "-y", str(output_gif)] proc_palette = subprocess.run(cmd_palette, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if proc_palette.returncode != 0: self._append_result(f"{input_file.name}: Ошибка palettegen: {proc_palette.stderr}", "red") continue proc_gif = subprocess.run(cmd_gif, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if proc_gif.returncode != 0: self._append_result(f"{input_file.name}: Ошибка конвертации в GIF: {proc_gif.stderr}", "red") continue try: palette_file.unlink() except FileNotFoundError: pass input_size = get_file_size(input_file) output_size = get_file_size(output_gif) reduction = f"{round(((output_size - input_size) / input_size) * 100, 2)}%" if input_size > 0 else "0%" color = "red" if output_size > input_size else "green" self._append_result(f"{input_file.name}: {input_size} KB → {output_size} KB ({reduction}).", color) def _append_result(self, text, color): self.results_text.config(state=tk.NORMAL) self.results_text.insert("1.0", text + "\n", (color,)) self.results_text.config(state=tk.DISABLED) def _preserve_selection(self, event=None): self.last_selected_indices = self.file_listbox.curselection() def _restore_selection(self): if self.last_selected_indices: self.file_listbox.selection_clear(0, tk.END) for i in self.last_selected_indices: self.file_listbox.selection_set(i) def _update_estimate(self, event=None): try: fps = int(float(self.fps_slider.get())) except ValueError: fps = 10 try: max_colors = int(float(self.colors_slider.get())) except ValueError: max_colors = 64 scale_str = self.scale_entry.get().strip() w, h = parse_scale(scale_str) if not self.selected_files or not self.current_video_info or self.current_video_info['duration'] <= 0: self.estimate_label.config(text="Примерная оценка размера GIF: недостаточно данных", foreground="blue") return orig_w = self.current_video_info['width'] orig_h = self.current_video_info['height'] duration = self.current_video_info['duration'] final_w, final_h = w, h if w == -1 and h == -1: self.estimate_label.config(text="Примерная оценка размера GIF: некорректный SCALE", foreground="blue") return elif w == -1: final_w = int(round((orig_w / orig_h) * h)) if orig_h != 0 else 0 elif h == -1: final_h = int(round((orig_h / orig_w) * w)) if orig_w != 0 else 0 if final_w <= 0 or final_h <= 0: self.estimate_label.config(text="Примерная оценка размера GIF: итоговое разрешение не рассчитано", foreground="blue") return frames = duration * fps bpp = math.log2(max(2, max_colors)) pixels_per_frame = final_w * final_h size_approx_kb = (frames * pixels_per_frame * (bpp/8) * 0.2) / 1024 self.estimate_label.config(text=f"Примерная оценка размера GIF: ~ {size_approx_kb:.2f} KB", foreground="blue") if __name__ == "__main__": root = tk.Tk() app = GifConverterApp(root) root.lift() root.attributes("-topmost", True) root.after(0, root.attributes, "-topmost", False) root.mainloop()