laravel+python3+ffmpeg 实现视频转换工具demo实例

laravel+python3 实现视频转换工具

准备步骤
  • 采取同步方式作为demo实现 如果需要大文件转换,可以放到队列中异步处理
  • 调整nginx和php服务器的最大处理时间,否则同步方式的处理 如果传入大文件,处理到最后会因为nginx或者php造成中断
  • 服务器安装ffmpeg 和python3,及python依赖库
实现功能
  • 对视频的 编码方式、帧率、I帧间隔、B帧控制、码率、分辨率、及时间戳水印和自定义文字水印进行控制转码
  • 对原视频和处理后的视频的信息展示
  • 处理后的视频下载及保存

思路

编写python代码,调动ffmpeg进行视频相关控制并输出结果至指定文件,
blade编写前端,控制调整参数,laravel接收参数并调用python代码,将结果进行输出

代码参考

路由文件省略

前端blade

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>视频转换工具</title>
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background-color: #f4f6f9;
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
        }

        .container {
            background-color: #ffffff;
            padding: 30px;
            border-radius: 8px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            width: 1000px;
            max-width: 90%;
            box-sizing: border-box;
        }

        h1 {
            text-align: center;
            margin-bottom: 25px;
            color: #333333;
        }

        .form-group {
            margin-bottom: 20px;
        }

        label {
            display: block;
            margin-bottom: 8px;
            color: #555555;
            font-weight: bold;
        }

        input[type="text"],
        input[type="number"],
        select,
        input[type="file"],
        input[type="color"] {
            width: 100%;
            padding: 10px;
            border: 1px solid #cccccc;
            border-radius: 4px;
            box-sizing: border-box;
            transition: border-color 0.3s;
        }

        input[type="text"]:focus,
        input[type="number"]:focus,
        select:focus,
        input[type="file"]:focus,
        input[type="color"]:focus {
            border-color: #007bff;
            outline: none;
        }

        .checkbox-group {
            display: flex;
            align-items: center;
        }

        .checkbox-group input {
            margin-right: 10px;
        }

        button {
            width: 100%;
            padding: 12px;
            background-color: #007bff;
            color: #ffffff;
            border: none;
            border-radius: 4px;
            font-size: 16px;
            cursor: pointer;
            transition: background-color 0.3s;
        }

        #getInfoButton {
            background-color: #28a745;
            margin-top: 10px;
        }

        button:disabled {
            background-color: #a0c8f0;
            cursor: not-allowed;
        }

        button:hover:not(:disabled) {
            background-color: #0056b3;
        }

        #getInfoButton:hover:not(:disabled) {
            background-color: #218838;
        }

        #result, #videoInfoResult {
            margin-top: 20px;
            text-align: center;
        }

        #videoInfoResult {
            text-align: left;
            background-color: #f8f9fa;
            padding: 15px;
            border-radius: 5px;
            border: 1px solid #ddd;
        }

        #result a {
            color: #007bff;
            text-decoration: none;
            font-weight: bold;
        }

        #result a:hover {
            text-decoration: underline;
        }

        #error {
            color: red;
            font-weight: bold;
        }

        /* Loading Spinner Styles */
        .spinner-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(255, 255, 255, 0.8);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 9999;
            display: none; /* Hidden by default */
        }

        .spinner {
            border: 8px solid #f3f3f3;
            border-top: 8px solid #007bff;
            border-radius: 50%;
            width: 60px;
            height: 60px;
            animation: spin 1s linear infinite;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        /* 响应式设计 */
        @media (max-width: 768px) {
            .container {
                width: 95%;
                padding: 20px;
            }

            button {
                font-size: 14px;
                padding: 10px;
            }

            #resultsContainer {
                flex-direction: column;
            }
        }

        /* 新增日志和 new_info 展示区域样式 */
        #resultsContainer {
            display: flex;
            gap: 20px;
            margin-top: 20px;
            display: none; /* Hidden by default */
        }

        #logResult {
            flex: 1;
            background-color: #f1f1f1;
            padding: 15px;
            border-radius: 5px;
            border: 1px solid #ddd;
            max-height: 300px;
            overflow-y: auto;
            white-space: pre-wrap; /* 保持日志格式 */
            font-family: monospace;
            color: #333333;
        }

        #newInfoResult {
            flex: 1;
            background-color: #f9f9f9;
            padding: 15px;
            border-radius: 5px;
            border: 1px solid #ddd;
            max-height: 300px;
            overflow-y: auto;
            font-family: Arial, sans-serif;
            color: #333333;
        }

        /* 增加水印颜色选择器的尺寸 */
        #watermark_color {
            width: 60px; /* 调整宽度,使其更明显 */
            height: 40px; /* 增加高度以便更好地显示颜色 */
            padding: 0;
            border: none;
            cursor: pointer;
        }

        /* 自定义水印文字输入框样式 */
        #customWatermarkInput {
            margin-top: 10px;
        }
    </style>
</head>
<body>
@include('layouts.login')
@include('console')
@include('dock')

<div class="container">
    <h1>视频转换工具</h1>
    <label>1. H265H264编码互相转换,建议文件在3分钟内可以使用此功能转换(预计需要5分钟才能转换完成)</label>
    <label>2. 支持大小在5GB以内的文件</label>
    <label>3. H264最大支持4096x2304,H265最大支持7680x4320,H.264H.265编码器要求分辨率的宽度和高度除以2必须为偶数。</label>
    <label>4. 为保证服务器性能,超过15分钟的转换均会被服务器中止,请确保需要转换的文件不会过大。</label>
    <label>5. 视频复杂度低时会低于您设置的码率,您设置的码率可能无法完全达到预期标准,系统会针对您的视频自动匹配最接近您设定的码率。</label>

    <form id="convertForm">
        @csrf
        <div class="form-group">
            <label for="file">选择视频文件:</label>
            <input type="file" id="file" name="file" accept="video/*" required>
        </div>

        <div class="form-group">
            <label for="rr">分辨率(宽 x 高):</label>
            <input type="text" id="rr" name="rr" placeholder="例如:960x540" value="960x540" required>
        </div>

        <div class="form-group">
            <label for="code_style">编码格式:</label>
            <select id="code_style" name="code_style">
                <option value="h264">H.264</option>
                <option value="h265">H.265</option>
                <!-- 可以根据需要添加更多编码格式 -->
            </select>
        </div>

        <div class="form-group">
            <label for="i_frame">关键帧间隔(I-Frame Interval):</label>
            <input type="number" id="i_frame" name="i_frame" min="1" max="250" value="25" required>
        </div>

        <div class="form-group">
            <label for="fps">帧率(FPS):</label>
            <input type="number" id="fps" name="fps" min="1" max="120" value="30" required>
        </div>

        <div class="form-group">
            <label for="bitrate">码率(kbps):</label>
            <input type="number" id="bitrate" name="bitrate" min="10" max="20000" value="1000" required>
        </div>

        <div class="form-group checkbox-group">
            <input type="checkbox" id="clear_b_frame" name="clear_b_frame" checked>
            <label for="clear_b_frame">清除 B</label>
        </div>

        <!-- 新增的水印选择框 -->
        <div class="form-group">
            <label for="watermark">水印:</label>
            <select id="watermark" name="watermark" required>
                <option value="0">不添加</option>
                <option value="1">添加时间戳水印</option>
                <option value="2">添加自定义文字水印</option>
            </select>
        </div>

        <!-- 新增的水印颜色选择框(默认隐藏) -->
        <div class="form-group" id="watermarkColorGroup" style="display: none;">
            <label for="watermark_color">水印颜色:</label>
            <input type="color" id="watermark_color" name="watermark_color" value="#FFFFFF">
        </div>

        <!-- 新增的自定义水印文字输入框(默认隐藏) -->
        <div class="form-group" id="customWatermarkInput" style="display: none;">
            <label for="custom_watermark">自定义水印文字:</label>
            <input type="text" id="custom_watermark" name="watermark_text" placeholder="例如:Confidential" value="">
        </div>
        <!-- 结束 -->

        <button type="submit" id="submitButton">转换视频</button>
        <button type="button" id="getInfoButton">获取视频信息</button>
    </form>

    <div id="result"></div>
    <div id="resultsContainer">
        <div id="logResult"></div>
        <div id="newInfoResult"></div>
    </div>
    <div id="videoInfoResult"></div>
</div>

<!-- Loading Spinner -->
<div class="spinner-overlay" id="spinnerOverlay">
    <div class="spinner"></div>
</div>

<script src="{{ asset('getToken.js') }}"></script>
<script>
    document.addEventListener('DOMContentLoaded', function() {
        const form = document.getElementById('convertForm');
        const submitButton = document.getElementById('submitButton');
        const getInfoButton = document.getElementById('getInfoButton');
        const spinnerOverlay = document.getElementById('spinnerOverlay');
        const logResultDiv = document.getElementById('logResult'); // 获取日志展示区域
        const newInfoResultDiv = document.getElementById('newInfoResult'); // 获取 new_info 展示区域
        const resultsContainer = document.getElementById('resultsContainer'); // 获取结果容器
        let isSubmitting = false;

        // Disable the submit button initially
        submitButton.disabled = true;

        // Store original values
        let originalValues = {};
        let fileSelected = false; // Track whether a file has been selected

        // 获取水印相关的表单元素
        const watermarkSelect = document.getElementById('watermark');
        const watermarkColorGroup = document.getElementById('watermarkColorGroup');
        const customWatermarkInput = document.getElementById('customWatermarkInput');
        const customWatermarkField = document.getElementById('custom_watermark');

        // 处理水印选择变化
        watermarkSelect.addEventListener('change', function() {
            const selected = this.value;
            if (selected === '1') {
                // 添加时间戳水印,只显示颜色选择器
                watermarkColorGroup.style.display = 'block';
                customWatermarkInput.style.display = 'none';
            } else if (selected === '2') {
                // 添加自定义文字水印,显示颜色选择器和文字输入框
                watermarkColorGroup.style.display = 'block';
                customWatermarkInput.style.display = 'block';
            } else {
                // 不添加水印,隐藏颜色选择器和文字输入框
                watermarkColorGroup.style.display = 'none';
                customWatermarkInput.style.display = 'none';
            }
            checkForParameterChanges();
        });

        // 处理视频转换表单提交
        form.addEventListener('submit', async function(event) {
            event.preventDefault();

            if (isSubmitting) return;
            isSubmitting = true;

            spinnerOverlay.style.display = 'flex';
            resultsContainer.style.display = 'none'; // 隐藏结果区域

            const formData = new FormData(form);

            // Check if the clear_b_frame checkbox is checked
            const clearBFrameCheckbox = document.getElementById('clear_b_frame');
            formData.set('clear_b_frame', clearBFrameCheckbox.checked ? '1' : '0');

            // 处理水印字段
            const watermarkType = watermarkSelect.value;
            if (watermarkType === '2') {
                const customWatermark = customWatermarkField.value.trim();
                if (customWatermark === '') {
                    alert('请填写自定义水印文字。');
                    spinnerOverlay.style.display = 'none';
                    isSubmitting = false;
                    return;
                }
                formData.set('watermark_text', customWatermark);
            } else {
                formData.delete('watermark_text'); // 不传递 watermark_text
            }

            submitButton.disabled = true; // Disable submit button during submission
            const formElements = form.elements;
            for (let i = 0; i < formElements.length; i++) {
                formElements[i].disabled = true; // Disable all form elements
            }

            try {
                const response = await fetch('/api/general/convert-video', {
                    method: 'POST',
                    body: formData
                });

                if (!response.ok) {
                    throw new Error(`服务器返回状态码 ${response.status}`);
                }

                const result = await response.json();
                const resultDiv = document.getElementById('result');
                resultDiv.innerHTML = ''; // Clear previous results

                if (result.status === true) {
                    if (result.path) {
                        resultDiv.innerHTML = `<p>转换成功:<a href="${result.path}" download>点击下载视频</a></p>`;
                    } else {
                        resultDiv.innerHTML = `<p id="error">未知响应格式</p>`;
                    }
                } else {
                    resultDiv.innerHTML = `<p id="error">错误:${result.error || '未知错误'}</p>`;
                }

                // 显示日志和 new_info 内容
                if (result.log || result.new_info) {
                    resultsContainer.style.display = 'flex';

                    // 显示日志内容
                    if (result.log) {
                        logResultDiv.innerText = result.log;
                    } else {
                        logResultDiv.innerText = '无日志信息';
                    }

                    // 显示 new_info 内容
                    if (result.new_info) {
                        const info = result.new_info;

                        // 格式化 new_info 显示
                        newInfoResultDiv.innerHTML = `
                            <h3>转换后视频信息</h3>
                            <p><strong>帧率 (Framerate):</strong> ${info.framerate || '未知'}</p>
                            <p><strong>编码格式 (Codec):</strong> ${info.codec || '未知'}</p>
                            <p><strong>分辨率 (Resolution):</strong> ${info.resolution || '未知'}</p>
                            <p><strong>时长 (Time):</strong> ${info.time ? formatTime(info.time) : '未知'}</p>
                            <p><strong>关键帧间隔 (Keyframe Interval):</strong> ${info.keyframe_interval || '未知'}</p>
                            <p><strong>包含 B(Contains B-frames):</strong> ${info.contains_b_frames ? '是' : '否'}</p>
                            <p><strong>码率 (Bitrate):</strong> ${info.bitrate || '未知'}</p>
                        `;
                    } else {
                        newInfoResultDiv.innerText = '无转换后视频信息';
                    }
                }

            } catch (error) {
                console.error('Error:', error);
                const resultDiv = document.getElementById('result');
                resultDiv.innerHTML = `<p id="error">请求失败,请稍后再试。</p>`;
            } finally {
                spinnerOverlay.style.display = 'none';

                // Re-enable form elements
                for (let i = 0; i < formElements.length; i++) {
                    formElements[i].disabled = false; // Enable all form elements
                }

                isSubmitting = false;
            }
        });

        // 处理获取视频信息按钮点击
        getInfoButton.addEventListener('click', async function() {
            const fileInput = document.getElementById('file');
            const file = fileInput.files[0];

            if (!file) {
                alert('请先选择一个视频文件');
                return;
            }

            const formData = new FormData();
            formData.append('file', file);

            spinnerOverlay.style.display = 'flex';
            getInfoButton.disabled = true;

            try {
                const response = await fetch('/api/general/video-info', {
                    method: 'POST',
                    body: formData,
                    headers: {
                        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
                    }
                });

                if (!response.ok) {
                    throw new Error(`服务器返回状态码 ${response.status}`);
                }

                const info = await response.json();
                const infoDiv = document.getElementById('videoInfoResult');

                if (info && !info.error) {
                    // Store original values for comparison
                    originalValues = {
                        resolution: info.resolution || '960x540', // Default value if not available
                        framerate: info.framerate || 30, // Default value if not available
                        bitrate: info.bitrate ? info.bitrate.replace(' kb/s', '') : 1000, // Remove ' kb/s' and set default
                        keyframe_interval: info.keyframe_interval || 25, // Default value if not available
                        codec: info.codec === 'h265' ? 'h265' : 'h264', // Set based on codec
                        watermark: '0', // Default watermark
                        watermark_color: '#FFFFFF', // Default color
                        watermark_text: '' // Default text (unused)
                    };

                    // Populate fields with fetched video info
                    document.getElementById('rr').value = originalValues.resolution;
                    document.getElementById('fps').value = originalValues.framerate;
                    document.getElementById('bitrate').value = originalValues.bitrate;
                    document.getElementById('i_frame').value = originalValues.keyframe_interval;
                    document.getElementById('code_style').value = originalValues.codec;

                    // Reset watermark selection and hide related fields
                    watermarkSelect.value = '0';
                    watermarkColorGroup.style.display = 'none';
                    customWatermarkInput.style.display = 'none';

                    // Clear custom watermark input
                    customWatermarkField.value = '';

                    // Mark that a file has been selected and information has been retrieved
                    fileSelected = true;

                    // Enable submit button if watermark is not used or if parameters have changed
                    checkForParameterChanges();

                    infoDiv.innerHTML = `
                        <h3>原视频信息</h3>
                        <p><strong>帧率 (Framerate):</strong> ${info.framerate || '未知'}</p>
                        <p><strong>编码格式 (Codec):</strong> ${info.codec || '未知'}</p>
                        <p><strong>分辨率 (Resolution):</strong> ${info.resolution || '未知'}</p>
                        <p><strong>时长 (Time):</strong> ${info.time ? formatTime(info.time) : '未知'}</p>
                        <p><strong>关键帧间隔 (Keyframe Interval):</strong> ${info.keyframe_interval || '未知'}</p>
                        <p><strong>包含 B(Contains B-frames):</strong> ${info.contains_b_frames ? '是' : '否'}</p>
                        <p><strong>码率 (Bitrate):</strong> ${info.bitrate || '未知'}</p>
                    `;

                } else {
                    infoDiv.innerHTML = `<p id="error">无法获取视频信息:${info.error || '未知错误'}</p>`;
                }
            } catch (error) {
                console.error('Error:', error);
                const infoDiv = document.getElementById('videoInfoResult');
                infoDiv.innerHTML = `<p id="error">请求失败,请稍后再试。</p>`;
            } finally {
                spinnerOverlay.style.display = 'none';
                getInfoButton.disabled = false; // 允许用户再次点击获取信息
            }
        });

        // 检查参数变化的函数
        function checkForParameterChanges() {
            if (!fileSelected || !originalValues || Object.keys(originalValues).length === 0) {
                submitButton.disabled = true;
                return;
            }

            const currentResolution = document.getElementById('rr').value;
            const currentFPS = document.getElementById('fps').value;
            const currentBitrate = document.getElementById('bitrate').value;
            const currentKeyframeInterval = document.getElementById('i_frame').value;
            const currentCodec = document.getElementById('code_style').value;
            const watermarkType = watermarkSelect.value;
            const currentWatermarkColor = document.getElementById('watermark_color').value;
            const currentCustomWatermark = document.getElementById('custom_watermark').value.trim();

            // 检查当前值是否与原始值匹配
            let isUnchanged = (
                currentResolution === originalValues.resolution &&
                currentFPS == originalValues.framerate &&
                currentBitrate == originalValues.bitrate &&
                currentKeyframeInterval == originalValues.keyframe_interval &&
                currentCodec === originalValues.codec &&
                watermarkType === '0'
            );

            // 如果水印不是默认值 '0',则认为参数已更改
            let isWatermarkChanged = false;
            if (watermarkType === '1') {
                // 添加时间戳水印,检查颜色是否变化
                isWatermarkChanged = currentWatermarkColor !== originalValues.watermark_color;
            } else if (watermarkType === '2') {
                // 添加自定义文字水印,检查颜色和文字是否变化
                isWatermarkChanged = (
                    currentWatermarkColor !== originalValues.watermark_color ||
                    currentCustomWatermark !== originalValues.watermark_text
                );
            }

            // Enable submit button if any parameter has changed or watermark is added/modified
            submitButton.disabled = isUnchanged && !isWatermarkChanged;
        }

        // 在输入框变化时实时检查参数
        document.querySelectorAll('input, select').forEach(element => {
            element.addEventListener('input', checkForParameterChanges);
            element.addEventListener('change', checkForParameterChanges);
        });

        // 格式化时间(秒)为时:分:秒
        function formatTime(seconds) {
            const hrs = Math.floor(seconds / 3600);
            const mins = Math.floor((seconds % 3600) / 60);
            const secs = Math.floor(seconds % 60);
            return `${hrs}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`;
        }

        // 页面刷新提醒
        window.addEventListener('beforeunload', function (e) {
            if (isSubmitting) {
                e.preventDefault();
                e.returnValue = '';
            }
        });
    });
</script>
</body>
</html>

后端控制器代码

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\ApiService;
use App\Services\AutoTestService;
use App\Services\VideoService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class VideoController extends Controller
{
    protected $videoService;

    public function __construct(VideoService $videoService)
    {
        $this->videoService = $videoService;
    }

    public function showFfmpegPage()
    {
        return view('ffmpeg');
    }



    public function videoInfo(Request $request)
    {
        // 定义存储路径和文件名(可根据需要自定义)
        $storagePath = 'public/user-video';
        $file = $request->file('file');
        $fileName = time() . '_' . $file->getClientOriginalName();

        // 将文件存储到指定路径
        $path = $file->storeAs($storagePath, $fileName);

        // 获取文件的完整路径
        $fullPath = Storage::path($path);
        $apiService = new ApiService();
        return $apiService->getFpsAndCodeStyle($fullPath);
    }


    public function convert(Request $request)
    {
        // 获取上传的文件
        $file = $request->file('file');

        // 定义存储路径和文件名(可根据需要自定义)
        $storagePath = 'public/user-video';
//        $fileName = str_replace(' ', '', time() . '_' . $file->getClientOriginalName());
        $fileName = str_replace(' ', '', time() . $file->getClientOriginalName());

        // 将文件存储到指定路径
        $path = $file->storeAs($storagePath, $fileName);

        // 获取文件的完整路径
        $fullPath = Storage::path($path);

        // 获取其他参数
        $rr = $request->input('rr', '960x540');
        $codeStyle = $request->input('code_style', 'h264');
        $iFrame = $request->input('i_frame', 25);
        $clearBFrame = $request->input('clear_b_frame', false);


        $fps = $request->input('fps');
        $bitrate = $request->input('bitrate');

        $watermark = $request->input('watermark',0);
        $watermarkText = $request->input('watermark_text');

        $watermarkColor = $request->input('watermark_color');

        $server = new AutoTestService();
        $watermarkContent=0;
        if($watermark==1){
            $watermarkContent=1;
        }
        if($watermark==2){
            $watermarkContent=$watermarkText;
        }

        // 传递文件的完整路径到服务
        return $server->convertedVideo($fullPath, $rr, $codeStyle, $iFrame, $clearBFrame,$fps,$bitrate,$watermarkContent,$watermarkColor);
    }


}

调用服务代码

 public function convertedVideo($videoPath, $rr, $codeStyle, $iFrame, $clearBFrame, $fps, $bitrate,$watermark,$watermarkColor)
    {
        // 取消 PHP 脚本的执行时间限制,以允许最长 20 分钟的等待
        set_time_limit(0);
        $apiService=new ApiService();
        // 定义保存路径
        $savePath = Storage::path('public/user-converted-video');

        // 确保保存路径存在
        if (!is_dir($savePath)) {
            if (!mkdir($savePath, 0755, true)) {
                return [
                    'status' => false,
                    'error' => '无法创建保存目录',
                    'log' => '',
                    'new_info' => [],
                ];
            }
        }

        // 生成日志文件路径,基于视频文件名
        $videoFileBaseName = pathinfo($videoPath, PATHINFO_FILENAME); // 例如从 '/a/c/ads.mp4' 获取 'ads'
        $logFilePath = $savePath . '/' . $videoFileBaseName . '.log'; // 例如 '/a/b/ads.log'

        // 构建后台执行的命令,并将输出重定向到日志文件
        $cmd = 'nohup python3 /var/www/autotest/platform/storage/app/public/converted_video.py '
            . $videoPath . ' '
            . $savePath . ' '
            . $rr . ' '
            . $codeStyle . ' '
            . $iFrame . ' '
            . $clearBFrame . ' '
            . $fps . ' '
            . $bitrate . " '"
            . $watermark . "' '"
            . $watermarkColor .
            "' > " . $logFilePath . ' 2>&1 &';

        // SSH 连接设置
        $host = 'workspace'; // Workspace 容器的主机名或 IP
        $username = 'root'; // SSH 用户名
        $key = Storage::get('insecure_id_rsa'); // 获取 SSH 私钥内容
        $rsa = PublicKeyLoader::load($key); // 加载私钥

        // 建立 SSH 连接
        $ssh = new SSH2($host, 22);
        if (!$ssh->login($username, $rsa)) {
            // 登录失败处理
            return [
                'status' => false,
                'error' => 'SSH 登录失败',
                'log' => '',
            ];
        }

        // 执行后台命令
        $ssh->exec($cmd);

        // 断开 SSH 连接
        $ssh->disconnect();

        // 开始轮询日志文件
        $maxAttempts = 180; // 15 分钟 / 5 秒
        $attempt = 0;
        $sleepSeconds = 5;
        $result = [
            'status' => false,
            'error' => '视频转换超时(超过20分钟)',
            'log' => '',
        ];

        while ($attempt < $maxAttempts) {
            // 每 5 秒等待
            sleep($sleepSeconds);
            $attempt++;

            // 检查日志文件是否存在
            if (!file_exists($logFilePath)) {
                // 日志文件尚未创建,继续等待
                continue;
            }

            // 读取日志文件内容
            $logContent = file_get_contents($logFilePath);
            if ($logContent === false) {
                // 读取日志文件失败,继续等待
                continue;
            }

            // 按行分割日志内容
            $logLines = explode("\n", $logContent);

            // 获取最后一个非空行
            $lastLine = '';
            for ($i = count($logLines) - 1; $i >= 0; $i--) {
                $line = trim($logLines[$i]);
                if ($line !== '') {
                    $lastLine = $line;
                    break;
                }
            }

            // 检查最后一行是否为 'true' 或 'false'
            if ($lastLine === 'true') {
                // 转换成功,检查转换后的视频文件是否存在
                $convertedFileName = basename($videoPath); // 假设转换后文件名与原文件名相同
                $convertedFilePath = $savePath . '/' . $convertedFileName;

                if (file_exists($convertedFilePath)) {
                    // 获取外网可访问的 URL
                    $convertedVideoUrl = Storage::url('public/user-converted-video/' . $convertedFileName);
                    $result = [
                        'status' => true,
                        'path' => $convertedVideoUrl,
                        'log' => $logContent,
                        'new_info' => $apiService->getFpsAndCodeStyle(Storage::path('public/user-converted-video/' . $convertedFileName)),

                    ];
                    break;
                } else {
                    // 日志中标记为成功,但未找到转换后的视频文件
                    $result = [
                        'status' => false,
                        'error' => '视频转换成功,但未找到生成的文件',
                        'log' => $logContent,
                        'new_info' =>[],
                    ];
                    break;
                }
            } elseif ($lastLine === 'false') {
                // 转换失败
                $result = [
                    'status' => false,
                    'error' => '视频转换失败',
                    'log' => $logContent,
                    'new_info' =>[],
                ];
                break;
            }

            // 如果日志中未包含 'true' 或 'false',继续等待
        }

        // 返回最终结果
        return $result;
    }

 public function getFpsAndCodeStyle($filePath)
    {
        if (!file_exists($filePath)) {
            return[
                'framerate' => 0,
                'codec' => 'unknown',
                'resolution' => 'unknown',
                'time' => 0,
                'keyframe_interval' => 'unknown',  // 关键帧间隔
                'contains_b_frames' => false,      // 是否包含B帧
                'bitrate' => 'unknown'             // 码率
            ];
        }
        $ffmpegOutput = shell_exec('ffmpeg -i "' . $filePath . '" 2>&1');

        $info = [
            'framerate' => 0,
            'codec' => 'unknown',
            'resolution' => 'unknown',
            'time' => 0,
            'keyframe_interval' => 'unknown',  // 关键帧间隔
            'contains_b_frames' => false,      // 是否包含B帧
            'bitrate' => 'unknown'             // 码率
        ];

        // 匹配帧率
        if (preg_match('/, (\d+(\.\d+)?) fps,/', $ffmpegOutput, $matches)) {
            $info['framerate'] = (float)$matches[1];
        }

        // 匹配编码方式
        if (preg_match('/Video: (h264|hevc|h265|vp8|vp9|av1|mpeg2video|mpeg4|wmv|prores|[a-zA-Z0-9]+)/', $ffmpegOutput, $matches)) {
            $codec = $matches[1];
            if ($codec === 'hevc') {
                $codec = 'h265';
            }
            $info['codec'] = $codec;
        }

        // 匹配分辨率
        if (preg_match('/, (\d{2,5}x\d{2,5})[, ]/', $ffmpegOutput, $matches)) {
            $info['resolution'] = $matches[1];
        }

        // 匹配时长
        if (preg_match('/Duration: ((\d+):(\d+):(\d+\.\d+))/s', $ffmpegOutput, $matches)) {
            $hours = (int)$matches[2];
            $minutes = (int)$matches[3];
            $seconds = (float)$matches[4];
            $info['time'] = round($hours * 3600 + $minutes * 60 + $seconds, 0);
        }

        // 匹配码率
        if (preg_match('/bitrate: (\d+ kb\/s)/', $ffmpegOutput, $matches)) {
            $info['bitrate'] = $matches[1];
        }

        // 使用 ffprobe 获取帧信息,包括帧类型
        $ffprobeOutput = shell_exec('ffprobe -v error -read_intervals 0%+15 -select_streams v:0 -show_frames -show_entries frame=pict_type -print_format json "' . $filePath . '" 2>&1');

        $ffprobeData = json_decode($ffprobeOutput, true);

        $lastKeyframeIndex = null;
        $containsBFrame = false;
        if(!isset($ffprobeData['frames'])){
            return $info;
        }
        // 遍历每一帧的信息,检查是否有B帧以及计算关键帧间隔
        foreach ($ffprobeData['frames'] as $index => $frame) {

            // 检查是否有B帧
            if ($frame['pict_type'] === 'B') {
                $containsBFrame = true;
            }

            // 计算关键帧间隔(I帧之间的帧数)
            if ($frame['pict_type'] === 'I') {
                if ($lastKeyframeIndex !== null) {
                    $info['keyframe_interval'] = $index - $lastKeyframeIndex;
                }
                $lastKeyframeIndex = $index;
            }
        }

        // 设置是否包含B帧的信息
        $info['contains_b_frames'] = $containsBFrame;

        return $info;
    }

python处理脚本

import os
import subprocess
import sys
import json

def get_video_info(video_path):
    """使用 ffprobe 获取视频的基本信息,包括分辨率、帧率和 B 帧"""
    cmd = [
        'ffprobe', '-v', 'error', '-select_streams', 'v', '-show_entries',
        'stream=width,height,r_frame_rate,has_b_frames,bit_rate', '-of', 'json', video_path
    ]
    result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    try:
        stream_info = json.loads(result.stdout.decode('utf-8'))['streams'][0]
    except (IndexError, json.JSONDecodeError):
        print("无法获取视频信息。请确保输入文件是有效的视频文件。")
        sys.exit(1)

    # 返回流信息并确保返回值存在
    return {
        'width': stream_info.get('width'),
        'height': stream_info.get('height'),
        'r_frame_rate': stream_info.get('r_frame_rate'),
        'has_b_frames': stream_info.get('has_b_frames'),
        'bit_rate': stream_info.get('bit_rate', None)  # 如果没有比特率信息,设置为 None
    }

def convert_video(input_file, output_folder, resolution, codec, gop_size, remove_bframes, frame_rate, bit_rate, watermark, watermark_color):
    # 设置输出文件夹
    os.makedirs(output_folder, exist_ok=True)

    # 输出文件路径
    output_file = os.path.join(output_folder, os.path.basename(input_file))

    # 检查输出文件是否已经存在
    if os.path.exists(output_file):
        print(f"文件 {output_file} 已存在,正在删除...")
        os.remove(output_file)
        print(f"已删除 {output_file}")

    # 获取原始视频信息
    video_info = get_video_info(input_file)
    original_resolution = f"{video_info['width']}x{video_info['height']}"
    try:
        original_framerate = eval(video_info['r_frame_rate'])  # 将帧率转换为浮点数
    except (TypeError, SyntaxError):
        original_framerate = 30.0  # 默认帧率
    has_b_frames = int(video_info['has_b_frames'])  # 0 表示没有 B 帧,>0 表示有 B 帧

    print(f"原始分辨率: {original_resolution}, 原始帧率: {original_framerate}fps, 是否有B帧: {'有' if has_b_frames else '无'}")

    # 获取原始码率,如果不存在则设置为 None
    original_bitrate = int(video_info['bit_rate']) // 1000 if video_info['bit_rate'] else None

    # 初始化 ffmpeg 命令
    ffmpeg_cmd = ['ffmpeg', '-i', input_file, '-y']

    # 构建视频过滤器列表
    filters = []

    # 处理分辨率
    if resolution.lower() != original_resolution.lower():
        print(f"分辨率将从 {original_resolution} 转换为 {resolution}")
        # 添加 scale 过滤器,替换 'x' 为 ':'
        filters.append(f'scale={resolution.replace("x", ":")}')
    else:
        print("分辨率与原始视频相同,跳过分辨率处理。")

    # 处理水印
    if watermark != '0':
        if watermark == '1':
            print("添加时间戳水印")
            text = '%{pts\\:hms}'
        else:
            print(f"添加自定义文字水印: {watermark}")
            text = watermark.replace("'", "\\'")  # 转义单引号

        # 使用 pts 或自定义文字作为水印文本
        # 指定颜色
        drawtext_filter = f"drawtext=fontsize=24:fontcolor={watermark_color}@0.8:x=10:y=10:text='{text}'"
        filters.append(drawtext_filter)
    else:
        print("不添加水印")

    # 如果有任何过滤器,添加到 ffmpeg 命令中
    if filters:
        filter_chain = ",".join(filters)
        ffmpeg_cmd += ['-vf', filter_chain]

    # 处理编码格式
    if codec == 'h265':
        print(f"转换为 H.265 编码")
        ffmpeg_cmd += ['-c:v', 'libx265']
    elif codec == 'h264':
        print(f"转换为 H.264 编码")
        ffmpeg_cmd += ['-c:v', 'libx264']
    else:
        print(f"未知的编码格式: {codec},跳过转换")
        return

    # 处理 I 帧间隔
    ffmpeg_cmd += ['-g', str(gop_size)]

    # 处理 B 帧移除
    if remove_bframes == '1' and has_b_frames > 0:
        print("移除 B 帧")
        ffmpeg_cmd += ['-bf', '0']
    elif has_b_frames == 0:
        print("原始视频没有 B 帧,跳过 B 帧处理。")
    else:
        print("保留 B 帧")
        ffmpeg_cmd += ['-bf', '2']

    # 设置帧率
    if frame_rate:
        if float(frame_rate) != original_framerate:
            print(f"设置帧率为 {frame_rate} fps")
            ffmpeg_cmd += ['-r', str(frame_rate)]
        else:
            print("帧率与原始视频相同,跳过帧率处理。")

    # 设置码率
    if bit_rate:
        if original_bitrate is not None and int(bit_rate) != original_bitrate:  # 仅在存在原始码率时比较
            print(f"设置码率为 {bit_rate} kbps")
            ffmpeg_cmd += ['-b:v', f'{bit_rate}k']
        elif original_bitrate is None:
            print("原始视频没有比特率信息,设置码率为默认值")
            ffmpeg_cmd += ['-b:v', f'{bit_rate}k']
        else:
            print("码率与原始视频相同,跳过码率处理。")

    # 输出路径
    ffmpeg_cmd += [output_file]

    # 打印最终的 FFmpeg 命令(可选,便于调试)
    print("执行的 FFmpeg 命令:", ' '.join(ffmpeg_cmd))

    # 执行 FFmpeg 命令,输出信息
    try:
        process = subprocess.run(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        if process.returncode != 0:
            print("FFmpeg 转换过程中出错:")
            print(process.stderr)
            print('false')
            sys.exit(1)
        else:
            print(f"{os.path.basename(input_file)} 转换完成!")
            print('true')
    except Exception as e:
        print(f"发生异常: {e}")
        print('false')
        sys.exit(1)

if __name__ == "__main__":
    try:
        if len(sys.argv) != 11:
            print("使用方法: python3 convert_video.py 视频文件路径 保存路径 分辨率 编码格式 I帧间隔 是否去掉B帧 帧率 码率 添加水印(0: 不使用, 1: 添加时间戳水印, 其他: 添加自定义文字) 水印颜色")
            sys.exit(1)

        video_path = sys.argv[1]
        output_folder = sys.argv[2]
        resolution = sys.argv[3]
        codec = sys.argv[4]
        gop_size = sys.argv[5]
        remove_bframes = sys.argv[6]
        frame_rate = sys.argv[7]
        bit_rate = sys.argv[8]
        watermark = sys.argv[9]
        watermark_color = sys.argv[10]

        # 检查水印参数
        if watermark == '0':
            pass  # 不添加水印
        elif watermark == '1':
            pass  # 添加时间戳水印
        else:
            if not watermark.strip():
                print("添加自定义文字水印时,水印文字不能为空。")
                sys.exit(1)

        # 检查视频文件是否存在
        if not os.path.isfile(video_path):
            print(f"文件 {video_path} 不存在!")
            sys.exit(1)

        # 执行视频转换
        convert_video(
            video_path,
            output_folder,
            resolution,
            codec,
            gop_size,
            remove_bframes,
            frame_rate,
            bit_rate,
            watermark,
            watermark_color
        )

    except Exception as e:
        print('false')
        sys.exit(1)

展示效果


laravel+python3+ffmpeg 实现视频转换工具demo实例

本作品采用《CC 协议》,转载必须注明作者和本文链接
chowjiawei
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 3

我觉得不需要py 直接丢到队列任务中去执行

4个月前 评论
chowjiawei (楼主) 4个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
测开 @ 新大陆数字技术股份有限公司
文章
76
粉丝
42
喜欢
238
收藏
410
排名:238
访问:4.0 万
私信
所有博文
博客标签
社区赞助商