обложка
обложка

Вступление

Привет, Хабр! Я графический дизайнер. Занимаюсь созданием сайтов, иллюстраций, немного работаю с видео и в качестве хобби увлекаюсь 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()

Комментарии (19)


  1. DirectoriX
    03.01.2025 19:12

    К сожалению (а может и к счастью), никакого "сжатия с помощью ИИ" в статье не нашлось, только

    нейросеть за меня написала GUI, который комбинирует несколько параметров FFMPEG


  1. LaInvestor
    03.01.2025 19:12

    Да. . Заголовок не соответствует статьи .

    Я ожидал , что ИИ сможет гифку сжать каким-то своим хитрым алгоритмом )

    Да и нафига писать свое приложение , если сейчас все в вебе ? Да и есть миллион готовых решений .


    1. VetaOne Автор
      03.01.2025 19:12

      А мне раньше хватало ezgif в основном. Но тут я попробовал его и CloudConvert ещё. У них, например, у обоих нет возможности установить количество цветов. Это можно сделать в Фотошопе, но там это так же долго, и я бы не сказал, что удобно. Ну, тут да, больше программа ради программы получилась, хоть и с возможностью практического применения


    1. Markscheider
      03.01.2025 19:12

      нафига писать свое приложение , если сейчас все в вебе

      Постоянно пользовался различными "media-convert-online", но в какой-то момент устал закрывать всплывающие рекламные окна :)

      Установил андроидную версию FFMPEG, радуюсь как ребенок. Формально - это гуй над консольным ffmpeg так что там можно и руками командную строку указать.


  1. heinrich_wirth
    03.01.2025 19:12

    Ай ай

    Интересно, но название не соответствует реальности


  1. Wesha
    03.01.2025 19:12

    я сам его сделал!

    «Мы пахали: я и трактор.»

    работает он идеально

    В перевод на хуманский: «за те полчаса, пока я с ним игрался, я не наткнулся ни на один случай, когда бы он не сработал (а если и наткнулся — то не заметил этого)».

    А я вот уже заметил
    Он все гифки принудительно приводит к 10 FPS. Кого-то ждёт сюрпрайз...
    Он все гифки принудительно приводит к 10 FPS. Кого-то ждёт сюрпрайз...


    1. VetaOne Автор
      03.01.2025 19:12

      Спасибо, хотел сейчас найти и исправить этот момент, но не нашёл. Fps же берётся из настроек, то есть он принудительно устанавливается на 10 fps, если ползунок стоит на значении 10 fps. Я бы точно отличил 10 от 30, и не должен был такой очевидный косяк пропустить. Или я не туда смотрю?


      1. FillCT
        03.01.2025 19:12

        И не найдете. Wesha просто зацепился на ваш промежуточный вариант из скриншота "интерфейс gmini". А то что там input и output тоже представлены константами его совсем не смутило.

        А вообще здорово. Сейчас можно написать относительно несложный инструмент с помощью нейросети без знания множества моментов. Сам так плагин для подсветки синтаксиса и тему под него получал для sublime text на chatgtp4. Хотя понятия не имел как пишется плагин и как его подключать.


        1. VetaOne Автор
          03.01.2025 19:12

          И не найдете.

          Понял, спасибо. Да скрин я в качестве примера интерфейса gmini приложил, даже не догадался в него заглянуть ))

          Сейчас можно написать относительно несложный инструмент с помощью нейросети без знания множества моментов.

          Да, тут полностью согласен, это и была основная мысль этого текста которую хотелось донести.


        1. Wesha
          03.01.2025 19:12

          Ну уж извините, я на просмотр этой халабуды затратил примерно 15 секунд, что в глаза прямо-таки выпрыгнуло — про то и написал.

          А более пространно у меня в профиле написано:

          Все программисты в мире делятся на две категории:
          — Те, кто считает, что ChatGPT кодит на порядок лучше их;
          — Те, кто считает, что ChatGPT кодит на порядок хуже их.
          И те, и другие абсолютно правы.


  1. gfiopl8
    03.01.2025 19:12

    Приложения для бесплатного джемини нет, но в телефонах можно использовать тг бота который работает на ключах юзеров, просто даешь ему свой API ключ от бесплатного джемини и он с ним работает так же как обычные чатгпт боты - отвечает на голосовые, картинки, гуглит итп всё силами джемини. https://t.me/kun4sun_bot

    ЗЫ а простые задачи типа переделай мне видео в гифку стандартный чатгпт решает вообще сам полностью, просто кидаешь в него видяшку и говоришь чистым русским языком что хочешь сделать с ней (уменьши до 300кб или что ты там хотел), чатгпт сам пишет скрипт для этого, запускает и выдает тебе готовый результат


    1. VetaOne Автор
      03.01.2025 19:12

      можно использовать тг бота который работает на ключах юзеров

      А какое практическое применение у Telegram-бота? Для каких-то быстрых и простых вопросов? Просто в веб-интерфейсе хотя бы история каждого чата сохраняется, можно быстро создать новый и переспросить что-то, если длина контекста подходит к концу, например. А в Telegram всё будет как в одном чате, это же неудобно?

      чатгпт сам пишет скрипт для этого, запускает и выдает тебе готовый результат

      А это круто! Мне кажется, это недавно добавили, так как пару месяцев назад я тоже пытался что-то конвертировать, и он не справлялся. Сейчас попробовал в Gemini и в ChatGPT. Gemini не справилась, а у ChatGPT получилось. Правда, его хватило только на одну GIF-ку. Не знаю, как им люди без подписки пользуются.


      1. gfiopl8
        03.01.2025 19:12

        В телеграме есть аналог вкладок с историей. Создаешь там свою группу, в ней включаешь темы (или треды или форумы или как там это называется) и у тебя получается группа в которой можно создавать подгруппы, выглядит это точно так же как вкладки на сайте чатгпт.

        Затаскиваешь в группу бота и в каждой подгруппе-вкладке у него будет отдельная память, отдельные настройки, промпты температура итп.

        Посмотреть как это примерно выглядит и работает можно в публичной группе https://t.me/ChatGPT_Habr_community

        В приватной группе будет тоже самое но только ты там один на 1 с ботом, без посторонних.

        Этот бот всеядный, в него можно любые ключи пихать, в том числе коммерческие, оригинальные и от агрегаторов, так что можно иметь в нем и клода и гпт и любую китайскую хрень.


  1. orekh
    03.01.2025 19:12

    Присмотритесь к https://gif.ski/ , по качеству созданных гифок превосходит ffmpeg, можно регулировать силу сжатия.


    1. VetaOne Автор
      03.01.2025 19:12

      Спасибо. Звучит круто. Посмотрю


    1. VetaOne Автор
      03.01.2025 19:12

      Я тут немного погрузился в тему, посравнивал разные форматы, которые поддерживают анимацию и прозрачность и наткнулся на AVIF. До этого пару раз встречался с ним, но раньше думал: "Что это вообще такое и как его сконвертировать в какой-нибудь PNG, чтобы нормально открыть? )))".

      Сейчас попробовал на примере моей GIF-ки, сравнил AVIF с APNG и WebP. И он показывает какие-то фантастические результаты на их фоне. Грубо говоря, одна и та же анимация в AVIF весит в 6 раз меньше, чем в WebP, и в 25 раз меньше, чем в APNG


      1. Tomasina
        03.01.2025 19:12

        Небольшой вес - это супер.
        Но сколько устройств/приложений сможет его открыть?


        1. funca
          03.01.2025 19:12

          Как минимум, AVIF поддерживается всеми популярными браузерами уже года три, если не больше.


  1. David_Osipov
    03.01.2025 19:12

    Вполне неплохо. Ток рекомендую подредактировать название статьи, чтобы соответствовать контенту. Я продакт и решил поучиться фронту и SEO через написание своего сайта, правда я у Gemini просто спрашиваю что делает этот класс или этот тэг - получается быстрее, чем рыться в доках.