momo707577045

momo707577045 查看完整档案

广州编辑  |  填写毕业院校  |  填写所在公司/组织 blog.luckly-mjw.cn/tool-show/index.html 编辑
编辑

个人动态

momo707577045 发布了文章 · 2月1日

GitHub 「特定」文件夹或文件下载工具

工具在线地址,推荐使用 chrome 浏览器。

使用方式

  • 打开想要下载的 github 文件夹,如 facebook 的 react-dom 文件夹,则打开该页面。
  • 复制页面链接,粘贴到本工具输入框。
  • 点击下载。

背景

  • github 不支持部分文件下载,只能下载整个项目。
  • github 国内网速较慢,只能下载整个项目需要等待很长时间。譬如 facebook 的 react 项目,有时候我们只想下载 react-dom 来学习。但却要下载包含 react-js 等 35 各其他功能的整个项目。导致无谓的时间浪费。
  • 网络上有相关解决方案,譬如修改 svn,修改 git 路径等,但均有一定使用成本和软件依赖。本项目意在将使用成本降到最低。

特点

  • 无依赖,无需安装软件,不需要依赖 svn,甚至连 git 都不需要。有浏览器即可使用。
  • 操作简单,复制 github 页面链接,点击下载即可。
  • 支持特定分支和特定 tag 的文件下载,精确到单文件下载。

功能说明

  • 【解析下载】输入 github 链接,点击下载文件。
  • 【重新下载错误文件】当部分文件下载失败时,点击该按钮,重新下载错误文件。
  • 【强制下载现有文件】将已经下载好的文件强制整合下载。可以提前观看已经下载的文件。该操作不影响当前下载进程。
  • 【文件条】对应每一个文件的下载情况。「灰色」:待下载,「绿色」:下载成功,「红色」:下载失败。点击红色文件栏可重新下载对应错误文件。

特别说明

  • 项目使用到 github 提供的开放性 API,故仅支持 github 文件下载。不支持 gitlab,gitee 等平台。
  • 目标仓库需为公开仓库,否则无权限下载。

原理

  • 文件下载使用的是 github 开放 API,如「罗列所有分支」「罗列所有tag」「下载文件」等
  • 文件压缩,使用的是 jszip 库

项目源码

  • 项目只是简单的功能组合,都比较简单。
  • 代码没有做优化,理解思路,看看就好。
查看原文

赞 4 收藏 0 评论 0

momo707577045 赞了文章 · 1月20日

npm私服安装教程 - verdaccio 入门

0x00 前言

最近想搭一个 npm 私服,找了一下网上的资料,发现了两个项目:

sinopia 之前好像很火,但是作者弃坑了,从项目里的一个issue(见文末)发现,有人fork了一个项目,就是本文要使用的 verdaccio,目前该项目在积极维护中
cnpm 看起来虽然最近有commit,但是已经一年没有 release了,而且安装比较复杂

最后选择了verdaccio。

Verdaccio is a lightweight private npm proxy registry built in Node.js

0x01 安装

全局安装

$ sudo npm install -g verdaccio

笔者安装的是 verdaccio@3.2.0,需要 node > 6.12.0, npm >=3.x

我们使用 pm2 来管理进程,需要全局安装 pm2

如果 npm 安装失败可以使用 yarn

$ sudo npm install -g pm2

启动 verdaccio

$ sudo pm2 start verdaccio

然后可以查看 进程启动情况

$ pm2 ls

配置nginx反向代理

由于 verdaccio 默认是启动在 4873 端口,方便起见,配置 nginx 反向代理到该端口
如果需要查看 verdaccio 的端口号,可以用 pm2 ls 查看到 verdaccio 的 pid,然后使用:

$ netstat -nap | grep <pid>

查看占用的端口号

下面贴出 nginx 配置,centos 的nginx 配置文件在 /etc/nginx/conf.d/
新建一个配置文件,然后使用如下内容即可

server {
  listen 80;
  server_name registry.npm.your.server;
  location / {
    proxy_pass              http://127.0.0.1:4873/;
    proxy_set_header        Host $host;
  }
}

重启 nginx

$ sudo nginx -s reload

0x02 使用

项目中配置

访问:http://registry.npm.your.server 可以看到 verdaccio 已经安装完成。

然后就可以发布你的 npm 包了~

方便起见,可以在项目目录下新建 .npmrc 文件,写入如下内容:

registry=http://registry.npm.your.server

然后项目安装的包就会先从你自己的npm下载,如果找不到就会找官方npm源

修改上游npm源

由于 verdaccio 默认使用的是npm官方的源,你可以改成淘宝的源

他的配置文件在 /home/<username>/.config/verdaccio/config.yaml

其中有个 uplinks 字段

uplinks:
  npmjs:
  url: https://registry.npmjs.org/ 

将 url 改为 https://registry.npm.taobao.org/ 即可

参考文章

查看原文

赞 8 收藏 6 评论 2

momo707577045 赞了文章 · 2020-12-02

gitlab-ci配置详解(二)

jobs(任务)

.gitlab-ci.yml允许用户创建无数多个任务.但是每个任务必须有一个独一无二的名字,但不能是以下保留字.一个任务是由一列参数定义的,来决定任务的工作内容和行为.

job_name:
  # 要跑的脚本或命令列表
  script:
    - rake spec
    - coverage
  # pipelines阶段
  stage: test
  # 只针对哪个分支
  only:
    - master
  # 除了哪个分支以外
  except:
    - develop
  # 指定哪些runner适用该job
  tags:
    - ruby
    - postgres
  # 是否容错
  allow_failure: true
关键字是否必须描述
script必须定义Runner需要执行的脚本或命令
image非必须需要使用的docker镜像,请查阅该文档
services非必须定义了所需的docker服务,请查阅该文档
stage非必须定义了工作的场景阶段,默认是test
type非必须stage的别名,不赞成使用
variables非必须在job级别上定义的变量
only非必须定义哪些git引用(分支)适用该job
except非必须定义了哪些git引用(分支)不适用该job
tags非必须定义了哪些runner适用该job(runner在创建时会要求用户输入标签名来代表该runner)
allow_failure非必须允许任务失败,但是如果失败,将不会改变提交状态
when非必须定义job什么时候能被执行,可以是on_success,on_failure,always或者manual
dependencies非必须定义了该job依赖哪一个job,如果设置该项,你可以通过artifacts设置
artifacts非必须所谓工件。。就是在依赖项之间传递的东西,类似cache,但原理与cache不同
cache非必须定义需要被缓存的文件、文件夹列表
before_script非必须覆盖在根元素上定义的before_script
after_script非必须覆盖在根元素上定义的after_script
environment非必须定义让job完成部署的环境名称
retry非必须定义job失败后的自动重试次数

script

script是一段由Runner执行的shell脚本,例如:

job:
  script: "bundle exec rspec"

这个参数也可以使用数组包涵好几条命令:

job:
  script:
    - uname -a
    - bundle exec rspec

有些时候,script命令需要被单引号或者双引号所包裹。举个例子,命令中包涵冒号的时候,该命令需要被引号所包裹,这样YAML解析器才知道该命令语句不是“key: value”语法的一部分。当命令中包涵以下字符时需要注意打引号::{}[],&*#?|-<>=!%@`

stage

stage指定一组job在不同场景阶段执行。在相同stage下的job(任务)将会被并行的执行。关于stage更多用法的描述,请查看stages

only and except(简易说明)

onlyexcept两个参数说明了job什么时候将会被创建:

  1. only定义了job需要执行的所在分支或者标签
  2. except定义了job不会执行的所在分支或者标签

以下是这两个参数的几条用法规则:

  1. onlyexcept如果都存在在一个job声明中,则所需引用将会被onlyexcept所定义的分支过滤.
  2. onlyexcept允许使用正则
  3. onlyexcept允许使用指定仓库地址,但是不forks仓库

此外,onlyexcept允许使用以下一些特殊关键字:

描述
branches当一个分支被push上来
tags当一个打了tag的分支被push上来
api当一个pipline被piplines api所触发调起,详见piplines api
external当使用了GitLab以外的CI服务
pipelines针对多项目触发器而言,当使用CI_JOB_TOKEN并使用gitlab所提供的api创建多个pipelines的时候
pushes当pipeline被用户的git push操作所触发的时候
schedules针对预定好的pipline而言(每日构建一类~,具体请看链接
triggers用token创建piplines的时候
web在GitLab页面上Pipelines标签页下,你按了run pipline的时候

下面的例子,job将会只在issue-开头的refs下执行,反之则其他所有分支被跳过:

job:
  # use regexp
  only:
    - /^issue-.*$/
  # use special keyword
  except:
    - branches

在这个例子中,job只会在打了tag的分支,或者被api所触发,或者每日构建任务上运行,

job:
  # use special keywords
  only:
    - tags
    - triggers
    - schedules

如果指定了仓库路径,该任务将会在指定仓库路径被push或者其他操作的时候被运行,但是不会forks,(注,仓库路径只能是父仓库,也就是你指定了仓库里远程仓库的那个仓库地址~)

job:
  only:
    - branches@gitlab-org/gitlab-ce
  except:
    - master@gitlab-org/gitlab-ce

上面这个例子的job将会在父仓库gitlab-org/gitlab-ce的非master分支有提交时运行.有点拗口

only and except(复杂版本)

从GitLab10引入的
这是个测试特性,可能在没有通知的情况下更改该特性

自从GitLab10.0以后,我们可以配置更加复杂的job策略。

GitLab现在同时支持简单和复杂的job策略定义。现在我们甚至可以使用数组或者对象的配置方案来配置我们的job策略。

在复杂的定义下,现在有两个参数可用,refskubernetes.refs的策略等同于设置一般的only/except配置,但是kubernetes只有一个可选值,active.

请看下面的例子,该job只会在计划被触发时或者master分支被push时触发,并且先决条件是kubernetes服务是活跃的(你启用了kubernetes服务作为执行器,相关请看gitlab ci runner的文档,ce用户一般用求不到)。

job:
  only:
    refs:
      - master
      - schedules
    kubernetes: active

variables

在job级别允许用户定义变量,他的工作方式和全局级别的变量定义相同,不过该变量作用域仅限于当前job。

当您再job级别使用了variables定义变量,它将会覆盖YAML设置的全局变量和预定义的变量,如果你要在job级别屏蔽全局定义的变量,你可以用空对象覆盖它:

job_name:
    variables: {}

job变量的优先级在variables文档中有所阐述

tags

tags这个参数是用来选择允许哪些runners来执行该jub的。

当你初始化Runner并注册一个Runner的时候,你被要求为Runner指定一个或多个标签,例如我的一个Runner被注册为test1

job:
    tags:
        - test1
        - ruby

上面的声明将会保证该job将会被标签上有test1ruby的runner所执行。如果没有就不执行

allow_failure

allow_failure被用于当你想允许job失败而不阻塞剩下的CI套件执行的时候,失败的job不会影响到commit状态(pipelines执行完会在commit上标记失败或成功状态,但是不会阻止commit提交)

当allow_failure为true,并且job失败了,pipline将会置绿或者置成功显示,但是你会在commit页面或者job页面看到一条“CI build passed with warnings”的警告信息哈。这样用户就能注意到失败并采取其他措施。

在下面的例子中,job1和job2将会并行执行(事实告诉我们其实还是顺序执行),不过如果job1失败了,整个pipeline不会停止,并且会流转到下一个场景阶段继续执行:

job1:
  stage: test
  script:
  - execute_script_that_will_fail
  allow_failure: true

job2:
  stage: test
  script:
  - execute_script_that_will_succeed

job3:
  stage: deploy
  script:
  - deploy_to_staging

上面的例子实测,job1显示警告,job2通过,job3通过

when

when参数是确定该job在失败或者没失败的时候执行不执行的参数。

when支持以下几个值之一:

  1. on_success 只有在之前场景执行的所有作业成功的时候才执行当前job,这个就是默认值,我们用最小配置的时候他默认就是这个值,所以失败的时候pipeline会停止执行后续任务
  2. on_failure 只有在之前场景执行的任务中至少有一个失败的时候才执行
  3. always 不管之前场景阶段的状态,总是执行
  4. manual ~手动执行job的时候触发(webui上点的)。请阅读manual action

下面是例子:

stages:
- build
- cleanup_build
- test
- deploy
- cleanup

build_job:
  stage: build
  script:
  - make build

cleanup_build_job:
  stage: cleanup_build
  script:
  - cleanup build when failed
  when: on_failure

test_job:
  stage: test
  script:
  - make test

deploy_job:
  stage: deploy
  script:
  - make deploy
  when: manual

cleanup_job:
  stage: cleanup
  script:
  - cleanup after jobs
  when: always

上面的例子将会:

  1. 只有在build_job失败的时候执行cleanup_build_job
  2. 在pipeline最后一步,不管前面是失败或者成功,执行cleanup_job
  3. 允许你在GitLabUI上手动执行deploy_job

manual actions

自GitLab8.10引入,Blocking manual actions自GitLab9.0引入,Protected actions自GitLab9.2引入

手动操作是一类特殊的job类型,该类型不会自定执行;只有用户在gitlab ui上点击执行按钮的时候才会被触发。手动执行操作可以在pipeline页面,build场景,environment页面,和deployment页面上找到按钮按

其中一个场景就是,你在生产部署页面摁部署按钮。

阅读更多,请移步environments documentation.

手动操作可以是可选的或者是阻塞的。阻塞的手动操作将会阻塞定义了手动操作的场景步骤。这个时候你可以看到pipline页面上有个play按钮,如果你按play按钮,可以恢复pipeline的执行(一个类比,手动触发部署行为)。

当一个pipeline被阻塞了,那么即使pipeline commit状态为succeeds,你也不能执行merge操作(不懂先看下面一段话,当你设置不允许失败的时候,整个pipline被阻塞,在提交分支merge的时候,pipeline由于状态为manual,不能merge),被阻塞的pipelines也有一个特别的状态,叫做manual

手动操作默认是非阻塞的,如果你想让手动操作阻塞,你必须为job设置allow_failure:false(不设置默认为true,无法阻塞pipeline)

可选操作的状态是无法改变pipeline整体状态的,只有阻塞操作可以

手动操作被认为是白箱操作,所以当用户想要触发操作的时候,是有权限保护的。换句话说,用户如果想要触发手动操作,你必须有合并到当前分支的权限

environment

注意:

environment是用于定义一个job(作业)部署到某个具名的环境,如果environment被指定,但是没有叫该名的environment存在,一个新的environment将会被自动创建(实际上这个环境并不是指向真实环境,设置这条会将相应job显示在CI面板,environments视图上,然后可以反复操作相关job)

在下面这个最简单的表单里,environment关键字可以被设置为:

deploy_to_production:
  stage: deploy
  script: git push production HEAD:master
  environment:
    name: production

在上面这个例子中,deploy_to_production作业将会执行部署操作部署到production环境

environment:name

注意

  • 在GitLab8.11中引入
  • 8.11之前,你可以environment: environmentName这样设置环境名,但现在推荐你在name关键字下设置环境名
  • name参数可以使用任何已定义的CI变量,包括预定义变量,秘密变量和yaml文件定义的变量,但是你不能使用script脚本中定义的变量。

environmen名可以包括以下内容

  • letters字母
  • digits数字
  • spaces空格
  • -
  • _
  • /
  • $
  • {
  • }

通用的环境名是qa,staging和production,不过你也可以设置任何你喜欢的名字

除了直接在environment后面定义环境名,你也可以使用name关键字来定义环境名,将其当做一个分离的值来设置

deploy to production:
  stage: deploy
  script: git push production HEAD:master
  #environment: production
  environment:
    name: production

environment:url

注意:

  • 自GitLab8.11引入
  • 在8.11之前,这个url只能在GitLabUi上设置,现在推荐你在yaml文件中书写
  • url参数同样能使用任何CI的变量,除了script之中定义的以外

这是个可选选项,设置该选项,在gitLab ui上将会展示一个去往该url的链接按钮。

在下面的例子里,如果job做完了,gitlab将会在merge request页面或者environments或者deployments页面创建一个按钮,按钮指向https://prod.example.com

deploy to production:
  stage: deploy
  script: git push production HEAD:master
  environment:
    name: production
    url: https://prod.example.com

environment:on_stop

注意:

  • 在GitLab8.13中引入
  • 从8.14开始,当你在environment中设置了中断操作,gitlab将会在相关的分支被删除的时候自动触发中断对应行为

关闭environments可以通过在environment中定义关键字on_stop来实现。他指向了一个具名的job,该job的environment:action设置为stop

请参阅environment:action章节查看更多

environment:action

GitLab8.13引入

action关键字和on_stop关键字相关,定义在job的environment中,用于响应关闭环境的操作

下面是一个实例:

review_app:
  stage: deploy
  script: make deploy-app
  environment:
    name: review
    on_stop: stop_review_app

stop_review_app:
  stage: deploy
  script: make delete-app
  when: manual
  environment:
    name: review
    action: stop

在上面的例子中,我们建立了一个review_app并部署到review环境,并且我们在on_stop下同样定义了一个新的job名为stop_review_app。一旦review_app作业成功完成,ci将可以在手动操作的时候触发stop_review_app的任务,在这个例子中,我们使用when来达到手动触发停止review app的功能。

stop_review_app作业需要结合以下关键字去定义:

  1. when
  2. environment:name
  3. environment:action
  4. stage (必须和写on_stop那个job定义的相同)

dynamic environments

动态环境

注意:

  • 该特性自GitLab8.12和GITlAB Runner1.6被引入
  • $CI_ENVIRONMENT_SLUG变量自 GitLab8.15引入
  • nameurl参数可以是任何定义的CI变量,除了script里定义的以外
deploy as review app:
  stage: deploy
  script: make deploy
  environment:
    name: review/$CI_COMMIT_REF_NAME
    url: https://$CI_ENVIRONMENT_SLUG.example.com/

deploy as review app job会被标明为部署(deployment页面可见,10版里没有貌似)并且动态创建一个review/&dollar;CI_COMMIT_REF_NAME环境, &dollar;CI_COMMIT_REF_NAME是由Runner设置的环境变量(分支名). &dollar;CI_ENVIRONMENT_SLUG变量是基于enviroment:name的, 但是会做url安全处理. 在该例子中, 如果deploy as review app在分支pow下被执行, 凑出来的访问链接是这样的https://review-pow.example.com/.

你可以通过该访问链接来访问你的程序,这以为这个app的服务主机已经配置好了(???)

想了解更多可以查看review-apps-nginx该示例

artifacts

注意:

  • 自从gitlab Runner 0.7.0引入,并且windows平台不适用
  • windows平台的支持是从版本1.0.0开始的
  • 在Gitlab9.2之前,缓存将会在artifacts操作之后被重新储存
  • 在此之后,缓存将会在artifacts操作之前被重新储存
  • 目前来说,不是所有解析器(shell, docker什么的那些)都支持artifacts,所以乖乖用cache
  • 除非job作业成功完成,要不然artifacts默认不会被收集的

artifacts 被用于在job作业成功后将制定列表里的文件或文件夹附加到job上,传递给下一个job,如果要在两个job之间传递artifacts,你必须设置dependencies,下面有几个例子

传递所有binaries和.config:

artifacts:
  paths:
  - binaries/
  - .config

传递所有git没有追踪的文件

artifacts:
  untracked: true

传递binaries文件夹里所有内容和git没有追踪的文件

artifacts:
  untracked: true
  paths:
  - binaries/

禁止传递来的artifact:

job:
  stage: build
  script: make build
  dependencies: []

有时候用户可能只需要为打过标签的发行版创建artifacts去避免将临时构建的artifacts传递到生产服务器存储。

那么这时候我们可以只为打tags的行为创建artifacts:

default-job:
  script:
    - mvn test -U
  except:
    - tags

release-job:
  script:
    - mvn package -U
  artifacts:
    paths:
    - target/*.war
  only:
    - tags

最终artifacts将会在job执行完毕后送到GitLab ui前台来,你可以直接下载它(在tag页,在details页,在pipeline页的下载按钮上都会出现)。

artifacts:name

GitLab8.6 GitLab Runner1.1.0引入

name指令允许你对artifacts压缩包重命名,你可以为每个artifect压缩包都指定一个特别的名字,这样对你在gitlab上下载artifect的压缩包有用
.artifacts:name的值可以使用任何预定义的变量,它的默认值是artifacts,所以如果你不设置,在gitlab上就会看到artifacts.zip的下载名

示例

创建一个压缩包命名为当前job名

job:
  artifacts:
    name: "$CI_JOB_NAME"

创建一个压缩包,命名为分支或者标签名,并且只包含未追踪的文件

job:
   artifacts:
     name: "$CI_COMMIT_REF_NAME"
     untracked: true

创建一个压缩包,命名为“job名_分支名”

job:
  artifacts:
    name: "${CI_JOB_NAME}_${CI_COMMIT_REF_NAME}"
    untracked: true

创建一个压缩包,命名为“场景阶段名_分支名”

job:
  artifacts:
    name: "${CI_JOB_STAGE}_${CI_COMMIT_REF_NAME}"
    untracked: true

如果你用的是 Windows batch脚本,请用%替换$号

如果你用的是powershell跑脚本,你需要使用&dollar;env:替换$

artifacts:when

GitLab 8.9 and GitLab Runner v1.3.0引入

artifacts:when用于job失败或者未失败时使用

artifacts:when能设置以下值:

  1. on_success 这个值是默认的,当job成功时上传artifacts
  2. on_failure 当job执行失败时,上床artifacts
  3. always 不管失败与否,都上传

示例配置

当失败时上传artifacts

job:
  artifacts:
    when: on_failure

artifacts:expire_in

GitLab 8.9 and GitLab Runner v1.3.0中引入

artifacts:expire_in 用于设置artifacts上传包的失效时间. 如果不设置,artifacts的打包是永远存在于gitlab上的. expire_in 允许你指定artifacts过期时间, 在该期间内,artifacts包将储存在gitLab上.

如果设置了过期时间,你可以在job页面找到一个keep按钮,按了以后可以覆盖过期时间,让artifacts永远存在.

过期之后,用户将无法访问到artifacts包,artifacts将会在每小时执行的定时任务里被清除。

expire_in 的值要表示经过的时间. 下面是一些例子:

  • '3 mins 4 sec'
  • '2 hrs 20 min'
  • '2h20min'
  • '6 mos 1 day'
  • '47 yrs 6 mos and 4d'
  • '3 weeks and 2 days'

示例配置

设置artifacts一星期过期

job:
  artifacts:
    expire_in: 1 week

dependencies

GitLab 8.6 and GitLab Runner v1.1.1中引入

该特性需要和artifacts何用,是用于将artifacts在两个jobs之间(主要是两个不同stage的job之间)传递的(下面几段翻译的巨烂,因为自己没有使用过,不知道到底是啥意思)

注意所有之前的场景状态都是默认传递artifacts的

为了使用该特性,你需要在job上下文中定义dependencies并且列出所有运行本作业之前的作业(包涵artifacts下载设置的 )。你只能在需要传递的job的前一个job(上一个stage状态)里定义。如果你在定义了artifacts的job里或者该job后面的job里定义依赖,runner会扔出一个错误。如果你想阻止下载artifacts,你需要设置一个空数组来跳过下载,当使用dependencies的时候,前一个job不会因为job执行失败或者手动操作的阻塞而报错

在下面的例子里,我们定义了两个job有artifacts,其中一个是build:osx另一个是build:linux,当test:osx的作业被执行的时候,从build:osx来的artifacts会被下载并解压缩出来,同样的事情发生在test:linux和build:linux的artifacts上

deploy job会下载所有的artifacts从所有之前的jobs下,这是由于他所处的stage优先级

build:osx:
  stage: build
  script: make build:osx
  artifacts:
    paths:
    - binaries/

build:linux:
  stage: build
  script: make build:linux
  artifacts:
    paths:
    - binaries/

test:osx:
  stage: test
  script: make test:osx
  dependencies:
  - build:osx

test:linux:
  stage: test
  script: make test:linux
  dependencies:
  - build:linux

deploy:
  stage: deploy
  script: make deploy

在上面一个例子中由于没有使用stages设置pipline场景顺序,所以执行顺序是build - test - deploy按照你的书写顺序来,artifacts被上传到gitlab服务器,在下一个dependencies

before_script and after_script

会复写全局设置

before_script:
- global before script

job:
  # 会执行该句而不执行全局设置
  before_script:
  - execute this instead of global before script
  script:
  - my command
  after_script:
  - execute this after my script

coverage

GitLab 8.17引入

coverage 允许你设置代码覆盖率输出,其值从job的输出获取

其值只能设置正则,所以必须用//包裹来表示正则语句,你必须转移特殊字符.

A simple example:

job1:
  script: rspec
  coverage: '/Code coverage: \d+\.\d+/'

retry

GitLab 9.5引入

retry允许用户设置重试次数。

当job失败,并且配置了,retry,则会在重试次数内进行重试

test:
  script: rspec
  retry: 2

Git Strategy(git策略)

GitLab 8.9 作为实验特性引入,任何时候都可能完全移除该特性,慎用。
GIT_STRATEGY=none需要GitLab Runner 1.7以上版本支持

你可以通过在全局变量设置位置或者job局部变量设置位置来设置GIT_STRATEGY用以获取应用最近更新的代码。如果没有指定,默认的项目设置将会被使用。

该选项有三个可能的值:clone,fetch和none

clone是最慢的选项,如果设置该值,每个job将会都克隆一遍仓库,确保项目工作空间总是原始的正确的。

variables:
  GIT_STRATEGY: clone

fetch是更快的操作选项,因为他重用了项目的工作空间(如果没有的话,会去clone), git clean用于撤销上一个job的任何操作,git fetch是用来重新获取上一个job运行到当前job产生的commit

variables:
  GIT_STRATEGY: fetch

none也同样重用了项目空间(但是他会跳过所有git操作,包括如果存在的gitlab runner的预克隆脚本)。其主要用于只是为了操作artifacts的job上(例如depoly部署行为)。此时Git仓库的数据可能是存在的,但它一定不是最新的。所以在设置了none的job里你应该依赖从cache或者artifacts来的数据,而不是仓库数据。

variables:
  GIT_STRATEGY: none

Git Checkout

GitLab Runner 9.3引入

GIT_CHECKOUT变量是和GIT_STRATEGY:clone, GIT_STRATEGY:fetch合用的,用来指定git checkout命令是否需要被执行,如果没有设置GIT_CHECKOUT,那么runner任务将会使用默认值true,也就是每次checkout相应分支。GIT_CHECKOUT可以设置在全局variables上,也可以设置在job的variables上

但是如果这个值被设置为false,runner将会有以下行为:

  • 当做fetch操作的时候,更新仓库并保留当前版本的工作副本
  • 当做clone操作的时候,克隆仓库并保留默认分支的工作副本

如果设置这个值为true,这意味着,runner的clone和fetch策略都会让项目工作区的工作副本更新到最新版本

variables:
  GIT_STRATEGY: clone
  GIT_CHECKOUT: false
script:
  - git checkout master
  - git merge $CI_BUILD_REF_NAME

Git Submodule Strategy

Requires GitLab Runner v1.10+

GIT_SUBMODULE_STRATEGY用于在构建之前控制git子模块用,像GIT_STRATEGY一样,他可以在全局variables里设置,也能在jobs下的variables设置

有三个可选值 none,normal,recursive

  • none默认不引入子模块,和,Runner1.10以前的默认行为一样,也是默认值
  • normal默认只引入第一级子模块,跟下面相等

    git submodule sync
    git submodule update --init
  • recursive意味着递归下载所有子模块,和下面的操作相等

    git submodule sync --recursive
    git submodule update --init --recursive

git策略和git子模块策略只是一种配置糖,你完全可以执行自己的脚本完成相同的操作(git策略的其他选项可以加快作业执行速度什么的,那要看个人需求了。)所以如果你要配置子模块策略,你要保证你项目底下有.gitmodules文件,并配置了以下内容:

  • 公开的仓库http(s) url地址或者
  • 在同一个GitLab服务器上的相对仓库地址,查看git submodules文档

Shallow cloning (浅克隆)

该特性是gitLab8.9引入的实验特性,在未来任何时候都有可能被移除

你可以通过GIT_DEPTH来设置抓取或者克隆深度。这将使得仓库进行浅克隆, 如果你的仓库有特别大量的commits或者仓库好久没更新了,该设置将显著的提高克隆速度。该参数会发送给git fetch和git clone操作(其实就相当于git fetch --depth=xxx, git clone --depth=xxx。但是由于git fetch和git clone是runner在执行job时帮你做的,所以需要此配置。)

注意,如果你的克隆深度设置为1,并且此时你在执行一个队列的job或者重试一个job,你的job作业可能会失败的

由于git抓取和克隆操作是基于一个ref的(例如ref为一个分支名),所以Runners不能直接去克隆一个具体的commit(用sha哈希索引的)。如果在执行队列里有多个job,或者你正在重试执行某个job,那么此时要求你需要job测试的commit必须在克隆的仓库的git历史里可查,要不然会报错的。如果设置的GIT_DEPTH值太小,你可能克隆不到更早一些的commits。此时,你在job日志里会看到一条unresolved reference的日志。这个时候你可能考虑一下,把GIT_DEPTH值设置高一些.

当设置了GIT_DEPTH的时候,由于仓库只呈现一部分git历史,所以一些依赖于git describe的job(那些only: tags的那种)可能无法正常工作

抓取或者克隆最新三条commits:

variables:
  GIT_DEPTH: "3"

Hidden keys(jobs)(job隐藏键名)

自 GitLab 8.6 and GitLab Runner v1.1.1引入

如果你想暂时屏蔽某job作业,而不是直接注释该job定义:

#hidden_job:
#  script:
#    - run test

那么你大可不必全部用#注释掉,你可以在job key名钱加一个点,此时gitlab ci执行到这就不会执行该job了:

.hidden_job:
  script:
    - run test

你可以使用该特性来忽略job,也可以使用YAML的专有特性(语法)来替换隐藏件

Special YAML features (YAML专有特性)

你可以使用YAML的专有语法和特性来定义你的.gitlab-ci.yml,例如(锚点&,别名*,合并数据<<)。通过YAML语法特性可以减轻你的配置的复杂性

阅读更多

Anchors(锚点)

GitLab 8.6 and GitLab Runner v1.1.1引入

YAML有一个名叫‘锚点’的遍历特性,该特性可以让你在文档中方便的复制内容,锚点可以用来复制属性或者继承属性,这里有一个很好的例子,利用锚点和隐藏键来为你的job制作job模板

下面的例子使用了锚点的map数据合并,该例子将创建两个job,test1和test2,他们都会继承.job_template的参数,同时他们拥有自己的script定义:

.job_template: &job_definition  # Hidden key that defines an anchor named 'job_definition'
  image: ruby:2.1
  services:
    - postgres
    - redis

test1:
  <<: *job_definition           # Merge the contents of the 'job_definition' alias
  script:
    - test1 project

test2:
  <<: *job_definition           # Merge the contents of the 'job_definition' alias
  script:
    - test2 project

&锚点符号后跟的是设置的锚点名(job_definition),<<符号意思是“合并给与的hash map到当前map里来”,*表示索引被命名的锚点(job_definition),经过解析后,上面的例子将是这样的:

.job_template:
  image: ruby:2.1
  services:
    - postgres
    - redis

test1:
  image: ruby:2.1
  services:
    - postgres
    - redis
  script:
    - test1 project

test2:
  image: ruby:2.1
  services:
    - postgres
    - redis
  script:
    - test2 project

让我们来看看其他例子,这次我们使用锚点来定义两个服务设置的模板,该例子将创建两个job,test:postgres和test:mysql,他们将共享由.job_template定义的script指令,并分别适配由.postgre_services和.mysql_services磨边定义的services指令。

.job_template: &job_definition
  script:
    - test project

.postgres_services:
  services: &postgres_definition
    - postgres
    - ruby

.mysql_services:
  services: &mysql_definition
    - mysql
    - ruby

test:postgres:
  <<: *job_definition
  services: *postgres_definition

test:mysql:
  <<: *job_definition
  services: *mysql_definition

上面的例子展开等同于:

.job_template:
  script:
    - test project

.postgres_services:
  services:
    - postgres
    - ruby

.mysql_services:
  services:
    - mysql
    - ruby

test:postgres:
  script:
    - test project
  services:
    - postgres
    - ruby

test:mysql:
  script:
    - test project
  services:
    - mysql
    - ruby

Triggers(触发器)

Triggers被用于重建特定分支,tag或者commit,他是api触发的。

阅读更多关于Triggers的内容

pages

pages是一类特殊的job,他是设计被用来将你的静态内容(你的web服务需要用到的)上传到GitLab上(类似对应pages分支展示静态页面对吧)。它有指定的特殊语法,下面连个设置是必须的

  1. 任何静态内容都必须放在一个叫public/的文件夹下
  2. 你必须定义artifacts的上传路径为public/

下面的例子简单的将静态资源移动到public文件夹下,为了防止无限执行cp,我们创建一个文件夹为.public,最后将.public改名为public

pages:
  stage: deploy
  script:
  - mkdir .public
  - cp -r * .public
  - mv .public public
  artifacts:
    paths:
    - public
  only:
  - master

阅读更多GitLab Pages user documentation

验证.gitlab-ci.yml的合法性

gitlab都有一个Lint工具,你可以在gitlab实例的/ci/lint下找到链接(ci页面就有)

使用保留关键字

如果你发现你使用某些特定值(例如true或者false)但是发现验证合法性不通过,请尝试使用引号包住他们,或者在你runner下把他们移动到其他地方(例如/bin/true)

跳过job

如果你的commit信息包涵[ci skip]或者[skip ci],不论大消息,这个commit将会被创建,但是job会被跳过

官方例子

Visit the examples README to see a list of examples using GitLab CI with various languages.

gitlab-ci配置详解(一)
gitlab-ci配置详解(二)

资料

centos7简单安装gitlab-ce/ee(官网quick start)
GitLab简明安装指南
GitLab设置stmp发件
postfix mail command not find
gitLab修改默认端口
GitLab使用已有的nginx服务
GitLab-CI与GitLab-Runner
GitLab-Runner官方文档
基于Gitlab CI搭建持续集成环境
如何汉化GitLab
非GitLab集成包手装GitLab

查看原文

赞 43 收藏 34 评论 8

momo707577045 发布了文章 · 2020-10-28

百度统计 API 接入的坑及 access_token 生成工具

两种账号模式

  • 「百度商业账号」:面向百度推广、百度网盟、百度联盟、百度统计、百度司南等账号,可以理解为面向企业用户。
  • 「百度账户」:面向百度搜索、百度贴吧、百度云盘、百度知道、百度文库等产品,面向一般个体用户。
  • 两种账户系统不一样,不互通。网上教程与类库,针对的是「百度商业账号」,针对「百度账户」的教程几乎没有。
  • 本文介绍的是「百度账户」的接入方式。

两种账户系统使用的调用凭证不同

  • 「百度商业账号」

    • 调用接口使用的是「token」
    • 「token」在「百度统计-数据导出服务」页面,点击「立即开通」,由系统自动生成。
  • 「百度账户」

    • 在「百度统计-数据导出服务」页没有「立即开通」的按钮。
    • 调用接口使用的是「access_token」,而不是「token」
    • 「access_token」需要经过 oauth/2.0 协议生成。

access_token 生成过程

首次换取 access_token 的过程,需要在浏览器端完成,无法纯服务器端完成

  • 【第零步】

  • 【第一步】

    • 在浏览器中,访问这个链接 http://openapi.baidu.com/oauth/2.0/authorize?response_type=code&client_id=${apiKey}&redirect_uri=${redirect_uri}&scope=basic&display=popup
    • 浏览器将重定向到百度授权的页面,用户进行登录授权
  • 【第二步】

    • 用户登录授权,输入账号密码授权
    • 授权成功后,将重定向到 redirect_uri 中,并在 URL 中携带 code 参数。
    • redirect_uri 地址,需要在「安全设置」中配置
    • 这一步需要在浏览器中完成,无法脱离浏览器,靠纯服务器完成。
  • 【第三步】

    • 通过 code 换取 access_token
    • 访问这个链接http://openapi.baidu.com/oauth/2.0/token?grant_type=authorization_code&code=${code}&client_id=${apiKey}&client_secret=${secretKey}&redirect_uri=${redirect_uri}
    • 返回只是一个包含「access_token」与「refresh_token」的 json
  • 【刷新 access_token 】

    • access_token 的有效期是一个月,refresh_token 是十年。
    • 通过「apiKey」「secretKey」与「refresh_token」可调用接口刷新「access_token」与「refresh_token」。
    • 无需再经由服务器。
  • 【总结】

    • 除去首次获取「access_token」外,后续接口调用,以及「access_token」的刷新,均可在服务器端完成。需要再经由浏览器。
  • 【注意】

    • redirect_uri 与百度统计的网站域名没有关系。只校验是否在「安全设置」中配置过。除此以外,没有限制(内网地址都行)。
    • redirect_uri 只在「第一步」「第三步」中使用,只用于首次换取 access_token,只使用一次。刷新 access_token 不需要用到。

redirect_uri 保存不生效的坑

  • 笔者偶现 redirect_uri 保存不生效,实际生效的仍是之前填的回调地址。
  • 可新开项目即可解决该问题。

access_token 生成器

鉴于只有初次创建 access_token 的过程才会到浏览器,且后续情况 redirect_uri 都是无意义的。故首次获取 access_token 的过程是不可复用,只需一次的。笔者将该步骤封装成工具,供获取 access_token 使用。

使用方式

  • 将工具链接http://blog.luckly-mjw.cn/tool-show/baidu-statistics/index.html填入「安全设置」中。redirect_uri 只用于回调,无域名限制,无安全风险。
  • 填入「API Key」「Secret Key」,仅用于后续将 code 换取为 access_token。只保存在 localStorage,获取成功后清除。不经过接口,不会上传到云端。可查阅源码验证,无安全风险。
  • 点击按钮,即可完成 access_token 获取,将获取后的 「access_token」 与 「refresh_token」保存至服务器。即可永久刷新,正常调用接口。

项目地址

本地部署生成器

  • 若不放心工具的使用,可下载 node 后端脚本,启动服务器完成该过程
  • 【第零步】下载脚本,并填入「API Key」「Secret Key」
  • 【第一步】执行node index.js,启动脚本
  • 【第二步】将本地 URL http://127.0.0.1:10005/填入「安全设置」中
  • 【第三步】在浏览器中方式http://127.0.0.1:10005/getCode,即可获取 access_token

接口调试官方地址

  • access_token 获取成功后,可通过该工具,测试 access_token 的有效性,并验证接口调用结果。

完结撒花,感谢阅读。

查看原文

赞 0 收藏 0 评论 0

momo707577045 发布了文章 · 2020-09-30

无差别视频提取工具

背景

  • 之前笔者实现了m3u8 视频在线提取工具,可对 m3u8 视频进行提取,合并,下载。实现整个视频下载流程。
  • 后续还实现了非定制性的 ASE 解密功能(不提供定制性服务,定制性解密,属于破解,侵权行为,需尊重知识产权)
  • 但上述工具仍存在一定的通用性问题。为彻底解决通用性,实现无差别视频提取,开发了这个工具。

特点

  • 优点,通用性强,无差别提取,只要使用到 MES 主流媒体播放技术的视频,均可捕获。
  • 优点,足够简单,在视频播放的最后一个步骤进行拦截,规避视频加载,加密,解密等复杂过程。
  • 缺点,被动,无法主动干预视频加载,只可被动捕获视频资源。
  • 缺点,有一定门槛,依赖 chrome 浏览器开发者模式,无法实现全自动化,有一定使用门槛。

使用示例链接

功能说明

  • 【已捕获 0 个片段】

    • 显示程序已捕获的视频片段数。
  • 【下载已捕获片段】

    • 可以强制下载已经捕获的片段,无需等待整个视频全部捕获完成。
  • 【十倍速捕获】

    • 由于视频捕获是依赖视频加载进度的。
    • 点击该按钮,可以十倍速播放,加速视频加载,加速视频捕获。
  • 当视频全部加载完成,将触发自动下载。

    • 若无触发,可手动点击「下载已捕获片段」按钮,对捕获到的视频进行下载。

使用方式

示例实验链接

  • 复制工具代码

    • 可以直接复制本文中的核心源码
    • 也可以点开示例实验链接,点击按钮,快速复制工具代码。
  • 打开目前页面的控制台
  • ctrl + f ,输入 <iframe,判断是否存在 iframe 内嵌页面。若存在 iframe,请看完本说明,再继续查看下一节「iframe 解决方案」。若无,则下一步
  • 打开代码调试面板
  • 在调试面板中,找到当前页面的代码

    • 注意文件的寻找方法,需根据 URL 中的路径层级寻找。
    • 点击下方按钮,对代码进行排版。

  • 搜索,找到第一个 <script 标签,并设置多个断点

    • 搜索,如果第一个 <script 标签是一个链接。
    • 则找到对应文件,设置断点。
  • 刷新页面,出现如下状态,则证明断点设置成功

    • 若页面白屏,为正常现象,按照步骤继续执行即可。

  • 在 console 栏,粘贴工具代码,回车
  • 回到 source 栏,点击按钮,恢复运行
  • 若页面出现这几个按钮,则证明注入成功,工具运行成功
  • 正常观看视频,等待视频捕获

    • 可点击「十倍速捕获」,接口视频播放速度,加快视频捕获速度。
  • 若页面出现如下弹窗,即捕获完成,视频自动下载(也可以点击「下载已捕获片段」,手动下载)
  • 视频下载完成,得到「音频」文件,「视频」文件

    • 可使用专属播放器,进行播放。
    • 也可以使用其他工具,进行合并。

iframe 解决方案

示例实验链接

  • 找到 iframe 标签,复制 src 中的 url,新建页面打开该 url。

    • 如果该新建页面能正常播放视频,则在该新建页面,使用上述「使用说明」即可。
  • 如果新建页面没有正常播放页面,则回到原页面,换一种方式实现。
  • 回到原页面,找到 iframe 内嵌页面的源码。

    • 同样搜索 <script,但这一次,要找带 src 的 script 标签
  • 找到该 src 对应的文件,并打断点
  • 刷新页面,并在源文件中,插入代码

    • 注意,打断点和插入代码是在不同的栏,打断点的栏中,有「:format」标识。插入代码的栏,没有该标识。

  • 粘贴代码,ctrl + s 进行保存
  • 恢复执行(操作方式,查看上一节「使用说明」)
  • 完成代码插入,捕获视频

特别说明

  • 在代码操作过程中,页面白屏是正常的,按照步骤继续执行即可。
  • 如果不行,安装使用说明,多试几遍就可以了。可能是视频广告导致。
  • 注意 Chrome 的多文件下载询问,如果拒绝过,需要重新打开。
  • 视频捕获,分为「视频」文件与「音频」文件,「视频」文件是纯视频,没声音的。需要搭配「音频」文件播放。点击这里,使用专属播放器。

专属播放器

  • 由于采集工具是单独对「视频」和「音频」分开采集的。
  • 使用普通播放器可能无法正常播放。
  • 可利用本工具同时加载「视频」和「音频」同步播放。
  • 本工具还附有倍速播放功能。

原理

  • 主流视频媒体播放技术,均使用到 MES 技术
  • MES 技术播放流程一般如下:

    • 创建 video 播放器标签。
    • 拉取视频片段。
    • 解密视频片段(如果对视频进行了加密操作)
    • 解析视频片段,分为「视频轨」「音频轨」。
    • 将每个片段的「视频轨」「音频轨」,"喂给" video 标签进行播放。
    • 当已加载的视频片段快要播完时,重复第二个步骤,拉取新的视频片段,进行投喂。
  • 本工具的核心逻辑

    • 覆写视频片段的"投喂"操作。
    • 插入自定义代码,收集"投喂"的「视频」「音频」资源,进行下载。

核心源码(共 91 行)

(function () {

  let _sourceBufferList = []
  let $btnDownload = document.createElement('div')
  let $downloadNum = document.createElement('div')
  let $tenRate = document.createElement('div') // 十倍速播放

  // 十倍速播放
  function _tenRatePlay () {
    let $domList = document.getElementsByTagName('video')
    for (let i = 0, length = $domList.length; i < length; i++) {
      const $dom = $domList[i]
      $dom.playbackRate = 10
    }
  }

  // 下载捕获到的资源
  function _download () {
    _sourceBufferList.forEach((target) => {
      const mime = target.mime.split(';')[0]
      const type = mime.split('/')[1]
      const fileBlob = new Blob(target.bufferList, { type: mime }) // 创建一个Blob对象,并设置文件的 MIME 类型
      const a = document.createElement('a')
      a.download = `${document.title}.${type}`
      a.href = URL.createObjectURL(fileBlob)
      a.style.display = 'none'
      document.body.appendChild(a)
      a.click()
      a.remove()
    })
  }

  // 监听资源全部录取成功
  let _endOfStream = window.MediaSource.prototype.endOfStream
  window.MediaSource.prototype.endOfStream = function () {
    alert('资源全部捕获成功,即将下载!')
    _download()
    _endOfStream.call(this)
  }

  // 捕获资源
  let _addSourceBuffer = window.MediaSource.prototype.addSourceBuffer
  window.MediaSource.prototype.addSourceBuffer = function (mime) {
    console.log(mime)
    let sourceBuffer = _addSourceBuffer.call(this, mime)
    let _append = sourceBuffer.appendBuffer
    let bufferList = []
    _sourceBufferList.push({
      mime,
      bufferList,
    })
    sourceBuffer.appendBuffer = function (buffer) {
      $downloadNum.innerHTML = `已捕获 ${_sourceBufferList[0].bufferList.length} 个片段`
      bufferList.push(buffer)
      _append.call(this, buffer)
    }
    return sourceBuffer
  }

  // 添加操作的 dom
  function _appendDom () {
    const baseStyle = `
      position: fixed;
      top: 50px;
      right: 50px;
      height: 40px;
      padding: 0 20px;
      z-index: 9999;
      color: white;
      cursor: pointer;
      font-size: 16px;
      font-weight: bold;
      line-height: 40px;
      text-align: center;
      border-radius: 4px;
      background-color: #3498db;
      box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.3);
    `
    $tenRate.innerHTML = '十倍速捕获'
    $downloadNum.innerHTML = '已捕获 0 个片段'
    $btnDownload.innerHTML = '下载已捕获片段'
    $tenRate.style = baseStyle + `top: 150px;`
    $btnDownload.style = baseStyle + `top: 100px;`
    $downloadNum.style = baseStyle
    $btnDownload.addEventListener('click', _download)
    $tenRate.addEventListener('click', _tenRatePlay)
    document.getElementsByTagName('html')[0].insertBefore($tenRate, document.getElementsByTagName('head')[0]);
    document.getElementsByTagName('html')[0].insertBefore($downloadNum, document.getElementsByTagName('head')[0]);
    document.getElementsByTagName('html')[0].insertBefore($btnDownload, document.getElementsByTagName('head')[0]);
  }

  _appendDom()
})() 

项目源码

声明

本项目仅用于学习,交流,切勿用于侵权行为。

查看原文

赞 10 收藏 5 评论 22

momo707577045 赞了文章 · 2020-09-25

Blob协议 - 浏览器的数据包装

我们在使用网页展示数据的时候,我们会使用 data: 协议来显示数据,比较常见的场景如上传图片显示,一般来说都是通过从文件中读取之后,转换成base64代码,然后使用 data: 协议来显示图片,具体操作很多,如 FileReader 从文件中读取二进制, 使用Canvas绘制之后转换成二进制等,我提出来的主要原因是用于网页视频下载

现代HTML5视频播放的常见操作

相较而言,对于视频的提取,我们通常提取的是视频的原始地址,比如说 https://hostname/path/to/video.mp4?token=token_value?t=some_time,版权方或者视频所有者会将视频通过各种验证(包括不限于时间,机器,IP,登录会话)等方式来保证视频的不被下载,在早些时候,大部分的视频在HTML5时代,都是原始的视频链接,加上一堆的验证TOKEN来保证视频被下载的难度,经过一段时间的发展,大家好像发现了一个通用解决方案,也就是 blob: 协议,通过JS从服务器处理视频分片,然后在浏览器本地通过 URL.createObjectURL 创建一个 URI 来提供视频下载,代码如下:

var data = URL.createObjectURL(new Blob(["hello,world"], {type: "text/plain"}))

得到的URI如下:blob:https://dxkite.cn/1fbd093a-d40f-4798-9114-85992f0929fb,在浏览器新标签中打开将会是 hello,world 的一个纯文本响应,有效期为页面的存活期或者手动调用 URL.revokeObjectURL 回收数据,当数据回收之后,文件就会被删除,在视频播放器的操作中,通常就是这样一份代码,从后端获取数据之后处理成 blob: 协议的数据,播放的时候就很难通过视频链接(这里是 blob 协议的地址)来获取视频,因为创建之后就删除了

const video = document.getElementById('video');
const obj_url = window.URL.createObjectURL(blob);
video.src = obj_url;
video.play()
window.URL.revokeObjectURL(obj_url);

获取blob流的可行方案

一般来说,视频播放的时候,视频创建的 blob 流就回收了,但是,众所周知嘛,浏览器是咱自己的东西,想要他变成自己的形状不是很容易吗?JS这东西,什么都可以改,比如:

(function() {
    'use strict';
    console.log('hook running');
    var _cou = window.URL.createObjectURL;
    window.URL.createObjectURL = function (obj) {
        var url = _cou(obj)
        console.log("createObjectURL obj:", obj)
        console.log("createObjectURL url:", url)
        return url
    }

    window.URL.revokeObjectURL = function (url) {
        console.log("revokeObjectURL:", url)
    }
})();

在所有脚本执行之前对其进行HOOK即可,如在B站Hook,可以看到Hook的地址和播放器的地址:

1.png
2.png

PS: 屑,blob没有释放他也失效了,我可能需要一个比较另类的方式继续下去

视频播放的获取

我都已经侵入了你的网页,你怎么显示还不是归我管?继续修改JS,添加一层MediaSource的方法Hook:

    // invoke SourceBuffer
    var _addSourceBuffer = window.MediaSource.prototype.addSourceBuffer
    window.MediaSource.prototype.addSourceBuffer = function(mime) {
        console.log("MediaSource.addSourceBuffer ",mime)
        var sourceBuffer = _addSourceBuffer.call(this, mime)
        var _append = sourceBuffer.appendBuffer
        sourceBuffer.appendBuffer= function(buffer) {
            console.log(mime, buffer)
            _append.call(this, buffer)
        }
        return sourceBuffer
    }

以上代码实现了HOOK网页中的媒体资源的控制,对,获取到了视频的原始二进制数据,屑,这次文档到这里结束了,再搞下去网站都要没了。

依旧是拜年祭,ArrayBuffer为视频二进制数据,接下来只要导出即可

3.png


参考文献


re: https://dxkite.cn/index.php/a...

查看原文

赞 3 收藏 0 评论 5

momo707577045 发布了文章 · 2020-09-10

无依赖的 tinypng node 脚本

无依赖的 tinypng node 脚本

特点

  • 【无依赖,纯脚本】

    • 下载脚本代码,直接使用 node 命令即可运行。
    • 将使用门槛降到最低。
  • 【过滤重复压缩】

    • 自动记录已被压缩过的图片,跳过压缩,加快进度。
    • 记录图片压缩后的 md5 值,再次运行压缩脚本时,跳过压缩。
    • 通过 md5 值比较文件变更,即使「文件迁移」也能自动过滤。
    • 通过 md5 值比较文件变更,即使「使用同名文件替换」也能自动识别,并压缩,没有漏网之鱼。
  • 【替换源文件】

    • 压缩成功,直接替换源文件,不生成冗余文件,不需要复制粘贴,移动图片。
    • 静默压缩,对项目无感知,无任何影响。
  • 【自动切换 api key】

    • tinypng 申请的 api key 每月只有 500 次免费压缩额度。
    • 可设置多个 api key,当某 key 超过使用次数时,自动切换下一个 key 进行压缩。
  • 【压缩报告】

    • 记录每个图片的压缩数据,并生成汇总信息。
  • 【压缩安全边界】

    • 压缩安全线,当压缩比例低于该百分比值时,保持源文件,避免过分压缩,损伤图片质量。
  • 【源码携带详细备注,自带测试图片】

    • 降低源码阅读门槛,降低测试门槛,减低使用门槛。
    • 推荐阅读源码,打破恐惧,便于定制个性化需求。

专为小型项目定制

  • 纯脚本,不依赖 gulp,不依赖 webpack,无需搭建脚手架环境
  • 小型项目,或者只有几个静态页面,搭建脚手架的成本过高。本脚解决的即是脚手架依赖的问题。
  • 当然,中大型项目也可以用,只是其「无依赖」的特点在里面没那么突出。中大型项目推荐使用其 gulp 版本,实现更灵活的配置。

单文件使用方式

  • 第一步,点击下载源码
  • 第二步,在脚本文件头部添加 tinypng 的 api key

    global.tinypngConf = { 
       apiKeyList: [ 
           // 'XgNgkoyWbdIZd8OizINMjX2TpxAd_Gp3', // 无效 key 
           // 'IAl6s3ekmONUVMEqWZdIp1nV2ItJL1PC', // 无效 key
           'IAl6s3ekmONUVMEqWZdIp1nV2ItJLyPC', // 有效 key 
       ] 
    } 

    配置图

  • 第三步,赋予脚本文件「可执行」权限,chmod +x ./mtp.js
  • 第四步,将脚本文件放置到项目所在目录
    运行效果
  • 第五步,在项目所在目录运行脚本node ./mtp.js
    运行效果
  • 后续使用,仅需最后两步「第四步」「第五步」

全局配置使用方式

  • 第一步,全局安装npm install -g tinypng-script-with-cache
  • 第二步,全局配置 api key
    mtp setKey XgNgkoyWbdIZd8OizINMjX2TpxAd_Gp3,IAl6s3ekmONUVMEqWZdIp1nV2ItJLyPC
  • 第三步,在项目所在目录运行脚本 mtp
  • 后续使用,无需配置,直接在目标目录运行 mtp
    运行效果

参数传递方式

默认配置

  • 默认压缩「运行命令所在文件夹」下的图片
  • 「命令传参」优先级高于「修改源文件设置」

修改源文件设置

  • 在源文件头部,写入全局参数,程序运行时自动获取
  • 全部参考配置如下

    global.tinypngConf = { 
       basePath: '/Users/mjw/Desktop/git/tinypng-script-with-cache/test-img', // 压缩路径 
       createMd5FormOrigin: false, // 不进行压缩操作,只生成现有图片的 md5 信息,并作为缓存。用于「初次项目接入」及手动清理冗余的「图片md5信息」 
       apiKeyList: [ // tiny png 的 api key 数组,当其中一个不可用或超过使用次数时,自动切换下一个 key 调用 
         'IAl6s3ekmONUVMEqWZdIp1nV2ItJLyPC', // 有效 key 
       ]
    } 

    配置图

命令传参

  • 参数通过空格区分
  • 参数一:压缩路径
  • 参数二:是否不进行压缩操作,只生成现有图片的 md5 信息。除空字符串''外,其余值均为 true
  • 参数三:apiKeyList,以逗号区分,
  • 传参参考

    node ./mtp.js /Users/mjw/Desktop/git/tinypng-script-with-cache/test-img '' IAl6s3ekmONUVMEqWZdIp1nV2ItJLyPC 

    运行效果

配置合并优先级源码

const vfs = require('vinyl-fs');  
let tinypng = require('./tinypng-with-cache')  
  
let apiKeyList = [] // 接口 key 默认为空  
let basePath = process.cwd() // 默认运行脚本所在目录  
let createMd5FormOrigin = false // 不进行压缩操作,只生成现有图片的 md5 信息,并作为缓存。用于「初次项目接入」及手动清理冗余的「图片md5信息」  
  
// 如果有全局传值  
if (global.tinypngConf) {  
    basePath = tinypngConf.basePath || basePath     
    apiKeyList = tinypngConf.apiKeyList || apiKeyList 
    createMd5FormOrigin = tinypngConf.createMd5FormOrigin || createMd5FormOrigin
}  
  
// 动态参数传值  
basePath = process.argv[2] || basePath  
createMd5FormOrigin = process.argv[3] || createMd5FormOrigin  
apiKeyList = process.argv[4] ? process.argv[4].split(',') : apiKeyList  
  
let fileFilter = [ 
 basePath + '/**/*.png',
 basePath + '/**/*.jpg', 
 basePath + '/**/*.jpeg', 
 '!/**/node_modules/*', // 忽略无需遍历的文件,路径匹配语法参考:https://www.gulpjs.com.cn/docs/getting-started/explaining-globs/
]  
  
console.log({  
 basePath,
 apiKeyList,
 fileFilter, 
 createMd5FormOrigin
})  
  
if (!apiKeyList.length) {  
   return console.error('tinypng-script-with-cache', 'tinypny key 列表不能为空!')
}  
  
vfs.src(fileFilter, {  
 base: './', // 对文件使用相路径,为了后面覆盖源文件 
 nodir: true, // 忽略文件夹
})
.pipe(tinypng({  
    apiKeyList, 
    reportFilePath: basePath + '/tinypngReport.json', // 不设置,则不进行日志记录 
    md5RecordFilePath: basePath + '/tinypngMd5Record.json', // 不设置,则不进行缓存过滤 
    minCompressPercentLimit: 10, // 默认值为零,最小压缩百分比限制,为保证图片质量,当压缩比例低于该值时,保持源文件,避免过分压缩,损伤图片质量 createMd5FormOrigin, // 不进行压缩操作,只生成现有图片的 md5 信息,并作为缓存。用于「初次项目接入」及手动清理冗余的「图片md5信息」
 }))  
.pipe(vfs.dest('./', { overwrite: true })) // 覆写原文件  

项目地址

二次开发,生成自定义脚本

  • git clone 下载项目
  • npm install 安装依赖
  • 修改「tinypng-mjw.js」与「tinypng-with-cache.js」源文件
  • 执行npx webpack --config webpack.config.js命令,进行打包
  • 生成目标文件dist/mtp.js

测试资源

  • test-img:图片压缩测试目录
  • test-img-origin:测试图片备份目录,用于恢复测试

运行效果

运行效果

压缩报告

压缩报告

md5 记录

md5 记录

gulp 版本请参考这里

查看原文

赞 0 收藏 0 评论 3

momo707577045 发布了文章 · 2020-09-04

tinypng-script-with-cache 图片压缩神器,「过滤重复压缩」「直接替换源文件」的 tinypng 压缩脚本

「过滤重复压缩」

「替换源文件」

「静默压缩,不生成冗余文件」

项目特点

  • 【过滤重复压缩】

    • 自动记录已被压缩过的图片,跳过压缩,加快进度。
    • 记录图片压缩后的 md5 值,再次运行压缩脚本时,跳过压缩。
    • 通过 md5 值比较文件变更,即使「文件迁移」也能自动过滤。
    • 通过 md5 值比较文件变更,即使「使用同名文件替换」也能自动识别,并压缩,没有漏网之鱼。
  • 【替换源文件】

    • 压缩成功,直接替换源文件,不生成冗余文件,不需要复制粘贴,移动图片。
    • 静默压缩,对项目无感知,无任何影响。
  • 【自动切换 api key】

    • tinypng 申请的 api key 每月只有 500 次免费压缩额度。
    • 可设置多个 api key,当某 key 超过使用次数时,自动切换下一个 key 进行压缩。
  • 【压缩报告】

    • 记录每个图片的压缩数据,并生成汇总信息。
  • 【压缩安全边界】

    • 压缩安全线,当压缩比例低于该百分比值时,保持源文件,避免过分压缩,损伤图片质量。
  • 【源码携带详细备注,自带测试图片】

    • 降低源码阅读门槛,降低测试门槛,减低使用门槛。
    • 推荐阅读源码,打破恐惧,便于定制个性化需求。

项目地址

参数介绍

| 参数名 | 值类型 | 是否必填 | 参数作用 | 默认值 | 推荐值 |
| :------: | :------: | :------: | :------: | :------: | :------: |
| apiKeyList | Array | 必填 | tiny png 的 api key 数组,当其中一个不可用或超过使用次数时,自动切换下一个 key 调用 | 无 | 无 |
| reportFilePath | Number | 非必填 | 压缩报告文件路径,记录图片的压缩比例,生产压缩报告 | 无 | __dirname + '/tinyPngReport.json' |
| md5RecordFilePath | Number | 非必填 | 压缩后图片 md5 记录文件,如果待压缩图片的 md5 值存在于该文件,则跳过压缩,解决「重复压缩」问题 | 无 | __dirname + '/md5RecordFilePath.json' |
| minCompressPercentLimit | Number | 非必填 | 压缩安全线,当压缩比例低于该百分比时,保持源文件,避免图片过分压缩,损伤显示质量 | 0 | 10 |
| createMd5FormOrigin | Boolean | 非必填 | 不进行压缩操作,只生成现有图片的 md5 信息,并作为缓存。用于「初次项目接入」及手动清理冗余的「图片md5信息」 | false | false |

参数配置示例

let gulp = require('gulp')  
let tinypng = require('./gulp-tinypng-with-cache')  
  
const projectPath = __dirname + '/test-img' // 测试项目路径,可通过 test-img-origin 恢复未压缩前图片  
const apiKeyList = [  
  // 'XgNgkoyWbdIZd8OizINMjX2TpxAd_Gp3', // 无效 key  
  // 'IAl6s3ekmONUVMEqWZdIp1nV2ItJL1PC', // 无效 key   
 'IAl6s3ekmONUVMEqWZdIp1nV2ItJLyPC', // 有效 key  
]  
  
gulp.task('default', function () {  
  return gulp.src([  
    projectPath + '/**/*.png',  
    projectPath + '/**/*.jpg',  
    projectPath + '/**/*.jpeg',  
    '!/**/node_modules/*', // 忽略无需遍历的文件,路径匹配语法参考:https://www.gulpjs.com.cn/docs/getting-started/explaining-globs/  
 ], {  
    base: './', // 对文件使用相路径,为了后面覆盖源文件  
    nodir: true, // 忽略文件夹  
 })  
  .pipe(tinypng({  
    apiKeyList,  
    reportFilePath: __dirname + '/tinypngReport.json', // 不设置,则不进行日志记录  
    md5RecordFilePath: __dirname + '/tinypngMd5Record.json', // 不设置,则不进行缓存过滤  
    minCompressPercentLimit: 10, // 默认值为零,最小压缩百分比限制,为保证图片质量,当压缩比例低于该值时,保持源文件,避免过分压缩,损伤图片质量  
    createMd5FormOrigin: false, // 不进行压缩操作,只生成现有图片的 md5 信息,并作为缓存。用于「初次项目接入」及手动清理冗余的「图片md5信息」  
 }))  
  .pipe(gulp.dest('./', { overwrite: true })) // 覆写原文件  
})

组件集成步骤

  • 第一步:npm install -S gulp-tinypng-with-cache
  • 第二步:根据示例进行参数配置
  • 第三步:gulp

示例运行步骤

  • 第一步:cd 到当前项目
  • 第二步:npm install
  • 第三步:gulp

测试资源

  • test-img:图片压缩测试目录
  • test-img-origin:测试图片备份目录,用于恢复测试

运行效果

运行效果

压缩报告

压缩报告

md5 记录

md5 记录

特别感谢

  • 感谢 Gaurav Jassal,本项目改编自他的 gulp-tinypng
查看原文

赞 1 收藏 1 评论 0

momo707577045 收藏了文章 · 2020-09-02

JS错误监控总结

前言

做好错误监控,将用户使用时的错误日志上报,可以帮助我们更快的解决一些问题。目前开源的比较好的前端监控有

那前端监控是怎么实现的呢?要想了解这个,需要知道前端错误大概分为哪些以及如何捕获处理。

前端错误分为JS运行时错误、资源加载错误和接口错误三种。

一、JS运行时错误

JS运行时错误一般使用window.onerror捕获,但是有一种特殊情况就是promise被reject并且错误信息没有被处理的时候抛出的错误

1.1 一般情况的JS运行时错误

使用window.onerror和window.addEventListener('error')捕获。

window.onerror = function (msg, url, lineNo, columnNo, error) 
    { 
       // 处理error信息
    } 
 
    window.addEventListener('error', event =>  
    {  
       console.log('addEventListener error:' + event.target); 
    }, true); 
    // true代表在捕获阶段调用,false代表在冒泡阶段捕获。使用true或false都可以
例子:https://jsbin.com/lujahin/edit?html,console,output 点击button抛出错误,分别被window.onerror和window.addEventListener('error')捕获

1.2 Uncaught (in promise)

当promise被reject并且错误信息没有被处理的时候,会抛出一个unhandledrejection,并且这个错误不会被window.onerror以及window.addEventListener('error')捕获,需要用专门的window.addEventListener('unhandledrejection')捕获处理

window.addEventListener('unhandledrejection', event => 
    { 
       console.log('unhandledrejection:' + event.reason); // 捕获后自定义处理
    });
https://developer.mozilla.org...
例子:https://jsbin.com/jofomob/edit?html,console,output 点击button抛出unhandledrejection错误,并且该错误仅能被window.addEventListener('unhandledrejection')捕获

1.3 console.error

一些特殊情况下,还需要捕获处理console.error,捕获方式就是重写window.console.error

var consoleError = window.console.error; 
window.console.error = function () { 
    alert(JSON.stringify(arguments)); // 自定义处理
    consoleError && consoleError.apply(window, arguments); 
};
例子:https://jsbin.com/pemigew/edit?html,console,output

1.4 特别说明跨域日志

什么是跨域脚本error?

https://developer.mozilla.org...
当加载自不同域的脚本中发生语法错误时,为避免信息泄露(参见bug 363897),语法错误的细节将不会报告,而代之简单的"Script error."。在某些浏览器中,通过在<script>使用crossorigin属性并要求服务器发送适当的 CORS HTTP 响应头,该行为可被覆盖。一个变通方案是单独处理"Script error.",告知错误详情仅能通过浏览器控制台查看,无法通过JavaScript访问。

例子: http://sandbox.runjs.cn/show/... 请打开页面打开控制台。该页面分别加载了两个不同域的js脚本,配置了crossorigin的window.onerror可以报出详细的错误,没有配置crossorigin只能报出'script error',并且没有错误信息

1.5 特别说明sourceMap

在线上由于JS一般都是被压缩或者打包(webpack)过,打包后的文件只有一行,因此报错会出现第一行第5000列出现JS错误,给排查带来困难。sourceMap存储打包前的JS文件和打包后的JS文件之间一个映射关系,可以根据打包后的位置快速解析出对应源文件的位置。

但是出于安全性考虑,线上设置sourceMap会存在不安全的问题,因为网站使用者可以轻易的看到网站源码,此时可以设置.map文件只能通过公司内网访问降低隐患

sourceMap配置devtool: 'inline-source-map'
如果使用了uglifyjs-webpack-plugin 必须把 sourceMap设置为true
https://doc.webpack-china.org...

1.6 其它

1.6.1 sentry把所有的回调函数使用try catch封装一层
https://github.com/getsentry/raven-js/blob/master/src/raven.js

1.6.2 vue errorHandler
https://vuejs.org/v2/api/#errorHandler
其原理也是使用try catch封装了nextTick,$emit, watch,data等
https://github.com/vuejs/vue/blob/dev/dist/vue.runtime.js

二、资源加载错误

使用window.addEventListener('error')捕获,window.onerror捕获不到资源加载错误

https://jsbin.com/rigasek/edit?html,console 图片资源加载错误。此时只有window.addEventListener('error')可以捕获到

window.onerror和window.addEventListener('error')的异同:相同点是都可以捕获到window上的js运行时错误。区别是1.捕获到的错误参数不同 2.window.addEventListener('error')可以捕获资源加载错误,但是window.onerror不能捕获到资源加载错误

window.addEventListener('error')捕获到的错误,可以通过target?.src || target?.href区分是资源加载错误还是js运行时错误

三、接口错误

所有http请求都是基于xmlHttpRequest或者fetch封装的。所以要捕获全局的接口错误,方法就是封装xmlHttpRequest或者fetch

3.1 封装xmlHttpRequest

if(!window.XMLHttpRequest) return;
var xmlhttp = window.XMLHttpRequest;
var _oldSend = xmlhttp.prototype.send;
var _handleEvent = function (event) {
    if (event && event.currentTarget && event.currentTarget.status !== 200) {
          // 自定义错误上报 }
}
xmlhttp.prototype.send = function () {
    if (this['addEventListener']) {
        this['addEventListener']('error', _handleEvent);
        this['addEventListener']('load', _handleEvent);
        this['addEventListener']('abort', _handleEvent);
    } else {
        var _oldStateChange = this['onreadystatechange'];
        this['onreadystatechange'] = function (event) {
            if (this.readyState === 4) {
                _handleEvent(event);
            }
            _oldStateChange && _oldStateChange.apply(this, arguments);
        };
    }
    return _oldSend.apply(this, arguments);
}

3.2 封装fetch

if(!window.fetch) return;
    let _oldFetch = window.fetch;
    window.fetch = function () {
        return _oldFetch.apply(this, arguments)
        .then(res => {
            if (!res.ok) { // True if status is HTTP 2xx
                // 上报错误
            }
            return res;
        })
        .catch(error => {
            // 上报错误
            throw error;  
        })
}

结论

  1. 使用window.onerror捕获JS运行时错误
  2. 使用window.addEventListener('unhandledrejection')捕获未处理的promise reject错误
  3. 重写console.error捕获console.error错误
  4. 在跨域脚本上配置crossorigin="anonymous"捕获跨域脚本错误
  5. window.addEventListener('error')捕获资源加载错误。因为它也能捕获js运行时错误,为避免重复上报js运行时错误,此时只有event.srcElement inatanceof HTMLScriptElement或HTMLLinkElement或HTMLImageElement时才上报
  6. 重写window.XMLHttpRequest和window.fetch捕获请求错误

利用以上原理,简单写了一个JS监控,只处理了一些JS错误,暂时没有做和性能相关的监控
https://github.com/Lie8466/better-js

如果发现文章有错误,欢迎指正。

查看原文

momo707577045 赞了文章 · 2020-09-02

JS错误监控总结

前言

做好错误监控,将用户使用时的错误日志上报,可以帮助我们更快的解决一些问题。目前开源的比较好的前端监控有

那前端监控是怎么实现的呢?要想了解这个,需要知道前端错误大概分为哪些以及如何捕获处理。

前端错误分为JS运行时错误、资源加载错误和接口错误三种。

一、JS运行时错误

JS运行时错误一般使用window.onerror捕获,但是有一种特殊情况就是promise被reject并且错误信息没有被处理的时候抛出的错误

1.1 一般情况的JS运行时错误

使用window.onerror和window.addEventListener('error')捕获。

window.onerror = function (msg, url, lineNo, columnNo, error) 
    { 
       // 处理error信息
    } 
 
    window.addEventListener('error', event =>  
    {  
       console.log('addEventListener error:' + event.target); 
    }, true); 
    // true代表在捕获阶段调用,false代表在冒泡阶段捕获。使用true或false都可以
例子:https://jsbin.com/lujahin/edit?html,console,output 点击button抛出错误,分别被window.onerror和window.addEventListener('error')捕获

1.2 Uncaught (in promise)

当promise被reject并且错误信息没有被处理的时候,会抛出一个unhandledrejection,并且这个错误不会被window.onerror以及window.addEventListener('error')捕获,需要用专门的window.addEventListener('unhandledrejection')捕获处理

window.addEventListener('unhandledrejection', event => 
    { 
       console.log('unhandledrejection:' + event.reason); // 捕获后自定义处理
    });
https://developer.mozilla.org...
例子:https://jsbin.com/jofomob/edit?html,console,output 点击button抛出unhandledrejection错误,并且该错误仅能被window.addEventListener('unhandledrejection')捕获

1.3 console.error

一些特殊情况下,还需要捕获处理console.error,捕获方式就是重写window.console.error

var consoleError = window.console.error; 
window.console.error = function () { 
    alert(JSON.stringify(arguments)); // 自定义处理
    consoleError && consoleError.apply(window, arguments); 
};
例子:https://jsbin.com/pemigew/edit?html,console,output

1.4 特别说明跨域日志

什么是跨域脚本error?

https://developer.mozilla.org...
当加载自不同域的脚本中发生语法错误时,为避免信息泄露(参见bug 363897),语法错误的细节将不会报告,而代之简单的"Script error."。在某些浏览器中,通过在<script>使用crossorigin属性并要求服务器发送适当的 CORS HTTP 响应头,该行为可被覆盖。一个变通方案是单独处理"Script error.",告知错误详情仅能通过浏览器控制台查看,无法通过JavaScript访问。

例子: http://sandbox.runjs.cn/show/... 请打开页面打开控制台。该页面分别加载了两个不同域的js脚本,配置了crossorigin的window.onerror可以报出详细的错误,没有配置crossorigin只能报出'script error',并且没有错误信息

1.5 特别说明sourceMap

在线上由于JS一般都是被压缩或者打包(webpack)过,打包后的文件只有一行,因此报错会出现第一行第5000列出现JS错误,给排查带来困难。sourceMap存储打包前的JS文件和打包后的JS文件之间一个映射关系,可以根据打包后的位置快速解析出对应源文件的位置。

但是出于安全性考虑,线上设置sourceMap会存在不安全的问题,因为网站使用者可以轻易的看到网站源码,此时可以设置.map文件只能通过公司内网访问降低隐患

sourceMap配置devtool: 'inline-source-map'
如果使用了uglifyjs-webpack-plugin 必须把 sourceMap设置为true
https://doc.webpack-china.org...

1.6 其它

1.6.1 sentry把所有的回调函数使用try catch封装一层
https://github.com/getsentry/raven-js/blob/master/src/raven.js

1.6.2 vue errorHandler
https://vuejs.org/v2/api/#errorHandler
其原理也是使用try catch封装了nextTick,$emit, watch,data等
https://github.com/vuejs/vue/blob/dev/dist/vue.runtime.js

二、资源加载错误

使用window.addEventListener('error')捕获,window.onerror捕获不到资源加载错误

https://jsbin.com/rigasek/edit?html,console 图片资源加载错误。此时只有window.addEventListener('error')可以捕获到

window.onerror和window.addEventListener('error')的异同:相同点是都可以捕获到window上的js运行时错误。区别是1.捕获到的错误参数不同 2.window.addEventListener('error')可以捕获资源加载错误,但是window.onerror不能捕获到资源加载错误

window.addEventListener('error')捕获到的错误,可以通过target?.src || target?.href区分是资源加载错误还是js运行时错误

三、接口错误

所有http请求都是基于xmlHttpRequest或者fetch封装的。所以要捕获全局的接口错误,方法就是封装xmlHttpRequest或者fetch

3.1 封装xmlHttpRequest

if(!window.XMLHttpRequest) return;
var xmlhttp = window.XMLHttpRequest;
var _oldSend = xmlhttp.prototype.send;
var _handleEvent = function (event) {
    if (event && event.currentTarget && event.currentTarget.status !== 200) {
          // 自定义错误上报 }
}
xmlhttp.prototype.send = function () {
    if (this['addEventListener']) {
        this['addEventListener']('error', _handleEvent);
        this['addEventListener']('load', _handleEvent);
        this['addEventListener']('abort', _handleEvent);
    } else {
        var _oldStateChange = this['onreadystatechange'];
        this['onreadystatechange'] = function (event) {
            if (this.readyState === 4) {
                _handleEvent(event);
            }
            _oldStateChange && _oldStateChange.apply(this, arguments);
        };
    }
    return _oldSend.apply(this, arguments);
}

3.2 封装fetch

if(!window.fetch) return;
    let _oldFetch = window.fetch;
    window.fetch = function () {
        return _oldFetch.apply(this, arguments)
        .then(res => {
            if (!res.ok) { // True if status is HTTP 2xx
                // 上报错误
            }
            return res;
        })
        .catch(error => {
            // 上报错误
            throw error;  
        })
}

结论

  1. 使用window.onerror捕获JS运行时错误
  2. 使用window.addEventListener('unhandledrejection')捕获未处理的promise reject错误
  3. 重写console.error捕获console.error错误
  4. 在跨域脚本上配置crossorigin="anonymous"捕获跨域脚本错误
  5. window.addEventListener('error')捕获资源加载错误。因为它也能捕获js运行时错误,为避免重复上报js运行时错误,此时只有event.srcElement inatanceof HTMLScriptElement或HTMLLinkElement或HTMLImageElement时才上报
  6. 重写window.XMLHttpRequest和window.fetch捕获请求错误

利用以上原理,简单写了一个JS监控,只处理了一些JS错误,暂时没有做和性能相关的监控
https://github.com/Lie8466/better-js

如果发现文章有错误,欢迎指正。

查看原文

赞 56 收藏 41 评论 6

momo707577045 回答了问题 · 2020-08-31

解决使用gulp replace怎么替换原文件,而不是生成新文件?

在官方文档中找到了gulp 中文文档
image.png
具体操作代码如下

  gulp.src('./app/**/*.js', {base: './'})
      .pipe(prettify())
      .pipe(gulp.dest('./'),  { overwrite: true });

关注 2 回答 3

momo707577045 赞了文章 · 2020-08-20

贝塞尔曲线算法之JS获取点

什么是贝塞尔曲线?

贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。

图片描述
这个一阶贝塞尔曲线绘制过程,黑点按百分比t从P0->P1移动,看不出什么呢~ 那继续看后面的图

图片描述
这个是二阶贝塞尔曲线,从P0->P1有个小绿点按百分比t运动,从P1->P2也有个小绿点按百分比t运动,两个绿点之间也有个小黑点按百分比t运动,这个黑点产生的轨迹就是一个二阶贝塞尔曲线。

图片描述
这个是三阶贝塞尔曲线,同理,绿点有3个,点与点之间都是按百分比t运动,最终得到一个小黑点。这个小黑点的运动轨迹就是三阶贝塞尔。

图片描述
同理,还有四阶贝塞尔。

图片描述
同理,六阶贝塞尔,N阶贝塞尔。

实际上,我们的运用中,3阶贝塞尔就已经足够满足我们的业务需求了,生活中,多个三阶贝塞尔曲线可以组合成任意一条曲线,我们的photoshop里面的钢笔工具就是3阶贝塞尔曲线实现的。

贝塞尔曲线方程解析

数学家已经给了我们公式:

图片描述

不好意思,高数还给了老师,这尼玛公式看不懂啊~ 没关系,我们简化下就能看懂了。

// t是百分比,a是参数

// 1阶贝塞尔曲线公式
function onebsr(t, a1, a2) {
    return a1 + (a2 - a1) * t;
}

// 2阶贝塞尔曲线公式
function twobsr(t, a1, a2, a3) {
    return ((1 - t) * (1 - t)) * a1 + 2 * t * (1 - t) * a2 + t * t * a3;
}

// 3阶贝塞尔曲线公式
function threebsr(t, a1, a2, a3, a4) {
    return a1 * (1 - t) * (1 - t) * (1 - t) + 3 * a2 * t * (1 - t) * (1 - t) + 3 * a3 * t * t * (1 - t) + a4 * t * t * t;
}

根据公式,我们可以带入坐标进行计算

/**
     * @desc 一阶贝塞尔
     * @param {number} t 当前百分比
     * @param {Array} p1 起点坐标
     * @param {Array} p2 终点坐标
     */
    oneBezier(t, p1, p2) {
        const [x1, y1] = p1;
        const [x2, y2] = p2;
        let x = x1 + (x2 - x1) * t;
        let y = y1 + (y2 - y1) * t;
        return [x, y];
    }

    /**
     * @desc 二阶贝塞尔
     * @param {number} t 当前百分比
     * @param {Array} p1 起点坐标
     * @param {Array} p2 终点坐标
     * @param {Array} cp 控制点
     */
    twoBezier(t, p1, cp, p2) {
        const [x1, y1] = p1;
        const [cx, cy] = cp;
        const [x2, y2] = p2;
        let x = (1 - t) * (1 - t) * x1 + 2 * t * (1 - t) * cx + t * t * x2;
        let y = (1 - t) * (1 - t) * y1 + 2 * t * (1 - t) * cy + t * t * y2;
        return [x, y];
    }

    /**
     * @desc 三阶贝塞尔
     * @param {number} t 当前百分比
     * @param {Array} p1 起点坐标
     * @param {Array} p2 终点坐标
     * @param {Array} cp1 控制点1
     * @param {Array} cp2 控制点2
     */
    threeBezier(t, p1, cp1, cp2, p2) {
        const [x1, y1] = p1;
        const [x2, y2] = p2;
        const [cx1, cy1] = cp1;
        const [cx2, cy2] = cp2;
        let x =
            x1 * (1 - t) * (1 - t) * (1 - t) +
            3 * cx1 * t * (1 - t) * (1 - t) +
            3 * cx2 * t * t * (1 - t) +
            x2 * t * t * t;
        let y =
            y1 * (1 - t) * (1 - t) * (1 - t) +
            3 * cy1 * t * (1 - t) * (1 - t) +
            3 * cy2 * t * t * (1 - t) +
            y2 * t * t * t;
        return [x, y];
    }

算法封装

我把贝塞尔曲线封装了下,添加了一个获取路径点的方法,然后使用span标签绘制到页面上的效果。

我们看看DEMO中1~3阶贝塞尔曲线上获取点的效果
图片描述
图片描述
图片描述

demo的github地址:https://github.com/mtsee/Bezier

查看原文

赞 45 收藏 26 评论 1

momo707577045 赞了文章 · 2020-08-10

浅析Vue.nextTick()原理

1、为什么用Vue.nextTick()

首先来了解一下JS的运行机制。

JS运行机制(Event Loop)

JS执行是单线程的,它是基于事件循环的。

  1. 所有同步任务都在主线程上执行,形成一个执行栈。
  2. 主线程之外,会存在一个任务队列,只要异步任务有了结果,就在任务队列中放置一个事件。
  3. 当执行栈中的所有同步任务执行完后,就会读取任务队列。那些对应的异步任务,会结束等待状态,进入执行栈。
  4. 主线程不断重复第三步。

这里主线程的执行过程就是一个tick,而所有的异步结果都是通过任务队列来调度。Event Loop 分为宏任务和微任务,无论是执行宏任务还是微任务,完成后都会进入到一下tick并在两个tick之间进行UI渲染

由于Vue DOM更新是异步执行的,即修改数据时,视图不会立即更新,而是会监听数据变化,并缓存在同一事件循环中,等同一数据循环中的所有数据变化完成之后,再统一进行视图更新。为了确保得到更新后的DOM,所以设置了 Vue.nextTick()方法。

2、什么是Vue.nextTick()

是Vue的核心方法之一,官方文档解释如下:

在下次DOM更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的DOM。
MutationObserver

先简单介绍下MutationObserver:MO是HTML5中的API,是一个用于监视DOM变动的接口,它可以监听一个DOM对象上发生的子节点删除、属性修改、文本内容修改等。

调用过程是要先给它绑定回调,得到MO实例,这个回调会在MO实例监听到变动时触发。这里MO的回调是放在microtask中执行的。

// 创建MO实例
const observer = new MutationObserver(callback)

const textNode = '想要监听的Don节点'

observer.observe(textNode, {
    characterData: true // 说明监听文本内容的修改
})
源码浅析

nextTick 的实现单独有一个JS文件来维护它,在src/core/util/next-tick.js中。

nextTick 源码主要分为两块:能力检测和根据能力检测以不同方式执行回调队列。

能力检测

由于宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,再使用宏任务。

// 空函数,可用作函数占位符
import { noop } from 'shared/util' 

 // 错误处理函数
import { handleError } from './error'

 // 是否是IE、IOS、内置函数
import { isIE, isIOS, isNative } from './env'

// 使用 MicroTask 的标识符,这里是因为火狐在<=53时 无法触发微任务,在modules/events.js文件中引用进行安全排除
export let isUsingMicroTask = false 

 // 用来存储所有需要执行的回调函数
const callbacks = []

// 用来标志是否正在执行回调函数
let pending = false 

// 对callbacks进行遍历,然后执行相应的回调函数
function flushCallbacks () {
    pending = false
    // 这里拷贝的原因是:
    // 有的cb 执行过程中又会往callbacks中加入内容
    // 比如 $nextTick的回调函数里还有$nextTick
    // 后者的应该放到下一轮的nextTick 中执行
    // 所以拷贝一份当前的,遍历执行完当前的即可,避免无休止的执行下去
    const copies = callbcks.slice(0)
    callbacks.length = 0
    for(let i = 0; i < copies.length; i++) {
        copies[i]()
    }
}

let timerFunc // 异步执行函数 用于异步延迟调用 flushCallbacks 函数

// 在2.5中,我们使用(宏)任务(与微任务结合使用)。
// 然而,当状态在重新绘制之前发生变化时,就会出现一些微妙的问题
// (例如#6813,out-in转换)。
// 同样,在事件处理程序中使用(宏)任务会导致一些奇怪的行为
// 因此,我们现在再次在任何地方使用微任务。
// 优先使用 Promise
if(typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    timerFunc = () => {
        p.then(flushCallbacks)
        
        // IOS 的UIWebView, Promise.then 回调被推入 microTask 队列,但是队列可能不会如期执行
        // 因此,添加一个空计时器强制执行 microTask
        if(isIOS) setTimeout(noop)
    }
    isUsingMicroTask = true
} else if(!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString === '[object MutationObserverConstructor]')) {
    // 当 原生Promise 不可用时,使用 原生MutationObserver
    // e.g. PhantomJS, iOS7, Android 4.4
 
    let counter = 1
    // 创建MO实例,监听到DOM变动后会执行回调flushCallbacks
    const observer = new MutationObserver(flushCallbacks)
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true // 设置true 表示观察目标的改变
    })
    
    // 每次执行timerFunc 都会让文本节点的内容在 0/1之间切换
    // 切换之后将新值复制到 MO 观测的文本节点上
    // 节点内容变化会触发回调
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter) // 触发回调
    }
    isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => {
        setImmediate(flushCallbacks)
    }
} else {
    timerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

延迟调用优先级如下:
Promise > MutationObserver > setImmediate > setTimeout

export function nextTick(cb? Function, ctx: Object) {
    let _resolve
    // cb 回调函数会统一处理压入callbacks数组
    callbacks.push(() => {
        if(cb) {
            try {
                cb.call(ctx)
            } catch(e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    
    // pending 为false 说明本轮事件循环中没有执行过timerFunc()
    if(!pending) {
        pending = true
        timerFunc()
    }
    
    // 当不传入 cb 参数时,提供一个promise化的调用 
    // 如nextTick().then(() => {})
    // 当_resolve执行时,就会跳转到then逻辑中
    if(!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

next-tick.js 对外暴露了nextTick这一个参数,所以每次调用Vue.nextTick时会执行:

  • 把传入的回调函数cb压入callbacks数组
  • 执行timerFunc函数,延迟调用 flushCallbacks 函数
  • 遍历执行 callbacks 数组中的所有函数

这里的 callbacks 没有直接在 nextTick 中执行回调函数的原因是保证在同一个 tick 内多次执行nextTick,不会开启多个异步任务,而是把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕。

附加

noop 的定义如下

/**
 * Perform no operation.
 * Stubbing args to make Flow happy without leaving useless transpiled code
 * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/).
 */
export function noop (a?: any, b?: any, c?: any) {}

3、怎么用

语法Vue.nextTick([callback, context])

参数

  • {Function} [callback]:回调函数,不传时提供promise调用
  • {Object} [context]:回调函数执行的上下文环境,不传默认是自动绑定到调用它的实例上。
//改变数据
vm.message = 'changed'

//想要立即使用更新后的DOM。这样不行,因为设置message后DOM还没有更新
console.log(vm.$el.textContent) // 并不会得到'changed'

//这样可以,nextTick里面的代码会在DOM更新后执行
Vue.nextTick(function(){
    // DOM 更新了
    //可以得到'changed'
    console.log(vm.$el.textContent)
})

// 作为一个 Promise 使用 即不传回调
Vue.nextTick()
  .then(function () {
    // DOM 更新了
  })

Vue实例方法vm.$nextTick做了进一步封装,把context参数设置成当前Vue实例。

4、小结

使用Vue.nextTick()是为了可以获取更新后的DOM 。
触发时机:在同一事件循环中的数据变化后,DOM完成更新,立即执行Vue.nextTick()的回调。

同一事件循环中的代码执行完毕 -> DOM 更新 -> nextTick callback触发

1596618069-5a5da8c8522c2_articlex

应用场景:

  • 在Vue生命周期的created()钩子函数进行的DOM操作一定要放在Vue.nextTick()的回调函数中。

    原因:是created()钩子函数执行时DOM其实并未进行渲染。

  • 在数据变化后要执行的某个操作,而这个操作需要使用随数据改变而改变的DOM结构的时候,这个操作应该放在Vue.nextTick()的回调函数中。

    原因:Vue异步执行DOM更新,只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变,如果同一个watcher被多次触发,只会被推入到队列中一次。

版本分析

2.6 版本优先使用 microtask 作为异步延迟包装器,且写法相对简单。而2.5 版本中,nextTick 的实现是 microTimerFunc、macroTimerFunc 组合实现的,延迟调用优先级是:Promise > setImmediate > MessageChannel > setTimeout,具体见源码。

2.5 版本在重绘之前状态改变时会有小问题(如 #6813)。此外,在事件处理程序中使用 macrotask 会导致一些无法规避的奇怪行为(如 #7109#7153等)。

microtask 在某些情况下也是会有问题的,因为 microtask 优先级比较高,事件会在顺序事件(如#4521#6690 有变通方法)之间甚至在同一事件的冒泡过程中触发(#6566)。

参考:

查看原文

赞 23 收藏 8 评论 0

momo707577045 赞了回答 · 2020-07-30

解决正则表达式如何匹配递增数字

(\d{3})\1

关注 5 回答 3

momo707577045 赞了文章 · 2020-07-24

全面进阶 H5 直播

视频格式?编码?

如果我们想要理解 HTML5 视频,首先需要知道,你应该知道,但你不知道的内容?那怎么去判断呢?
ok,很简单,我提几个问题即可,如果某些童鞋知道答案的话,可以直接跳过。

  1. 你知道 ogg,mp4,flv,webm(前面加个点 .)这些叫做什么吗?

  2. 那 FLV,MPEG-4,VP8 是啥?

  3. 如果,基友问你要片源,你会说我这是 mp4 的还是 MPEG-4 的呢?

当然,还有一些问题,我这里就不废话了。上面主要想说的其实就两个概念:视频文件格式(容器格式),视频编解码器(视频编码格式)。当然,还有另外一种,叫做音频编解码器。简而言之,就是这三个概念比较重要:

  • 视频文件格式(容器格式)

  • 视频编解码器(视频编码格式)

  • 音频编解码器(音频编码格式)

这里,我们主要讲解一下前面两个。视频一开始会由两个端采集,一个是视频输入口,是一个音频输入口。然后,采集的数据会分别进行相关处理,简而言之就是,将视频/音频流,通过一定的手段转换为比特流。最终,将这里比特流以一定顺序放到一个盒子里进行存放,从而生成我们最终所看到的,比如,mp4/mp3/flv 等等音视频格式。

视频编码格式

视频编码格式就是我们上面提到的第一步,将物理流转换为比特流,并且进行压缩。同样,它的压缩编码格式会决定它的视频文件格式。所以,第一步很重要。针对于 HTML5 中的 video/audio,它实际上是支持多种编码格式的,但局限于各浏览器厂家的普及度,目前视频格式支持度最高的是 MPEG-4/H.264,音频则是 MP3/AC3。(下面就主要说下视频的,音频就先不谈了。)

目前市面上,主流浏览器支持的几个有:

  • H.264

  • MEPG-4 第 2 部分

  • VP8

  • Ogg

  • WebM(免费)

其它格式,我们这里就不过多赘述,来看一下前两个比较有趣的。如下图:

demo

请问,上面箭头所指的编码格式是同一个吗?

答案是:No~

因为,MPEG-4 实际上是于 1999 年提出的一个标准。而 H.264 则是后台作为优化提出的新的标准。简单来说就是,我们通常说的 MPEG-4 其实就是MPEG-4 Part 2。而,H.264 则是MPEG-4(第十部分,也叫ISO/IEC 14496-10),又可以理解为 MPEG-4 AVC。而两者,不同的地方,可以参考:latthias 的讲解。简单的区别是:H.264 压缩率比以前的 MPEG-4(第 2 部分) 高很多。简单可以参考的就是:

demo

详细参考: 编码格式详解

视频文件格式

视频文件格式实际上,我们常常称作为容器格式,也就是,我们一般生活中最经常谈到的格式,flv,mp4,ogg 格式等。它就可以理解为将比特流按照一定顺序放进特定的盒子里。那选用不同格式来装视频有什么问题吗?
答案是,没有任何问题,但是你需要知道如何将该盒子解开,并且能够找到对应的解码器进行解码。那如果按照这样看的话,对于这些 mp4,ogv,webm等等视频格式,只要我有这些对应的解码器以及播放器,那么就没有任何问题。那么针对于,将视频比特流放进一个盒子里面,如果其中某一段出现问题,那么最终生成的文件实际上是不可用的,因为这个盒子本身就是有问题的。
不过,上面有一个误解的地方在于,我只是将视频理解为一个静态的流。试想一下,如果一个视频需要持续不断的播放,例如,直播,现场播报等。这里,我们就拿 TS/PS 流来进行讲解。

  • PS(Program Stream): 静态文件流

  • TS(Transport Stream): 动态文件流

针对于上面两种容器格式,实际上是对一个视频比特流做了不一样的处理。

  • PS: 将完成视频比特流放到一个盒子里,生成固定的文件

  • TS: 将接受到的视频,分成不同的盒子里。最终生成带有多个盒子的文件。

那么结果就是,如果一个或多个盒子出现损坏,PS 格式无法观看,而 TS 只是会出现跳帧或者马赛克效应。两者具体的区别就是:对于视频的容错率越高,则会选用 TS,对视频容错率越低,则会选用 PS。

常用为:

  • AVI:MPEG-2,DIVX,XVID,AC-1,H.264;

  • WMV:WMV,AC-1;

  • RM、RMVB:RV, RM;

  • MOV:MPEG-2,XVID,H.264;

  • TS/PS:MPEG-2,H.264,MPEG-4;

  • MKV:可以封装所有的视频编码格式。

详细参考:视频文件格式

直播协议

2016 年是直播元年,一是由于各大宽带提供商顺应民意增宽降价,二是大量资本流进了直播板块,促进了技术的更新迭代。市面上,最常用的是 Apple 推出的 HLS 直播协议(原始支持 H5 播放),当然,还有 RTMP、HTTP-FLV、RTP等。
这里,再问一个问题:

  1. HLS 和 MPEG-4/H.264 以及容器格式 TS/PS 是啥关系?

简单来说,没关系。

HLS 根本就不会涉及到视频本身的解码问题。它的存在只是为了确保你的视频能够及时,快速,正确的播放。

现在,直播行业依旧很火,而 HTML5 直播,一直以来都是一个比较蛋疼的内容。一是,浏览器厂商更新速度比较慢,二是,这并不是我们前端专攻的一块,所以,有时候的确很鸡肋。当然,进了前端,你就别想着休息。接下来,我们来详细的看一下市面上主流的几个协议。

HLS

HLS 全称是 HTTP Live Streaming。这是 Apple 提出的直播流协议。目前,IOS 和 高版本 Android 都支持 HLS。那什么是 HLS 呢?
HLS 主要的两块内容是 .m3u8 文件和 .ts 播放文件。接受服务器会将接受到的视频流进行缓存,然后缓存到一定程度后,会将这些视频流进行编码格式化,同时会生成一份 .m3u8 文件和其它很多的 .ts 文件。根据 wiki 阐述,HLS 的基本架构为:

  • 服务器:后台服务器接受视频流,然后进行编码和片段化。

    • 编码:视频格式编码采用 H.264。音频编码为 AAC, MP3, AC-3,EC-3。然后使用 MPEG-2 Transport Stream 作为容器格式。

    • 分片:将 TS 文件分成若干个相等大小的 .ts 文件。并且生成一个 .m3u8 作为索引文件(确保包的顺序)

  • 分发:由于 HLS 是基于 HTTP 的,所以,作为分发,最常用的就是 CDN 了。

  • 客户端:使用一个 URL 去下载 m3u8 文件,然后,开始下载 ts 文件,下载完成后,使用 playback software(即时播放器) 进行播放。

这里,我们着重介绍一下客户端的过程。首先,直播之所以是直播,在于它的内容是实时更新的。那 HLS 是怎么完成呢?
我们使用 HLS 直接就用一个 video 进行包括即可:

<video controls autoplay>  
    <source data-original="http://devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8" type="application/vnd.apple.mpegurl" /> 
    <p class="warning">Your browser does not support HTML5 video.</p>  
</video>

根据上面的描述,它实际上就是去请求一个 .m3u8 的索引文件。该文件包含了对 .ts 文件的相关描述,例如:

#EXT-X-VERSION:3            PlayList 的版本,可带可不带。下面有说明
#EXTM3U                     m3u文件头
#EXT-X-TARGETDURATION:10    分片最大时长,单位为 s
#EXT-X-MEDIA-SEQUENCE:1     第一个TS分片的序列号,如果没有,默认为 0
#EXT-X-ALLOW-CACHE          是否允许cache
#EXT-X-ENDLIST              m3u8文件结束符
#EXTINF                     指定每个媒体段(ts)的持续时间(秒),仅对其后面的URI有效

不过,这只是一个非常简单,不涉及任何功能的直播流。实际上,HLS 的整个架构,可以分为:

stream_playlists_2x.png-35.5kB

当然,如果你使用的是 masterplaylist 作为链接,如:

<video controls autoplay>  
    <source data-original="http://devimages.apple.com/iphone/samples/bipbop/masterplaylist.m3u8" type="application/vnd.apple.mpegurl" /> 
    <p class="warning">Your browser does not support HTML5 video.</p>  
</video>

我们看一下,masterplaylist 里面具体的内容是啥:

#EXTM3U
#EXT-X-VERSION:6
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2855600,CODECS="avc1.4d001f,mp4a.40.2",RESOLUTION=960x540
live/medium.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=5605600,CODECS="avc1.640028,mp4a.40.2",RESOLUTION=1280x720
live/high.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1755600,CODECS="avc1.42001f,mp4a.40.2",RESOLUTION=640x360
live/low.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=545600,CODECS="avc1.42001e,mp4a.40.2",RESOLUTION=416x234
live/cellular.m3u8

EXT-X-STREAM-INF 这个标签头代表:当前用户的播放环境。masterplaylist 主要干的事就是根据, 当前用户的带宽,分辨率,解码器等条件决定使用哪一个流。所以,master playlist 是为了更好的用户体验而存在的。不过,弊端就是后台储备流的量会成倍增加。
现在,我们来主要看一下,如果你使用 master playlist,那么整个流程是啥?
当填写了 master playlist URL,那么用户只会下载一次该 master playlist。接着,播放器根据当前的环境决定使用哪一个 media playlist(就是 子 m3u8 文件)。如果,在播放当中,用户的播放条件发生变化时,播放器也会切换对应的 media playlist。关于 master playlist 内容,我们就先介绍到这里。
关于 HLS,感觉主要内容还在 media playlist 上。当然,media playlist 还分为三种 list:

  • live playlist: 动态列表。顾名思义,该列表是动态变化的,里面的 ts 文件会实时更新,并且过期的 ts 索引会被删除。默认,情况下都是使用动态列表。

  • event playlist: 静态列表。它和动态列表主要区别就是,原来的 ts 文件索引不会被删除,该列表是不断更新,而且文件大小会逐渐增大。它会在文件中,直接添加 #EXT-X-PLAYLIST-TYPE:EVENT 作为标识。

  • VOD playlist: 全量列表。它就是将所有的 ts 文件都列在 list 当中。如果,使用该列表,就和播放一整个视频没有啥区别了。它是使用 #EXT-X-ENDLIST 表示文件结尾。

live playlist DEMO:

#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:26
#EXTINF:9.901,
http://media.example.com/wifi/segment26.ts
#EXTINF:9.901,
http://media.example.com/wifi/segment27.ts
#EXTINF:9.501,
http://media.example.com/wifi/segment28.ts

evet playlist DEMO:

#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:EVENT
#EXTINF:9.9001,
http://media.example.com/wifi/segment0.ts
#EXTINF:9.9001,
http://media.example.com/wifi/segment1.ts
#EXTINF:9.9001,
http://media.example.com/wifi/segment2.ts

VOD playlist DEMO:

#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:9.9001,
http://media.example.com/wifi/segment0.ts
#EXTINF:9.9001,
http://media.example.com/wifi/segment1.ts
#EXTINF:9.9001,
http://media.example.com/wifi/segment2.ts
#EXT-X-ENDLIST

上面提到过一个 EXT-X-VERSION 这样的标签,这是用来表示当前 HLS 的版本。那 HLS 有哪些版本呢?
根据 apple 官方文档 的说明,我们可以了解到,不同版本的区别:

page.png-18.4kB

当然,HLS 支持的功能,并不只是分片播放(专门适用于直播),它还包括其他应有的功能。

  • 使用 HTTPS 加密 ts 文件

  • 快/倒放

  • 广告插入

  • 不同分辨率视频切换

HLS 的弊端

由于 HLS 是基于 HTTP 的,所以,它关于 HTTP 的好处,我们大部分都了解,比如,高兼容性,高可扩展性等。不过正由于是 HTTP 协议,所以会在握手协议上造成一定的延迟性。HLS 首次连接时,总共的延时包括:

  1. TCP 握手,2. m3u8 文件下载,3. m3u8 下的 ts 文件下载。

其中,每个 ts 文件,大概会存放 5s~10s 的时长,并且每个 m3u8 文件会存放 3~8 个 ts 文件。我们折中算一下,5 个 ts 文件,每个时长大约 8s 那么,总的下来,一共延时 40s。当然,这还不算上 TCP 握手,m3u8 文件下载等问题。那优化办法有吗?有的,那就是减少每个 m3u8 文件中的 ts 数量和 ts 文件时长,不过,这样也会成倍的增加后台承受流量请求的压力。所以,这还是需要到业务中去探索最优的配置(打个广告:腾讯云的直播视频流业务,做的确实挺棒。)
关于 HLS 的详细内容,可以参考:HLS 详解
关于 m3u8 文件的标签内容,可以参考:HLS 标签头详解
总而言之,HLS 之所以能这么流行,关键在于它的支持度是真的广,所以,对于一般 H5 直播来说,应该是非常友好的。不过,既然是直播,关键在于它的实时性,而 HLS 天生就存在一定的延时,所以,就可以考虑其他低延时的方案,比如 RTMP,HTTP-FLV。下面,我们来看一下 RTMP 内容。

RTMP

RTMP 全称为:Real-Time Messaging Protocol 。它是专门应对实时交流场景而开发出来的一个协议。它爹是 Macromedia,后来卖身给了 Adobe。RTMP 根据不同的业务场景,有很多变种:

  • 纯 RTMP 使用 TCP 连接,默认端口为 1935(有可能被封)。

  • RTMPS: 就是 RTMP + TLS/SSL

  • RTMPE: RTMP + encryption。在 RTMP 原始协议上使用,Adobe 自身的加密方法

  • RTMPT: RTMP + HTTP。使用 HTTP 的方式来包裹 RTMP 流,这样能直接通过防火墙。

  • RTMFP: RMPT + UDP。该协议常常用于 P2P 的场景中,针对延时有变态的要求。

既然是 Adobe 公司开发的(算吧),那么,该协议针对的就是 Flash Video,即,FLV。不过,在移动端上,Flash Player 已经被杀绝了,那为啥还会出现这个呢?简单来说,它主要是针对 PC 端的。RTMP 出现的时候,还是 零几 年的时候,IE 还在大行其道,Flash Player 也并未被各大浏览器所排斥。那时候 RTMP 毋庸置疑的可以在视频界有自己的一席之地。

RTMP 由于借由 TCP 长连接协议,所以,客户端向服务端推流这些操作而言,延时性很低。它会将上传的流分成不同的分片,这些分片的大小,有时候变,有时候不会变。默认情况下就是,64B 的音频数据 + 128B 的视频数据 + 其它数据(比如 头,协议标签等)。但 RTMP 具体传输的时候,会将分片进一步划分为包,即,视频包,音频包,协议包等。因为,RTMP 在进行传输的时候,会建立不同的通道,来进行数据的传输,这样对于不同的资源,对不同的通道设置相关的带宽上限。

RTMP 处理的格式是 MP3/ACC + FLV1。
不过,由于支持性的原因,RTMP 并未在 H5 直播中,展示出优势。下列是简单的对比:

dff.png-15.8kB

HTTP-FLV

HTTP-FLV 和 RTMPT 类似,都是针对于 FLV 视频格式做的直播分发流。但,两者有着很大的区别。

  • 相同点

    • 两者都是针对 FLV 格式

    • 两者延时都很低

    • 两者都走的 HTTP 通道

  • 不同点

    • HTTP-FLv

      • 直接发起长连接,下载对应的 FLV 文件

      • 头部信息简单

    • RTMPT

      • 握手协议过于复杂

      • 分包,组包过程耗费精力大

通过上面来看,HTTP-FLV 和 RTMPT 确实不是一回事,但,如果了解 SRS(simple rtmp server),那么 对 HTTP-FLV 应该清楚不少。SRS 本质上,就是 RTMP + FLV 进行传输。因为 RTMP 发的包很容易处理,通常 RTMP 协议会作为视频上传端来处理,然后经由服务器转换为 FLV 文件,通过 HTTP-FLV 下发给用户。

STRU.png-2.9kB

现在市面上,比较常用的就是 HTTP-FLV 进行播放。但,由于手机端上不支持,所以,H5 的 HTTP-FLV 也是一个痛点。不过,现在 flv.js 可以帮助高版本的浏览器,通过 mediaSource 来进行解析。HTTP-FLV 的使用方式也很简单。和 HLS 一样,只需要添加一个连接即可:

<object type="application/x-shockwave-flash" data-original="http://s6.pdim.gs/static/a2a36bc596148316.flv"></object>

不过,并不是末尾是 .flv 的都是 HTTP-FLV 协议,因为,涉及 FLV 的流有三种,它们三种的使用方式都是一模一样的。

  • FLV 文件:相当于就是一整个文件,官方称为 渐进 HTTP 流。它的特点是只能渐进下载,不能进行点播。

  • FLV 伪流:该方式,可以通过在末尾添加 ?start=xxx 的参数,指定返回的对应开始时间视频数据。该方式比上面那种就多了一个点播的功能。本质上还是 FLV 直播。

  • FLV 直播流:这就是 HTTP-FLV 真正所支持的流。SRS 在内部使用的是 RTMP 进行分发,然后在传给用户的使用,经过一层转换,变为 HTTP 流,最终传递给用户。

上面说到,HTTP-FLV 就是长连接,简而言之只需要加上一个 Connection:keep-alive 即可。关键是它的响应头,由于,HTTP-FLV 传递的是视频格式,所有,它的 Content-TypeTransfer-Encoding 需要设置其它值。

Content-Type:video/x-flv
Expires:Fri, 10 Feb 2017 05:24:03 GMT
Pragma:no-cache
Transfer-Encoding:chunked

不过,一般而言,直播服务器一般和业务服务是不会放在一块的,所以这里,可能会额外需要支持跨域直播的相关技术。在 XHR2 里面,解决办法也很简单,直接使用 CORS 即可:

// 那么整个响应头,可以为:
Access-Control-Allow-credentials:true
Access-Control-Allow-max-age:86400
Access-Control-Allow-methods:GET,POST,OPTIONS
Access-Control-Allow-Origin:*
Cache-Control:no-cache
Content-Type:video/x-flv
Expires:Fri, 10 Feb 2017 05:24:03 GMT
Pragma:no-cache
Transfer-Encoding:chunked

对于 HTTP-FLV 来说,关键难点在于 RTMP 和 HTTP 协议的转换,这里我就不多说了。因为,我们主要针对的是前端开发,讲一下和前端相关的内容。

接下来,我们在主要来介绍一下 FLV 格式的。因为,后面我们需要通过 mediaSource 来解码 FLV。

FLV 格式浅析

FLV 原始格式,Adobe 可以直接看 flv格式详解。我这里就抽主要的内容讲讲。FLV 也是与时俱进,以前 FLV 的格式叫做 FLV,新版的可以叫做 F4V。两者的区别,简单的区分方法就是:

  • FLV 是专门针对 Flash 播放器的

  • F4V 是有点像 MEPG 格式的 Flash 播放,主要为了兼容 H.264/ACC。F4V 不支持 FLV(两者本来都不是同一个格式)

这里我们主要针对 FLV 进行相关了解。因为,一般情况下,后台发送视频流时,为了简洁快速,就是发送 FLV 视频。FLV 由于年限比较久,它所支持的内容是 H.263,VP6 codec。FLV 一般可以嵌套在 .swf 文件当中,不过,对于 HTTP-FLV 等 FLV 直播流来说,一般直接使用 .flv 文件即可。在 07 年的时候,提出了 F4V 这个视频格式,当然,FLV 等也会向前兼容。

flv

这里,我们来正式介绍一下 FLV 的格式。一个完整的 FLV 流包括 FLV Header + FLV Packets。

FLV Header

FLV 格式头不难,就几个字段:

FieldData TypeDefaultDetails
Signaturebyte[3]“FLV”有三个B的大小,算是一种身份的象征
Versionuint81只有 0x01 是有效的。其实就是默认值
Flagsuint8 bitmask0x05表示该流的特征。0x04 是 audio,0x01 是 video,0x05 是 audio+video
Header Sizeuint32_be9用来跳过多余的头

FLV Packets

在 FLV 的头部之后,就正式开始发送 FLV 文件。文件会被拆解为数个包(FLV tags)进行传输。每个包都带有 15B 的头。前 4 个字节是用来代表前一个包的头部内容,用来完成倒放的功能。整个包的结构为:

FLV

具体解释如下:

字段字段大小默认值详解
Size of previous packetuint32_be0关于前一个包的信息,如果是第一个包,则该部分为 NULL
Packet Typeuint818设置包的内容,如果是第一个包,则该部分为 AMF 元数据
Payload Sizeuint24_bevaries该包的大小
Timestamp Loweruint24_be0起始时间戳
Timestamp Upperuint80持续时间戳,通常加上 Lower 实际上戳,代表整个时间。
Stream IDuint24_be0流的类型,第一个流设为 NULL
Payload Datafreeformvaries传输数据

其中,由于 Packet Type 的值可以取多个, 需要额外说明一下。

  • Packet Type

    • 1: RTMP 包的大小

    • 3: RTMP 字节读包反馈,RTMP ping,RTMP 服务器带宽,RTMP 客户端带宽

    • 8: 音频和视频的数据

    • 15: RTMP flex 流

    • 24: 经过封装的 flash video。

上面是关于 FLV 简单的介绍。不过,如果没有 `
Media Source Extensions` 的帮助,那么上面说的基本上全是废话。由于,Flash Player 已经被时代所遗弃,所以,我们不能在浏览器上,顺利的播放 FLV 视频。接下来,我们先来详细了解一下 MSE 的相关内容。

Media Source Extensions

在没有 MSE 出现之前,前端对 video 的操作,仅仅局限在对视频文件的操作,而并不能对视频流做任何相关的操作。现在 MSE 提供了一系列的接口,使开发者可以直接提供 media stream。

那 MSE 是如何完成视频流的加载和播放呢?

入门实例

这可以参考 google 的 MSE 简介

var vidElement = document.querySelector('video');

if (window.MediaSource) {
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen);
} else {
  console.log("The Media Source Extensions API is not supported.")
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/webm; codecs="opus, vp9"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime);
  var videoUrl = 'droid.webm';
  fetch(videoUrl)
    .then(function(response) {
      return response.arrayBuffer();
    })
    .then(function(arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function(e) {
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream();
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer);
    });
}

可以从上面的代码看出,一套完整的执行代码,不仅需要使用 MSE 而且,还有一下这些相关的 API。

  • HTMLVideoElement.getVideoPlaybackQuality()

  • SourceBuffer

  • SourceBufferList

  • TextTrack.sourceBuffer

  • TrackDefault

  • TrackDefaultList

  • URL.createObjectURL()

  • VideoPlaybackQuality

  • VideoTrack.sourceBuffer

我们简单讲解一下上面的流程。根据 google 的阐述,整个过程可以为:

image.png-16kB

  • 第一步,通过异步拉取数据。

  • 第二步,通过 MediaSource 处理数据。

  • 第三步,将数据流交给 audio/video 标签进行播放。

而中间传递的数据都是通过 Buffer 的形式来进行传递的。

image.png-29.5kB

中间有个需要注意的点,MS 的实例通过 URL.createObjectURL() 创建的 url 并不会同步连接到 video.src。换句话说,URL.createObjectURL() 只是将底层的流(MS)和 video.src 连接中间者,一旦两者连接到一起之后,该对象就没用了。

那么什么时候 MS 才会和 video.src 连接到一起呢?

创建实例都是同步的,但是底层流和 video.src 的连接时异步的。MS 提供了一个 sourceopen 事件给我们进行这项异步处理。一旦连接到一起之后,该 URL object 就没用了,处于内存节省的目的,可以使用 URL.revokeObjectURL(vidElement.src) 销毁指定的 URL object。

mediaSource.addEventListener('sourceopen', sourceOpen);

function sourceOpen(){
    URL.revokeObjectURL(vidElement.src)
}

MS 对流的解析

MS 提供了我们对底层音视频流的处理,那一开始我们怎么决定以何种格式进行编解码呢?

这里,可以使用 addSourceBuffer(mime) 来设置相关的编码器:

  var mime = 'video/webm; codecs="opus, vp9"';  
  var sourceBuffer = mediaSource.addSourceBuffer(mime);  

然后通过,异步拉取相关的音视频流:

fetch(url)
.then(res=>{
    return res.arrayBuffer();
})
.then(buffer=>{
    sourceBuffer.appendBuffer(buffer);
})

如果视频已经传完了,而相关的 Buffer 还在占用内存,这时候,就需要我们显示的中断当前的 Buffer 内容。那么最终我们的异步处理结果变为:

fetch(url)
.then(res=>{
    return res.arrayBuffer();
})
.then(function(arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function(e) {
      // 是否有持续更新的流
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
        // 没有,则中断连接
          mediaSource.endOfStream();
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer);
    });

上面我们大致了解了一下关于 Media Source Extensions 的大致流程,但里面的细节我们还没有细讲。接下来,我们来具体看一下 MSE 一篮子的生态技术包含哪些内容。首先是,MediaSource

MediaSource

MS(MediaSource) 可以理解为多个视频流的管理工具。以前,我们只能下载一个清晰度的流,并且不能平滑切换低画质或者高画质的流,而现在我们可以利用 MS 实现这里特性。我们先来简单了解一下他的 API。

MS 的创建

创建一个 MS:

var mediaSource = new MediaSource();

相关方法

addSourceBuffer()

该是用来返回一个具体的视频流,接受一个 mimeType 表示该流的编码格式。例如:

var mimeType = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
var sourceBuffer = mediaSource.addSourceBuffer(mimeType);

sourceBuffer 是直接和视频流有交集的 API。例如:

function sourceOpen (_) {
  var mediaSource = this;
  var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
  fetchAB(assetURL, function (buf) {
    sourceBuffer.addEventListener('updateend', function (_) {
      mediaSource.endOfStream();
      video.play();
    });
    // 通过 fetch 添加视频 Buffer
    sourceBuffer.appendBuffer(buf);
  });
};

它通过 appendBuffer 直接添加视频流,实现播放。不过,在使用 addSourceBuffer 创建之前,还需要保证当前浏览器是否支持该编码格式。

removeSourceBuffer()

用来移除某个 sourceBuffer。移除也主要是考虑性能原因,将不需要的流移除以节省相应的空间,格式为:

mediaSource.removeSourceBuffer(sourceBuffer);

endOfStream()

用来表示接受的视频流的停止,注意,这里并不是断开,相当于只是下好了一部分视频,然后你可以进行播放。此时,MS 的状态变为:ended。例如:


  var mediaSource = this;
  var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
  fetchAB(assetURL, function (buf) {
    sourceBuffer.addEventListener('updateend', function (_) {
      mediaSource.endOfStream(); // 结束当前的接受
      video.play(); // 可以播放当前获得的流
    });
    sourceBuffer.appendBuffer(buf);
  });

isTypeSupported()

该是用来检测当前浏览器是否支持指定视频格式的解码。格式为:

var isItSupported = mediaSource.isTypeSupported(mimeType); // 返回值为 Boolean

mimeType 可以为 type 或者 type + codec。

例如:

// 不同的浏览器支持不一样,不过基本的类型都支持。
MediaSource.isTypeSupported('audio/mp3'); // false,这里应该为 audio/mpeg 
MediaSource.isTypeSupported('video/mp4'); // true
MediaSource.isTypeSupported('video/mp4; codecs="avc1.4D4028, mp4a.40.2"'); // true

这里有一份具体的 mimeType 参考列表。

MS 的状态

当 MS 从创建开始,都会自带一个 readyState 属性,用来表示其当前打开的状态。MS 有三个状态:

  • closed: 当前 MS 没有和 media element(比如:video.src) 相关联。创建时,MS 就是该状态。

  • open: source 打开,并且准备接受通过 sourceBuffer.appendBuffer 添加的数据。

  • ended: 当 endOfStream() 执行完成,会变为该状态,此时,source 依然和 media element 连接。

var mediaSource = new MediaSource;
mediaSource.readyState; // 默认为 closed

当由 closed 变为 open 状态时,需要监听 sourceopen 事件。

video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', sourceOpen);

MS 针对这几个状态变化,提供了相关的事件:sourceopensourceendedsourceclose

  • sourceopen: 当 "closed" to "open" 或者 "ended" to "open" 时触发。

  • sourceended: 当 "open" to "ended" 时触发。

  • sourceclose: 当 "open" to "closed" 或者 "ended" to "closed" 时触发。

MS 还提供了其他的监听事件 sourceopen,sourceended,sourceclose,updatestart,update,updateend,error,abort,addsourcebuffer,removesourcebuffer. 这里主要选了比较重要的,其他的可以参考官方文档。

MS 属性

比较常用的属性有: duration,readyState。

  • duration: 获得当前媒体播放的时间,既可以设置(get),也可以获取(set)。单位为 s(秒)

mediaSource.duration = 5.5; // 设置媒体流播放的时间
var myDuration = mediaSource.duration; // 获得媒体流开始播放的时间

在实际应用中为:

sourceBuffer.addEventListener('updateend', function (_) {
      mediaSource.endOfStream();
      mediaSource.duration = 120; // 设置当前流播放的时间
      video.play();
    });
  • readyState: 获得当前 MS 的状态。取值上面已经讲过了: closedopenended

var mediaSource = new MediaSource;
  //此时的 mediaSource.readyState 状态为 closed

以及:

sourceBuffer.addEventListener('updateend', function (_) {
      mediaSource.endOfStream(); // 调用该方法后结果为:ended
      video.play();
    });

除了上面两个属性外,还有 sourceBuffersactiveSourceBuffers 这两个属性。用来返回通过 addSourceBuffer() 创建的 SourceBuffer 数组。这没啥过多的难度。

接下来我们就来看一下靠底层的 sourceBuffer

SourceBuffer

SourceBuffer 是由 mediaSource 创建,并直接和 HTMLMediaElement 接触。简单来说,它就是一个流的容器,里面提供的 append()remove() 来进行流的操作,它可以包含一个或者多个 media segments。同样,接下来,我们再来看一下该构造函数上的基本属性和内容。

基础内容

前面说过 sourceBuffer 主要是一个用来存放流的容器,那么,它是怎么存放的,它存放的内容是啥,有没有顺序等等。这些都是 sourceBuffer 最最根本的问题。OK,接下来,我们来看一下的它的基本架构有些啥。

参考 W3C,可以基本了解到里面的内容为:

interface SourceBuffer : EventTarget {
             attribute AppendMode          mode;
    readonly attribute boolean             updating;
    readonly attribute TimeRanges          buffered;
             attribute double              timestampOffset;
    readonly attribute AudioTrackList      audioTracks;
    readonly attribute VideoTrackList      videoTracks;
    readonly attribute TextTrackList       textTracks;
             attribute double              appendWindowStart;
             attribute unrestricted double appendWindowEnd;
             attribute EventHandler        onupdatestart;
             attribute EventHandler        onupdate;
             attribute EventHandler        onupdateend;
             attribute EventHandler        onerror;
             attribute EventHandler        onabort;
    void appendBuffer(BufferSource data);
    void abort();
    void remove(double start, unrestricted double end);
};

上面这些属性决定了其 sourceBuffer 整个基础。

首先是 mode。上面说过,SB(SourceBuffer) 里面存储的是 media segments(就是你每次通过 append 添加进去的流片段)。SB.mode 有两种格式:

  • segments: 乱序排放。通过 timestamps 来标识其具体播放的顺序。比如:20s的 buffer,30s 的 buffer 等。

  • sequence: 按序排放。通过 appendBuffer 的顺序来决定每个 mode 添加的顺序。timestamps 根据 sequence 自动产生。

那么上面两个哪个是默认值呢?

看情况,讲真,没骗你。

media segments 天生自带 timestamps,那么 mode 就为 segments ,否则为 sequence。所以,一般情况下,我们是不用管它的值。不过,你可以在后面,将 segments 设置为 sequence 这个是没毛病的。反之,将 sequence 设置为 segments 就有问题了。

var bufferMode = sourceBuffer.mode;
if (bufferMode == 'segments') {
  sourceBuffer.mode = 'sequence';
}

然后另外两个就是 bufferedupdating

  • buffered:返回一个 timeRange 对象。用来表示当前被存储在 SB 中的 buffer。

  • updating: 返回 Boolean,表示当前 SB 是否正在被更新。例如: SourceBuffer.appendBuffer(), SourceBuffer.appendStream(), SourceBuffer.remove() 调用时。

另外还有一些其他的相关属性,比如 textTracks,timestampOffset,trackDefaults,这里就不多说了。实际上,SB 是一个事件驱动的对象,一些常见的处理,都是在具体的事件中完成的。那么它又有哪些事件呢?

事件触发

在 SB 中,相关事件触发包括:

  • updatestart: 当 updating 由 false 变为 true。

  • update:当 append()/remove() 方法被成功调用完成时,updating 由 true 变为 false。

  • updateend: append()/remove() 已经结束

  • error: 在 append() 过程中发生错误,updating 由 true 变为 false。

  • abort: 当 append()/remove() 过程中,使用 abort() 方法废弃时,会触发。此时,updating 由 true 变为 false。

注意上面有两个事件比较类似:updateupdateend。都是表示处理的结束,不同的是,update 比 updateend 先触发。

sourceBuffer.addEventListener('updateend', function (e) {
    // 当指定的 buffer 加载完后,就可以开始播放
      mediaSource.endOfStream();
      video.play();
    });

相关方法

SB 处理流的方法就是 +/- : appendBuffer, remove。另外还有一个中断处理函数 abort()

  • appendBuffer(ArrayBuffer):用来添加 ArrayBuffer。该 ArrayBuffer 一般是通过 fetch 的 response.arrayBuffer(); 来获取的。

  • remove(start, end): 用来移除具体某段的 media segments。

    • @param start/end: 都是时间单位(s)。用来表示具体某段的 media segments 的范围。

  • abort(): 用来放弃当前 append 流的操作。不过,该方法的业务场景也比较有限。它只能用在当 SB 正在更新流的时候。即,此时通过 fetch,已经接受到新流,并且使用 appendBuffer 添加,此为开始的时间。然后到 updateend 事件触发之前,这段时间之内调用 abort()。有一个业务场景是,当用户移动进度条,而,此时 fetch 已经获取前一次的 media segments,那么可以使用 abort 放弃该操作,转而请求新的 media segments。具体可以参考:abort 使用

上面主要介绍了处理音视频流需要用的 Web 技术,后面章节,我们接入实战,具体来讲一下,如何做到使用 MSE 进行 remux 和 demux。

查看原文

赞 14 收藏 39 评论 1

momo707577045 赞了文章 · 2020-07-24

「1.4万字」玩转前端 Video 播放器 | 多图预警

Web 开发者们一直以来想在 Web 中使用音频和视频,但早些时候,传统的 Web 技术不能够在 Web 中嵌入音频和视频,所以一些像 Flash、Silverlight 的专利技术在处理这些内容上变得很受欢迎。这些技术能够正常的工作,但是却有着一系列的问题,包括无法很好的支持 HTML/CSS 特性、安全问题,以及可行性问题。

幸运的是,当 HTML5 标准公布后,其中包含许多的新特性,包括 <video><audio> 标签,以及一些 JavaScript APIs 用于对其进行控制。随着通信技术和网络技术的不断发展,目前音视频已经成为大家生活中不可或缺的一部分。此外,伴随着 5G 技术的慢慢普及,实时音视频领域还会有更大的想象空间。

接下来本文将从八个方面入手,全方位带你一起探索前端 Video 播放器和主流的流媒体技术。阅读完本文后,你将了解以下内容:

  • 为什么一些网页中的 Video 元素,其视频源地址是采用 Blob URL 的形式;
  • 什么是 HTTP Range 请求及流媒体技术相关概念;
  • 了解 HLS、DASH 的概念、自适应比特率流技术及流媒体加密技术;
  • 了解 FLV 文件结构、flv.js 的功能特性与使用限制及内部的工作原理;
  • 了解 MSE(Media Source Extensions)API 及相关的使用;
  • 了解视频播放器的原理、多媒体封装格式及 MP4 与 Fragmented MP4 封装格式的区别;

在最后的 阿宝哥有话说 环节,阿宝哥将介绍如何实现播放器截图、如何基于截图生成 GIF、如何使用 Canvas 播放视频及如何实现色度键控等功能。

一、传统的播放模式

大多数 Web 开发者对 <video> 都不会陌生,在以下 HTML 片段中,我们声明了一个 <video> 元素并设置相关的属性,然后通过 <source> 标签设置视频源和视频格式:

<video id="mse" autoplay=true playsinline controls="controls">
   <source data-original="https://h5player.bytedance.com/video/mp4/xgplayer-demo-720p.mp4" type="video/mp4">
   你的浏览器不支持Video标签
</video>

上述代码在浏览器渲染之后,在页面中会显示一个 Video 视频播放器,具体如下图所示:

(图片来源:https://h5player.bytedance.co...

通过 Chrome 开发者工具,我们可以知道当播放 xgplayer-demo-720p.mp4 视频文件时,发了 3 个 HTTP 请求:

此外,从图中可以清楚地看到,头两个 HTTP 请求响应的状态码是 206。这里我们来分析第一个 HTTP 请求的请求头和响应头:

在上面的请求头中,有一个 range: bytes=0- 首部信息,该信息用于检测服务端是否支持 Range 请求。如果在响应中存在 Accept-Ranges 首部(并且它的值不为 “none”),那么表示该服务器支持范围请求。

在上面的响应头中, Accept-Ranges: bytes 表示界定范围的单位是 bytes 。这里 Content-Length 也是有效信息,因为它提供了要下载的视频的完整大小。

1.1 从服务器端请求特定的范围

假如服务器支持范围请求的话,你可以使用 Range 首部来生成该类请求。该首部指示服务器应该返回文件的哪一或哪几部分。

1.1.1 单一范围

我们可以请求资源的某一部分。这里我们使用 Visual Studio Code 中的 REST Client 扩展来进行测试,在这个例子中,我们使用 Range 首部来请求 www.example.com 首页的前 1024 个字节。

对于使用 REST Client 发起的 单一范围请求,服务器端会返回状态码为 206 Partial Content 的响应。而响应头中的 Content-Length 首部现在用来表示先前请求范围的大小(而不是整个文件的大小)。Content-Range 响应首部则表示这一部分内容在整个资源中所处的位置。

1.1.2 多重范围

Range 头部也支持一次请求文档的多个部分。请求范围用一个逗号分隔开。比如:

$ curl http://www.example.com -i -H "Range: bytes=0-50, 100-150"

对于该请求会返回以下响应信息:

因为我们是请求文档的多个部分,所以每个部分都会拥有独立的 Content-TypeContent-Range 信息,并且使用 boundary 参数对响应体进行划分。

1.1.3 条件式范围请求

当重新开始请求更多资源片段的时候,必须确保自从上一个片段被接收之后该资源没有进行过修改。

If-Range 请求首部可以用来生成条件式范围请求:假如条件满足的话,条件请求就会生效,服务器会返回状态码为 206 Partial 的响应,以及相应的消息主体。假如条件未能得到满足,那么就会返回状态码为 200 OK 的响应,同时返回整个资源。该首部可以与 Last-Modified 验证器或者 ETag 一起使用,但是二者不能同时使用。

1.1.4 范围请求的响应

与范围请求相关的有三种状态:

  • 在请求成功的情况下,服务器会返回 206 Partial Content 状态码。
  • 在请求的范围越界的情况下(范围值超过了资源的大小),服务器会返回 416 Requested Range Not Satisfiable (请求的范围无法满足) 状态码。
  • 在不支持范围请求的情况下,服务器会返回 200 OK 状态码。

剩余的两个请求,阿宝哥就不再详细分析了。感兴趣的小伙伴,可以使用 Chrome 开发者工具查看一下具体的请求报文。通过第 3 个请求,我们可以知道整个视频的大小大约为 7.9 MB。若播放的视频文件太大或出现网络不稳定,则会导致播放时,需要等待较长的时间,这严重降低了用户体验。

那么如何解决这个问题呢?要解决该问题我们可以使用流媒体技术,接下来我们来介绍流媒体。

二、流媒体

流媒体是指将一连串的媒体数据压缩后,经过网上分段发送数据,在网上即时传输影音以供观赏的一种技术与过程,此技术使得数据包得以像流水一样发送;如果不使用此技术,就必须在使用前下载整个媒体文件。

流媒体实际指的是一种新的媒体传送方式,有声音流、视频流、文本流、图像流、动画流等,而非一种新的媒体。流媒体最主要的技术特征就是流式传输,它使得数据可以像流水一样传输。流式传输是指通过网络传送媒体技术的总称。实现流式传输主要有两种方式:顺序流式传输(Progressive Streaming)和实时流式传输(Real Time Streaming)。

目前网络上常见的流媒体协议:

通过上表可知,不同的协议有着不同的优缺点。在实际使用过程中,我们通常会在平台兼容的条件下选用最优的流媒体传输协议。比如,在浏览器里做直播,选用 HTTP-FLV 协议是不错的,性能优于 RTMP+Flash,延迟可以做到和 RTMP+Flash 一样甚至更好。

而由于 HLS 延迟较大,一般只适合视频点播的场景,但由于它在移动端拥有较好的兼容性,所以在接受高延迟的条件下,也是可以应用在直播场景。

讲到这里相信有些小伙伴会好奇,对于 Video 元素来说使用流媒体技术之后与传统的播放模式有什么直观的区别。下面阿宝哥以常见的 HLS 流媒体协议为例,来简单对比一下它们之间的区别。

通过观察上图,我们可以很明显地看到,当使用 HLS 流媒体网络传输协议时,<video> 元素 src 属性使用的是 blob:// 协议。讲到该协议,我们就不得不聊一下 Blob 与 Blob URL。

2.1 Blob

Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScript 中 Blob 类型的对象表示不可变的类似文件对象的原始数据。

Blob 由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成:

MIME(Multipurpose Internet Mail Extensions)多用途互联网邮件扩展类型,是设定某种扩展名的文件用一种应用程序来打开的方式类型,当该扩展名文件被访问的时候,浏览器会自动使用指定应用程序来打开。多用于指定一些客户端自定义的文件名,以及一些媒体文件打开方式。

常见的 MIME 类型有:超文本标记语言文本 .html text/html、PNG图像 .png image/png、普通文本 .txt text/plain 等。

为了更直观的感受 Blob 对象,我们先来使用 Blob 构造函数,创建一个 myBlob 对象,具体如下图所示:

如你所见,myBlob 对象含有两个属性:size 和 type。其中 size 属性用于表示数据的大小(以字节为单位),type 是 MIME 类型的字符串。Blob 表示的不一定是 JavaScript 原生格式的数据。比如 File 接口基于 Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

2.2 Blob URL/Object URL

Blob URL/Object URL 是一种伪协议,允许 Blob 和 File 对象用作图像,下载二进制数据链接等的 URL 源。在浏览器中,我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:<origin>/<uuid>,对应的示例如下:

blob:https://example.org/40a5fb5a-d56d-4a33-b4e2-0acf6a8e5f641

浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。生成的 URL 仅在当前文档打开的状态下才有效。但如果你访问的 Blob URL 不再存在,则会从浏览器中收到 404 错误。

上述的 Blob URL 看似很不错,但实际上它也有副作用。虽然存储了 URL → Blob 的映射,但 Blob 本身仍驻留在内存中,浏览器无法释放它。映射在文档卸载时自动清除,因此 Blob 对象随后被释放。但是,如果应用程序寿命很长,那不会很快发生。因此,如果我们创建一个 Blob URL,即使不再需要该 Blob,它也会存在内存中。

针对这个问题,我们可以调用 URL.revokeObjectURL(url) 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。

2.3 Blob vs ArrayBuffer

其实在前端除了 Blob 对象 之外,你还可能会遇到 ArrayBuffer 对象。它用于表示通用的,固定长度的原始二进制数据缓冲区。你不能直接操纵 ArrayBuffer 的内容,而是需要创建一个 TypedArray 对象或 DataView 对象,该对象以特定格式表示缓冲区,并使用该对象读取和写入缓冲区的内容。

Blob 对象与 ArrayBuffer 对象拥有各自的特点,它们之间的区别如下:

  • 除非你需要使用 ArrayBuffer 提供的写入/编辑的能力,否则 Blob 格式可能是最好的。
  • Blob 对象是不可变的,而 ArrayBuffer 是可以通过 TypedArrays 或 DataView 来操作。
  • ArrayBuffer 是存在内存中的,可以直接操作。而 Blob 可以位于磁盘、高速缓存内存和其他不可用的位置。
  • 虽然 Blob 可以直接作为参数传递给其他函数,比如 window.URL.createObjectURL()。但是,你可能仍需要 FileReader 之类的 File API 才能与 Blob 一起使用。
  • Blob 与 ArrayBuffer 对象之间是可以相互转化的:

    • 使用 FileReader 的 readAsArrayBuffer() 方法,可以把 Blob 对象转换为 ArrayBuffer 对象;
    • 使用 Blob 构造函数,如 new Blob([new Uint8Array(data]);,可以把 ArrayBuffer 对象转换为 Blob 对象。

在前端 AJAX 场景下,除了常见的 JSON 格式之外,我们也可能会用到 Blob 或 ArrayBuffer 对象:

function GET(url, callback) {
  let xhr = new XMLHttpRequest();
  xhr.open('GET', url, true);
  xhr.responseType = 'arraybuffer'; // or xhr.responseType = "blob";
  xhr.send();

  xhr.onload = function(e) {
    if (xhr.status != 200) {
      alert("Unexpected status code " + xhr.status + " for " + url);
      return false;
    }
    callback(new Uint8Array(xhr.response)); // or new Blob([xhr.response]);
  };
}

在以上示例中,通过为 xhr.responseType 设置不同的数据类型,我们就可以根据实际需要获取对应类型的数据了。介绍完上述内容,下面我们先来介绍目前应用比较广泛的 HLS 流媒体传输协议。

三、HLS

3.1 HLS 简介

HTTP Live Streaming(缩写是 HLS)是由苹果公司提出基于 HTTP 的流媒体网络传输协议,它是苹果公司 QuickTime X 和 iPhone 软件系统的一部分。它的工作原理是把整个流分成一个个小的基于 HTTP 的文件来下载,每次只下载一些。当媒体流正在播放时,客户端可以选择从许多不同的备用源中以不同的速率下载同样的资源,允许流媒体会话适应不同的数据速率。

此外,当用户的信号强度发生抖动时,视频流会动态调整以提供出色的再现效果。

(图片来源:https://www.wowza.com/blog/hl...

最初, 仅 iOS 支持 HLS。但现在 HLS 已成为专有格式,几乎所有设备都支持它。顾名思义,HLS(HTTP Live Streaming)协议通过标准的 HTTP Web 服务器传送视频内容。这意味着你无需集成任何特殊的基础架构即可分发 HLS 内容。

HLS 拥有以下特性:

  • HLS 将播放使用 H.264 或 HEVC / H.265 编解码器编码的视频。
  • HLS 将播放使用 AAC 或 MP3 编解码器编码的音频。
  • HLS 视频流一般被切成 10 秒的片段。
  • HLS 的传输/封装格式是 MPEG-2 TS。
  • HLS 支持 DRM(数字版权管理)。
  • HLS 支持各种广告标准,例如 VAST 和 VPAID。

为什么苹果要提出 HLS 这个协议,其实它的主要是为了解决 RTMP 协议存在的一些问题。比如 RTMP 协议不使用标准的 HTTP 接口传输数据,所以在一些特殊的网络环境下可能被防火墙屏蔽掉。但是 HLS 由于使用的 HTTP 协议传输数据,通常情况下不会遇到被防火墙屏蔽的情况。除此之外,它也很容易通过 CDN(内容分发网络)来传输媒体流。

3.2 HLS 自适应比特流

HLS 是一种自适应比特率流协议。因此,HLS 流可以动态地使视频分辨率自适应每个人的网络状况。如果你正在使用高速 WiFi,则可以在手机上流式传输高清视频。但是,如果你在有限数据连接的公共汽车或地铁上,则可以以较低的分辨率观看相同的视频。

在开始一个流媒体会话时,客户端会下载一个包含元数据的 Extended M3U(m3u8)Playlist 文件,用于寻找可用的媒体流。

(图片来源:https://www.wowza.com/blog/hl...

为了便于大家的理解,我们使用 hls.js 这个 JavaScript 实现的 HLS 客户端,所提供的 在线示例,来看一下具体的 m3u8 文件。

x36xhzz.m3u8

#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2149280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=1280x720,NAME="720"
url_0/193039199_mp4_h264_aac_hd_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=246440,CODECS="mp4a.40.5,avc1.42000d",RESOLUTION=320x184,NAME="240"
url_2/193039199_mp4_h264_aac_ld_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=460560,CODECS="mp4a.40.5,avc1.420016",RESOLUTION=512x288,NAME="380"
url_4/193039199_mp4_h264_aac_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x480,NAME="480"
url_6/193039199_mp4_h264_aac_hq_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=6221600,CODECS="mp4a.40.2,avc1.640028",RESOLUTION=1920x1080,NAME="1080"
url_8/193039199_mp4_h264_aac_fhd_7.m3u8

通过观察 Master Playlist 对应的 m3u8 文件,我们可以知道该视频支持以下 5 种不同清晰度的视频:

  • 1920x1080(1080P)
  • 1280x720(720P)
  • 848x480(480P)
  • 512x288
  • 320x184

而不同清晰度视频对应的媒体播放列表,会定义在各自的 m3u8 文件中。这里我们以 720P 的视频为例,来查看其对应的 m3u8 文件:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:11
#EXTINF:10.000,
url_462/193039199_mp4_h264_aac_hd_7.ts
#EXTINF:10.000,
url_463/193039199_mp4_h264_aac_hd_7.ts
#EXTINF:10.000,
url_464/193039199_mp4_h264_aac_hd_7.ts
#EXTINF:10.000,
...
url_525/193039199_mp4_h264_aac_hd_7.ts
#EXT-X-ENDLIST

当用户选定某种清晰度的视频之后,将会下载该清晰度对应的媒体播放列表(m3u8 文件),该列表中就会列出每个片段的信息。HLS 的传输/封装格式是 MPEG-2 TS(MPEG-2 Transport Stream),是一种传输和存储包含视频、音频与通信协议各种数据的标准格式,用于数字电视广播系统,如 DVB、ATSC、IPTV 等等。

需要注意的是利用一些现成的工具,我们是可以把多个 TS 文件合并为 mp4 格式的视频文件。 如果要做视频版权保护,那我们可以考虑使用对称加密算法,比如 AES-128 对切片进行对称加密。当客户端进行播放时,先根据 m3u8 文件中配置的密钥服务器地址,获取对称加密的密钥,然后再下载分片,当分片下载完成后再使用匹配的对称加密算法进行解密播放。

对上述过程感兴趣的小伙伴可以参考 Github 上 video-hls-encrypt 这个项目,该项目深入浅出介绍了基于 HLS 流媒体协议视频加密的解决方案并提供了完整的示例代码。

(图片来源:https://github.com/hauk0101/v...

介绍完苹果公司推出的 HLS (HTTP Live Streaming)技术,接下来我们来介绍另一种基于 HTTP 的动态自适应流 —— DASH。

四、DASH

4.1 DASH 简介

基于 HTTP 的动态自适应流(英语:Dynamic Adaptive Streaming over HTTP,缩写 DASH,也称 MPEG-DASH)是一种自适应比特率流技术,使高质量流媒体可以通过传统的 HTTP 网络服务器以互联网传递。 类似苹果公司的 HTTP Live Streaming(HLS)方案,MPEG-DASH 会将内容分解成一系列小型的基于 HTTP 的文件片段,每个片段包含很短长度的可播放内容,而内容总长度可能长达数小时。

内容将被制成多种比特率的备选片段,以提供多种比特率的版本供选用。当内容被 MPEG-DASH 客户端回放时,客户端将根据当前网络条件自动选择下载和播放哪一个备选方案。客户端将选择可及时下载的最高比特率片段进行播放,从而避免播放卡顿或重新缓冲事件。也因如此,MPEG-DASH 客户端可以无缝适应不断变化的网络条件并提供高质量的播放体验,拥有更少的卡顿与重新缓冲发生率。

MPEG-DASH 是首个基于 HTTP 的自适应比特率流解决方案,它也是一项国际标准。MPEG-DASH 不应该与传输协议混淆 —— MPEG-DASH 使用 TCP 传输协议。不同于 HLS、HDS 和 Smooth Streaming,DASH 不关心编解码器,因此它可以接受任何编码格式编码的内容,如 H.265、H.264、VP9 等。

虽然 HTML5 不直接支持 MPEG-DASH,但是已有一些 MPEG-DASH 的 JavaScript 实现允许在网页浏览器中通过 HTML5 Media Source Extensions(MSE)使用 MPEG-DASH。另有其他 JavaScript 实现,如 bitdash 播放器支持使用 HTML5 加密媒体扩展播放有 DRM 的MPEG-DASH。当与 WebGL 结合使用,MPEG-DASH 基于 HTML5 的自适应比特率流还可实现 360° 视频的实时和按需的高效流式传输。

4.2 DASH 重要概念

  • MPD:媒体文件的描述文件(manifest),作用类似 HLS 的 m3u8 文件。
  • Representation:对应一个可选择的输出(alternative)。如 480p 视频,720p 视频,44100 采样音频等都使用 Representation 描述。
  • Segment(分片):每个 Representation 会划分为多个 Segment。Segment 分为 4 类,其中,最重要的是:Initialization Segment(每个 Representation 都包含 1 个 Init Segment),Media Segment(每个 Representation 的媒体内容包含若干 Media Segment)。

(图片来源:https://blog.csdn.net/yue_hua...

在国内 Bilibili 于 2018 年开始使用 DASH 技术,至于为什么选择 DASH 技术。感兴趣的小伙伴可以阅读 我们为什么使用DASH 这篇文章。

讲了那么多,相信有些小伙伴会好奇 MPD 文件长什么样?这里我们来看一下西瓜视频播放器 DASH 示例中的 MPD 文件:

<?xml version="1.0"?>
<!-- MPD file Generated with GPAC version 0.7.2-DEV-rev559-g61a50f45-master  at 2018-06-11T11:40:23.972Z-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" minBufferTime="PT1.500S" type="static" mediaPresentationDuration="PT0H1M30.080S" maxSegmentDuration="PT0H0M1.000S" profiles="urn:mpeg:dash:profile:full:2011">
 <ProgramInformation moreInformationURL="http://gpac.io">
  <Title>xgplayer-demo_dash.mpd generated by GPAC</Title>
 </ProgramInformation>

 <Period duration="PT0H1M30.080S">
  <AdaptationSet segmentAlignment="true" maxWidth="1280" maxHeight="720" maxFrameRate="25" par="16:9" lang="eng">
   <ContentComponent id="1" contentType="audio" />
   <ContentComponent id="2" contentType="video" />
   <Representation id="1" mimeType="video/mp4" codecs="mp4a.40.2,avc3.4D4020" width="1280" height="720" frameRate="25" sar="1:1" startWithSAP="0" bandwidth="6046495">
    <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>
    <BaseURL>xgplayer-demo_dashinit.mp4</BaseURL>
    <SegmentList timescale="1000" duration="1000">
     <Initialization range="0-1256"/>
      <SegmentURL mediaRange="1257-1006330" indexRange="1257-1300"/>
      <SegmentURL mediaRange="1006331-1909476" indexRange="1006331-1006374"/>
      ...
      <SegmentURL mediaRange="68082016-68083543" indexRange="68082016-68082059"/>
    </SegmentList>
   </Representation>
  </AdaptationSet>
 </Period>
</MPD>

(文件来源:https://h5player.bytedance.co...

在播放视频时,西瓜视频播放器会根据 MPD 文件,自动请求对应的分片进行播放。

前面我们已经提到了 Bilibili,接下来不得不提其开源的一个著名的开源项目 —— flv.js,不过在介绍它之前我们需要来了解一下 FLV 流媒体格式。

五、FLV

5.1 FLV 文件结构

FLV 是 FLASH Video 的简称,FLV 流媒体格式是随着 Flash MX 的推出发展而来的视频格式。由于它形成的文件极小、加载速度极快,使得网络观看视频文件成为可能,它的出现有效地解决了视频文件导入 Flash 后,使导出的 SWF 文件体积庞大,不能在网络上很好的使用等问题。

FLV 文件由 FLV Header 和 FLV Body 两部分构成,而 FLV Body 由一系列的 Tag 构成:

5.1.1 FLV 头文件

FLV 头文件:(9 字节)

  • 1-3:前 3 个字节是文件格式标识(FLV 0x46 0x4C 0x56)。
  • 4-4:第 4 个字节是版本(0x01)。
  • 5-5:第 5 个字节的前 5 个 bit 是保留的必须是 0。

    • 第 5 个字节的第 6 个 bit 音频类型标志(TypeFlagsAudio)。
    • 第 5 个字节的第 7 个 bit 也是保留的必须是 0。
    • 第5个字节的第8个bit视频类型标志(TypeFlagsVideo)。
  • 6-9: 第 6-9 的四个字节还是保留的,其数据为 00000009。
  • 整个文件头的长度,一般是 9(3+1+1+4)。
5.1.2 tag 基本格式

tag 类型信息,固定长度为 15 字节:

  • 1-4:前一个 tag 长度(4字节),第一个 tag 就是 0。
  • 5-5:tag 类型(1 字节);0x8 音频;0x9 视频;0x12 脚本数据。
  • 6-8:tag 内容大小(3 字节)。
  • 9-11:时间戳(3 字节,毫秒)(第 1 个 tag 的时候总是为 0,如果是脚本 tag 就是 0)。
  • 12-12:时间戳扩展(1 字节)让时间戳变成 4 字节(以存储更长时间的 flv 时间信息),本字节作为时间戳的最高位。

在 flv 回放过程中,播放顺序是按照 tag 的时间戳顺序播放。任何加入到文件中时间设置数据格式都将被忽略。

  • 13-15:streamID(3 字节)总是 0。

FLV 格式详细的结构图如下图所示:

在浏览器中 HTML5 的 <video> 是不支持直接播放 FLV 视频格式,需要借助 flv.js 这个开源库来实现播放 FLV 视频格式的功能。

5.2 flv.js 简介

flv.js 是用纯 JavaScript 编写的 HTML5 Flash Video(FLV)播放器,它底层依赖于 Media Source Extensions。在实际运行过程中,它会自动解析 FLV 格式文件并喂给原生 HTML5 Video 标签播放音视频数据,使浏览器在不借助 Flash 的情况下播放 FLV 成为可能。

5.2.1 flv.js 的特性
  • 支持播放 H.264 + AAC / MP3 编码的 FLV 文件;
  • 支持播放多段分段视频;
  • 支持播放 HTTP FLV 低延迟实时流;
  • 支持播放基于 WebSocket 传输的 FLV 实时流;
  • 兼容 Chrome,FireFox,Safari 10,IE11 和 Edge;
  • 极低的开销,支持浏览器的硬件加速。
5.2.2 flv.js 的限制
  • MP3 音频编解码器无法在 IE11/Edge 上运行;
  • HTTP FLV 直播流不支持所有的浏览器。
5.2.3 flv.js 的使用
<script data-original="flv.min.js"></script>
<video id="videoElement"></video>
<script>
    if (flvjs.isSupported()) {
        var videoElement = document.getElementById('videoElement');
        var flvPlayer = flvjs.createPlayer({
            type: 'flv',
            url: 'http://example.com/flv/video.flv'
        });
        flvPlayer.attachMediaElement(videoElement);
        flvPlayer.load();
        flvPlayer.play();
    }
</script>

5.3 flv.js 工作原理

flv.js 的工作原理是将 FLV 文件流转换为 ISO BMFF(Fragmented MP4)片段,然后通过 Media Source Extensions API 将 mp4 段喂给 HTML5 <video> 元素。flv.js 的设计架构图如下图所示:

(图片来源:https://github.com/bilibili/f...

有关 flv.js 工作原理更详细的介绍,感兴趣的小伙们可以阅读 花椒开源项目实时互动流媒体播放器 这篇文章。现在我们已经介绍了 hls.jsflv.js 这两个主流的流媒体解决方案,其实它们的成功离不开 Media Source Extensions 这个幕后英雄默默地支持。因此,接下来阿宝哥将带大家一起认识一下 MSE(Media Source Extensions)。

六、MSE

6.1 MSE API

媒体源扩展 API(Media Source Extensions) 提供了实现无插件且基于 Web 的流媒体的功能。使用 MSE,媒体串流能够通过 JavaScript 创建,并且能通过使用 audiovideo 元素进行播放。

近几年来,我们已经可以在 Web 应用程序上无插件地播放视频和音频了。但是,现有架构过于简单,只能满足一次播放整个曲目的需要,无法实现拆分/合并数个缓冲文件。早期的流媒体主要使用 Flash 进行服务,以及通过 RTMP 协议进行视频串流的 Flash 媒体服务器。

媒体源扩展(MSE)实现后,情况就不一样了。MSE 使我们可以把通常的单个媒体文件的 src 值替换成引用 MediaSource 对象(一个包含即将播放的媒体文件的准备状态等信息的容器),以及引用多个 SourceBuffer 对象(代表多个组成整个串流的不同媒体块)的元素。

为了便于大家理解,我们来看一下基础的 MSE 数据流:

MSE 让我们能够根据内容获取的大小和频率,或是内存占用详情(例如什么时候缓存被回收),进行更加精准地控制。 它是基于它可扩展的 API 建立自适应比特率流客户端(例如 DASH 或 HLS 的客户端)的基础。

在现代浏览器中创造能兼容 MSE 的媒体非常费时费力,还要消耗大量计算机资源和能源。此外,还须使用外部应用程序将内容转换成合适的格式。虽然浏览器支持兼容 MSE 的各种媒体容器,但采用 H.264 视频编码、AAC 音频编码和 MP4 容器的格式是非常常见的,所以 MSE 需要兼容这些主流的格式。此外 MSE 还为开发者提供了一个 API,用于运行时检测容器和编解码是否受支持。

6.2 MediaSource 接口

MediaSource 是 Media Source Extensions API 表示媒体资源 HTMLMediaElement 对象的接口。MediaSource 对象可以附着在 HTMLMediaElement 在客户端进行播放。在介绍 MediaSource 接口前,我们先来看一下它的结构图:

(图片来源 —— https://www.w3.org/TR/media-s...

要理解 MediaSource 的结构图,我们得先来介绍一下客户端音视频播放器播放一个视频流的主要流程:

获取流媒体 -> 解协议 -> 解封装 -> 音、视频解码 -> 音频播放及视频渲染(需处理音视频同步)。

由于采集的原始音视频数据比较大,为了方便网络传输,我们通常会使用编码器,如常见的 H.264 或 AAC 来压缩原始媒体信号。最常见的媒体信号是视频,音频和字幕。比如,日常生活中的电影,就是由不同的媒体信号组成,除运动图片外,大多数电影还含有音频和字幕。

常见的视频编解码器有:H.264,HEVC,VP9 和 AV1。而音频编解码器有:AAC,MP3 或 Opus。每个媒体信号都有许多不同的编解码器。下面我们以西瓜视频播放器的 Demo 为例,来直观感受一下音频轨、视频轨和字幕轨:

现在我们来开始介绍 MediaSource 接口的相关内容。

6.2.1 状态
enum ReadyState {
    "closed", // 指示当前源未附加到媒体元素。
    "open", // 源已经被媒体元素打开,数据即将被添加到SourceBuffer对象中
    "ended" // 源仍附加到媒体元素,但endOfStream()已被调用。
};
6.2.2 流终止异常
enum EndOfStreamError {
    "network", // 终止播放并发出网络错误信号。
    "decode" // 终止播放并发出解码错误信号。
};
6.2.3 构造器
[Constructor]
interface MediaSource : EventTarget {
    readonly attribute SourceBufferList    sourceBuffers;
    readonly attribute SourceBufferList    activeSourceBuffers;
    readonly attribute ReadyState          readyState;
             attribute unrestricted double duration;
             attribute EventHandler        onsourceopen;
             attribute EventHandler        onsourceended;
             attribute EventHandler        onsourceclose;
  
    SourceBuffer addSourceBuffer(DOMString type);
    void         removeSourceBuffer(SourceBuffer sourceBuffer);
    void         endOfStream(optional EndOfStreamError error);
    void         setLiveSeekableRange(double start, double end);
    void         clearLiveSeekableRange();
    static boolean isTypeSupported(DOMString type);
};
6.2.4 属性
  • MediaSource.sourceBuffers —— 只读:返回一个 SourceBufferList 对象,包含了这个 MediaSource 的SourceBuffer 的对象列表。
  • MediaSource.activeSourceBuffers —— 只读:返回一个 SourceBufferList 对象,包含了这个MediaSource.sourceBuffers 中的 SourceBuffer 子集的对象—即提供当前被选中的视频轨(video track),启用的音频轨(audio tracks)以及显示/隐藏的字幕轨(text tracks)的对象列表
  • MediaSource.readyState —— 只读:返回一个包含当前 MediaSource 状态的集合,即使它当前没有附着到一个 media 元素(closed),或者已附着并准备接收 SourceBuffer 对象(open),亦或者已附着但这个流已被 MediaSource.endOfStream() 关闭。
  • MediaSource.duration:获取和设置当前正在推流媒体的持续时间。
  • onsourceopen:设置 sourceopen 事件对应的事件处理程序。
  • onsourceended:设置 sourceended 事件对应的事件处理程序。
  • onsourceclose:设置 sourceclose 事件对应的事件处理程序。
6.2.5 方法
  • MediaSource.addSourceBuffer():创建一个带有给定 MIME 类型的新的 SourceBuffer 并添加到 MediaSource 的 SourceBuffers 列表。
  • MediaSource.removeSourceBuffer():删除指定的 SourceBuffer 从这个 MediaSource 对象中的 SourceBuffers 列表。
  • MediaSource.endOfStream():表示流的结束。
6.2.6 静态方法
  • MediaSource.isTypeSupported():返回一个 Boolean 值表明给定的 MIME 类型是否被当前的浏览器支持—— 这意味着是否可以成功的创建这个 MIME 类型的 SourceBuffer 对象。
6.2.7 使用示例
var vidElement = document.querySelector('video');

if (window.MediaSource) { // (1)
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen); 
} else {
  console.log("The Media Source Extensions API is not supported.")
}

function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime); // (2)
  var videoUrl = 'hello-mse.mp4';
  fetch(videoUrl) // (3)
    .then(function(response) {
      return response.arrayBuffer();
    })
    .then(function(arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function(e) { (4)
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream(); 
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer); // (5)
    });
}

以上示例介绍了如何使用 MSE API,接下来我们来分析一下主要的工作流程:

  • (1) 判断当前平台是否支持 Media Source Extensions API,若支持的话,则创建 MediaSource 对象,且绑定 sourceopen 事件处理函数。
  • (2) 创建一个带有给定 MIME 类型的新的 SourceBuffer 并添加到 MediaSource 的 SourceBuffers 列表。
  • (3) 从远程流服务器下载视频流,并转换成 ArrayBuffer 对象。
  • (4) 为 sourceBuffer 对象添加 updateend 事件处理函数,在视频流传输完成后关闭流。
  • (5) 往 sourceBuffer 对象中添加已转换的 ArrayBuffer 格式的视频流数据。

上面阿宝哥只是简单介绍了一下 MSE API,想深入了解它实际应用的小伙伴,可以进一步了解一下 hls.jsflv.js 项目。接下来阿宝哥将介绍音视频基础之多媒体容器格式。

七、多媒体封装格式

一般情况下,一个完整的视频文件是由音频和视频两部分组成的。常见的 AVI、RMVB、MKV、ASF、WMV、MP4、3GP、FLV 等文件只能算是一种封装格式。H.264,HEVC,VP9 和 AV1 等就是视频编码格式,MP3、AAC 和 Opus 等就是音频编码格式。 比如:将一个 H.264 视频编码文件和一个 AAC 音频编码文件按 MP4 封装标准封装以后,就得到一个 MP4 后缀的视频文件,也就是我们常见的 MP4 视频文件了。

音视频编码的主要目的是压缩原始数据的体积,而封装格式(也称为多媒体容器),比如 MP4,MKV,是用来存储/传输编码数据,并按一定规则把音视频、字幕等数据组织起来,同时还会包含一些元信息,比如当前流中包含哪些编码类型、时间戳等,播放器可以按照这些信息来匹配解码器、同步音视频。

为了能更好地理解多媒体封装格式,我们再来回顾一下视频播放器的原理。

7.1 视频播放器原理

视频播放器是指能播放以数字信号形式存储的视频的软件,也指具有播放视频功能的电子器件产品。大多数视频播放器(除了少数波形文件外)携带解码器以还原经过压缩的媒体文件,视频播放器还要内置一整套转换频率以及缓冲的算法。大多数的视频播放器还能支持播放音频文件。

视频播放基本处理流程大致包括以下几个阶段:

(1)解协议

从原始的流媒体协议数据中删除信令数据,只保留音视频数据,如采用 RTMP 协议传输的数据,经过解协议后输出 flv 格式的数据。

(2)解封装

分离音频和视频压缩编码数据,常见的封装格式 MP4,MKV,RMVB,FLV,AVI 这些格式。从而将已经压缩编码的视频、音频数据放到一起。例如 FLV 格式的数据经过解封装后输出 H.264 编码的视频码流和 AAC 编码的音频码流。

(3)解码

视频,音频压缩编码数据,还原成非压缩的视频,音频原始数据,音频的压缩编码标准包括 AAC,MP3,AC-3 等,视频压缩编码标准包含 H.264,MPEG2,VC-1 等经过解码得到非压缩的视频颜色数据如 YUV420P,RGB 和非压缩的音频数据如 PCM 等。

(4)音视频同步

将同步解码出来的音频和视频数据分别送至系统声卡和显卡播放。

了解完视频播放器的原理,下一步我们来介绍多媒体封装格式。

7.2 多媒体封装格式

对于数字媒体数据来说,容器就是一个可以将多媒体数据混在一起存放的东西,就像是一个包装箱,它可以对音、视频数据进行打包装箱,将原来的两块独立的媒体数据整合到一起,当然也可以单单只存放一种类型的媒体数据。

有时候,多媒体容器也称封装格式,它只是为编码后的多媒体数据提供了一个 “外壳”,也就是将所有的处理好的音频、视频或字幕都包装到一个文件容器内呈现给观众,这个包装的过程就叫封装。 常用的封装格式有: MP4,MOV,TS,FLV,MKV 等。这里我们来介绍大家比较熟悉的 MP4 封装格式。

7.2.1 MP4 封装格式

MPEG-4 Part 14(MP4)是最常用的容器格式之一,通常以 .mp4 文件结尾。它用于 HTTP(DASH)上的动态自适应流,也可以用于 Apple 的 HLS 流。MP4 基于 ISO 基本媒体文件格式(MPEG-4 Part 12),该格式基于 QuickTime 文件格式。MPEG 代表动态图像专家组,是国际标准化组织(ISO)和国际电工委员会(IEC)的合作。MPEG 的成立是为了设置音频和视频压缩与传输的标准。

MP4 支持多种编解码器,常用的视频编解码器是 H.264 和 HEVC,而常用的音频编解码器是 AAC,AAC 是著名的 MP3 音频编解码器的后继产品。

MP4 是由一些列的 box 组成,它的最小组成单元是 box。MP4 文件中的所有数据都装在 box 中,即 MP4 文件由若干个 box 组成,每个 box 有类型和长度,可以将 box 理解为一个数据对象块。box 中可以包含另一个 box,这种 box 称为 container box。

一个 MP4 文件首先会有且仅有 一个 ftype 类型的 box,作为 MP4 格式的标志并包含关于文件的一些信息,之后会有且只有一个 moov 类型的 box(movie box),它是一种 container box,可以有多个,也可以没有,媒体数据的结构由 metadata 进行描述。

相信有些读者会有疑问 —— 实际的 MP4 文件结构是怎么样的?通过使用 mp4box.js 提供的在线服务,我们可以方便的查看本地或在线 MP4 文件内部的结构:

mp4box.js 在线地址:https://gpac.github.io/mp4box...

由于 MP4 文件结构比较复杂(不信请看下图),这里我们就不继续展开,有兴趣的读者,可以自行阅读相关文章。

接下来,我们来介绍 Fragmented MP4 容器格式。

7.2.2 Fragmented MP4 封装格式

MP4 ISO Base Media 文件格式标准允许以 fragmented 方式组织 box,这也就意味着 MP4 文件可以组织成这样的结构,由一系列的短的 metadata/data box 对组成,而不是一个长的 metadata/data 对。Fragmented MP4 文件结构如下图所示,图中只包含了两个 fragments:

(图片来源 —— https://alexzambelli.com/blog...

在 Fragmented MP4 文件中含有三个非常关键的 boxes:moovmoofmdat

  • moov(movie metadata box):用于存放多媒体 file-level 的元信息。
  • mdat(media data box):和普通 MP4 文件的 mdat 一样,用于存放媒体数据,不同的是普通 MP4 文件只有一个 mdat box,而 Fragmented MP4 文件中,每个 fragment 都会有一个 mdat 类型的 box。
  • moof(movie fragment box):用于存放 fragment-level 的元信息。该类型的 box 在普通的 MP4 文件中是不存在的,而在 Fragmented MP4 文件中,每个 fragment 都会有一个 moof 类型的 box。

Fragmented MP4 文件中的 fragment 由 moofmdat 两部分组成,每个 fragment 可以包含一个音频轨或视频轨,并且也会包含足够的元信息,以保证这部分数据可以单独解码。Fragment 的结构如下图所示:

(图片来源 —— https://alexzambelli.com/blog...

同样,利用 mp4box.js 提供的在线服务,我们也可以清晰的查看 Fragmented MP4 文件的内部结构:

我们已经介绍了 MP4 和 Fragmented MP4 这两种容器格式,我们用一张图来总结一下它们之间的主要区别:

八、阿宝哥有话说

8.1 如何实现视频本地预览

视频本地预览的功能主要利用 URL.createObjectURL() 方法来实现。URL.createObjectURL() 静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。 这个新的 URL 对象表示指定的 File 对象或 Blob 对象。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>视频本地预览示例</title>
  </head>
  <body>
    <h3>阿宝哥:视频本地预览示例</h3>
    <input type="file" accept="video/*" onchange="loadFile(event)" />
    <video
      id="previewContainer"
      controls
      width="480"
      height="270"
      style="display: none;"
    ></video>

    <script>
      const loadFile = function (event) {
        const reader = new FileReader();
        reader.onload = function () {
          const output = document.querySelector("#previewContainer");
          output.style.display = "block";
          output.src = URL.createObjectURL(new Blob([reader.result]));
        };
        reader.readAsArrayBuffer(event.target.files[0]);
      };
    </script>
  </body>
</html>

8.2 如何实现播放器截图

播放器截图功能主要利用 CanvasRenderingContext2D.drawImage() API 来实现。Canvas 2D API 中的 CanvasRenderingContext2D.drawImage() 方法提供了多种方式在 Canvas 上绘制图像。

drawImage API 的语法如下:

void ctx.drawImage(image, dx, dy);

void ctx.drawImage(image, dx, dy, dWidth, dHeight);

void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

其中 image 参数表示绘制到上下文的元素。允许任何的 canvas 图像源(CanvasImageSource),例如:CSSImageValue,HTMLImageElement,SVGImageElement,HTMLVideoElement,HTMLCanvasElement,ImageBitmap 或者 OffscreenCanvas。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>播放器截图示例</title>
  </head>
  <body>
    <h3>阿宝哥:播放器截图示例</h3>
    <video id="video" controls="controls" width="460" height="270" crossorigin="anonymous">
      <!-- 请替换为实际视频地址 -->
      <source data-original="https://xxx.com/vid_159411468092581" />
    </video>
    <button onclick="captureVideo()">截图</button>
    <script>
      let video = document.querySelector("#video");
      let canvas = document.createElement("canvas");
      let img = document.createElement("img");
      img.crossOrigin = "";
      let ctx = canvas.getContext("2d");

      function captureVideo() {
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
        img.src = canvas.toDataURL();
        document.body.append(img);
      }
    </script>
  </body>
</html>

现在我们已经知道如何获取视频的每一帧,其实在结合 gif.js 这个库提供的 GIF 编码功能,我们就可以快速地实现截取视频帧生成 GIF 动画的功能。这里阿宝哥不继续展开介绍,有兴趣的小伙伴可以阅读 使用 JS 直接截取 视频片段 生成 gif 动画 这篇文章。

8.3 如何实现 Canvas 播放视频

使用 Canvas 播放视频主要是利用 ctx.drawImage(video, x, y, width, height) 来对视频当前帧的图像进行绘制,其中 video 参数就是页面中的 video 对象。所以如果我们按照特定的频率不断获取 video 当前画面,并渲染到 Canvas 画布上,就可以实现使用 Canvas 播放视频的功能。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>使用 Canvas 播放视频</title>
  </head>
  <body>
    <h3>阿宝哥:使用 Canvas 播放视频</h3>
    <video id="video" controls="controls" style="display: none;">
      <!-- 请替换为实际视频地址 -->
      <source data-original="https://xxx.com/vid_159411468092581" />
    </video>
    <canvas
      id="myCanvas"
      width="460"
      height="270"
      style="border: 1px solid blue;"
    ></canvas>
    <div>
      <button id="playBtn">播放</button>
      <button id="pauseBtn">暂停</button>
    </div>
    <script>
      const video = document.querySelector("#video");
      const canvas = document.querySelector("#myCanvas");
      const playBtn = document.querySelector("#playBtn");
      const pauseBtn = document.querySelector("#pauseBtn");
      const context = canvas.getContext("2d");
      let timerId = null;

      function draw() {
        if (video.paused || video.ended) return;
        context.clearRect(0, 0, canvas.width, canvas.height);
        context.drawImage(video, 0, 0, canvas.width, canvas.height);
        timerId = setTimeout(draw, 0);
      }

      playBtn.addEventListener("click", () => {
        if (!video.paused) return;
        video.play();
        draw();
      });

      pauseBtn.addEventListener("click", () => {
        if (video.paused) return;
        video.pause();
        clearTimeout(timerId);
      });
    </script>
  </body>
</html>

8.4 如何实现色度键控(绿屏效果)

上一个示例我们介绍了使用 Canvas 播放视频,那么可能有一些小伙伴会有疑问,为什么要通过 Canvas 绘制视频,Video 标签不 “香” 么?这是因为 Canvas 提供了 getImageDataputImageData 方法使得开发者可以动态地更改每一帧图像的显示内容。这样的话,我们就可以实时地操纵视频数据来合成各种视觉特效到正在呈现的视频画面中。

比如 MDN 上的 ”使用 canvas 处理视频“ 的教程中就演示了如何使用 JavaScript 代码执行色度键控(绿屏或蓝屏效果)。所谓的色度键控,又称色彩嵌空,是一种去背合成技术。Chroma 为纯色之意,Key 则是抽离颜色之意。把被拍摄的人物或物体放置于绿幕的前面,并进行去背后,将其替换成其他的背景。此技术在电影、电视剧及游戏制作中被大量使用,色键也是虚拟摄影棚(Virtual studio)与视觉效果(Visual effects)当中的一个重要环节。

下面我们来看一下关键代码:

processor.computeFrame = function computeFrame() {
    this.ctx1.drawImage(this.video, 0, 0, this.width, this.height);
    let frame = this.ctx1.getImageData(0, 0, this.width, this.height);
    let l = frame.data.length / 4;

    for (let i = 0; i < l; i++) {
      let r = frame.data[i * 4 + 0];
      let g = frame.data[i * 4 + 1];
      let b = frame.data[i * 4 + 2];
      if (g > 100 && r > 100 && b < 43)
        frame.data[i * 4 + 3] = 0;
    }
    this.ctx2.putImageData(frame, 0, 0);
    return;
}

以上的 computeFrame() 方法负责获取一帧数据并执行色度键控效果。利用色度键控技术,我们还可以实现纯客户端实时蒙版弹幕。这里阿宝哥就不详细介绍了,感兴趣的小伙伴可以阅读一下创宇前端 弹幕不挡人!基于色键技术的纯客户端实时蒙版弹幕 这篇文章。

九、参考资源

查看原文

赞 52 收藏 32 评论 3

momo707577045 收藏了文章 · 2020-07-24

Media Source Extension官方文档(第二部分)

2. MediaSource Object

MediaSource对象表示HTMLMediaElement元素的一个媒体数据源。它会记录源的readyState和一个可以添加媒体数据去展示的SourceBuffer对象的列表。MediaSource对象由web应用创建然后绑定到HTMLMediaElement元素上。web应用通过添加SourceBuffer对象给sourceBuffers属性从而添加媒体数据到source中。当需要播放的时候,HTMLMediaElement从MediaSource对象中读取媒体数据。

每个MediaSource对象都由一个实时的可检索的range变量来存储归一化后的TimeRanges对象。当MediaSource对象创建的时候这个变量初始化为一个空的TimeRanges对象,通过setLiveSeekableRange() 和 clearLiveSeekableRange()方法来维护,然后通过HTMLMediaElement Extensions 来更改HTMLMediaElement.seekable属性。
ReadyState

状态值描述
closed表示source还没有绑定到media元素上
opensource被media元素打开并且有可用的SourceBuffer对象在sourceBuffers中
endedsource还被绑定在media元素上,但是endOfStream() 执行过了

构造函数

[Constructor]
interface MediaSource : EventTarget {
    readonly attribute SourceBufferList    sourceBuffers;
    readonly attribute SourceBufferList    activeSourceBuffers;
    readonly attribute ReadyState          readyState;
             attribute unrestricted double duration;
             attribute EventHandler        onsourceopen;
             attribute EventHandler        onsourceended;
             attribute EventHandler        onsourceclose;
    SourceBuffer addSourceBuffer(DOMString type);
    void         removeSourceBuffer(SourceBuffer sourceBuffer);
    void         endOfStream(optional EndOfStreamError error);
    void         setLiveSeekableRange(double start, double end);
    void         clearLiveSeekableRange();
    static boolean isTypeSupported(DOMString type);
};

2.1 属性值

sourceBuffers 类型:SourceBufferList,只读
MediaSource相关的SourceBuffer列表,当readyState=="closed"的时候总是空的,一旦readyState变为"open"状态时,就可以通过 addSourceBuffer()方法添加SourceBuffer对象到列表中

activeSourceBuffers 类型:SourceBufferList,只读

sourceBuffers中selected video track, the enabled audio track(s), and the "showing" or "hidden" text track(s)的一个子集。

readyState 类型:ReadyState,只读
MediaSource对象的当前状态,刚创建的时候一定是‘closed’。

duration 类型:unrestricted double
MediaSource刚创建的时候,初始化值为NaN。
如何获取?

  1. 如果readyState为closed,则返回NaN,这一步可以丢弃
  2. 返回该属性当前值
    如何设置?
  3. 如果设置了负值或者NaN,会抛出TypeError异常,并丢弃剩余步骤
  4. 如果readyState不是open,会抛出InvalidStateError异常,并丢弃剩余步骤
  5. sourceBuffers属性中的任何一个SourceBuffer的updating值为true, ,会抛出InvalidStateError异常,并丢弃剩余步骤
  6. 运行duration change algorithm算法可以设置新的duration给当前属性

Note
如果当前有更高end time的缓冲帧,duration change algorithm会调整新的更大的duration。
appendBuffer()和 endOfStream()方法会更新duration在特定的情况下。

onsourceopen 类型:EventHandler
sourceopen事件的处理回调

onsourceended 类型:EventHandler
sourceended事件的处理回调

onsourceclose 类型:EventHandler
sourceclose事件的处理回调

2.2 方法

addSourceBuffer
入参:type, 返回SourceBuffer对象,调用如:

sourceBuffer = ms.addSourceBuffer('video/mp4; codecs="avc1.4d401f"');

添加一个新的SourceBuffer对象到sourceBuffers属性,接下来ua需要执行:

  1. 如果type为空字符串(''),则抛出TypeError异常,并丢弃剩余步骤
  2. 如果type为不支持的MIME类型,则抛出 NotSupportedError异常,并丢弃剩余步骤
  3. 如果ua不能处理更多的SourceBuffer内容,则抛出QuotaExceededError异常,并丢弃剩余步骤

比如当媒体元素到了HAVE_METADATA状态的时候,ua就不支持播放中更多track的添加了

  1. 如果readyState不是open,则抛出InvalidStateError异常,并丢弃剩余步骤
  2. 常见一个新的SourceBuffer对象和相关资源
  3. 设置新对象的generate timestamps flag为相关类型的[MSE-REGISTRY] entry
  4. 如果generate timestamps flag值为true,设置新对象的mode属性为“sequence”,否则设置为“segments”
  5. 把新对象添加到sourceBuffers中,并且触发一个addsourcebuffer事件
  6. 返回这个新对象

removeSourceBuffer

endOfStream
入参:error(EndOfStreamError),无返回值

  • setLiveSeekableRange*
    更新 HTMLMediaElement Extensions元素的live seekable range变量
  • clearLiveSeekableRange*
  • isTypeSupported* static
    检测MediaSource对象是否支持创建特定MIME类型的SourceBuffer

只代表浏览器是否支持的能力,不代表addSourceBuffer()方法有足够的资源创建新的SourceBuffer

2.3 事件

事件名称描述
sourceopenreadyState 从close到open 或 从ended到open
sourceendedreadyState 从open到ended
sourceclosereadyState 从open到closed 或 从open到ended

2.4 算法(待补充)

查看原文

momo707577045 赞了文章 · 2020-07-24

Media Source Extension官方文档(第二部分)

2. MediaSource Object

MediaSource对象表示HTMLMediaElement元素的一个媒体数据源。它会记录源的readyState和一个可以添加媒体数据去展示的SourceBuffer对象的列表。MediaSource对象由web应用创建然后绑定到HTMLMediaElement元素上。web应用通过添加SourceBuffer对象给sourceBuffers属性从而添加媒体数据到source中。当需要播放的时候,HTMLMediaElement从MediaSource对象中读取媒体数据。

每个MediaSource对象都由一个实时的可检索的range变量来存储归一化后的TimeRanges对象。当MediaSource对象创建的时候这个变量初始化为一个空的TimeRanges对象,通过setLiveSeekableRange() 和 clearLiveSeekableRange()方法来维护,然后通过HTMLMediaElement Extensions 来更改HTMLMediaElement.seekable属性。
ReadyState

状态值描述
closed表示source还没有绑定到media元素上
opensource被media元素打开并且有可用的SourceBuffer对象在sourceBuffers中
endedsource还被绑定在media元素上,但是endOfStream() 执行过了

构造函数

[Constructor]
interface MediaSource : EventTarget {
    readonly attribute SourceBufferList    sourceBuffers;
    readonly attribute SourceBufferList    activeSourceBuffers;
    readonly attribute ReadyState          readyState;
             attribute unrestricted double duration;
             attribute EventHandler        onsourceopen;
             attribute EventHandler        onsourceended;
             attribute EventHandler        onsourceclose;
    SourceBuffer addSourceBuffer(DOMString type);
    void         removeSourceBuffer(SourceBuffer sourceBuffer);
    void         endOfStream(optional EndOfStreamError error);
    void         setLiveSeekableRange(double start, double end);
    void         clearLiveSeekableRange();
    static boolean isTypeSupported(DOMString type);
};

2.1 属性值

sourceBuffers 类型:SourceBufferList,只读
MediaSource相关的SourceBuffer列表,当readyState=="closed"的时候总是空的,一旦readyState变为"open"状态时,就可以通过 addSourceBuffer()方法添加SourceBuffer对象到列表中

activeSourceBuffers 类型:SourceBufferList,只读

sourceBuffers中selected video track, the enabled audio track(s), and the "showing" or "hidden" text track(s)的一个子集。

readyState 类型:ReadyState,只读
MediaSource对象的当前状态,刚创建的时候一定是‘closed’。

duration 类型:unrestricted double
MediaSource刚创建的时候,初始化值为NaN。
如何获取?

  1. 如果readyState为closed,则返回NaN,这一步可以丢弃
  2. 返回该属性当前值
    如何设置?
  3. 如果设置了负值或者NaN,会抛出TypeError异常,并丢弃剩余步骤
  4. 如果readyState不是open,会抛出InvalidStateError异常,并丢弃剩余步骤
  5. sourceBuffers属性中的任何一个SourceBuffer的updating值为true, ,会抛出InvalidStateError异常,并丢弃剩余步骤
  6. 运行duration change algorithm算法可以设置新的duration给当前属性

Note
如果当前有更高end time的缓冲帧,duration change algorithm会调整新的更大的duration。
appendBuffer()和 endOfStream()方法会更新duration在特定的情况下。

onsourceopen 类型:EventHandler
sourceopen事件的处理回调

onsourceended 类型:EventHandler
sourceended事件的处理回调

onsourceclose 类型:EventHandler
sourceclose事件的处理回调

2.2 方法

addSourceBuffer
入参:type, 返回SourceBuffer对象,调用如:

sourceBuffer = ms.addSourceBuffer('video/mp4; codecs="avc1.4d401f"');

添加一个新的SourceBuffer对象到sourceBuffers属性,接下来ua需要执行:

  1. 如果type为空字符串(''),则抛出TypeError异常,并丢弃剩余步骤
  2. 如果type为不支持的MIME类型,则抛出 NotSupportedError异常,并丢弃剩余步骤
  3. 如果ua不能处理更多的SourceBuffer内容,则抛出QuotaExceededError异常,并丢弃剩余步骤

比如当媒体元素到了HAVE_METADATA状态的时候,ua就不支持播放中更多track的添加了

  1. 如果readyState不是open,则抛出InvalidStateError异常,并丢弃剩余步骤
  2. 常见一个新的SourceBuffer对象和相关资源
  3. 设置新对象的generate timestamps flag为相关类型的[MSE-REGISTRY] entry
  4. 如果generate timestamps flag值为true,设置新对象的mode属性为“sequence”,否则设置为“segments”
  5. 把新对象添加到sourceBuffers中,并且触发一个addsourcebuffer事件
  6. 返回这个新对象

removeSourceBuffer

endOfStream
入参:error(EndOfStreamError),无返回值

  • setLiveSeekableRange*
    更新 HTMLMediaElement Extensions元素的live seekable range变量
  • clearLiveSeekableRange*
  • isTypeSupported* static
    检测MediaSource对象是否支持创建特定MIME类型的SourceBuffer

只代表浏览器是否支持的能力,不代表addSourceBuffer()方法有足够的资源创建新的SourceBuffer

2.3 事件

事件名称描述
sourceopenreadyState 从close到open 或 从ended到open
sourceendedreadyState 从open到ended
sourceclosereadyState 从open到closed 或 从open到ended

2.4 算法(待补充)

查看原文

赞 3 收藏 3 评论 6

momo707577045 赞了文章 · 2020-07-24

Media Source Extension官方文档(第一部分)

摘要

本说明通过允许javascript来生成看到播放的流媒体扩展了HTMLMediaElement[HTML51]对象。允许javascript来生成流促进了很多用途,如可自适应的流和可进行时间变换的直播流。

1. 介绍

这一节是非规范性的(non-normative)。
这个特性允许JavaScript去动态地为<audio>和<video>创建媒体流。它定义了一个MediaSource对象来给HTMLMediaElement提供媒体数据的源。MediaSource对象拥有一个或多个SourceBuffer对象。浏览器应用通过添加数据片段(data segments)给SourceBuffer对象,然后根据系统性能和其他因素来适应不同质量的后续data。SourceBuffer对象里的数据是被组建成需要被编码和播放的音频、视频和文字信息的轨道缓冲(track buffer)格式的。被用于扩展的二进制流格式结构如下图所示:
clipboard.png

1.1 目标

  • 允许js去创建media stream,独立于普通的拉流播放的方式。
  • 定义了一种加快自适应流,广告插入,时戳转换,视频编辑的分割和缓存模式。
  • 最小化js中的媒体解析需要
  • 尽可能的呃管理浏览器的播放缓存
  • 提供二进制流格式操作需要
  • 不需要支持特定的媒体格式和编解码器(codec)

这个说明定义了

  • 浏览器和web app处理媒体数据的规范行为
  • 其他定义媒体格式的

1.2 一些名词定义

Active Track Buffers
一个提供开启中的音频track,选中的视频track,和正在显示或隐藏的字幕track的编码过的帧集合的track buffer。这些tracks都和activeSourceBuffers列表中的SourceBuffer对象有关。

Append Window
添加buffer时用于筛选coded frames的一个pts的range。append window表示一个连续的有单一开始和结束时间的时间区间。只有pts在这个时间区间内的编码帧才允许被添加到SourceBuffer中,其余的都会被筛选出去。append window的开始和结束时间是受appendWindowStart和appendWindowEnd两个属性分别控制。

Coded Frame
一个有presentation timestamp(pts), decode timestamp(dts)和coded frame duration的媒体数据单元。

Coded Frame Duration
一个coded frame的时长,对视频和文字而言,duration就是指一个视频帧或者文字需要被展示的时间长度,对于音频而言,duration就是指这一帧中包括的采样的和。比如:一个包含441个采样样本的采样率是@44100Hz的音频帧的时长就是10ms。

Coded Frame Group
一组响铃的,dts单调递增没有gap的coded frames集合。如果被coded frame processing algorithm算法检测到的不连续片段就会触发abort方法从一个新的coded frame group开始重新播放。

Decode Timestamp
The decode timestamp(就是通常说的dts)表示最晚的这一帧和任何独立帧需要被解码的时间(假设可以被立刻解码和渲染,应该等于这个presentation order里最先被渲染的帧的pts)。如果这一帧不能在渲染顺序中被解码出来或者没有dts,那么dts就等于pts。

Initialization Segment
一系列的包括了需要解码media segment序列的初始化信息的二进制数据。包括了codec初始化信息,多路segment的Track ID的映射和时间戳偏移等。

Media Segment
一序列的包括了封装信息和时间戳信息的媒体数据二进制数据。Media Segment总是和最新添加的initialization segment相关。

MediaSource object URL
MediaSource object URL是一个唯一的通过createObjectURL()方法生成的Blob URI。用于绑定一个MediaSource对西那个到一个HTMLMediaElement元素上。

Parent Media Source
一个SourceBuffer对象的Parent Media Source是创建它的MediaSource对象。

Presentation Start Time
Presentation Start Time

Presentation Interval
一个coded frame的Presentation Interval是一个从pts到pts+coded frame's duration时间间隔。比如有一帧的pts是10s,coded frame duration是100ms,那么the presentation interval就是[10-10.1)。注意:起始时间是闭区间,结束时间是开区间。

Presentation Order
coded frames 渲染的顺序。The presentation order通过把coded frames 根据pts单调递增的排列起来获得。

Presentation Timestamp
视频帧显示的具体时间,表示了这一帧应该什么时候被播放器渲染。

Random Access Point
一个media segment可以不依赖之前数据解码和连续播放的位置。对于视频来说就是I-frames的位置,对音频来说大多数帧都可以作为random access point。因为视频轨道的random access point分布更稀疏,所以这些位置通常被当作复路流(multiplexed stream,我理解就是音视频等混合起来的流)的random access point。

SourceBuffer byte stream format specification
byte stream format specification特性描述了SourceBuffer实例允许的二进制流格式。是根据传入addSourceBuffer()方法的type。

SourceBuffer configuration
一个MediaSource实例下面的一个或多个SourceBuffer对象里的tracks集合。一个MediaSource对象必须支持以下至少一个设置:

  • 一个拥有音频和/或视频的SourceBuffer
  • 两个SourceBuffer,其中一个单独处理音频轨道,一个单独处理视频轨道

Track Description
一个二进制流数据结构,提供了单个track需要的TrackID,codec设置和其他metadata。一个initialization segment的每个track description都需要一个唯一的Track ID,如果不唯一的话,浏览器必须执行一个append error algorithm算法。

Track ID
用于识别二进制流数据属于那个track的标识。每个track description中的Track ID标识了一个 media segment属于的track。

查看原文

赞 8 收藏 7 评论 1

momo707577045 赞了文章 · 2020-07-01

npm install 你很明白吗?

npm install 你很明白吗
dependencies 依赖
devDependencies 开发依赖

【当我们敲 npm install 的时候会安装哪些依赖,dependencies 和 devDependencies 都会安装吗?还是只安装 dependencies ?】
【项目依赖包是放在 dependencies 和 devDependencies ?】

简单问两个问题,勾起大家对 npm install,dependencies ,devDependencies 的回忆。

下面进入正文

npm install 也支持直接输入 Github 代码库地址

npm install git://github.com/package/path.git
npm install git://github.com/package/path.git#0.1.0

安装之前,npm install 会先检查,node_modules 目录之中是否已经存在指定模块。如果存在,就不再重新安装了,即使远程仓库已经有了一个新版本,也是如此。

如果你希望,一个模块不管是否安装过, npm 都要强制重新安装,可以使用 -f 或 --force 参数。

npm install <packageName> --force

npm install 的时候会安装哪个里面的依赖?dependencies 还是 devDependencies

【npm install 默认会安装 dependencies 字段和 devDependencies 字段中的所有模块】。 如果软件包具有 package-lock 或 shrinkwrap 文件,则依赖项的安装将由此驱动,如果两个文件都存在,则 npm-shrinkwrap.json 优先。 请参阅 package-lock.json 和 npm-shrinkwrap。

【ok,那么也就是说,当我们在拿到一个项目的时候,使用 npm install 是会安装 dependencies 和 devDependencies 里所有的依赖包的。】

那么是否意味着,我们在安装依赖包的时候,不需要过多的去纠结是使用 -S 还是 -D 呢 ?随便安装到 dependencies 或者 devDependencies 里都行,反正 npm install 的时候,都会安装 dependencies 和 devDependencies 里面的依赖。

其实不然!

如果使用 --production 参数,可以只安装 dependencies 字段的模块。

$ npm install --production
或者
$ NODE_ENV=production npm install

【所以,我们做好 dependencies 和 devDependencies 的区分的话,在使用 npm install --production 的时候,还是有区别的。】

不过,感觉这个 npm install --production 的使用场景不是很多。我好像没怎么用,可能以后会用到吧。

内心os:既然 npm install --production 我用不到,那我在安装依赖包的时候,还是随意吧,放到 dependencies 或者 devDependencies 都无所谓,反正 npm install 的时候会把 dependencies 和 devDependencies 里面的依赖包都安装下来。哈哈~~,随意使用 -S 或者 -D 咯~

错!
错!!
错!!!
这样想就错了。dependencies 和 devDependencies 还是有明显区别的。我们接着来看。

我们在安装依赖包的时候,要如何区分是安装到 dependencies 还是 devDependencies 中呢?

dependencies 依赖
这个可以说是我们 npm 核心一项内容,依赖管理,这个对象里面的内容就是我们这个项目所依赖的 js 模块包。下面这段代码表示我们依赖了 markdown-it 这个包,版本是 ^8.1.0 ,代表最小依赖版本是 8.1.0 ,如果这个包有更新,那么当我们使用 npm install 命令的时候,npm 会帮我们下载最新的包。当别人引用我们这个包的时候,包内的依赖包也会被下载下来。

"dependencies": {
    "markdown-it": "^8.1.0"
}

devDependencies 开发依赖
【在我们开发的时候会用到的一些包,只是在开发环境中需要用到,但是在别人引用我们包的时候,不会用到这些内容,放在 devDependencies 的包,在别人引用的时候不会被 npm 下载】。

"devDependencies": {
    "autoprefixer": "^6.4.0",0",
    "babel-preset-es2015": "^6.0.0",
    "babel-preset-stage-2": "^6.0.0",
    "babel-register": "^6.0.0",
    "webpack": "^1.13.2",
    "webpack-dev-middleware": "^1.8.3",
    "webpack-hot-middleware": "^2.12.2",
    "webpack-merge": "^0.14.1",
    "highlightjs": "^9.8.0"
}

当你有了一个完整的 package.json 文件的时候,就可以让人一眼看出来,这个模块的基本信息,和这个模块所需要依赖的包。我们可以通过 npm install 就可以很方便的下载好这个模块所需要的包。

结论:当你在开发一个 npm 包的时候,还是要好好管理你的 dependencies 依赖 和 devDependencies 依赖。 之前有个同事写了一个 loading 组件,发到 npm 上面去了,他跟我说简单好用,我就用了。但是我发现他这么小的一个组件,为什么包这么大。一看,原来他写这个 npm 包的时候,所有的依赖都放到 dependencies 里面了(包括 gulp,browser-asyc,压缩代码的,express…等等一些他开发时用的工具)。

你们说,他是不是挺狠的!

查看原文

赞 2 收藏 1 评论 0