当前位置: 首页 > article >正文

Python-基于PyQt5,Pillow,pathilb,imageio,moviepy,sys的GIF(动图)制作工具(进阶版)

前言:在抖音,快手等社交平台上,我们常常见到各种各样的GIF动画。在各大评论区里面,GIF图片以其短小精悍、生动有趣的特点,被广泛用于分享各种有趣的场景、搞笑的瞬间、精彩的动作等,能够快速吸引我们的注意力,增强内容的传播性和互动性。生活中,我们将各种有趣的人物表情、动作、台词等制作成GIF表情包,既可以更生动地表达我们此时的情感和态度,也让聊天的过程拥有了更多的趣味性和幽默感。当然,GIF动画不止在娱乐领域里应用广泛,在计算机的网页设计中很多时候也会使用GIF动画可以为页面增添动态效果,使页面更加生动活泼,吸引用户的注意力。例如,可以在网页的标题、导航栏、按钮等元素中添加GIF动画,提升页面的视觉效果和用户体验等。总而言之,GIF动画在我们的日常生活中扮演着重要的角色,我们有必要了解GIF动画的制作方法及相关制作工具。话不多说,我们今天就来学习一下如何利用Python来制作一款GIF动画工具。

 编程思路:与上次一样,本次编程我们同样会调用到Python中的众多库:包括诸如PyQt5,pillow,moviepy等的第三方库和sys,pathlib等的标准库。PyQt5被用于创建一个图形用户界面 (GUI) 应用程序(具体为代码中的GifMakerGUI类)。我们将创建窗口和布局(这里包括GUI窗口的大小,位置等),创建GUI中的相关组件(如按钮,标签,菜单等),处理事件和信号(主要负责将用户触发的事件与GUI控件联系起来),应用程序的启动和运行等。Pillow是Python中很重要的一个图片处理库,利用它我们可以对图片进行图像操作(包括图片的加载,操作,保存等),图像转换(包括图像颜色表示模式的转换(如RGB转HSV),以及图像尺寸大小的设置),图像序列处理(保存图像序列为GIF或其他格式),图像合成等操作。与pillow不同,moviepy是一个视频文件处理库(具体来说,它可以进行视频剪辑(打开,截取视频文件,也能进行音频处理(合成音频剪辑,视频音频合并,音频文件保存等))。imageio库比较简单,它主要被用于处理图像序列(简单来说就是将一系列图像保存为动画文件,如本次的GIF)。

第一步:导入库

标准库:sys,pathlib。

第三方库:PyQt5,pillow,imageio,moviepy。

#导入库
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from moviepy import VideoFileClip, CompositeAudioClip
import imageio
from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
                             QLabel, QLineEdit, QPushButton, QFileDialog,
                             QProgressBar, QMessageBox, QCheckBox, QColorDialog,
                             QComboBox, QSpinBox, QListWidget, QMenu, QAction)
from PyQt5.QtCore import QThread, pyqtSignal, QSettings, Qt
from PyQt5.QtGui import QPixmap, QMovie
import math
import random
from PIL.ImageFilter import EMBOSS, CONTOUR, FIND_EDGES, SHARPEN #为新增滤镜效果方法

第二步:创建功能类

两个:

GifCreator类,在后台线程中创建GIF或MP4文件(主要取决于用户是否选择音频文件(不选则生成gif动图,选则生成MP4文件))。

GifMakerGUI类,用于创建一个图形用户界面(GUI)应用程序,允许我们选择源文件(视频或图片)、添加音频文件、设置输出路径和参数,并启动GIF或MP4文件(同上)的生成过程。

与上次不同的是,本次添加了很多扩展的实用性功能:

1,给FPS(帧数)和GIF的"高","宽"值的设置新增了智能推荐功能(即在用户选定视频/图片文件后,程序会根据用户所给的文件大小和尺存自动填充FPS(帧数)和GIF的"高","宽"值),同时也保留了用户的个性化设置功能。

2,新增文件预览功能(即用户在选定视频/图片文件后,窗口会加载出文件的预览图像,方便用户查看选中的文件是否为需要处理的文件)。

3,新增水印相关参数设置(包括水印颜色,水印大小,水印动画,水印位置),用户可自行对任意参数进行个性化设置。

4,添加了图片滤镜功能,让用户体验不一样的GIF呈现效果。 

5,新增历史纪录功能,用户可以看到以往文件的存放位置纪录,点击纪录,用户可以查看该纪录下文件的相关参数。

#Gif生成器类
class GifCreator(QThread):
    progress_updated = pyqtSignal(int)
    finished = pyqtSignal(str)
    error_occurred = pyqtSignal(str)

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.font = None
        if config['text']:
            try:
                self.font = ImageFont.truetype("arial.ttf", config['text_size'])
            except:
                self.font = ImageFont.load_default()

    def run(self):
        try:
            if self.config['audio_path'] and not self.config['output'].endswith('.mp4'):
                self.error_occurred.emit("音频仅支持MP4输出格式")
                return

            if self.config['source_type'] == 'video':
                self._create_from_video()
            else:
                self._create_from_images()

            if self.config['audio_path']:
                self._add_audio()

            self.finished.emit(self.config['output'])
        except Exception as e:
            self.error_occurred.emit(str(e))

    def _process_frame(self, frame, index):
        img = Image.fromarray(frame).resize(self.config['size'])
        if self.config['text']:
            draw = ImageDraw.Draw(img)
            text_position = self._get_animated_text_position(index)
            draw.text(text_position, self.config['text'], font=self.font, fill=self.config['text_color'])
        if self.config['filter']:
            img = self._apply_filter(img)
        self.progress_updated.emit(int((index + 1) / self.total_frames * 100))
        return img


    def _get_animated_text_position(self, index):
        x, y = self.config['text_position']
        base_x, base_y = self.config['text_position']
        max_width, max_height = self.config['size']

        if self.config['text_animation'] == '滚动':
            x = (x + index * 5) % max_width
        elif self.config['text_animation'] == '渐入':
            alpha = min(255, index * 10)
            self.config['text_color'] = self.config['text_color'][:3] + (alpha,)
        elif self.config['text_animation'] == '跳动':
            y = base_y + int(10 * math.sin(index * 0.5))
        elif self.config['text_animation'] == '闪烁':
            alpha = 128 + int(127 * math.sin(index))
            self.config['text_color'] = self.config['text_color'][:3] + (alpha,)
        elif self.config['text_animation'] == '随机移动':
            if index % 10 == 0:
                self.rand_x = random.randint(0, max_width - 100)
                self.rand_y = random.randint(0, max_height - 30)
            x, y = self.rand_x, self.rand_y
        return (x, y)



    def _apply_filter(self, img):
        filter_map = {
            '黑白': lambda x: x.convert('L'),
            '复古': lambda x: x.convert('RGB').filter(ImageFilter.SMOOTH),
            '模糊': lambda x: x.filter(ImageFilter.BLUR),
            '边缘检测': lambda x: x.filter(FIND_EDGES),
            '锐化': lambda x: x.filter(SHARPEN),
            '浮雕': lambda x: x.filter(EMBOSS),
            '轮廓': lambda x: x.filter(CONTOUR),
            '油画': lambda x: x.filter(ImageFilter.SMOOTH_MORE).filter(ImageFilter.EDGE_ENHANCE)

        }
        return filter_map.get(self.config['filter'], lambda x: x)(img)

    #新79区
    def _create_from_video(self):
        with VideoFileClip(str(self.config['sources'][0])) as clip:
            if self.config['duration']:
                clip = clip.subclip(0, self.config['duration'])
            self.total_frames = int(clip.duration * self.config['fps'])
            frames = []
            for i, frame in enumerate(clip.iter_frames(fps=self.config['fps'])):
                frames.append(self._process_frame(frame, i))

        frames[0].save(
            self.config['output'],
            save_all=True,
            append_images=frames[1:],
            optimize=True,
            duration=1000 // self.config['fps'],
            loop=0
        )

    def _create_from_images(self):
        images = []
        self.total_frames = len(self.config['sources'])
        for i, img_path in enumerate(self.config['sources']):
            with Image.open(img_path) as img:
                img = img.convert('RGBA').resize(self.config['size'])
                if self.config['text']:
                    draw = ImageDraw.Draw(img)
                    text_position = self._get_animated_text_position(i)
                    draw.text(text_position, self.config['text'], font=self.font, fill=self.config['text_color'])
                if self.config['filter']:
                    img = self._apply_filter(img)
                images.append(img)
            self.progress_updated.emit(int((i + 1) / self.total_frames * 100))

        imageio.mimsave(
            self.config['output'],
            [img.convert('P', palette=Image.ADAPTIVE) for img in images],
            fps=self.config['fps'],
            palettesize=256
        )

    def _add_audio(self):
        video_clip = VideoFileClip(self.config['output'])
        audio_clip = CompositeAudioClip([VideoFileClip(self.config['audio_path']).audio])
        final_clip = video_clip.set_audio(audio_clip)
        final_clip.write_videofile(self.config['output'], codec='libx264')




#Gif窗口生成类
class GifMakerGUI(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()
        self.worker = None
        self.settings = QSettings("MyCompany", "GifMaker")
        self.history = []
        self.load_history()

        # 添加上下文菜单
        self.history_list.setContextMenuPolicy(Qt.CustomContextMenu)
        self.history_list.customContextMenuRequested.connect(self.show_history_menu)

    def show_history_menu(self, pos):
        menu = QMenu()
        delete_action = QAction("删除选中项", self)
        clear_action = QAction("清空历史记录", self)

        delete_action.triggered.connect(self.delete_selected_history)
        clear_action.triggered.connect(self.clear_history)

        menu.addAction(delete_action)
        menu.addAction(clear_action)
        menu.exec_(self.history_list.mapToGlobal(pos))

    def delete_selected_history(self):
        selected = self.history_list.currentRow()
        if selected >= 0:
            del self.history[selected]
            self.settings.setValue("history", self.history)
            self.update_history_list()

    def clear_history(self):
        self.history = []
        self.settings.setValue("history", self.history)
        self.update_history_list()

    def initUI(self):
        self.setWindowTitle('高级GIF制作工具')
        self.setGeometry(300, 300, 800, 600)

        layout = QVBoxLayout()

        # 源文件选择
        self.source_btn = QPushButton('选择源文件(视频/图片)')
        self.source_btn.clicked.connect(self.select_source)
        self.source_label = QLabel('未选择文件')
        self.preview_label = QLabel()
        layout.addWidget(self.source_btn)
        layout.addWidget(self.source_label)
        layout.addWidget(self.preview_label)

        # 音频文件选择
        self.audio_btn = QPushButton('添加音频文件')
        self.audio_btn.clicked.connect(self.select_audio)
        self.audio_label = QLabel('未选择音频文件')
        layout.addWidget(self.audio_btn)
        layout.addWidget(self.audio_label)

        # 输出设置
        output_layout = QHBoxLayout()
        self.output_btn = QPushButton('选择输出路径')
        self.output_btn.clicked.connect(self.select_output)
        self.output_entry = QLineEdit()
        output_layout.addWidget(self.output_btn)
        output_layout.addWidget(self.output_entry)
        layout.addLayout(output_layout)

        # 参数设置
        params_layout = QHBoxLayout()
        self.fps_entry = QLineEdit('10')
        self.size_w_entry = QLineEdit()
        self.size_h_entry = QLineEdit()
        self.duration_entry = QLineEdit()
        self.text_entry = QLineEdit()
        params_layout.addWidget(QLabel('FPS:'))
        params_layout.addWidget(self.fps_entry)
        params_layout.addWidget(QLabel('宽:'))
        params_layout.addWidget(self.size_w_entry)
        params_layout.addWidget(QLabel('高:'))
        params_layout.addWidget(self.size_h_entry)
        layout.addLayout(params_layout)

        text_layout = QHBoxLayout()
        text_layout.addWidget(QLabel('文字水印:'))
        text_layout.addWidget(self.text_entry)
        layout.addLayout(text_layout)

        # 水印颜色、大小、位置和动画设置
        watermark_layout = QHBoxLayout()
        self.color_btn = QPushButton('选择水印颜色')
        self.color_btn.clicked.connect(self.select_color)
        self.text_size_spin = QSpinBox()
        self.text_size_spin.setRange(10, 100)
        self.text_size_spin.setValue(24)
        self.position_combo = QComboBox()
        self.position_combo.addItems(["左上", "右上", "左下", "右下", "居中"])
        self.animation_combo = QComboBox()
        #self.animation_combo.addItems(["无", "滚动", "渐入"])
        self.animation_combo.addItems(["无", "滚动", "渐入", "跳动", "闪烁", "随机移动"])
        watermark_layout.addWidget(self.color_btn)
        watermark_layout.addWidget(QLabel('水印大小:'))
        watermark_layout.addWidget(self.text_size_spin)
        watermark_layout.addWidget(QLabel('水印位置:'))
        watermark_layout.addWidget(self.position_combo)
        watermark_layout.addWidget(QLabel('水印动画:'))
        watermark_layout.addWidget(self.animation_combo)
        layout.addLayout(watermark_layout)

        # 图片滤镜设置

        filter_layout = QHBoxLayout()
        self.filter_combo = QComboBox()
        #self.filter_combo.addItems(["无", "黑白", "复古", "模糊"])
        self.filter_combo.addItems(["无", "黑白", "复古", "模糊", "边缘检测",
                                    "锐化", "浮雕", "轮廓", "油画"])
        filter_layout.addWidget(QLabel('图片滤镜:'))
        filter_layout.addWidget(self.filter_combo)
        layout.addLayout(filter_layout)


        # 历史记录管理
        self.history_list = QListWidget()
        self.history_list.itemDoubleClicked.connect(self.load_from_history)
        layout.addWidget(QLabel('历史记录:'))
        layout.addWidget(self.history_list)

        # 进度条
        self.progress = QProgressBar()
        layout.addWidget(self.progress)

        # 操作按钮
        self.start_btn = QPushButton('开始生成')
        self.start_btn.clicked.connect(self.start_process)
        layout.addWidget(self.start_btn)

        self.setLayout(layout)

    def select_source(self):
        files, _ = QFileDialog.getOpenFileNames(
            self, '选择源文件', '',
            '视频文件 (*.mp4 *.mov *.avi);;图片文件 (*.png *.jpg *.jpeg)'
        )
        if files:
            self.source_label.setText(f'已选择 {len(files)} 个文件')
            self.source_files = [Path(f) for f in files]
            self.show_preview(self.source_files[0])
            self.recommend_size(self.source_files[0])

    def show_preview(self, file_path):
        if file_path.suffix.lower() in ['.mp4', '.mov', '.avi']:
            with VideoFileClip(str(file_path)) as clip:
                frame = clip.get_frame(0)
                img = Image.fromarray(frame)
                img.thumbnail((200, 200))
                img.save("preview.png")
                self.preview_label.setPixmap(QPixmap("preview.png"))
        else:
            img = Image.open(file_path)
            img.thumbnail((200, 200))
            img.save("preview.png")
            self.preview_label.setPixmap(QPixmap("preview.png"))

    def recommend_size(self, file_path):
        if file_path.suffix.lower() in ['.mp4', '.mov', '.avi']:
            with VideoFileClip(str(file_path)) as clip:
                width, height = clip.size
        else:
            with Image.open(file_path) as img:
                width, height = img.size

        # 推荐尺寸
        recommended_width = min(640, width)
        recommended_height = int((recommended_width / width) * height)

        self.size_w_entry.setText(str(recommended_width))
        self.size_h_entry.setText(str(recommended_height))

    def select_audio(self):
        file, _ = QFileDialog.getOpenFileName(
            self, '选择音频文件', '', '音频文件 (*.mp3 *.wav)'
        )
        if file:
            self.audio_label.setText(Path(file).name)
            self.audio_file = file

    def select_output(self):
        file, _ = QFileDialog.getSaveFileName(
            self, '保存输出文件', '',
            'GIF文件 (*.gif);;MP4文件 (*.mp4)'
        )
        if file:
            self.output_entry.setText(file)

    def select_color(self):
        color = QColorDialog.getColor()
        if color.isValid():
            self.text_color = color.getRgb()

    def validate_input(self):
        required = [
            (self.source_files, '请选择源文件'),
            (self.output_entry.text(), '请设置输出路径'),
            (self.fps_entry.text().isdigit(), 'FPS必须为数字'),
            (self.size_w_entry.text().isdigit(), '宽度必须为数字'),
            (self.size_h_entry.text().isdigit(), '高度必须为数字')
        ]
        for condition, message in required:
            if not condition:
                QMessageBox.warning(self, '输入错误', message)
                return False
        return True

    def start_process(self):
        if not self.validate_input():
            return

        config = {
            'sources': self.source_files,
            'output': self.output_entry.text(),
            'fps': int(self.fps_entry.text()),
            'size': (int(self.size_w_entry.text()), int(self.size_h_entry.text())),
            'duration': None,  # 可添加持续时间设置
            'text': self.text_entry.text(),
            'audio_path': getattr(self, 'audio_file', None),
            'source_type': 'video' if self.source_files[0].suffix.lower() in ['.mp4', '.mov', '.avi'] else 'image',
            'text_color': getattr(self, 'text_color', (255, 0, 0)),
            'text_position': self.get_text_position(),
            'text_size': self.text_size_spin.value(),
            'text_animation': self.animation_combo.currentText(),
            'filter': self.filter_combo.currentText()
        }

        self.worker = GifCreator(config)
        self.worker.progress_updated.connect(self.update_progress)
        self.worker.finished.connect(self.process_finished)
        self.worker.error_occurred.connect(self.show_error)
        self.start_btn.setEnabled(False)
        self.worker.start()


    def get_text_position(self):
        position = self.position_combo.currentText()
        width, height = int(self.size_w_entry.text()), int(self.size_h_entry.text())
        position_map = {
            "左上": (10, 10),
            "右上": (width - 100, 10),
            "左下": (10, height - 30),
            "右下": (width - 100, height - 30),
            "居中": (width // 2, height // 2)
        }
        return position_map.get(position, (10, 10))

    def update_progress(self, value):
        self.progress.setValue(value)

    def process_finished(self, output_path):
        self.start_btn.setEnabled(True)
        QMessageBox.information(self, '完成', f'文件已生成:{output_path}')
        self.save_history()
        self.update_history_list()

    def show_error(self, message):
        self.start_btn.setEnabled(True)
        QMessageBox.critical(self, '错误', message)

    def save_history(self):
        history_item = {
            'source_files': [str(f) for f in self.source_files],
            'output_path': self.output_entry.text(),
            'fps': self.fps_entry.text(),
            'size': f"{self.size_w_entry.text()},{self.size_h_entry.text()}",
            'text': self.text_entry.text(),
            'text_color': getattr(self, 'text_color', (255, 0, 0)),
            'text_size': self.text_size_spin.value(),
            'text_position': self.position_combo.currentText(),
            'text_animation': self.animation_combo.currentText(),
            'filter': self.filter_combo.currentText()
        }
        self.history.append(history_item)
        self.settings.setValue("history", self.history)

    def load_history(self):
        self.history = self.settings.value("history", [])
        self.update_history_list()

    def update_history_list(self):
        self.history_list.clear()
        for item in self.history:
            self.history_list.addItem(item['output_path'])

    def load_from_history(self, item):
        index = self.history_list.row(item)
        history_item = self.history[index]
        self.source_files = [Path(f) for f in history_item['source_files']]
        self.source_label.setText(f'已选择 {len(self.source_files)} 个文件')
        self.output_entry.setText(history_item['output_path'])
        self.fps_entry.setText(history_item['fps'])
        size = history_item['size'].split(",")
        self.size_w_entry.setText(size[0])
        self.size_h_entry.setText(size[1])
        self.text_entry.setText(history_item['text'])
        self.text_color = history_item['text_color']
        self.text_size_spin.setValue(int(history_item['text_size']))
        self.position_combo.setCurrentText(history_item['text_position'])
        self.animation_combo.setCurrentText(history_item['text_animation'])
        self.filter_combo.setCurrentText(history_item['filter'])

第三步:创建驱动程序单元

 和上次一样,我们也需要创建一个单独的单元来驱动整个程序的正常运行,这就是驱动单元。

#驱动程序单元
if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = GifMakerGUI()
    ex.show()
    sys.exit(app.exec_())

第四步:完整代码展示

#导入库
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from moviepy import VideoFileClip, CompositeAudioClip
import imageio
from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
                             QLabel, QLineEdit, QPushButton, QFileDialog,
                             QProgressBar, QMessageBox, QCheckBox, QColorDialog,
                             QComboBox, QSpinBox, QListWidget, QMenu, QAction)
from PyQt5.QtCore import QThread, pyqtSignal, QSettings, Qt
from PyQt5.QtGui import QPixmap, QMovie
import math
import random
from PIL.ImageFilter import EMBOSS, CONTOUR, FIND_EDGES, SHARPEN #为新增滤镜效果方法


#Gif生成器类
class GifCreator(QThread):
    progress_updated = pyqtSignal(int)
    finished = pyqtSignal(str)
    error_occurred = pyqtSignal(str)

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.font = None
        if config['text']:
            try:
                self.font = ImageFont.truetype("arial.ttf", config['text_size'])
            except:
                self.font = ImageFont.load_default()

    def run(self):
        try:
            if self.config['audio_path'] and not self.config['output'].endswith('.mp4'):
                self.error_occurred.emit("音频仅支持MP4输出格式")
                return

            if self.config['source_type'] == 'video':
                self._create_from_video()
            else:
                self._create_from_images()

            if self.config['audio_path']:
                self._add_audio()

            self.finished.emit(self.config['output'])
        except Exception as e:
            self.error_occurred.emit(str(e))

    def _process_frame(self, frame, index):
        img = Image.fromarray(frame).resize(self.config['size'])
        if self.config['text']:
            draw = ImageDraw.Draw(img)
            text_position = self._get_animated_text_position(index)
            draw.text(text_position, self.config['text'], font=self.font, fill=self.config['text_color'])
        if self.config['filter']:
            img = self._apply_filter(img)
        self.progress_updated.emit(int((index + 1) / self.total_frames * 100))
        return img


    def _get_animated_text_position(self, index):
        x, y = self.config['text_position']
        base_x, base_y = self.config['text_position']
        max_width, max_height = self.config['size']

        if self.config['text_animation'] == '滚动':
            x = (x + index * 5) % max_width
        elif self.config['text_animation'] == '渐入':
            alpha = min(255, index * 10)
            self.config['text_color'] = self.config['text_color'][:3] + (alpha,)
        elif self.config['text_animation'] == '跳动':
            y = base_y + int(10 * math.sin(index * 0.5))
        elif self.config['text_animation'] == '闪烁':
            alpha = 128 + int(127 * math.sin(index))
            self.config['text_color'] = self.config['text_color'][:3] + (alpha,)
        elif self.config['text_animation'] == '随机移动':
            if index % 10 == 0:
                self.rand_x = random.randint(0, max_width - 100)
                self.rand_y = random.randint(0, max_height - 30)
            x, y = self.rand_x, self.rand_y
        return (x, y)



    def _apply_filter(self, img):
        filter_map = {
            '黑白': lambda x: x.convert('L'),
            '复古': lambda x: x.convert('RGB').filter(ImageFilter.SMOOTH),
            '模糊': lambda x: x.filter(ImageFilter.BLUR),
            '边缘检测': lambda x: x.filter(FIND_EDGES),
            '锐化': lambda x: x.filter(SHARPEN),
            '浮雕': lambda x: x.filter(EMBOSS),
            '轮廓': lambda x: x.filter(CONTOUR),
            '油画': lambda x: x.filter(ImageFilter.SMOOTH_MORE).filter(ImageFilter.EDGE_ENHANCE)

        }
        return filter_map.get(self.config['filter'], lambda x: x)(img)

    #新79区
    def _create_from_video(self):
        with VideoFileClip(str(self.config['sources'][0])) as clip:
            if self.config['duration']:
                clip = clip.subclip(0, self.config['duration'])
            self.total_frames = int(clip.duration * self.config['fps'])
            frames = []
            for i, frame in enumerate(clip.iter_frames(fps=self.config['fps'])):
                frames.append(self._process_frame(frame, i))

        frames[0].save(
            self.config['output'],
            save_all=True,
            append_images=frames[1:],
            optimize=True,
            duration=1000 // self.config['fps'],
            loop=0
        )

    def _create_from_images(self):
        images = []
        self.total_frames = len(self.config['sources'])
        for i, img_path in enumerate(self.config['sources']):
            with Image.open(img_path) as img:
                img = img.convert('RGBA').resize(self.config['size'])
                if self.config['text']:
                    draw = ImageDraw.Draw(img)
                    text_position = self._get_animated_text_position(i)
                    draw.text(text_position, self.config['text'], font=self.font, fill=self.config['text_color'])
                if self.config['filter']:
                    img = self._apply_filter(img)
                images.append(img)
            self.progress_updated.emit(int((i + 1) / self.total_frames * 100))

        imageio.mimsave(
            self.config['output'],
            [img.convert('P', palette=Image.ADAPTIVE) for img in images],
            fps=self.config['fps'],
            palettesize=256
        )

    def _add_audio(self):
        video_clip = VideoFileClip(self.config['output'])
        audio_clip = CompositeAudioClip([VideoFileClip(self.config['audio_path']).audio])
        final_clip = video_clip.set_audio(audio_clip)
        final_clip.write_videofile(self.config['output'], codec='libx264')




#Gif窗口生成类
class GifMakerGUI(QWidget):
    def __init__(self):
        super().__init__()
        self.initUI()
        self.worker = None
        self.settings = QSettings("MyCompany", "GifMaker")
        self.history = []
        self.load_history()

        # 添加上下文菜单
        self.history_list.setContextMenuPolicy(Qt.CustomContextMenu)
        self.history_list.customContextMenuRequested.connect(self.show_history_menu)

    def show_history_menu(self, pos):
        menu = QMenu()
        delete_action = QAction("删除选中项", self)
        clear_action = QAction("清空历史记录", self)

        delete_action.triggered.connect(self.delete_selected_history)
        clear_action.triggered.connect(self.clear_history)

        menu.addAction(delete_action)
        menu.addAction(clear_action)
        menu.exec_(self.history_list.mapToGlobal(pos))

    def delete_selected_history(self):
        selected = self.history_list.currentRow()
        if selected >= 0:
            del self.history[selected]
            self.settings.setValue("history", self.history)
            self.update_history_list()

    def clear_history(self):
        self.history = []
        self.settings.setValue("history", self.history)
        self.update_history_list()

    def initUI(self):
        self.setWindowTitle('高级GIF制作工具')
        self.setGeometry(300, 300, 800, 600)

        layout = QVBoxLayout()

        # 源文件选择
        self.source_btn = QPushButton('选择源文件(视频/图片)')
        self.source_btn.clicked.connect(self.select_source)
        self.source_label = QLabel('未选择文件')
        self.preview_label = QLabel()
        layout.addWidget(self.source_btn)
        layout.addWidget(self.source_label)
        layout.addWidget(self.preview_label)

        # 音频文件选择
        self.audio_btn = QPushButton('添加音频文件')
        self.audio_btn.clicked.connect(self.select_audio)
        self.audio_label = QLabel('未选择音频文件')
        layout.addWidget(self.audio_btn)
        layout.addWidget(self.audio_label)

        # 输出设置
        output_layout = QHBoxLayout()
        self.output_btn = QPushButton('选择输出路径')
        self.output_btn.clicked.connect(self.select_output)
        self.output_entry = QLineEdit()
        output_layout.addWidget(self.output_btn)
        output_layout.addWidget(self.output_entry)
        layout.addLayout(output_layout)

        # 参数设置
        params_layout = QHBoxLayout()
        self.fps_entry = QLineEdit('10')
        self.size_w_entry = QLineEdit()
        self.size_h_entry = QLineEdit()
        self.duration_entry = QLineEdit()
        self.text_entry = QLineEdit()
        params_layout.addWidget(QLabel('FPS:'))
        params_layout.addWidget(self.fps_entry)
        params_layout.addWidget(QLabel('宽:'))
        params_layout.addWidget(self.size_w_entry)
        params_layout.addWidget(QLabel('高:'))
        params_layout.addWidget(self.size_h_entry)
        layout.addLayout(params_layout)

        text_layout = QHBoxLayout()
        text_layout.addWidget(QLabel('文字水印:'))
        text_layout.addWidget(self.text_entry)
        layout.addLayout(text_layout)

        # 水印颜色、大小、位置和动画设置
        watermark_layout = QHBoxLayout()
        self.color_btn = QPushButton('选择水印颜色')
        self.color_btn.clicked.connect(self.select_color)
        self.text_size_spin = QSpinBox()
        self.text_size_spin.setRange(10, 100)
        self.text_size_spin.setValue(24)
        self.position_combo = QComboBox()
        self.position_combo.addItems(["左上", "右上", "左下", "右下", "居中"])
        self.animation_combo = QComboBox()
        #self.animation_combo.addItems(["无", "滚动", "渐入"])
        self.animation_combo.addItems(["无", "滚动", "渐入", "跳动", "闪烁", "随机移动"])
        watermark_layout.addWidget(self.color_btn)
        watermark_layout.addWidget(QLabel('水印大小:'))
        watermark_layout.addWidget(self.text_size_spin)
        watermark_layout.addWidget(QLabel('水印位置:'))
        watermark_layout.addWidget(self.position_combo)
        watermark_layout.addWidget(QLabel('水印动画:'))
        watermark_layout.addWidget(self.animation_combo)
        layout.addLayout(watermark_layout)

        # 图片滤镜设置

        filter_layout = QHBoxLayout()
        self.filter_combo = QComboBox()
        #self.filter_combo.addItems(["无", "黑白", "复古", "模糊"])
        self.filter_combo.addItems(["无", "黑白", "复古", "模糊", "边缘检测",
                                    "锐化", "浮雕", "轮廓", "油画"])
        filter_layout.addWidget(QLabel('图片滤镜:'))
        filter_layout.addWidget(self.filter_combo)
        layout.addLayout(filter_layout)


        # 历史记录管理
        self.history_list = QListWidget()
        self.history_list.itemDoubleClicked.connect(self.load_from_history)
        layout.addWidget(QLabel('历史记录:'))
        layout.addWidget(self.history_list)

        # 进度条
        self.progress = QProgressBar()
        layout.addWidget(self.progress)

        # 操作按钮
        self.start_btn = QPushButton('开始生成')
        self.start_btn.clicked.connect(self.start_process)
        layout.addWidget(self.start_btn)

        self.setLayout(layout)

    def select_source(self):
        files, _ = QFileDialog.getOpenFileNames(
            self, '选择源文件', '',
            '视频文件 (*.mp4 *.mov *.avi);;图片文件 (*.png *.jpg *.jpeg)'
        )
        if files:
            self.source_label.setText(f'已选择 {len(files)} 个文件')
            self.source_files = [Path(f) for f in files]
            self.show_preview(self.source_files[0])
            self.recommend_size(self.source_files[0])

    def show_preview(self, file_path):
        if file_path.suffix.lower() in ['.mp4', '.mov', '.avi']:
            with VideoFileClip(str(file_path)) as clip:
                frame = clip.get_frame(0)
                img = Image.fromarray(frame)
                img.thumbnail((200, 200))
                img.save("preview.png")
                self.preview_label.setPixmap(QPixmap("preview.png"))
        else:
            img = Image.open(file_path)
            img.thumbnail((200, 200))
            img.save("preview.png")
            self.preview_label.setPixmap(QPixmap("preview.png"))

    def recommend_size(self, file_path):
        if file_path.suffix.lower() in ['.mp4', '.mov', '.avi']:
            with VideoFileClip(str(file_path)) as clip:
                width, height = clip.size
        else:
            with Image.open(file_path) as img:
                width, height = img.size

        # 推荐尺寸
        recommended_width = min(640, width)
        recommended_height = int((recommended_width / width) * height)

        self.size_w_entry.setText(str(recommended_width))
        self.size_h_entry.setText(str(recommended_height))

    def select_audio(self):
        file, _ = QFileDialog.getOpenFileName(
            self, '选择音频文件', '', '音频文件 (*.mp3 *.wav)'
        )
        if file:
            self.audio_label.setText(Path(file).name)
            self.audio_file = file

    def select_output(self):
        file, _ = QFileDialog.getSaveFileName(
            self, '保存输出文件', '',
            'GIF文件 (*.gif);;MP4文件 (*.mp4)'
        )
        if file:
            self.output_entry.setText(file)

    def select_color(self):
        color = QColorDialog.getColor()
        if color.isValid():
            self.text_color = color.getRgb()

    def validate_input(self):
        required = [
            (self.source_files, '请选择源文件'),
            (self.output_entry.text(), '请设置输出路径'),
            (self.fps_entry.text().isdigit(), 'FPS必须为数字'),
            (self.size_w_entry.text().isdigit(), '宽度必须为数字'),
            (self.size_h_entry.text().isdigit(), '高度必须为数字')
        ]
        for condition, message in required:
            if not condition:
                QMessageBox.warning(self, '输入错误', message)
                return False
        return True

    def start_process(self):
        if not self.validate_input():
            return

        config = {
            'sources': self.source_files,
            'output': self.output_entry.text(),
            'fps': int(self.fps_entry.text()),
            'size': (int(self.size_w_entry.text()), int(self.size_h_entry.text())),
            'duration': None,  # 可添加持续时间设置
            'text': self.text_entry.text(),
            'audio_path': getattr(self, 'audio_file', None),
            'source_type': 'video' if self.source_files[0].suffix.lower() in ['.mp4', '.mov', '.avi'] else 'image',
            'text_color': getattr(self, 'text_color', (255, 0, 0)),
            'text_position': self.get_text_position(),
            'text_size': self.text_size_spin.value(),
            'text_animation': self.animation_combo.currentText(),
            'filter': self.filter_combo.currentText()
        }

        self.worker = GifCreator(config)
        self.worker.progress_updated.connect(self.update_progress)
        self.worker.finished.connect(self.process_finished)
        self.worker.error_occurred.connect(self.show_error)
        self.start_btn.setEnabled(False)
        self.worker.start()


    def get_text_position(self):
        position = self.position_combo.currentText()
        width, height = int(self.size_w_entry.text()), int(self.size_h_entry.text())
        position_map = {
            "左上": (10, 10),
            "右上": (width - 100, 10),
            "左下": (10, height - 30),
            "右下": (width - 100, height - 30),
            "居中": (width // 2, height // 2)
        }
        return position_map.get(position, (10, 10))

    def update_progress(self, value):
        self.progress.setValue(value)

    def process_finished(self, output_path):
        self.start_btn.setEnabled(True)
        QMessageBox.information(self, '完成', f'文件已生成:{output_path}')
        self.save_history()
        self.update_history_list()

    def show_error(self, message):
        self.start_btn.setEnabled(True)
        QMessageBox.critical(self, '错误', message)

    def save_history(self):
        history_item = {
            'source_files': [str(f) for f in self.source_files],
            'output_path': self.output_entry.text(),
            'fps': self.fps_entry.text(),
            'size': f"{self.size_w_entry.text()},{self.size_h_entry.text()}",
            'text': self.text_entry.text(),
            'text_color': getattr(self, 'text_color', (255, 0, 0)),
            'text_size': self.text_size_spin.value(),
            'text_position': self.position_combo.currentText(),
            'text_animation': self.animation_combo.currentText(),
            'filter': self.filter_combo.currentText()
        }
        self.history.append(history_item)
        self.settings.setValue("history", self.history)

    def load_history(self):
        self.history = self.settings.value("history", [])
        self.update_history_list()

    def update_history_list(self):
        self.history_list.clear()
        for item in self.history:
            self.history_list.addItem(item['output_path'])

    def load_from_history(self, item):
        index = self.history_list.row(item)
        history_item = self.history[index]
        self.source_files = [Path(f) for f in history_item['source_files']]
        self.source_label.setText(f'已选择 {len(self.source_files)} 个文件')
        self.output_entry.setText(history_item['output_path'])
        self.fps_entry.setText(history_item['fps'])
        size = history_item['size'].split(",")
        self.size_w_entry.setText(size[0])
        self.size_h_entry.setText(size[1])
        self.text_entry.setText(history_item['text'])
        self.text_color = history_item['text_color']
        self.text_size_spin.setValue(int(history_item['text_size']))
        self.position_combo.setCurrentText(history_item['text_position'])
        self.animation_combo.setCurrentText(history_item['text_animation'])
        self.filter_combo.setCurrentText(history_item['filter'])



#驱动程序单元
if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = GifMakerGUI()
    ex.show()
    sys.exit(app.exec_())

第五步:运行效果展示

 第六步:操作指南

运行程序,待程序初始化完成弹出窗口后,点击"选择源文件(视频/图片)",在系统中选择你想要处理的视频/图片("添加音频文件"这一步可以省略,因为本次我们学习的是GIF制作,不需要这一步),接着窗口中就会出现文件的预览图像。接着我们点击"选择输出路径",选择最终生成的GIF存放的位置。接着可以自行设置FPS(帧数),GIF高,宽(这里不建议自行设置,默认就好。一是FPS值过大处理时间太长,二是"高,"宽"值很难把控,生成的GIF容易变形)以及"文字水印"的内容等。然后就可以对水印各个参数进行设置了,接着是图片滤镜的设置。最后点击"开始生成"按钮,窗口会出现进对条提示处理进度,当进度条满100%后,需再等待一段时间(此时程序正在将处理好的文件存放在指定位置)。当窗口弹出小窗口提示"文件已生成",点击小窗口中的"OK"按钮。返回你设置的"选择输出路径"的存放路径,就可以看到生成的GIF动画。

 第七步:注意事项

- FPS值越大,文件处理时间越长,请谨慎设置;GIF的高,宽同理。(已设置默认值)

- 水印暂不支持中文字体,后面会改进。

- 文件的大小同样会影响程序的处理时间。

- 水印动画及图片滤镜并不多,还需继续优化。

- 历史纪录删除问题。

后面我会对以上问题进行优化/处理,敬请期待!


http://www.kler.cn/a/535619.html

相关文章:

  • YOLOv11实时目标检测 | 摄像头视频图片文件检测
  • linux常用基础命令 最新1
  • Ubutun本地部署DeepSeek R1
  • 优化fm.jiecao.jcvideoplayer_lib中视频横竖屏自动适配原视频方案
  • 数据结构:时间复杂度
  • LabVIEW涡轮诊断系统
  • Spring MVC整体结构介绍,图文+代码
  • JVM的详细讲解
  • 爬虫抓取时遇到反爬策略怎么办?
  • 鸿蒙生态潮起:开发者的逐浪之旅
  • 【工具篇】深度剖析 Veo2 工具:解锁 AI 视频创作新境界
  • Android 中实现 PDF 预览三种方式
  • 【机器学习】K近邻算法的实现
  • (四)QT——QMainWindow——界面菜单设计
  • 【React】setState进阶
  • git 项目的更新
  • C++ auto的使用
  • CVPR2021 | VMI-FGSM VNI-FGSM | 通过方差调整增强对抗攻击的可迁移性
  • vs code 使用教程
  • 采用gitlab的package registry 方式 通过api 上传发布包,解决git命令拉取大文件异常退出问题
  • Deno vs Node.js:性能对比深度解析
  • 2025简约的打赏系统PHP网站源码
  • 语义分割简述
  • Python-memoryutils:内存泄漏检测与防止工具
  • JS:将JS对象格式化为php语法形式(完美支持无unicode编码匹配的正则)
  • Debian安装Seafile