Making a VPAID 2 Compliant Flash Creative

Intro

现在国内的视频站点已陆续开始支持符合 VPAID 2 协议的广告了。VPAID 是 IAB 给出的一个广告规范,可让用户在广告播放期间与之交互,并知会支持 VPAID 创意的播放器相关的用户行为和事件。

VPAID 协议不处理跨平台的问题。一个符合 VPAID 协议的 Flash 广告,不能同 JavaScript 版本的 VPAID 播放器通信。因此,要支持多个平台,必须使用不同的语言制作多种格式的广告,或者经过其他协议转换。下面的内容是针对 Flash 平台的,我用的 IDE 是 Flash Professional CC 2014,和 ActionScript 3.0 语言。

VPAID 2 是可以独立于 VAST 3 工作的,所以下面只在描述一些交集事件的时候,会有和 VAST 3 协同工作的内容。

另外,这篇文章的重点是 VPAID 2 创意,不是 VPAID 2 播放器。下面会先给规范中提到的 Interface 和 Events 做注解,之后提供一个 VPAID 创意内部的视频播放控件,最后描述一个完整的创意展现、交互流程,并给出一份示例创意代码。

Interface

在 VPAID 2 规范的 6.1 一节里,给出了 VPAID 创意需要实现的方法,和推荐 VPAID 播放器获取到创意之后的封装方法。这里针对广告创意的实现,只关注前者。规范的 3.1 和 3.2 节详细描述了每一个属性和方法,下面列出这些属性和方法,并带上简要注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package {
  public interface IVPAID {
    // ------------ 属性 --------------

    // 是否是线性创意。这个属性可能会根据创意当前的状态而改变。
    // 例如,一个前贴创意应该一直返回 true。
    function get adLinear():Boolean;

    // 创意的宽度。
    function get adWidth():Number

    // 创意的高度。
    function get adHeight():Number

    // 创意是否处于展开状态。
    // 例如,一个 Overlay 式的非线性创意,默认为 false;
    // 在用户交互后,创意展开,变为线性创意,此时则应为 true。
    // 再如,一个 Overlay 式的非线性创意,默认为 false;
    // 在用户交互后,创意尺寸变大,但仍未非线性创意,此时应为 true。
    // 如果一个 Overlay 式的非线性创意有多种尺寸,
    // 则只能在一种尺寸下返回 true,其他尺寸都应返回 false,
    // 一般在其到达最大尺寸时,返回 true。
    // 对于一个尺寸固定的创意,返回 false。
    function get adExpanded():Boolean;

    // 创意是否可以跳过
    function get adSkippableState():Boolean;

    // 创意在当前状态的剩余时间。
    // 当剩余时间未知,例如用户正在交互时,可以返回 -2。
    // 但若剩余时间始终是未知,则说明该属性不可用,应返回 -1。
    // 剩余时间的单位是秒。
    function get adRemainingTime():Number;

    // 创意在当前状态的时长。
    // 当时长未知,例如用户正在交互,可以返回 -2。
    // 但若时长始终未知,说明该属性不可用,应返回 -1。
    function get adDuration():Number;

    // 创意的音量。值在 [0, 1] 之间。
    // 若不提供音量信息,则返回 -1。
    function get adVolume():Number;

    // 设置创意的音量。应该根据入参限制在 [0, 1] 之间。
    // 若不提供音量控制,则该 setter 应留空。
    function set adVolume(value:Number):void;

    // VPAID 创意是可以附带伴随广告信息的。
    // 如果这个属性有值,则应为 VAST 结果中 <AdCompanions>
    // 的字符串表述。否则返回空字符串。
    function get adCompanions():String;

    // 如果 VPAID 创意自带了 Icon,则返回 true。
    // 其他情况都应返回 false。
    function get adIcons():Boolean;

    // ------------ 方法 --------------

    // 这里讨论的是兼容 VPAID 2.0 协议的广告,返回 "2.0"
    function handshakeVersion(playerVPAIDVersion:String):String;

    // 请确保在该方法调用完成后,发送 "AdLoaded" 事件。
    // 应在这个方法内准备好创意所需的所有素材,例如获取创意视频 Metadata
    // 并根据 width 和 height 属性,将其缩放,居中展示。
    // 如果有额外的信息需要和播放器交互,将使用最后一个参数传递。
    // 例如,在我和优酷的合作中,优酷会传递给我一个链接字符串作为
    // environmentVars 的值,用于发送额外的用户交互追踪打点。
    function initAd(width:Number, height:Number, viewMode:String,
        desiredBitrate:Number, creativeData:String="",
        environmentVars:String=""):void;

    // 当外部播放器缩放的时候,可能会调用该方法,通知创意需要缩放。
    // 这里的 width 和 height 理论上是外部播放器允许的最大创意尺寸。
    // 例如,你可以根据这个尺寸做等比缩放。但为保证创意展现完整,
    // 缩放结果不应该超出这个尺寸。
    // 当缩放完成后,需要发送 "AdSizeChange" 事件,并确保
    // 该事件发送后,播放器检查 adWidth 和 adHeight 属性时,
    // 创意能够正确返回新的尺寸信息。
    function resizeAd(width:Number, height:Number, viewMode:String):void;

    // 当 initAd 方法完成,并发出 "AdLoaded" 事件之后,
    // 播放器便随时可以调用该方法启动创意。
    // 在该方法内做完所有操作开始播放创意时,应发送 "AdStarted" 事件。
    // 播放器应该只调用一次该方法。
    function startAd():void;

    // 播放器调用该方法终止或取消创意播放。
    // 在该方法内应清理所有素材,并发送 "AdStopped" 事件。
    // 播放器应该只调用一次该方法。
    function stopAd():void;

    // 暂停创意播放。若当前创意可以被暂停,则应发送 "AdPaused" 事件。
    // 播放器在调用该方法后如果没有接到 "AdPaused" 事件,
    // 便可以认为创意暂停失败或者无法暂停。
    function pauseAd():void;

    // 恢复创意播放。应发送 "AdPlaying" 事件。
    // 如果播放器在调用该方法后没有接到 "AdPlaying" 事件,
    // 则可以认为创意无法恢复播放。
    function resumeAd():void;

    // 在播放器调用以下两个方法后,都应发送 "AdExpandedChange" 事件,
    // 并更新 adExpanded 状态。
    // 一般创意本身有可展开、收起的内容,播放器可以调用这两个方法。
    // 当然,如果播放器不调用,创意本身也是可以自己调用
    // 但只要状态改变,就要发送 "AdExpandedChange" 事件。
    // 如果播放器调用 collapseAd 方法,创意必须要收到最小尺寸。
    function expandAd():void;
    function collapseAd():void;

    // 跳过当前创意。如果播放器在调用该方法时,
    // adSkippableState 属性是 false,则可以忽略此次调用。
    // 若可以跳过,则应该在清理完所有素材之后,
    // 发送 "AdSkipped" 事件。
    function skipAd():void;
  }
}

VPAID Events

在 Interface 一节的注释里,提到了一些事件。这些事件都是 VPAID 事件。若配合 VAST 3 规范,这里有一些事件是重合的。

在 VPAID 2 规范的 6.2 一节里,给出了所有事件;规范的 3.3 节详细描述了每一个事件。下面同样完整列出并作注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
package {

  import flash.events.Event;

  public class VPAIDEvent extends Event {

    // 播放器调用 initAd 方法,创意处理完所有初始化工作后,发送该事件。
    // 若无法完成初始化过程,则应发送 "AdError" 事件。
    // 所有其他事件都应在该事件发送之后发送。
    public static const AdLoaded:String = "AdLoaded";

    // 播放器调用 startAd 方法,创意开始播放时发送该事件。
    public static const AdStarted:String = "AdStarted";

    // 播放器调用 stopAd 方法,创意清理完所有素材后,发送该事件。
    // 创意不能主动发送该事件,去通知播放器调用 stopAd 方法。
    // 该事件发送后,创意再接到任何消息,都不应发送其他事件。
    public static const AdStopped:String = "AdStopped";

    // 播放器调用 skipAd 方法,创意清理完所有素材后,发送该事件。
    // 若支持低版本 VPAID,在该事件发出后立即发送 "AdStopped" 事件。
    public static const AdSkipped:String = "AdSkipped";

    // 通知播放器创意的线性属性变更。
    public static const AdLinearChange:String = "AdLinearChange";

    // 播放器调用 resizeAd 方法,创意完成缩放操作,并更新
    // adWidth 和 adHeight 属性后,发送该事件。
    public static const AdSizeChange:String = "AdSizeChange";

    // 通知播放器创意的扩展状态变更。
    // 例如,某些交互面板展开,从不可用变为可用状态时,应发送该事件。
    // 创意尺寸变更与该事件无关,应发送 "AdSizeChange" 事件。
    public static const AdExpandedChange:String = "AdExpandedChange";

    // 通知播放器创意的可跳过状态变更。
    public static const AdSkippableStateChange:String = "AdSkippableStateChange";

    // 该事件在 VPAID 2.0 中已废弃。
    public static const AdRemainingTimeChange:String = "AdRemainingTimeChange";

    // 通知播放器创意时长变更,请确保该事件发送前已经更新创意的
    // adDuration 和 adRemainingTime 属性。
    public static const AdDurationChange:String = "AdDurationChange";

    // 通知播放器创意的音量变更。
    public static const AdVolumeChange:String = "AdVolumeChange";

    // 通知播放器创意的用户可见部分开始。
    // 例如,一个前贴创意,在 "AdStart" 的同时应该触发该事件;
    // 再如,一个默认状态为 Overlay 非线性创意,在浮层一出现就应该触发该事件。
    public static const AdImpression:String = "AdImpression";

    // 以下 5 个事件分别在创意中的视频开始播放、播放至 1/4 处、播放至一半、
    // 播放至 3/4 处、完成播放时发送。
    public static const AdVideoStart:String = "AdVideoStart";
    public static const AdVideoFirstQuartile:String = "AdVideoFirstQuartile";
    public static const AdVideoMidpoint:String = "AdVideoMidpoint";
    public static const AdVideoThirdQuartile:String = "AdVideoThirdQuartile";
    public static const AdVideoComplete:String = "AdVideoComplete";

    // 当用户点击创意内,会产生页面跳转的地方时,发送该事件。
    // 可以带上点击跳转链接地址、追踪 ID 和是否需要播放器来开启新窗口
    // 这 3 个信息作为 data 一并发送。
    public static const AdClickThru:String = "AdClickThru";

    // 用户和创意有交互操作,但不需要发生页面跳转时,发送该事件。
    // 可以带上追踪 ID 作为 data 一并发送。
    public static const AdInteraction:String = "AdInteraction";

    // 以下 3 个事件同 VAST 3 中的 acceptInvitation,collapse,close。
    // 仅用于通知播放器发送相应的打点统计。
    public static const AdUserAcceptInvitation:String = "AdUserAcceptInvitation";
    public static const AdUserMinimize:String = "AdUserMinimize";
    public static const AdUserClose:String = "AdUserClose";

    // 播放器调用 pauseAd 方法,暂停所有动画和声音后,发送该事件。
    public static const AdPaused:String = "AdPaused";

    // 播放器调用 resumeAd 方法,恢复创意播放后,发送该事件。
    public static const AdPlaying:String = "AdPlaying";

    // 该事件可用于和播放器联调。
    public static const AdLog:String = "AdLog";

    // 创意发生致命错误时发送。
    public static const AdError:String = "AdError";

    private var _data:Object;

    public function VPAIDEvent(type:String, data:Object=null, bubbles:Boolean=false, cancelable:Boolean=false) {
      super(type, bubbles, cancelable);
      _data = data;
    }

    public function get data():Object {
      return _data;
    }

    override public function clone():Event {
      return new VPAIDEvent(type, data, bubbles, cancelable);
    }
  }
}

Security

为确保播放器能够和创意交互,在创意内需要配置 Security.allowDomain("*")。如果你知道你的创意只被某几个服务商加载,那么也可以配置具体的域名。但除了单独使用 "*" 之外, Security.allowDomain 方法并不支持通配符,所以配置具体域名的可能性比较小。

Video in VPAID Creative

一个常见的 VPAID 创意由两部分组成:视频和交互元素。创意的时长一般为视频的时长。当发生交互时,最常见的方式为视频暂停,并触发 AdDurationChange 事件。

在 VPAID Events 一节中提到的多数事件都是由播放器调用创意的一个方法,创意再发送对应事件。但视频播放事件是创意主动发送的。因此,这里需要一个视频播放控件,方便管理视频播放进度。

下面描述的视频控件有以下功能:

这个控件作为一个 ActionScript 3 Class 使用,这里定义其名称为 AdVideoPlayer

首先,是加载和播放视频的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 使用以下 3 个私有变量来播放视频
private var _video:Video;
private var _nc:NetConnection;
private var _ns:NetStream;

// 用于追踪 metadata 信息
private var _isMetaDataApplied:Boolean = false;

// 用于追踪视频是否开始播放
private var _isVideoStarted:Boolean = false;

// 其中,_video 是需要被创意展现的对象,
// 因此提供一个 getter
public function get video():Video {
  return _video;
}

// 使用 load 方法,传递一个视频地址,以加载视频
public function load(url:String):void {
  resetProgressTimer();
  _isVideoStarted = false;
  _isMetaDataApplied = false;

  _nc = new NetConnection();
  _nc.connect(null);
  _ns = new NetStream(_nc);
  _ns.client = this;
  _ns.play(updateTbUgcUrl(url));
  _ns.addEventListener(AsyncErrorEvent.ASYNC_ERROR, asyncErrorHandler);

  _video = new Video();
  _video.attachNetStream(_ns);

  // 准备就绪后将视频暂停。
  _ns.pause();
}

接下去提供一个可选的 duration 属性,表示强制时长,单位为秒。假如创意中配置了这个属性,则忽略视频文件本身的时长,所有视频进度事件的发送,以 duration 为基准进行计算。否则,以视频本身时长为基准来计算。 在看代码之前,先解释一下为什么需要这个属性。 例如,视频文件本身有 20s,需要投放在两个媒体。媒体 A 要求创意时长为 15s,媒体 B 则为 20s。那么可以和两个媒体约定,在创意的 initAd 方法里,通过最后一个参数,告知创意所需的时长。创意拿到时长后,新建一个 AdVideoPlayer 实例(假设实例名为 adVideoPlayer),并判断当前投放媒体。若为 A,则配置 adVideoPlayer.duartion = 15;若为 B,则不配置该属性,或者配置 adVideoPlayer.duartion=20。 这样,当创意投放在媒体 A 时,视频的 5 个进度事件将分别于 0s,3.75s,7.5s,11.25s 和 15s 处发送;当创意投放在媒体 B 时,视频的 5 个进度事件将分别于 0s,5s,10s,15s 和 20s 处发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private var _duration:Number = 0;
private var _videoDuration:Number = -1;

// 创意通过 duration 获取视频时长。
// 假如该属性没有设置过,则返回 _videoDuration。
// 初始化 AdVideoPlayer 时,默认给 _videoDuration
// 设置 -1,和 VPAID 中不支持获取 duration 对应。
public function get duration():Number {
  if (_duration > 0) {
    return _duration;
  }
  else {
    return _videoDuration;
  }
}

public function set duration(value:Number):void {
  if (value > 0) {
    _duration = value;
  }
}

public function get videoDuration():Number {
  return _videoDuration;
}

在上述 duration 的 getter 里,还用到了一个私有变量,_videoDuration。这个变量仅在 duration 没有被配置的情况下会用到,用于返回视频的真实时长。当然,即使在配置了 duration 之后,仍可以通过 videoDuration 这个 getter 来获取视频真实时长。

这就说到了视频长度的来源。在上述 load 方法里, 配置了 _ns.client = this。Flash 会自动在当前类的实例上,调用以下方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static const METADATA_FETCHED:String = "AdMetaDataFetched";
private var _width:Number = 320;
private var _height:Number = 240;

public function onMetaData(info:Object):void {
  if (!_isMetaDataApplied) {
    // 防止此方法可能多次被调用,导致信息变更
    _isMetaDataApplied = true;

    _width = info.width;
    _height = info.height;

    // 从 metadata 更新视频时长。
    _videoDuration = Number(info.duration);

    // 此处必须覆盖 Video 实例的尺寸。
    // 否则视频将以默认 320x240 拉伸缩放展现。
    // 这也是默认给 _width 和 _height 设置这两个值的原因,
    // 当 metadata 获取失败,返回默认的尺寸。
    _video.width = _width;
    _video.height = _height;

    dispatchEvent(new Event(METADATA_FETCHED));
  }
}

创意需要在播放器调用其 initAd 方法之后,准备好所有的素材,并发送 adLoaded 事件。那么,视频 metadata 成功获取后,便是一个合适的时间来发送这个事件。所以在上述 onMetaData 方法末尾,发送一个 METADATA_FETCHED 事件,供创意选择监听。

此外,上述 _width_height 是否暴露都无所谓,因为最初的 _video 实例已经是公开属性。

下面就要处理视频的播放进度了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 每 500ms 检查一次播放进度。
private const TIMER_STEP:Number = 500;

// 视频的当前播放时间。
private var _time:Number;

// 视频播放进度追踪 Timer。
private var _progressTimer:Timer;

// 以下 4 个状态,外加上述 _isVideoStarted,
// 用于处理视频进度事件。
private var _isFirstQuartilePassed:Boolean = false;
private var _isMidpointPassed:Boolean = false;
private var _isThirdQuartilePassed:Boolean = false;
private var _isVideoPlayCompleted:Boolean = false;

// 初始化进度追踪 Timer。
private function resetProgressTimer():void {
  if (_progressTimer) {
    _progressTimer.reset();
  }
  else {
    _progressTimer = new Timer(TIMER_STEP);
    _progressTimer.addEventListener(TimerEvent.TIMER, trackProgress);
  }
}

private function trackProgress(event:TimerEvent):void {
  // 因为视频播放完成并不代表创意结束,可能是通过 _duration
  // 来计算进度事件发送时间点的。因此视频结束后,时间可能继续往前。
  if (_isVideoPlayCompleted) {
    _time += TIMER_STEP / 1000;
  }
  else {
    _time = _ns.time;
  }

  var percent:Number = Math.round(_time / this.duration * 100);

  if (percent >= 25 && !_isFirstQuartilePassed) {
    _isFirstQuartilePassed = true;
    dispatchEvent(new VPAIDEvent(VPAIDEvent.AdVideoFirstQuartile));
  }

  if (percent >= 50 && !_isMidpointPassed) {
    _isMidpointPassed = true;
    dispatchEvent(new VPAIDEvent(VPAIDEvent.AdVideoMidpoint));
  }

  if (percent >= 75 && !_isThirdQuartilePassed) {
    _isThirdQuartilePassed = true;
    dispatchEvent(new VPAIDEvent(VPAIDEvent.AdVideoThirdQuartile));
  }

  if (percent >= 100) {
    _progressTimer.stop();
    dispatchEvent(new VPAIDEvent(VPAIDEvent.AdVideoComplete));
  }
}

那么如何知道视频结束?回顾之前的 _ns.client = this 配置,Flash 会在视频播放时自动调用下述方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
public function onPlayStatus(status:Object):void {
  if (status.code === "NetStream.Play.Complete") {

    // 若 duration 未曾配置,则在视频播放完成时发送相应事件。
    if (_duration === 0) {
      dispatchEvent(new VPAIDEvent(VPAIDEvent.AdVideoComplete));
      if (_progressTimer) _progressTimer.stop();
    }

    _time = _ns.time;
    _isVideoPlayCompleted = true;
  }
}

至此,我们还缺一个视频开始播放的事件。在上述 load 方法末尾,准备好视频之后,先暂停了视频的播放。这是为了让创意能够先发送 adLoaded 事件,待播放器调用 startAd 方法时,创意再告知播放控件可以开始播放视频(即下述 resume 方法)。此时,应发送视频开始播放事件。

下面的代码将这个步骤和播放、暂停整合在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function pause():void {
  _ns.pause();
  _progressTimer.stop();
}

public function resume():void {
  onVideoStarted();
  _ns.resume();
  _progressTimer.start();
}

private function onVideoStarted():void {
  if (_isVideoStarted) return;
  _isVideoStarted = true;
  dispatchEvent(new VPAIDEvent(VPAIDEvent.AdVideoStart));
  _progressTimer.start();
}

最后,提供一个清除当前视频的方法,供创意在 stopAdskipAd 时调用。

1
2
3
4
5
6
7
8
public function clean():void {
  resetProgressTimer();
  _video.attachNetStream(null);

  _ns.removeEventListener(AsyncErrorEvent.ASYNC_ERROR, asyncErrorHandler);
  _ns.close();
  _nc.close();
}

该类的完整代码,见 Sample 一节末尾提供的压缩包。

有一点需要指出,不一定所有的视频都能成功获取到 metadata,或者获取到的 metadata 可能不包含上述逻辑所需信息。因此,在使用该类的时候,建议配置 duration 属性,并在创意内部添加 metadata 超时逻辑,避免 AdLoaded 事件没有发出。

Sample

最后提供一个简易的 VPAID 2 创意示例。要运行这个示例,你需要有一个支持 VPAID 2 的播放器。为方便起见,这里我会使用一个非常简单的播放器。 A simple VPAID 2 compliant player

示例中的创意使用 Video in VPAID Creative 一节里的类来播放视频,实现了 Interface 一节所述的接口,并发送 VPAID Events 一节所述的部分事件。

整体流程大致描述为:

由于 VPAID 2 协议中没有明确播放器如何确认创意播放完成,可能是 adRemainingTime 为 0 时,也可能是 AdVideoComplete 事件发送时,或者可以和媒体约定其他时间点。但必须确定这个点,避免创意提供方无法完成某些数据打点,或者媒体播放器无法结束创意。

下面提供完整的代码,包括: