智能视频比例批量转换工具 v4.0-单视频转化9:16,多个视频批量转化

智能视频比例批量转换工具,主要功能是将视频自动裁剪转换为9:16垂直比例(适用于短视频平台),并保留原始音频。以下是核心功能的详细说明:
在这里插入图片描述
智能黑边检测与裁剪

    自动分析视频上下黑边区域

    通过多帧采样计算平均黑边高度

    支持动态阈值检测(非纯黑像素识别)

    自动裁剪去除检测到的黑边

自适应视频裁剪

    将视频裁剪为9:16比例(1080x1920等)

    居中裁剪原始画面

    自动计算有效画面区域

    宽度不足时提示错误

智能编码器选择

    优先使用现代编码器(H.264/AVC)

    备选传统编码器(XVID/DIVX等)

    自动检测系统可用编码器

    动态创建测试视频验证编码器

音频处理

    使用FFmpeg提取原始音频

    保持音频编码无损复制

    自动合并到处理后的视频

    支持无声视频处理

批量处理能力

    支持单个文件处理

    支持文件夹批量处理

    可选包含子文件夹

    自动遍历视频文件(支持MP4/AVI/MOV等格式)

安全与验证

    临时文件安全处理

    输出文件完整性验证

    自动清理中间文件

    处理失败安全回滚

进度与日志系统

    实时进度显示(文件/帧级别)

    详细处理日志记录

    当前文件状态提示

    最终统计报告

跨平台支持

    自动适配Windows/macOS界面风格

    系统信息自动检测

    FFmpeg路径自动识别

    文件路径系统兼容处理

用户界面特性

    现代风格的GUI设计

    直观的操作流程

    实时日志显示窗

    进度条动态反馈

    错误提示与状态栏通知

高级功能

    多线程处理(避免界面冻结)

    处理过程可取消

    自动重命名原始文件

    输出尺寸验证

    系统资源优化
import sys
import time
import os
import cv2
import platform
import subprocess
import tempfile
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                             QLabel, QLineEdit, QPushButton, QProgressBar, QTextEdit,
                             QFileDialog, QMessageBox, QGroupBox, QCheckBox, QStyle,
                             QSizePolicy, QFrame, QScrollArea)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
from PyQt5.QtGui import QIcon, QFont, QPalette, QColor


class VideoProcessor(QThread):
    progress_updated = pyqtSignal(int, str)
    processing_finished = pyqtSignal(bool, str)
    current_file_changed = pyqtSignal(str)

    def __init__(self, input_path, process_subfolders, parent=None):
        super().__init__(parent)
        self.input_path = input_path
        self.process_subfolders = process_subfolders
        self.cancel_flag = False
        self.supported_codecs = ['mp4v', 'avc1', 'X264', 'h264']
        self.fallback_codecs = ['DIVX', 'XVID', 'MJPG', 'MPEG']

    def run(self):
        try:
            if os.path.isfile(self.input_path):
                self.process_single_video(self.input_path)
            elif os.path.isdir(self.input_path):
                self.process_folder(self.input_path)
            else:
                self.progress_updated.emit(0, "错误:无效的输入路径")
                self.processing_finished.emit(False, "无效的输入路径")
        except Exception as e:
            self.progress_updated.emit(0, f"处理出错:{str(e)}")
            self.processing_finished.emit(False, str(e))

    def cancel(self):
        self.cancel_flag = True
        self.progress_updated.emit(0, "正在取消处理...")

    def process_folder(self, folder_path):
        video_files = []
        if self.process_subfolders:
            for root, _, files in os.walk(folder_path):
                for file in files:
                    if self.is_video_file(file):
                        video_files.append(os.path.join(root, file))
        else:
            for file in os.listdir(folder_path):
                if self.is_video_file(file):
                    video_files.append(os.path.join(folder_path, file))

        total_files = len(video_files)
        if total_files == 0:
            self.progress_updated.emit(0, "错误:文件夹中没有找到视频文件")
            self.processing_finished.emit(False, "没有找到视频文件")
            return

        processed_count = 0
        success_count = 0
        for video_file in video_files:
            if self.cancel_flag:
                break

            self.current_file_changed.emit(os.path.basename(video_file))
            success = self.process_single_video(video_file)
            processed_count += 1
            if success:
                success_count += 1
            progress = int((processed_count / total_files) * 100)
            self.progress_updated.emit(progress, f"已完成 {processed_count}/{total_files}")

        if self.cancel_flag:
            self.processing_finished.emit(False, "处理已取消")
        else:
            msg = f"处理完成!成功处理 {success_count}/{total_files} 个文件"
            self.processing_finished.emit(True, msg)

    def process_single_video(self, input_path):
        temp_video_path = None
        original_path = input_path
        try:
            output_path = self.generate_output_path(input_path)
            if not output_path:
                self.progress_updated.emit(0, "错误:无法生成输出路径")
                return False

            base, ext = os.path.splitext(output_path)
            temp_video_path = f"{base}_temp{ext}"

            cap = cv2.VideoCapture(input_path)
            if not cap.isOpened():
                self.progress_updated.emit(0, "错误:无法打开视频文件")
                return False

            fps = cap.get(cv2.CAP_PROP_FPS)
            total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
            width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
            height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

            self.progress_updated.emit(0, f"处理: {os.path.basename(input_path)}")
            self.progress_updated.emit(0, f"视频信息: {width}x{height} @ {fps:.2f}fps")

            # 自动检测黑边
            crop_top, crop_bottom = self.detect_black_bars(cap, height)
            if crop_top > 0 or crop_bottom > 0:
                self.progress_updated.emit(0, f"检测到黑边: 上 {crop_top}px, 下 {crop_bottom}px")
                effective_height = height - crop_top - crop_bottom
            else:
                effective_height = height

            target_ratio = 9 / 16
            target_height = effective_height
            target_width = int(target_height * target_ratio)

            if width < target_width:
                self.progress_updated.emit(0, f"错误:原始宽度不足(需要至少 {target_width}px)")
                return False

            crop_x = (width - target_width) // 2

            fourcc = self.find_working_codec((target_width, target_height), fps)
            if not fourcc:
                self.progress_updated.emit(0, "错误:没有找到可用的视频编码器!")
                return False

            out = cv2.VideoWriter(temp_video_path, fourcc, fps, (target_width, target_height))
            if not out.isOpened():
                self.progress_updated.emit(0, "错误:无法初始化视频写入器")
                return False

            current_frame = 0
            success_count = 0

            while cap.isOpened() and not self.cancel_flag:
                ret, frame = cap.read()
                if not ret:
                    break

                try:
                    # 先裁剪黑边,再裁剪两侧
                    if crop_top > 0 or crop_bottom > 0:
                        frame = frame[crop_top:height - crop_bottom, :]

                    cropped = frame[:, crop_x:crop_x + target_width]
                    out.write(cropped)
                    success_count += 1
                except Exception as e:
                    self.progress_updated.emit(0, f"帧处理错误:{str(e)}")
                    continue

                current_frame += 1
                progress = int((current_frame / total_frames) * 100)
                self.progress_updated.emit(progress, f"处理帧: {current_frame}/{total_frames}")

            cap.release()
            out.release()

            if self.cancel_flag:
                if temp_video_path and os.path.exists(temp_video_path):
                    os.remove(temp_video_path)
                return False

            if self.merge_audio(temp_video_path, output_path, input_path):
                self.progress_updated.emit(0, "音频合并成功")
                if os.path.exists(original_path):
                    os.remove(original_path)
                    self.progress_updated.emit(0, f"已删除原始文件: {os.path.basename(original_path)}")
                os.rename(output_path, original_path)
                self.progress_updated.emit(0, f"最终文件已保存到: {os.path.basename(original_path)}")
            else:
                if os.path.exists(temp_video_path):
                    os.rename(temp_video_path, output_path)
                self.progress_updated.emit(0, "警告:无法合并音频,生成无声视频")

            if self.verify_output_video(original_path):
                output_size = os.path.getsize(original_path) // 1024
                self.progress_updated.emit(100, f"处理完成!")
                self.progress_updated.emit(0, f"输出文件大小:{output_size}KB")
                self.progress_updated.emit(0, f"成功处理帧数:{success_count}/{total_frames}")
                return True
            else:
                self.progress_updated.emit(0, "警告:输出文件可能存在问题")
                return False

        except Exception as e:
            self.progress_updated.emit(0, f"处理出错:{str(e)}")
            return False
        finally:
            if temp_video_path and os.path.exists(temp_video_path):
                os.remove(temp_video_path)

    def detect_black_bars(self, cap, height, sample_frames=10):
        """自动检测视频上下黑边"""
        cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
        total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        step = max(1, total_frames // sample_frames)

        top_black = []
        bottom_black = []

        for i in range(sample_frames):
            ret, frame = cap.read()
            if not ret:
                break

            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
            _, thresh = cv2.threshold(gray, 15, 255, cv2.THRESH_BINARY)

            # 检测上部黑边
            top = 0
            for y in range(height):
                if thresh[y].mean() > 10:  # 非纯黑行
                    top = y
                    break

            # 检测下部黑边
            bottom = height
            for y in range(height - 1, -1, -1):
                if thresh[y].mean() > 10:  # 非纯黑行
                    bottom = y
                    break

            top_black.append(top)
            bottom_black.append(height - bottom - 1)

            for _ in range(step - 1):
                cap.read()

        cap.set(cv2.CAP_PROP_POS_FRAMES, 0)

        avg_top = int(sum(top_black) / len(top_black))
        avg_bottom = int(sum(bottom_black) / len(bottom_black))

        return avg_top, avg_bottom

    def generate_output_path(self, input_path):
        if not input_path:
            return ""
        dir_name = os.path.dirname(input_path)
        base_name = os.path.basename(input_path)
        name, ext = os.path.splitext(base_name)
        return os.path.join(dir_name, f"{name}_resized{ext}")

    def find_working_codec(self, frame_size, fps):
        test_filename = os.path.join(tempfile.gettempdir(), "_codec_test.mp4")

        for codec in self.supported_codecs:
            try:
                fourcc = cv2.VideoWriter_fourcc(*codec)
                writer = cv2.VideoWriter(test_filename, fourcc, fps, frame_size)
                if writer.isOpened():
                    writer.release()
                    os.remove(test_filename)
                    self.progress_updated.emit(0, f"找到可用编码器: {codec}")
                    return fourcc
            except Exception as e:
                continue

        for codec in self.fallback_codecs:
            try:
                fourcc = cv2.VideoWriter_fourcc(*codec)
                writer = cv2.VideoWriter(test_filename, fourcc, fps, frame_size)
                if writer.isOpened():
                    writer.release()
                    os.remove(test_filename)
                    self.progress_updated.emit(0, f"回退到备用编码器: {codec}")
                    return fourcc
            except:
                continue

        return None

    def merge_audio(self, temp_video_path, output_path, input_path):
        audio_path = os.path.join(tempfile.gettempdir(), "temp_audio.m4a")
        try:
            extract_cmd = [
                'ffmpeg',
                '-y',
                '-i', input_path,
                '-vn',
                '-acodec', 'copy',
                audio_path
            ]

            result = subprocess.run(extract_cmd, capture_output=True, text=True)
            if result.returncode != 0:
                self.progress_updated.emit(0, f"音频提取失败:{result.stderr}")
                return False

            merge_cmd = [
                'ffmpeg',
                '-y',
                '-i', temp_video_path,
                '-i', audio_path,
                '-c:v', 'copy',
                '-c:a', 'aac',
                '-strict', 'experimental',
                output_path
            ]

            merge_result = subprocess.run(merge_cmd, capture_output=True, text=True)
            if merge_result.returncode != 0:
                self.progress_updated.emit(0, f"音频合并失败:{merge_result.stderr}")
                return False

            return True
        except FileNotFoundError:
            self.progress_updated.emit(0, "错误:找不到ffmpeg,请先安装FFmpeg并添加到系统PATH")
            return False
        except Exception as e:
            self.progress_updated.emit(0, f"音频处理异常:{str(e)}")
            return False
        finally:
            if os.path.exists(audio_path):
                os.remove(audio_path)

    def verify_output_video(self, file_path):
        try:
            cap = cv2.VideoCapture(file_path)
            if not cap.isOpened():
                self.progress_updated.emit(0, "错误:输出视频无法打开")
                return False

            ret, frame = cap.read()
            cap.release()

            if not ret:
                self.progress_updated.emit(0, "警告:输出视频无有效帧数据")
                return False

            self.progress_updated.emit(0, "视频验证通过")
            return True
        except:
            return False

    def is_video_file(self, filename):
        video_extensions = ('.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv')
        return filename.lower().endswith(video_extensions)


class VideoResizerApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("智能视频比例批量转换工具 v4.0")
        self.setWindowIcon(QIcon(self.style().standardIcon(QStyle.SP_MediaPlay)))

        # 初始化变量
        self.input_path = ""
        self.processor = None

        # 设置主窗口样式
        self.setStyleSheet("""
            QMainWindow {
                background-color: #f5f7fa;
            }
            QGroupBox {
                border: 1px solid #d1d5db;
                border-radius: 6px;
                margin-top: 10px;
                padding-top: 15px;
                font-weight: 500;
                color: #374151;
            }
            QGroupBox::title {
                subcontrol-origin: margin;
                left: 10px;
                padding: 0 3px;
            }
            QTextEdit {
                background-color: #ffffff;
                border: 1px solid #d1d5db;
                border-radius: 4px;
                padding: 5px;
                font-family: 'Consolas', 'Courier New', monospace;
            }
            QPushButton {
                background-color: #4f46e5;
                color: white;
                border: none;
                border-radius: 4px;
                padding: 8px 16px;
                min-width: 80px;
            }
            QPushButton:hover {
                background-color: #4338ca;
            }
            QPushButton:disabled {
                background-color: #9ca3af;
            }
            QPushButton#cancelBtn {
                background-color: #ef4444;
            }
            QPushButton#cancelBtn:hover {
                background-color: #dc2626;
            }
            QLineEdit {
                border: 1px solid #d1d5db;
                border-radius: 4px;
                padding: 6px;
            }
            QProgressBar {
                border: 1px solid #d1d5db;
                border-radius: 4px;
                text-align: center;
            }
            QProgressBar::chunk {
                background-color: #4f46e5;
                border-radius: 3px;
            }
        """)

        # 创建主界面
        self.init_ui()

        # 显示系统信息
        self.show_system_info()

    def init_ui(self):
        # 主窗口部件
        main_widget = QWidget()
        self.setCentralWidget(main_widget)

        # 主布局
        main_layout = QVBoxLayout()
        main_layout.setContentsMargins(20, 20, 20, 20)
        main_layout.setSpacing(15)
        main_widget.setLayout(main_layout)

        # 标题
        title_label = QLabel("智能视频比例批量转换工具")
        title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #1f2937;")
        title_label.setAlignment(Qt.AlignCenter)
        main_layout.addWidget(title_label)

        # 输入区域
        input_group = QGroupBox("输入设置")
        input_layout = QVBoxLayout()
        input_group.setLayout(input_layout)
        main_layout.addWidget(input_group)

        # 文件选择
        file_layout = QHBoxLayout()
        self.input_entry = QLineEdit()
        self.input_entry.setPlaceholderText("选择视频文件或文件夹")
        self.input_entry.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        file_layout.addWidget(self.input_entry)

        browse_btn = QPushButton("浏览...")
        browse_btn.clicked.connect(self.select_input)
        file_layout.addWidget(browse_btn)
        input_layout.addLayout(file_layout)

        # 处理选项
        self.subfolder_check = QCheckBox("处理子文件夹中的视频文件")
        self.subfolder_check.setChecked(True)
        input_layout.addWidget(self.subfolder_check)

        # 控制按钮区域
        control_group = QGroupBox("操作控制")
        control_layout = QHBoxLayout()
        control_group.setLayout(control_layout)
        main_layout.addWidget(control_group)

        self.start_btn = QPushButton("开始转换")
        self.start_btn.clicked.connect(self.start_process)
        control_layout.addWidget(self.start_btn)

        self.cancel_btn = QPushButton("取消")
        self.cancel_btn.setObjectName("cancelBtn")
        self.cancel_btn.clicked.connect(self.cancel_process)
        self.cancel_btn.setEnabled(False)
        control_layout.addWidget(self.cancel_btn)

        # 进度显示
        progress_group = QGroupBox("处理进度")
        progress_layout = QVBoxLayout()
        progress_group.setLayout(progress_layout)
        main_layout.addWidget(progress_group)

        self.current_file_label = QLabel("当前文件: 无")
        self.current_file_label.setStyleSheet("font-weight: 500;")
        progress_layout.addWidget(self.current_file_label)

        self.progress_bar = QProgressBar()
        self.progress_bar.setRange(0, 100)
        self.progress_bar.setTextVisible(True)
        progress_layout.addWidget(self.progress_bar)

        # 日志区域
        log_group = QGroupBox("处理日志")
        log_layout = QVBoxLayout()
        log_group.setLayout(log_layout)
        main_layout.addWidget(log_group)

        self.log_text = QTextEdit()
        self.log_text.setReadOnly(True)
        self.log_text.setFont(QFont("Consolas", 9))

        # 添加滚动区域
        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        scroll_area.setWidget(self.log_text)
        log_layout.addWidget(scroll_area)

        # 状态栏
        self.statusBar().showMessage("就绪")

        # 窗口大小策略
        self.setMinimumSize(800, 600)
        if platform.system() == "Windows":
            self.resize(1000, 700)
        else:
            self.resize(900, 650)

    def select_input(self):
        options = QFileDialog.Options()
        file_path, _ = QFileDialog.getOpenFileName(self, "选择视频文件", "",
                                                   "视频文件 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*.*)",
                                                   options=options)
        if file_path:
            self.input_path = file_path
            self.input_entry.setText(file_path)
        else:
            dir_path = QFileDialog.getExistingDirectory(self, "选择视频文件夹")
            if dir_path:
                self.input_path = dir_path
                self.input_entry.setText(dir_path)

    def start_process(self):
        self.input_path = self.input_entry.text().strip()
        if not self.input_path:
            QMessageBox.warning(self, "警告", "请选择视频文件或文件夹")
            return

        if not os.path.exists(self.input_path):
            QMessageBox.warning(self, "警告", "指定的路径不存在")
            return

        # 禁用UI控件
        self.start_btn.setEnabled(False)
        self.cancel_btn.setEnabled(True)
        self.input_entry.setEnabled(False)
        self.subfolder_check.setEnabled(False)

        # 清空日志
        self.log_text.clear()

        # 创建并启动处理器线程
        self.processor = VideoProcessor(
            self.input_path,
            self.subfolder_check.isChecked()
        )
        self.processor.progress_updated.connect(self.update_progress)
        self.processor.processing_finished.connect(self.processing_finished)
        self.processor.current_file_changed.connect(self.update_current_file)
        self.processor.start()

        self.log_message("开始处理...")
        self.log_message(f"操作系统: {platform.system()} {platform.release()}")
        self.log_message(f"系统架构: {platform.architecture()[0]}")
        self.log_message(f"OpenCV版本: {cv2.__version__}")

    def cancel_process(self):
        if self.processor and self.processor.isRunning():
            self.processor.cancel()
            self.cancel_btn.setEnabled(False)
            self.statusBar().showMessage("正在取消处理...")

    def update_progress(self, progress, message):
        self.progress_bar.setValue(progress)
        if message:
            self.log_message(message)
            self.statusBar().showMessage(message)

    def update_current_file(self, filename):
        self.current_file_label.setText(f"当前文件: {filename}")

    def processing_finished(self, success, message):
        # 启用UI控件
        self.start_btn.setEnabled(True)
        self.cancel_btn.setEnabled(False)
        self.input_entry.setEnabled(True)
        self.subfolder_check.setEnabled(True)

        # 显示完成消息
        if message:
            self.log_message(message)
            self.statusBar().showMessage(message)

        if success:
            QMessageBox.information(self, "完成", "视频处理完成!")
        else:
            QMessageBox.warning(self, "警告", message if message else "处理过程中出现错误")

    def log_message(self, message):
        timestamp = time.strftime("%H:%M:%S")
        self.log_text.append(f"[{timestamp}] {message}")
        # 自动滚动到底部
        self.log_text.verticalScrollBar().setValue(self.log_text.verticalScrollBar().maximum())

    def show_system_info(self):
        self.log_message("系统信息:")
        self.log_message(f"操作系统: {platform.system()} {platform.release()}")
        self.log_message(f"系统架构: {platform.architecture()[0]}")
        self.log_message(f"Python版本: {platform.python_version()}")
        self.log_message(f"OpenCV版本: {cv2.__version__}")
        self.log_message("")


if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setStyle('Fusion')  # 使用Fusion样式


典型应用场景:

短视频内容创作

社交媒体视频适配

影视内容移动端适配

视频素材批量预处理

视频比例标准化处理

该工具通过自动化处理流程,将原本需要专业视频编辑软件操作的任务简化为"一键处理",显著提升了视频格式转换的效率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值