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. H265与H264编码互相转换,建议文件在3分钟内可以使用此功能转换(预计需要5分钟才能转换完成)</label>
<label>2. 支持大小在5GB以内的文件</label>
<label>3. H264最大支持4096x2304,H265最大支持7680x4320,H.264和H.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)
展示效果
本作品采用《CC 协议》,转载必须注明作者和本文链接
我觉得不需要py 直接丢到队列任务中去执行
先看看