Skip to content

05_课件 Manifest 与前端示例

什么是 Manifest?

Manifest 是后端在上传后“预处理”生成的一份 JSON 描述文件,用于告诉前端:

  • 这个课件的类型(document 或 video)
  • 如果是文档:总页数、每一页的可直接访问的图片 URL 列表
  • 如果是视频:可直接播放的 HLS 播放列表 m3u8 的 URL

这样前端无需下载并本地解析 PDF/PPT/视频,直接按 Manifest 渐进加载即可,首屏更快、设备更省电。

后端接口:

  • 上传课件:POST /upload/{course_id}/courseware
    • 支持 .pdf .ppt .pptx .mp4 .mov .mkv .webm
    • 返回 courseware_url(原文件路径)和 manifest_url
  • 拉取 Manifest:GET /upload/{course_id}/courseware/manifest
    • 可加 ?asset_id=... 指定某一次上传的资产版本;不传默认取最新

Manifest JSON 示例

文档型(PDF/PPT 转页图):

json
{
  "type": "document",
  "page_count": 3,
  "pages": [
    { "index": 1, "url": "/static/courseware/12/ab12cd/pages/0001.jpg" },
    { "index": 2, "url": "/static/courseware/12/ab12cd/pages/0002.jpg" },
    { "index": 3, "url": "/static/courseware/12/ab12cd/pages/0003.jpg" }
  ]
}

视频型(生成 HLS):

json
{
  "type": "video",
  "hls_url": "/static/courseware/12/ef34gh/hls/index.m3u8"
}

React 前端示例

以下是一个最小可用的 React 组件,自动请求 Manifest,根据类型展示文档分页或视频播放。

依赖:

  • 文档:无额外依赖(图片懒加载即可)
  • 视频:hls.jsnpm i hls.js
tsx
import React, { useEffect, useMemo, useRef, useState } from 'react';
import Hls from 'hls.js';

type DocumentManifest = {
  type: 'document';
  page_count: number;
  pages: { index: number; url: string }[];
};

type VideoManifest = {
  type: 'video';
  hls_url: string;
};

type Manifest = DocumentManifest | VideoManifest;

interface Props {
  courseId: number;
  backendBase: string; // 如 http://localhost:8000
  assetId?: string; // 可选,指定某次上传版本
}

export const CoursewareViewer: React.FC<Props> = ({ courseId, backendBase, assetId }) => {
  const [manifest, setManifest] = useState<Manifest | null>(null);
  const [error, setError] = useState<string | null>(null);
  const videoRef = useRef<HTMLVideoElement | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    const url = new URL(`/upload/${courseId}/courseware/manifest`, backendBase);
    if (assetId) url.searchParams.set('asset_id', assetId);
    fetch(url.toString(), { signal: controller.signal })
      .then(async (r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then((data) => setManifest(data))
      .catch((e) => setError(e.message));
    return () => controller.abort();
  }, [courseId, backendBase, assetId]);

  useEffect(() => {
    if (!manifest || manifest.type !== 'video') return;
    const video = videoRef.current;
    if (!video) return;
    if (Hls.isSupported()) {
      const hls = new Hls();
      hls.loadSource(new URL(manifest.hls_url, backendBase).toString());
      hls.attachMedia(video);
      return () => hls.destroy();
    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
      // Safari 原生支持 HLS
      video.src = new URL(manifest.hls_url, backendBase).toString();
    }
  }, [manifest, backendBase]);

  if (error) return <div>加载失败:{error}</div>;
  if (!manifest) return <div>加载中…</div>;

  if (manifest.type === 'document') {
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        {manifest.pages.map((p) => (
          <img
            key={p.index}
            src={new URL(p.url, backendBase).toString()}
            alt={`page-${p.index}`}
            loading="lazy"
            style={{ width: '100%', height: 'auto', background: '#f4f4f4' }}
          />
        ))}
      </div>
    );
  }

  // 视频
  return (
    <video ref={videoRef} controls style={{ width: '100%', maxHeight: 600 }} />
  );
};

使用示例:

tsx
<CoursewareViewer courseId={12} backendBase={"http://localhost:8000"} />

前端性能建议

  • 文档分页:按页懒加载,进入时预加载下一页;移动端可先加载低清晰度缩略图,再渐进替换高清。
  • 视频:开启 hls.js 的低延迟选项或使用多码率转码;弱网自动降码率。
  • 静态文件交给 Nginx/CDN:配置强缓存(带指纹的路径可 immutable)。