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

в 18:58, , рубрики: ffmpeg, figma, gif, gif-анимация, python, автоматизация, графический дизайн, искусственный интеллект, нейросети, сжатие видео
обложка

обложка

Вступление

Привет! Я графический дизайнер. Занимаюсь созданием сайтов, иллюстраций, немного работаю с видео и в качестве хобби увлекаюсь 3D. Я никогда не считал себя программистом. Да, я умею читать код, понимаю его логику, но вот так, чтобы самостоятельно сесть и написать что‑то с нуля... до недавнего времени это казалось мне чем‑то запредельным.

Проблема

Редко, но прилетают задачи сделать гифку из видео для рекламных площадок или для чего‑нибудь ещё. В этот раз попросили сделать новогоднее видео (баннер). Я отрисовал подарки на снегу в Стилистике сайта, добавил немного свечения и снег (Новый год же всё‑таки). Получилось зацикленное видео 320×200 пикселей на 10 секунд весом в 5 мегабайт. Всем всё понравилось, видео согласовали, но теперь нужно сделать из него гифку до 300 килобайт. Окей. Иду на ezgif.com, и после примерно пяти попыток уменьшения настроек получаю гифку размером 1.6 мегабайта. И тут я замечаю, что в настройках метода сжатия используется FFMPEG. А он у меня установлен (уже и не помню, зачем изначально я его себе ставил).

интерфейс сайта ezgif.com

интерфейс сайта ezgif.com

Идея

И тут меня осенило: а что если я у нейронки попрошу помочь с конвертацией 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. Это, если коротко, комбайн для работы с видео и аудио. Но в консоли, то есть графического интерфейса у него нет. А теперь у меня есть доступ ко всем его возможностям.

интерфейс gmini

интерфейс gmini

Раньше, чтобы сконвертировать какое‑то видео или уменьшить его размер, нужно было лезть в документацию, смотреть хелп, читать про те команды, которые можно и нужно использовать. А сейчас просто берёшь, просишь Gemini, и она тебе пишет команду, которая решает твой вопрос. И всё работает моментально, тебе не нужно ждать пока загрузится какой‑нибудь Adobe Media Encoder — FFMPEG часто видео конвертирует быстрее, чем Media Encoder открывается. То же самое касается и написания плагинов для Figma или экспрешенов и скриптов для After Effects.

Кстати, про плагины для фигмы, в комьюнити лежит файл с иконками от Paweł Kuna, у него почти 5000 потрясающих иконок и каждая проименованна например «corner‑up‑right», но когда мне нужно найти определенные иконки, то на поиск порой уходило слишком много времени, поэтому я написал плагин для фигмы, который название всех фреймов из этого файла сохранил в txt файл. Теперь, когда нужна очередная иконка, я закидываю в Gemini этот текстовый файл и прошу найти мне подходящие по смыслу иконки на нужные мне, например, пункты меню. И она находит, причём довольно точно находит, даже по ассоциациям. Тоже сильно сокращает время работы.

Извиняюсь, немного отвлёкся от GIF Конвертера.

Иконочки от Paweł Kuna

Иконочки от Paweł Kuna

Технические детали

За пару часов у меня получилось 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()

Автор: VetaOne

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js