Привет, Хабр! Я графический дизайнер. Занимаюсь созданием сайтов, иллюстраций, немного работаю с видео и в качестве хобби увлекаюсь 3D. Я никогда не считал себя программистом. Да, я умею читать код, понимаю его логику, но вот так, чтобы самостоятельно сесть и написать что‑то с нуля... до недавнего времени это казалось мне чем‑то запредельным.
Редко, но прилетают задачи сделать гифку из видео для рекламных площадок или для чего‑нибудь ещё. В этот раз попросили сделать новогоднее видео (баннер). Я отрисовал подарки на снегу в Стилистике сайта, добавил немного свечения и снег (Новый год же всё‑таки). Получилось зацикленное видео 320×200 пикселей на 10 секунд весом в 5 мегабайт. Всем всё понравилось, видео согласовали, но теперь нужно сделать из него гифку до 300 килобайт. Окей. Иду на ezgif.com, и после примерно пяти попыток уменьшения настроек получаю гифку размером 1.6 мегабайта. И тут я замечаю, что в настройках метода сжатия используется FFMPEG. А он у меня установлен (уже и не помню, зачем изначально я его себе ставил).
И тут меня осенило: а что если я у нейронки попрошу помочь с конвертацией MP4 > GIF, и тоже с помощью FFMPEG?
Тут нужно небольшое отступление. Я очень люблю автоматизировать рутинные процессы порой даже ценой больших усилий (когда сама автоматизация занимает больше времени, чем процесс, который я пытался автоматизировать). Но зато, когда процесс повторяется и у меня есть для этого готовое решение, я каждый раз радуюсь как ребенок.
Иду к нейронке с вопросом: «а мы можем MP4 конвертировать в GIF с помощью FFMPEG?» Она ответила положительно, чему я был несказанно рад. Запросил у неё все возможные настройки в FFMPEG, которые влияют на конечный размер гифки. Писать решил на Python, так как он у меня тоже установлен (нужен, чтобы запускать нейронки для генерации картинок локально на компе). Она довольно быстро накидала мне рабочий код (который работал в консоли). И тут, не очень‑то надеясь на положительный ответ, я спросил: а можем ли мы сюда добавить интерфейс со всеми настройками и ползуночками (сделать полноценную программу)? И нейронка ответила положительно. От этого я офигел во второй раз.
В итоге у меня получился мой софт по запросу. И не столь важно, как он работает, хотя работает он идеально. Важнее то, что это решает мои задачи ну и то что, я сам его сделал! Интерфейс получился простым и понятным.
Складываем видео в папку, выбираем параметры сжатия (FPS, количество цветов, дизеринг, разрешение, пресет), нажимаем на кнопку и получаем гифки!
Итак, мой помощник — это Gemini 2.0. До этого я сидел на платной подписке ChatGpt с момента основания, так как альтернатив особо не видел. Пробовал несколько раз Claude от Anthropic, но Chat подкупал своей экосистемой со своими приложениями под все устройства и потрясающим голосовым режимом. Но тут выходит Gemini 2.0 Flash Thinking Experimental. Не претендую на объективность своих тестов, но чисто по ощущениям я бы сравнил её с o1 от OpenAI, но бесплатная и безлимитная. Экосистема — это конечно, хорошо, но за 20$ в месяц можно и потерпеть работу в браузере. И вот уже месяц как я отказался от подписки чата и пользуюсь только Gemini.
Я просто задавал вопросы на русском простым языком, а она писала код. Это похоже на общение с очень умным другом, который хорошо погружён в твою тему и знает ответы на все возникающие в процессе работы технические вопросы. Я не лез в дебри программирования, я просто говорил: «Мне нужен код, который будет делать вот так». И она делала.
Я задавал вопросы, получал ответы и, как правило, готовый рабочий код, запускал, и если что‑то шло не так — снова задавал вопросы, и так по кругу.
Самое интересное было то, что благодаря Gemini, я смог «подружиться» с FFMPEG. Это, если коротко, комбайн для работы с видео и аудио. Но в консоли, то есть графического интерфейса у него нет. А теперь у меня есть доступ ко всем его возможностям.
Раньше, чтобы сконвертировать какое‑то видео или уменьшить его размер, нужно было лезть в документацию, смотреть хелп, читать про те команды, которые можно и нужно использовать. А сейчас просто берёшь, просишь Gemini, и она тебе пишет команду, которая решает твой вопрос. И всё работает моментально, тебе не нужно ждать пока загрузится какой‑нибудь Adobe Media Encoder — FFMPEG часто видео конвертирует быстрее, чем Media Encoder открывается. То же самое касается и написания плагинов для Figma или экспрешенов и скриптов для After Effects.
Кстати, про плагины для фигмы, в комьюнити лежит файл с иконками от Paweł Kuna, у него почти 5000 потрясающих иконок и каждая проименованна например «corner‑up‑right», но когда мне нужно найти определенные иконки, то на поиск порой уходило слишком много времени, поэтому я написал плагин для фигмы, который название всех фреймов из этого файла сохранил в txt файл. Теперь, когда нужна очередная иконка, я закидываю в Gemini этот текстовый файл и прошу найти мне подходящие по смыслу иконки на нужные мне, например, пункты меню. И она находит, причём довольно точно находит, даже по ассоциациям. Тоже сильно сокращает время работы.
Извиняюсь, немного отвлёкся от GIF Конвертера.
За пару часов у меня получилось 600 строк кода. (Оставлю их все в конце текста) И это всё работает! Основные «фишки» моего конвертера: простой интерфейс, быстрая работа, пояснения ко всем настройкам, т.к. мне редко нужно пользоваться и через полгода я скорее всего забуду, зачем та или иная настройка, поэтому я попросил Gemini подписать каждую и оценка размера гифки — так я хоть примерно понимаю, что получится.
Видео не ускорено, на одну небольшую гифку действительно уходит меньше секунды, при пакетном преобразовании это очень сильно заметно.
FPS — это количество кадров в секунду. Чем больше кадров, тем плавнее гифка, но и размер будет больше.
MAX_COLORS — это количество цветов. Чем больше цветов, тем лучше качество, но опять же, размер будет расти.
DITHER — это такая штука, которая помогает сглаживать переходы между цветами.
SCALE — тут всё понятно, разрешение гифки.
PRESET — это настройка скорости конвертации, от очень быстрой до очень медленной (но и с более высоким качеством).
На всё про всё у меня ушло где‑то 2–3 часа. В итоге у меня получилась программа, которая решает определенную задачу, и решает её так как мне нужно.
И самое главное — это только начало! Я вижу, в использовании нейросетей просто безграничные возможности. ИИ — это просто мощный инструмент, который может сделать нашу работу ещё более крутой. И, кстати, если у вас есть кейсы — как вам нейронки помогают в профессиональной деятельности, то будет очень интересно почитать.
Если вы хотите погрузиться в технические детали или просто попробовать запустить этот конвертор локально, то вот исходный код:
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()