AIGC标识 浏览器端素材压缩与上传实践:从 Canvas 到 WebCodecs 与 FFmpeg 回退

🧑‍💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣

素材上传不只是把 File 放进 FormData。图片可能分辨率过高,视频可能超过体积上限,不同设备的编解码能力也各不相同。合理的上传链路应先检查素材、在需要时征得用户同意、执行处理并校验结果,最后才发送网络请求。

本文以图片目标不超过 2MiB、超限视频目标不超过 20MiB 为例,介绍一套浏览器端素材处理方案:图片通过 Canvas 等比缩放和质量压缩;视频优先采用 WebCodecs,请求硬件加速,并通过分辨率降级、浏览器默认编码、FFmpeg WebAssembly 建立多层回退。

下文代码为突出技术逻辑的简化示例,不是可以直接复制运行的完整应用。界面通常标注 MB,但 20 * 1024 * 1024 实际对应 20MiB,前后端需要统一口径。

一、先把上传拆成几个阶段

完整链路可以分为:

选择素材
  ├─ 通用检查:文件类型、文件名、数量等
  ├─ 图片
  │    └─ 读取尺寸 → 等比缩放 → 质量压缩 → 体积检查
  └─ 视频
       ├─ 未超过体积上限 → 继续业务检查
       └─ 超过体积上限 → 批量确认
            ├─ 取消 → 不上传这些超限视频
            └─ 确认 → 排队 → 读取媒体信息 → 选择编码路径
                       → 转码 → 校验 → 必要时调整码率重试
  ↓
将处理成功的文件交给上传队列
  ↓
发送请求 → 等待服务端响应 → 完成或失败

这里有两个重要边界:

  • 压缩成功不等于上传成功,两者是不同任务。
  • 网络资源返回 200 不等于压缩成功,例如 WASM 下载成功,只表示引擎二进制已经获取。

对于视频,时长等业务规则应独立于编码模块。编码模块负责技术约束,不应该顺手新增或取消业务限制。

二、图片处理:等比缩放,不裁剪、不拉伸

2.1 按最长边计算缩放比例

假设原图为 width × height,最长边上限为 1920 像素:

const scale = Math.min(1, 1920 / Math.max(width, height));
const targetWidth = Math.max(1, Math.round(width * scale));
const targetHeight = Math.max(1, Math.round(height * scale));

使用相同缩放系数能保留原图比例。Math.min(1, ...) 则避免把小图放大。

例如,4000×3000 的图片缩小后为 1920×1440,而不是强制转换为 9:16。保留完整画面与强制填满固定比例区域,是两种不同需求。

2.2 Canvas 负责重采样与重新编码

canvas.width = targetWidth;
canvas.height = targetHeight;
context.drawImage(image, 0, 0, targetWidth, targetHeight);

const blob = await new Promise<Blob>((resolve, reject) => {
  canvas.toBlob(
    (result) => {
      if (result) resolve(result);
      else reject(new Error('图片编码失败'));
    },
    'image/jpeg',
    0.9
  );
});

Canvas 先生成目标尺寸的像素,再通过 toBlob() 导出压缩结果。JPEG 的质量参数会影响文件体积与视觉质量,但不存在“质量 0.8 就一定小于 2MiB”的保证。

PNG 通常不受有损质量参数控制,因此单纯不断降低 quality,未必能减小 PNG 体积。

2.3 先降低质量,再缩小尺寸

推荐策略是:

  1. 图片尺寸和体积均达标时,直接复用原文件。
  2. 尺寸超限时,先按最长边缩小。
  3. 尝试保留原图片格式导出。
  4. 仍超限时转成 JPEG,逐步降低质量。
  5. 达到质量下限仍超限,再缩小尺寸,并重新绘制、编码。
  6. 每次都检查真实 Blob.size,不能只相信参数。
while (blob.size > maxBytes) {
  blob = await encodeJpeg(quality);
  if (blob.size <= maxBytes) break;

  if (quality > 0.5) {
    quality = Math.max(0.5, quality - 0.1);
  } else {
    targetLongestSide = Math.max(1, Math.floor(targetLongestSide * 0.85));
    redrawAt(targetLongestSide);
    quality = 0.9;
  }
}

转换为 JPEG 时需要处理透明背景。可以先填充白色,再绘制原图,避免透明区域出现非预期颜色。格式改变后,文件扩展名和 MIME 类型也应同步改变。

三、超限视频为什么要先确认

视频压缩会消耗 CPU、GPU 和内存,也可能降低画质。用户选择超限文件后,不宜立即启动高成本转码。

对于一次选择的多个文件,可以先收集超限视频,再合并弹框:

上传的素材中,A.mp4、B.mp4 超出20MB,是否由前端进行压缩后上传?

提供两个明确动作:

  • 压缩后上传:只对用户确认的超限视频创建处理任务。
  • 取消并不上传:跳过这些超限视频,不影响其他正常素材。

批量收集可以使用微任务或短暂的批次调度,把同一轮选择产生的多个上传钩子合并处理。每个待处理文件都需要保存自己的完成回调,避免取消弹框后仍有 Promise 一直等待。

四、视频转码涉及哪些技术

一个 MP4 通常包含压缩后的视频轨道和音频轨道。处理它需要完成:

解封装 → 解码 → 画面缩放 → 视频/音频编码 → MP4 封装

相关技术分工如下:

技术 主要职责
WebCodecs 调用浏览器音视频解码器、编码器
Mediabunny 读取媒体容器、轨道与时间戳,组织转换和 MP4 封装
WebAssembly + FFmpeg 浏览器原生能力不足时的软件转码
Web Worker 将 FFmpeg 运算放到独立线程,减少对主线程的直接阻塞
AbortController 统一取消排队、读取和编码任务

WebGPU 或 WebGL 可以参与像素缩放、滤镜等画面处理,但本身不是 H.264 编码器。硬件视频编码的入口是浏览器提供的媒体编码能力,而不是给 Canvas 加一个 GPU 参数。

另外,Worker 不等于硬件加速。FFmpeg WASM 即使运行在 Worker 中,使用 libx264 时仍属于 CPU 软件编码。

五、目标输出:MP4、H.264、AAC 和 4:2:0

这些名词约束不同层面:

  • MP4:容器格式,负责组织轨道、时间戳和索引。
  • H.264 / AVC:视频压缩编码。
  • AAC:音频压缩编码;无音轨的视频无需凭空添加音轨。
  • 8-bit 4:2:0:视频位深与色度采样要求。
  • yuv420p:FFmpeg 中常用的 8-bit 平面 YUV 4:2:0 像素格式名称。

在 WebCodecs 路径中,不能像 FFmpeg 一样简单设置 -pix_fmt yuv420p。应检测最终 H.264 码流是否满足 8-bit 4:2:0;这与强制编码前帧的内存布局是不同概念。

4:2:0 也不等于 BT.709 等完整色彩标准。如果业务还要求色域、传递函数或色彩矩阵,必须另行明确。

六、先计算体积预算,再选择码率

视频文件大小大致可以估算为:

文件大小 ≈ 时长 ×(视频码率 + 所有音轨码率之和)÷ 8 + 封装开销

因此,限制大小不能只设置固定分辨率,也不能只靠固定质量参数。可以根据目标字节数与时长计算视频码率:

const safetyRatio = 0.95;
const audioBitrate = 96_000;

const videoBitrate =
  Math.floor((maxBytes * safetyRatio * 8) / duration) -
  audioTrackCount * audioBitrate;

预留 5% 空间是为了吸收封装开销和码率控制偏差,不是绝对保证。高运动场景、关键帧和不同编码器的码率实现都会影响真实体积。

如果码率预算已经低到不可用,应明确失败,而不是偷偷截断视频、删除音轨或上传超限文件。

七、WebCodecs:先检测能力,再编码

7.1 浏览器版本不是唯一条件

WebCodecs 通常需要 HTTPS 或 localhost 等安全上下文。即使是最新版 Chrome,实际支持情况仍受操作系统、显卡驱动、编码器、分辨率和码率等影响。

检测应逐层进行:

  1. 当前环境是否安全,编码和解码 API 是否存在。
  2. 原视频和音轨能否解码。
  3. 原音轨声道数、采样率下能否编码 AAC。
  4. 指定尺寸、码率下能否编码 H.264。
  5. 转换初始化后是否存在被丢弃的轨道。

单个配置不支持,不代表整个 WebCodecs 不可用。

7.2 建立分辨率与编码偏好的降级矩阵

推荐的能力检测顺序是:

prefer-hardware
  ├─ 原目标尺寸
  ├─ 最长边 1280
  ├─ 最长边 960
  └─ 最长边 640
        ↓ 全部不支持
no-preference
  ├─ 原目标尺寸
  ├─ 最长边 1280
  ├─ 最长边 960
  └─ 最长边 640
        ↓ 全部不支持
FFmpeg 软件回退

不应放大原素材,候选尺寸需要去重,并采用偶数宽高。偶数取整可能引入极小比例误差,应使用 contain 等方式避免裁掉画面。

for (const hardwareAcceleration of [
  'prefer-hardware',
  'no-preference',
] as const) {
  for (const size of candidateSizes) {
    signal.throwIfAborted();
    const config = { ...size, bitrate, hardwareAcceleration };
    if (await canEncodeVideo('avc', config)) {
      return config;
    }
  }
}
throw new Error('没有可用的 H.264 编码配置');

这里选择串行检测:命中后立即停止,避免同时初始化多组编码器。

7.2.1 生成候选分辨率:限制最长边、偶数对齐与去重

下面把候选尺寸生成拆成独立函数。输入使用媒体轨道的显示尺寸,使竖屏和横屏采用相同规则;有旋转元数据的素材,需要由媒体库正确处理显示方向。

type Size = { width: number; height: number };

function createCandidateSizes(
  sourceWidth: number,
  sourceHeight: number,
  maxEdge = 1920
): Size[] {
  if (
    !Number.isFinite(sourceWidth) ||
    !Number.isFinite(sourceHeight) ||
    sourceWidth < 2 ||
    sourceHeight < 2 ||
    !Number.isFinite(maxEdge) ||
    maxEdge < 2
  ) {
    throw new Error('无效的视频尺寸');
  }

  const sourceEdge = Math.max(sourceWidth, sourceHeight);
  const firstEdge = Math.min(sourceEdge, maxEdge);
  const edges = [firstEdge, 1280, 960, 640].filter(
    (edge, index, all) => edge <= firstEdge && all.indexOf(edge) === index
  );
  const seen = new Set<string>();
  const sizes: Size[] = [];

  for (const edge of edges) {
    const scale = edge / sourceEdge;
    // 向下取偶数:不超过源尺寸与最长边预算。
    const width = Math.floor((sourceWidth * scale) / 2) * 2;
    const height = Math.floor((sourceHeight * scale) / 2) * 2;
    // 极端长宽比可能让短边不足两个像素,不应强制放大短边。
    if (width < 2 || height < 2) continue;

    const key = `${width}x${height}`;
    if (seen.has(key)) continue;
    seen.add(key);
    sizes.push({ width, height });
  }

  if (!sizes.length) throw new Error('没有可用的偶数编码尺寸');
  return sizes;
}

console.table(createCandidateSizes(1920, 1012));
// 1920 × 1012
// 1280 × 674
//  960 × 506
//  640 × 336

console.table(createCandidateSizes(1080, 1920));
// 1080 × 1920
//  720 × 1280
//  540 × 960
//  360 × 640

该示例选择向下取偶数;四舍五入取偶数也可用于编码,但应统一生成、实际转换与输出校验的规则。偶数对齐后,输出矩形与原图可能有极小比例差异,转换时使用 contain 保留完整画面,必要时产生很窄的补边,而不是强制拉伸。

如果源视频最长边只有 800 像素,候选只包含原尺寸与最长边 640 的档位,不会生成 1280 或 960 的放大版本。

7.2.2 串行探测:硬件优先耗尽后,再尝试默认编码

以下示例使用 Mediabunny 的 canEncodeVideo 检测能力。将探测函数作为参数传入,也便于使用模拟结果验证降级顺序。

import { canEncodeVideo } from 'mediabunny';

type Acceleration = 'prefer-hardware' | 'no-preference';
type Candidate = Size & {
  bitrate: number;
  hardwareAcceleration: Acceleration;
};
type Probe = (config: Candidate) => Promise<boolean>;

function checkAbort(signal: AbortSignal): void {
  if (signal.aborted) {
    throw signal.reason ?? new DOMException('已取消', 'AbortError');
  }
}

async function selectEncodingConfig(
  sizes: Size[],
  bitrate: number,
  signal: AbortSignal,
  probe: Probe = (config) => canEncodeVideo('avc', config)
): Promise<Candidate | null> {
  if (!Number.isFinite(bitrate) || bitrate <= 0) {
    throw new Error('无效的视频码率');
  }

  const modes: Acceleration[] = ['prefer-hardware', 'no-preference'];
  for (const hardwareAcceleration of modes) {
    for (const size of sizes) {
      checkAbort(signal);
      const candidate: Candidate = {
        ...size,
        bitrate,
        hardwareAcceleration,
      };

      try {
        const supported = await probe(candidate);
        checkAbort(signal);
        console.debug('[编码探测]', { ...candidate, supported });
        if (supported) return candidate;
      } catch (error) {
        // 取消不是兼容性失败,必须立即停止,不能继续下一档或软件回退。
        checkAbort(signal);
        console.warn('[编码探测异常,继续下一配置]', {
          ...candidate,
          error,
        });
      }
    }
  }

  return null;
}

这里的顺序有意优先保留硬件加速机会:即使原尺寸默认编码可用,也先尝试较低分辨率的硬件优先配置。这是性能与清晰度之间的产品选择,而不是 WebCodecs 强制要求的顺序。

能力探测阶段不逐档执行完整转码,因此不会为每个候选都生成一个视频。不过检测调用本身仍有成本,不宜无节制增加档位。

此处各档使用相同的体积预算码率。降低分辨率主要减少每帧像素处理量、提高配置兼容性;它不会自动保证输出文件更小。若需要随像素数限制码率,应另行制定画质策略,最终仍以实际输出大小为准。

7.2.3 选择结果与实际编码、输出校验保持一致

async function encodeWithFallback(
  source: File,
  sourceSize: Size,
  bitrate: number,
  signal: AbortSignal
) {
  const sizes = createCandidateSizes(sourceSize.width, sourceSize.height);
  const selected = await selectEncodingConfig(sizes, bitrate, signal);
  checkAbort(signal);

  if (!selected) {
    // encodeSoftware 是应用提供的 FFmpeg 编码适配器。
    return encodeSoftware(source, { ...sizes[0], bitrate, signal });
  }

  console.info('[选中编码配置]', selected);
  try {
    // encodeBrowser 内部将 selected 传给实际 Conversion 视频配置。
    const file = await encodeBrowser(source, selected, signal);
    checkAbort(signal);
    // 必须返回实际尺寸,不能只把 File 交回上层。
    return {
      file,
      expectedSize: { width: selected.width, height: selected.height },
      engine: 'webcodecs',
    };
  } catch (error) {
    checkAbort(signal);
    console.warn('[浏览器编码执行失败,转软件编码]', { selected, error });
    return encodeSoftware(source, { ...sizes[0], bitrate, signal });
  }
}

// 调度层读取结果后,对照本次编码器返回的实际目标尺寸校验。
const result = await encodeWithFallback(source, sourceSize, bitrate, signal);
const actual = await inspectMedia(result.file);
if (
  actual.width !== result.expectedSize.width ||
  actual.height !== result.expectedSize.height
) {
  throw new Error('输出尺寸与本次选中的配置不一致');
}

encodeBrowser、encodeSoftware 与 inspectMedia 是示意适配器:软件编码也应返回相同结构,结果检查还需覆盖体积、时长、编码和音轨。软件回退使用哪一档尺寸应明确指定;示例选择初始目标尺寸,不隐式沿用最后一次失败探测的 640 像素档位。

这段示例展示的是“能力探测降级 + 执行失败转软件”,不是“每次实际编码失败后再把所有尺寸完整转码一遍”。后者可能放大等待成本,如需引入必须限制尝试次数。

7.2.4 用模拟探测验证顺序

不依赖特定显卡,也可以验证选择器是否按预期工作:

const calls: Candidate[] = [];
const selected = await selectEncodingConfig(
  createCandidateSizes(1920, 1012),
  4_000_000,
  new AbortController().signal,
  async (config) => {
    calls.push(config);
    return (
      config.hardwareAcceleration === 'no-preference' &&
      config.width === 1280
    );
  }
);

console.assert(calls.length === 6, '应先检测四档硬件,再检测两档默认配置');
console.assert(selected?.width === 1280, '应选择默认编码的 1280 档');
console.assert(selected?.hardwareAcceleration === 'no-preference');

还应覆盖原尺寸命中后停止、低分辨率源文件不放大、所有配置失败返回 null、单次探测异常继续,以及取消后不触发软件回退。模拟检测能验证调度逻辑,但不能代替真实浏览器的编码、音画同步和显卡兼容性验证。

prefer-hardware 只是偏好,并不能证明实际使用了 GPU;no-preference 则让浏览器选择实现,也可能使用软件编码。界面应使用“硬件优先”或“浏览器编码”,不能把能力检测通过直接描述为“已启用 GPU”。

7.3 把检测通过的配置用于实际转换

const conversion = await Conversion.init({
  input,
  output,
  video: {
    codec: 'avc',
    ...selectedConfig,
    fit: 'contain',
    forceTranscode: true,
  },
  audio: {
    codec: 'aac',
    bitrate: 96_000,
    forceTranscode: true,
  },
});

if (!conversion.isValid || conversion.discardedTracks.length > 0) {
  throw new Error('转换配置无效,或存在无法保留的轨道');
}

conversion.onProgress = reportProgress;
await conversion.execute();

能力检测成功与执行成功不是同一件事。实际编码仍可能因为资源不足、源数据异常或设备限制失败,因此还需要运行时回退。

八、FFmpeg WASM:兼容性兜底,不是免费加速

只有 WebCodecs 路径失败或不可用时,再动态加载 FFmpeg 模块、Worker 与 WASM 核心,避免正常路径承担较大的资源下载成本。

核心参数可以表达为:

ffmpeg -i input \
  -map 0:v:0 -map '0:a?' \
  -vf 'scale=1280:674:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=1280:674:(ow-iw)/2:(oh-ih)/2,setsar=1' \
  -c:v libx264 -preset fast -b:v 4000000 \
  -pix_fmt yuv420p \
  -c:a aac -b:a 96000 \
  -movflags +faststart output.mp4

上面的尺寸和码率只是示例,实际应来自任务计算结果。

  • 0:a? 表示音轨可选,无音轨时不因此失败。
  • 等比缩放加居中补边,避免强制拉伸。
  • libx264 完成 H.264 软件编码。
  • faststart 将索引提前,便于渐进播放,不是视频编码提速选项。
  • 未设置时长截断参数,目标是保留完整素材。

单线程 WASM 编码较慢。多线程版本还涉及 SharedArrayBuffer、跨源隔离和部署响应头,不能只替换依赖就假定可用。

九、输出校验才是能否上传的最终依据

编码完成后,应重新解析生成文件,至少检查:

  • 文件非空且实际大小不超过目标值。
  • 容器确实为 MP4,而不是只改了扩展名。
  • 视频编码为 H.264。
  • 存在的音轨均为 AAC,音轨数量符合预期。
  • 视频码流满足 8-bit 4:2:0。
  • 总时长、视频时长和各音轨时长未异常变化。
  • 输出尺寸与最终选中的编码配置一致。

最后一点很容易遗漏:如果能力探测从 1920 像素降到 1280 像素,校验不能继续拿最初的 1920 像素比较,否则正确的降级结果也会被误判失败。

工程上应让编码器返回实际选中的尺寸或配置,由调度层进行校验,而不是让调度器猜测。

时长检查需要容差,例如:

const tolerance = Math.max(0.25, originalDuration * 0.001);
const durationValid =
  Math.abs(resultDuration - originalDuration) <= tolerance;

封装时间基、音频编码延迟和帧边界可能造成小幅差异,不能一律严格相等。

9.1 超限后按实际结果修正码率

const nextBitrate = Math.floor(
  currentBitrate * (maxBytes / output.size) * 0.9
);

这比盲目每次减少固定码率更有针对性。但它依然只是下一轮估计,因此必须设置最大编码次数和最低码率预算。所有重试均失败时,应提示用户更换素材,不上传原始超限文件。

十、队列、进度与取消:让高成本任务可控

10.1 压缩队列和上传队列分开

压缩会同时持有源文件、解码帧、编码队列和输出缓冲区。并行处理多个高清视频,容易让内存与编码资源耗尽。

可以使用 Promise 链串行执行压缩:

let queue: Promise<unknown> = Promise.resolve();

function enqueue<T>(run: () => Promise<T>): Promise<T> {
  const task = queue.then(run);
  queue = task.catch(() => undefined);
  return task;
}

失败后吞掉队列链上的拒绝,仅用于让后续任务能继续;调用方仍应收到该任务自己的异常。

压缩成功的文件再进入独立上传队列。正在处理的素材也必须计入未完成任务,避免发布动作提前提交。

10.2 不要从头到尾只显示“压缩中”

建议展示以下阶段:

阶段 用户提示
queued 等待压缩
preparing 读取视频信息中
loading 加载引擎或检测编码配置中
compressing 视频压缩中 35%
validating 校验压缩结果中
uploading 上传中 60%
processing 上传完成,服务端处理中

编码百分比不能代表整个流程百分比,更不能直接复用为上传百分比。第二次编码应标明“第 2 次压缩”,而不是不解释地把 100% 退回 0%。

还应说明当前引擎及软件回退原因。编码资源下载完成之后长时间没有网络请求,可能只是编码正在本地执行,并不一定是卡死。

10.3 用 AbortController 贯穿生命周期

const controller = new AbortController();

try {
  await compress(file, { signal: controller.signal });
} finally {
  releaseResources();
}

// 用户取消、页面卸载或任务超时
controller.abort(new DOMException('处理已取消', 'AbortError'));

仅拒绝外层 Promise 不等于底层运算已经停止。取消应实际传递给转换器;FFmpeg 路径需要终止 Worker,媒体输入需要释放,Object URL 要撤销,定时器和事件监听也要移除。

用户主动取消时,不应该又自动启动软件回退。

十一、诊断日志要能回答“到底哪里失败”

“浏览器不支持所需编解码配置”适合做简短提示,但不足以定位原因。

每个阶段至少记录:

console.warn('[视频压缩] 编码配置检测失败', {
  stage: 'H.264 编码能力检测',
  width,
  height,
  bitrate,
  hardwareAcceleration,
  attempt,
  error,
});

对于 AAC,需要记录采样率和声道数;对于轨道解码,需要记录编码与检测结果;对于输出超限,需要记录实际字节数和目标字节数。

日志应避免输出用户文件内容、鉴权信息或完整媒体缓冲区。生产环境可以通过日志级别控制诊断输出。

十二、部署与验证不能只看类型检查

这类功能存在三层完全不同的成功标准:

  1. 编译成功:类型、模块解析与依赖预构建通过。
  2. 资源加载成功:编码模块、Worker、WASM 可以下载并正确初始化。
  3. 业务成功:真实视频转码、音画同步、大小校验及上传均正常。

现代媒体依赖可能使用 BigInt 等语法,因此构建目标必须与依赖和实际浏览器能力一致。开发环境可用也不代表生产路径、资源 MIME、Worker URL 或安全上下文配置一定正确。

建议覆盖至少这些场景:

  • 无音轨、单音轨、多音轨素材。
  • 竖屏、横屏、带旋转信息的视频。
  • 原配置硬件优先成功、分辨率降级成功、默认编码成功、软件回退成功。
  • 编码后仍超限、轨道被丢弃、结果校验失败。
  • 排队中取消、编码中取消、组件卸载、超时。
  • 真实目标设备播放,而不只是浏览器预览。

结语

浏览器端素材压缩的核心,不是找到一个万能压缩参数,而是建立一个可解释、可降级、可取消、可校验的处理系统:

  • 图片通过 Canvas 实现等比缩放与质量压缩。
  • 视频根据时长计算码率预算,而不是凭文件大小猜参数。
  • WebCodecs 优先尝试硬件,再逐级降低分辨率和放宽编码偏好。
  • FFmpeg WASM 仅作为必要的软件兜底。
  • 最终上传的是通过真实内容校验的结果,而不是“编码器没有报错”的结果。
  • 状态和日志揭示当前阶段,让用户和开发者都能理解等待的原因。

硬件优先改善的是成功路径的性能,多层回退改善的是兼容性,而严格的输出校验决定了最终结果是否可靠。

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

图片

posted @ 2026-11-13 16:27  林恒  阅读(10)  评论(0)    收藏  举报