laravel python3 ffmpeg 实现视频转换工具demo实例 | laravel china 社区-江南app体育官方入口

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>

后端控制器代码


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)

展示效果


本作品采用《cc 协议》,转载必须注明作者和本文链接
chowjiawei
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
以构建论坛项目 larabbs 为线索,展开对 laravel 框架的全面学习。应用程序架构思路贴近 laravel 框架的设计哲学。
讨论数量: 3

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

6天前
chowjiawei (楼主) 6天前

先看看

6天前

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