Skip to content

wordpress m3u8播放器

Published:

前言

起因是在服务器上装了wordpress-studio,然后想试试把平时拍的照片和视频放上去

但是吧,相机拍摄的4k视频,码率实在是有些高,30秒的视频就500M了,直接传到wordpress然后用自带的视频区块,加载速度惨不忍睹

于是乎找到万能的gpt,写一个插件让能支持m3u8切片视频播放的插件

不说废话了,直接开始吧

项目结构
│  hls-block.php
│  package-lock.json
│  package.json

├─assets
│      hls-block.js
│      hls.min.js
│      plyr.min.css
│      plyr.min.js

└─src
        edit.js
        index.js
        style.css

说明:

wp-content/plugins 新建一个目录存放插件

直接上代码了

hls-block.php
<?php
/**
 * Plugin Name: HLS Video Block
 * Description: Gutenberg 区块,支持 m3u8/HLS 视频播放和分辨率切换
 * Version: 1.0
 * Author: chatgpt
 */

if ( ! defined( 'ABSPATH' ) ) exit;

// 注册区块
function hls_video_block_register_block() {
    wp_register_script(
        'hls-video-block-editor',
        plugins_url( 'build/index.js', __FILE__ ),
        array( 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components', 'wp-i18n' ),
        filemtime( plugin_dir_path( __FILE__ ) . 'build/index.js' )
    );

    register_block_type( 'hls/video-block', array(
        'editor_script'   => 'hls-video-block-editor',
        'render_callback' => 'hls_video_block_render',
        'attributes'      => array(
            'file' => array(
                'type'    => 'string',
                'default' => '',
            ),
            'width' => array(
                'type'    => 'number',
                'default' => 800,
            ),
            'height' => array(
                'type'    => 'number',
                'default' => 450,
            ),
        ),
    ) );
}
add_action( 'init', 'hls_video_block_register_block' );

// 区块前端渲染
function hls_video_block_render( $attributes ) {
    if ( empty( $attributes['file'] ) ) {
        return '<div style="color:red;">未指定视频文件</div>';
    }
    $src0 = 'uploads/hls/' . $attributes['file'];
    $src = esc_url( content_url( 'uploads/hls/' . $attributes['file'] ) );
    $width = intval( $attributes['width'] );
    $height = intval( $attributes['height'] );
    $player_id = 'hls-block-player-' . uniqid();

    // 加载 video.js 和 hls.js
    wp_enqueue_script( 'plyrjs', plugins_url( 'assets/plyr.min.js', __FILE__ ), array(), null, true );
    wp_enqueue_style( 'plyr-css', plugins_url( 'assets/plyr.min.css', __FILE__ ) );
    wp_enqueue_script( 'hlsjs', plugins_url( 'assets/hls.min.js', __FILE__ ), array(), null, true );
    wp_enqueue_script( 'hls-block-js', plugins_url( 'assets/hls-block.js', __FILE__ ), array(), null, true );

    ob_start();
    ?>
    <figure class='wp-block-video'>
        <video id="<?php echo esc_attr( $player_id ); ?>" controls crossorigin playsinline width="<?php echo esc_attr( $width ); ?>" height="<?php echo esc_attr( $height ); ?>">
            <source type="application/x-mpegURL" src="<?php echo esc_url( $src ); ?>">
        </video>
    </firgure>
    <script>
    document.addEventListener('DOMContentLoaded', function() {
        if(window.hlss === undefined) {
            window.hlss = {};
        }
        const vidid = '<?php echo esc_js( $player_id ); ?>';
        const video = document.getElementById(vidid);
        const source = video.getElementsByTagName('source')[0].src;
        const defaultOptions = {};

        if (Hls.isSupported()) {
            const hls = new Hls();
            hls.loadSource(source);
            hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
                const availableQualities = hls.levels.map((l) => l.height)
                defaultOptions.quality = {
                    default: availableQualities[0],
                    options: availableQualities,
                    forced: true,        
                    onChange: (e) => updateQuality(e, vidid),
                };
                const player = new Plyr(video, defaultOptions);
            });
            hls.attachMedia(video);
            window.hlss[vidid] = hls;
        } else {
            const player = new Plyr(video, defaultOptions);
        }
    });
    </script>
    <?php
    return ob_get_clean();
}

// 提供 REST API 获取 m3u8 文件列表
add_action( 'rest_api_init', function () {
    register_rest_route( 'hls-block/v1', '/files', array(
        'methods' => 'GET',
        'callback' => function () {
            $dir = WP_CONTENT_DIR . '/uploads/hls/';
            $files = array();
            if ( is_dir( $dir ) ) {
                foreach ( glob( $dir . '*.m3u8' ) as $file ) {
                    $files[] = basename( $file );
                }
            }
            return rest_ensure_response( $files );
        },
        'permission_callback' => '__return_true',
    ) );
} );
src/index.js
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';

registerBlockType('hls/video-block', {
    title: '视频流',
    icon: 'format-video',
    category: 'media',
    attributes: {
        file: { type: 'string', default: '' },
        width: { type: 'number', default: 800 },
        height: { type: 'number', default: 450 },
    },
    edit: Edit,
    save: () => null,
    style: './style.css',
});
src/edit.js
import { useEffect, useState } from '@wordpress/element';
import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl, TextControl, RangeControl } from '@wordpress/components';

export default function Edit({ attributes, setAttributes, className }) {
    const { file, width, height } = attributes;
    const [files, setFiles] = useState([]);

    useEffect(() => {
        fetch('/wp-json/hls-block/v1/files')
            .then(res => res.json())
            .then(setFiles);
    }, []);

    // 处理下拉选择
    const handleSelectChange = (val) => {
        setAttributes({ file: val });
    };

    // 处理文本输入
    const handleInputChange = (val) => {
        setAttributes({ file: val });
    };

    return (
        <>
            <InspectorControls>
                <PanelBody title="视频设置" initialOpen={true}>
                    <SelectControl
                        label="选择m3u8文件"
                        value={file}
                        options={[
                            { label: '请选择', value: '' },
                            ...files.map(f => ({ label: f, value: f }))
                        ]}
                        onChange={handleSelectChange}
                        help="可从下拉选择,或在下方输入自定义文件名"
                    />
                    <TextControl
                        label="或输入文件名"
                        value={file}
                        onChange={handleInputChange}
                        placeholder="如 example.m3u8"
                        help="支持直接输入文件名"
                    />
                    <RangeControl
                        label="宽度"
                        value={width}
                        onChange={val => setAttributes({ width: val })}
                        min={320}
                        max={1920}
                    />
                    <RangeControl
                        label="高度"
                        value={height}
                        onChange={val => setAttributes({ height: val })}
                        min={180}
                        max={1080}
                    />
                </PanelBody>
            </InspectorControls>
            <div className={`hls-video-block ${className || ''}`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
                {file ? (
                    <div style={{ border: '1px solid #ccc', padding: 10 }}>
                        <strong>{ `${file}` }</strong>
                        { <video
                            controls
                            width={width}
                            height={height}
                            style={{ background: '#000', display: 'block', margin: '0 auto' }}
                        >
                            {/* <source src={`/wp-content/uploads/hls/${file}`} type="application/x-mpegURL" /> */}
                        </video> }
                        {/* <div style={{ fontSize: 12, color: '#888', textAlign: 'center' }}>保存后可切换分辨率</div> */}
                    </div>
                ) : (
                    <div style={{ color: 'red', textAlign: 'center' }}>请选择或输入 m3u8 文件</div>
                )}
            </div>
        </>
    );
}
src/style.css
.hls-video-block {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    margin: 2em 0;
}

.hls-video-block video {
    display: block;
    margin: 0 auto;
    background: #000;
    border-radius: 6px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}

.hls-video-block .hls-video-preview {
    border: 1px solid #ccc;
    padding: 10px;
    margin-bottom: 8px;
    background: #fafafa;
    border-radius: 6px;
}

.hls-video-block .hls-video-help {
    font-size: 12px;
    color: #888;
    text-align: center;
}

.hls-video-block .hls-video-error {
    color: #d63638;
    text-align: center;
    font-weight: bold;
}
assets/hls-block.js
function updateQuality(newQuality, vidid) {
    window.hlss[vidid].levels.forEach((level, levelIndex) => {
        if (level.height === newQuality) {
            window.hlss[vidid].currentLevel = levelIndex;
        }
    });
}
生成build

到插件根目录下

npm install wordpress/scripts
npx wp-scripts build

package.json参考

{
  "name": "hls-video-block",
  "version": "1.0.0",
  "description": "Gutenberg block for HLS/m3u8 video playback",
  "main": "build/index.js",
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start"
  },
  "devDependencies": {
    "@wordpress/scripts": "^26.0.0"
  }
}

assets下面其他的js和css手动下载后放入

python视频切片脚本参考

import os
import json
import subprocess
from pathlib import Path
import ffmpeg
import uuid

INPUT_DIR = 'input'
OUTPUT_DIR = 'output'
SEGMENT_TIME = 10  # 每段切成10秒

RESOLUTION_MAP = {
    "480p": 480,
    "720p": 720,
    "1080p": 1080,
    "1440p": 1440,
}

RESOLUTION_BITRATE_MAP = {
    240: 500_000,
    360: 950_000,
    480: 1_400_000,
    720: 2_800_000,
    1080: 5_500_000,
    1440: 8_200_000,
    2160: 14_200_000
}

def estimate_bandwidth(width, height):
    h = min(width, height)
    candidates = sorted(RESOLUTION_BITRATE_MAP.items())
    for res_h, bw in candidates:
        if h <= res_h:
            return bw
    return RESOLUTION_BITRATE_MAP[2160]

def get_video_resolution(filepath):
    probe = ffmpeg.probe(filepath)
    video_stream = next(stream for stream in probe['streams'] if stream['codec_type'] == 'video')
    width = video_stream['width']
    height = video_stream['height']
    return width, height

# ✅ 新增:根据 rotate 构造滤镜
def build_ffmpeg_rotate_filter(rotate):
    if rotate == 90:
        return "transpose=1"
    elif rotate == 180:
        return "hflip,vflip"
    elif rotate == 270:
        return "transpose=2"
    else:
        return None

def build_ffmpeg_scale_filter(width, height, rotate=None):
    rotate_filter = build_ffmpeg_rotate_filter(rotate)
    scale = f"scale={width}:{height}"
    if rotate_filter:
        # return f"{rotate_filter},{scale}"
        return f"{scale},{rotate_filter}"
    else:
        return scale

# ✅ 修改:支持 rotate 的压缩转码函数
def transcode_and_segment(input_path, output_dir, resolution_label, resolution_value, rotate=0):
    os.makedirs(output_dir, exist_ok=True)
    scale_filter = build_ffmpeg_scale_filter(-2, resolution_value, rotate)
    out_ts_pattern = os.path.join(output_dir, f"{resolution_label}_%04d.ts")
    out_m3u8 = os.path.join(output_dir, f"{resolution_label}.m3u8")

    cmd = [
        'ffmpeg', '-y', '-i', input_path,
        '-vf', scale_filter,
        '-c:a', 'aac', '-ar', '48000', '-b:a', '128k',
        '-c:v', 'h264_amf', '-b:v', '2500k',
        '-g', '48', '-keyint_min', '48', '-sc_threshold', '0',
        '-hls_time', str(SEGMENT_TIME),
        '-hls_playlist_type', 'vod',
        '-hls_segment_filename', out_ts_pattern,
        out_m3u8
    ]
    subprocess.run(cmd, check=True)

    actual_width, actual_height = get_video_resolution(out_ts_pattern.replace("_%04d.ts", "_0000.ts"))
    return actual_width, actual_height

# ✅ 修改:原始视频也支持 rotate
def segment_source(input_path, output_dir, rotate=0):
    os.makedirs(output_dir, exist_ok=True)
    out_ts_pattern = os.path.join(output_dir, "source_%04d.ts")
    out_m3u8 = os.path.join(output_dir, "source.m3u8")
    cmd = [
        'ffmpeg', '-y', '-i', input_path,
    ]

    rotate_filter = build_ffmpeg_rotate_filter(rotate)
    if rotate_filter:
        cmd += ['-vf', rotate_filter]
        cmd += ['-c:v', 'h264_amf']
    else:
        cmd += ['-c:v', 'copy']

    cmd += [
        '-c:a', 'aac', '-ar', '48000', '-b:a', '128k',
        '-hls_time', str(SEGMENT_TIME),
        '-hls_playlist_type', 'vod',
        '-hls_segment_filename', out_ts_pattern,
        out_m3u8
    ]
    subprocess.run(cmd, check=True)

    return get_video_resolution(out_ts_pattern.replace("_%04d.ts", "_0000.ts"))

def write_master_playlist(output_path, stream_infos, v_uuid):
    with open(output_path, 'w') as f:
        f.write('#EXTM3U\n')
        for label, resolution, filename in stream_infos:
            width, height = resolution
            w = max(width, height)
            h = min(width, height)
            bandwidth = estimate_bandwidth(width, height)
            f.write(f'#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={w}x{h}\n')
            f.write(f'{v_uuid}/{filename}\n')

def process_json(json_path):
    with open(json_path, 'r', encoding='utf-8') as f:
        config = json.load(f)

    input_file = config['input_file']
    v_uuid = config.get('uuid') or str(uuid.uuid4())
    rotate = int(config.get('rotate', 0))
    source = bool(config.get('source', True))
    title = config['title']
    targets = config['target']

    input_path = os.path.join(INPUT_DIR, input_file)
    output_base = os.path.join(OUTPUT_DIR, v_uuid)
    title_m3u8_path = os.path.join(OUTPUT_DIR, f"{title}.m3u8")

    print(f"处理视频: {input_file}")
    width, height = get_video_resolution(input_path)
    short_edge = min(width, height)

    stream_infos = []

    # 源视频切片(支持旋转)
    if source:
        source_resolution = segment_source(input_path, output_base, rotate)
        stream_infos.append(("source", source_resolution, "source.m3u8"))

    # 清晰度转码(支持旋转)
    for res_label in targets:
        res_value = RESOLUTION_MAP.get(res_label)
        if res_value and res_value < short_edge:
            try:
                actual_res = transcode_and_segment(input_path, output_base, res_label, res_value, rotate)
                stream_infos.append((res_label, actual_res, f"{res_label}.m3u8"))
            except Exception as e:
                print(f"转码 {res_label} 出错: {e}")
        else:
            print(f"跳过分辨率 {res_label},因为源视频短边为 {short_edge}")

    write_master_playlist(title_m3u8_path, stream_infos, v_uuid)
    print(f"✅ 已生成主播放列表: {title_m3u8_path}")
    os.rename(json_path, f"{json_path}.1")

def main():
    json_files = Path(INPUT_DIR).glob("*.json")
    for json_file in json_files:
        try:
            process_json(json_file)
        except Exception as e:
            print(f"处理 {json_file} 出错: {e}")

if __name__ == "__main__":
    main()

建input和output文件夹,然后input中放入需要切片的视频参数

{
    "input_file": "E:\\temp\\DSC_2682.MOV",
    "title": "视频标题",
    "rotate": 270, // 泥坑竖屏视频不会自动旋转,所以要加上这行,如果视频方向没问题就不用加
    "target": [ // 目标分辨率
        "720p",
        "1080p"
    ]
}

然后运行py脚本,output下面会生成 视频标题.m3u8 和一个 uuid 文件夹,

image-20250828152451667

这些文件剪切到wordpress根目录下面的 wp-content/uploads/hls

image-20250828152617363

文章编辑页面,添加区块,旋转视频流

image-20250828152859139

然后点击区块,到属性页面,选择视频文件

image-20250828153023608

发布后,播放时可以选择分辨率

image-20250828153139688

附件

hls-block.zip


Previous Post
compose和数据库
Next Post
电脑HDMI接电视颜色泛白