| // Copyright (c) 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @unrestricted |
| */ |
| export default class AnimationModel extends SDK.SDKModel { |
| /** |
| * @param {!SDK.Target} target |
| */ |
| constructor(target) { |
| super(target); |
| this._runtimeModel = /** @type {!SDK.RuntimeModel} */ (target.model(SDK.RuntimeModel)); |
| this._agent = target.animationAgent(); |
| target.registerAnimationDispatcher(new Animation.AnimationDispatcher(this)); |
| /** @type {!Map.<string, !Animation.AnimationModel.Animation>} */ |
| this._animationsById = new Map(); |
| /** @type {!Map.<string, !Animation.AnimationModel.AnimationGroup>} */ |
| this._animationGroups = new Map(); |
| /** @type {!Array.<string>} */ |
| this._pendingAnimations = []; |
| this._playbackRate = 1; |
| const resourceTreeModel = /** @type {!SDK.ResourceTreeModel} */ (target.model(SDK.ResourceTreeModel)); |
| resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.MainFrameNavigated, this._reset, this); |
| const screenCaptureModel = target.model(SDK.ScreenCaptureModel); |
| if (screenCaptureModel) { |
| this._screenshotCapture = new ScreenshotCapture(this, screenCaptureModel); |
| } |
| } |
| |
| _reset() { |
| this._animationsById.clear(); |
| this._animationGroups.clear(); |
| this._pendingAnimations = []; |
| this.dispatchEventToListeners(Events.ModelReset); |
| } |
| |
| /** |
| * @param {string} id |
| */ |
| animationCreated(id) { |
| this._pendingAnimations.push(id); |
| } |
| |
| /** |
| * @param {string} id |
| */ |
| _animationCanceled(id) { |
| this._pendingAnimations.remove(id); |
| this._flushPendingAnimationsIfNeeded(); |
| } |
| |
| /** |
| * @param {!Protocol.Animation.Animation} payload |
| */ |
| animationStarted(payload) { |
| // We are not interested in animations without effect or target. |
| if (!payload.source || !payload.source.backendNodeId) { |
| return; |
| } |
| |
| const animation = AnimationImpl.parsePayload(this, payload); |
| |
| // Ignore Web Animations custom effects & groups. |
| if (animation.type() === 'WebAnimation' && animation.source().keyframesRule().keyframes().length === 0) { |
| this._pendingAnimations.remove(animation.id()); |
| } else { |
| this._animationsById.set(animation.id(), animation); |
| if (this._pendingAnimations.indexOf(animation.id()) === -1) { |
| this._pendingAnimations.push(animation.id()); |
| } |
| } |
| |
| this._flushPendingAnimationsIfNeeded(); |
| } |
| |
| _flushPendingAnimationsIfNeeded() { |
| for (const id of this._pendingAnimations) { |
| if (!this._animationsById.get(id)) { |
| return; |
| } |
| } |
| |
| while (this._pendingAnimations.length) { |
| this._matchExistingGroups(this._createGroupFromPendingAnimations()); |
| } |
| } |
| |
| /** |
| * @param {!Animation.AnimationModel.AnimationGroup} incomingGroup |
| * @return {boolean} |
| */ |
| _matchExistingGroups(incomingGroup) { |
| let matchedGroup = null; |
| for (const group of this._animationGroups.values()) { |
| if (group._matches(incomingGroup)) { |
| matchedGroup = group; |
| group._update(incomingGroup); |
| break; |
| } |
| } |
| |
| if (!matchedGroup) { |
| this._animationGroups.set(incomingGroup.id(), incomingGroup); |
| if (this._screenshotCapture) { |
| this._screenshotCapture.captureScreenshots(incomingGroup.finiteDuration(), incomingGroup._screenshots); |
| } |
| } |
| this.dispatchEventToListeners(Events.AnimationGroupStarted, matchedGroup || incomingGroup); |
| return !!matchedGroup; |
| } |
| |
| /** |
| * @return {!Animation.AnimationModel.AnimationGroup} |
| */ |
| _createGroupFromPendingAnimations() { |
| console.assert(this._pendingAnimations.length); |
| const groupedAnimations = [this._animationsById.get(this._pendingAnimations.shift())]; |
| const remainingAnimations = []; |
| for (const id of this._pendingAnimations) { |
| const anim = this._animationsById.get(id); |
| if (anim.startTime() === groupedAnimations[0].startTime()) { |
| groupedAnimations.push(anim); |
| } else { |
| remainingAnimations.push(id); |
| } |
| } |
| this._pendingAnimations = remainingAnimations; |
| return new AnimationGroup(this, groupedAnimations[0].id(), groupedAnimations); |
| } |
| |
| /** |
| * @param {number} playbackRate |
| */ |
| setPlaybackRate(playbackRate) { |
| this._playbackRate = playbackRate; |
| this._agent.setPlaybackRate(playbackRate); |
| } |
| |
| /** |
| * @param {!Array.<string>} animations |
| */ |
| _releaseAnimations(animations) { |
| this._agent.releaseAnimations(animations); |
| } |
| |
| /** |
| * @override |
| * @return {!Promise} |
| */ |
| suspendModel() { |
| this._reset(); |
| return this._agent.disable(); |
| } |
| |
| /** |
| * @override |
| * @return {!Promise} |
| */ |
| resumeModel() { |
| if (!this._enabled) { |
| return Promise.resolve(); |
| } |
| return this._agent.enable(); |
| } |
| |
| ensureEnabled() { |
| if (this._enabled) { |
| return; |
| } |
| this._agent.enable(); |
| this._enabled = true; |
| } |
| } |
| |
| /** @enum {symbol} */ |
| export const Events = { |
| AnimationGroupStarted: Symbol('AnimationGroupStarted'), |
| ModelReset: Symbol('ModelReset') |
| }; |
| |
| /** |
| * @unrestricted |
| */ |
| export class AnimationImpl { |
| /** |
| * @param {!Animation.AnimationModel} animationModel |
| * @param {!Protocol.Animation.Animation} payload |
| */ |
| constructor(animationModel, payload) { |
| this._animationModel = animationModel; |
| this._payload = payload; |
| this._source = |
| new AnimationEffect(animationModel, /** @type {!Protocol.Animation.AnimationEffect} */ (this._payload.source)); |
| } |
| |
| /** |
| * @param {!Animation.AnimationModel} animationModel |
| * @param {!Protocol.Animation.Animation} payload |
| * @return {!Animation.AnimationModel.Animation} |
| */ |
| static parsePayload(animationModel, payload) { |
| return new AnimationImpl(animationModel, payload); |
| } |
| |
| /** |
| * @return {!Protocol.Animation.Animation} |
| */ |
| payload() { |
| return this._payload; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| id() { |
| return this._payload.id; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| name() { |
| return this._payload.name; |
| } |
| |
| /** |
| * @return {boolean} |
| */ |
| paused() { |
| return this._payload.pausedState; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| playState() { |
| return this._playState || this._payload.playState; |
| } |
| |
| /** |
| * @param {string} playState |
| */ |
| setPlayState(playState) { |
| this._playState = playState; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| playbackRate() { |
| return this._payload.playbackRate; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| startTime() { |
| return this._payload.startTime; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| endTime() { |
| if (!this.source().iterations) { |
| return Infinity; |
| } |
| return this.startTime() + this.source().delay() + this.source().duration() * this.source().iterations() + |
| this.source().endDelay(); |
| } |
| |
| /** |
| * @return {number} |
| */ |
| _finiteDuration() { |
| const iterations = Math.min(this.source().iterations(), 3); |
| return this.source().delay() + this.source().duration() * iterations; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| currentTime() { |
| return this._payload.currentTime; |
| } |
| |
| /** |
| * @return {!Animation.AnimationModel.AnimationEffect} |
| */ |
| source() { |
| return this._source; |
| } |
| |
| /** |
| * @return {!Animation.AnimationModel.Animation.Type} |
| */ |
| type() { |
| return /** @type {!Animation.AnimationModel.Animation.Type} */ (this._payload.type); |
| } |
| |
| /** |
| * @param {!Animation.AnimationModel.Animation} animation |
| * @return {boolean} |
| */ |
| overlaps(animation) { |
| // Infinite animations |
| if (!this.source().iterations() || !animation.source().iterations()) { |
| return true; |
| } |
| |
| const firstAnimation = this.startTime() < animation.startTime() ? this : animation; |
| const secondAnimation = firstAnimation === this ? animation : this; |
| return firstAnimation.endTime() >= secondAnimation.startTime(); |
| } |
| |
| /** |
| * @param {number} duration |
| * @param {number} delay |
| */ |
| setTiming(duration, delay) { |
| this._source.node().then(this._updateNodeStyle.bind(this, duration, delay)); |
| this._source._duration = duration; |
| this._source._delay = delay; |
| this._animationModel._agent.setTiming(this.id(), duration, delay); |
| } |
| |
| /** |
| * @param {number} duration |
| * @param {number} delay |
| * @param {!SDK.DOMNode} node |
| */ |
| _updateNodeStyle(duration, delay, node) { |
| let animationPrefix; |
| if (this.type() === Type.CSSTransition) { |
| animationPrefix = 'transition-'; |
| } else if (this.type() === Type.CSSAnimation) { |
| animationPrefix = 'animation-'; |
| } else { |
| return; |
| } |
| |
| const cssModel = node.domModel().cssModel(); |
| cssModel.setEffectivePropertyValueForNode(node.id, animationPrefix + 'duration', duration + 'ms'); |
| cssModel.setEffectivePropertyValueForNode(node.id, animationPrefix + 'delay', delay + 'ms'); |
| } |
| |
| /** |
| * @return {!Promise<?SDK.RemoteObject>} |
| */ |
| remoteObjectPromise() { |
| return this._animationModel._agent.resolveAnimation(this.id()).then( |
| payload => payload && this._animationModel._runtimeModel.createRemoteObject(payload)); |
| } |
| |
| /** |
| * @return {string} |
| */ |
| _cssId() { |
| return this._payload.cssId || ''; |
| } |
| } |
| |
| /** @enum {string} */ |
| export const Type = { |
| CSSTransition: 'CSSTransition', |
| CSSAnimation: 'CSSAnimation', |
| WebAnimation: 'WebAnimation' |
| }; |
| |
| /** |
| * @unrestricted |
| */ |
| export class AnimationEffect { |
| /** |
| * @param {!Animation.AnimationModel} animationModel |
| * @param {!Protocol.Animation.AnimationEffect} payload |
| */ |
| constructor(animationModel, payload) { |
| this._animationModel = animationModel; |
| this._payload = payload; |
| if (payload.keyframesRule) { |
| this._keyframesRule = new KeyframesRule(payload.keyframesRule); |
| } |
| this._delay = this._payload.delay; |
| this._duration = this._payload.duration; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| delay() { |
| return this._delay; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| endDelay() { |
| return this._payload.endDelay; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| iterationStart() { |
| return this._payload.iterationStart; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| iterations() { |
| // Animations with zero duration, zero delays and infinite iterations can't be shown. |
| if (!this.delay() && !this.endDelay() && !this.duration()) { |
| return 0; |
| } |
| return this._payload.iterations || Infinity; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| duration() { |
| return this._duration; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| direction() { |
| return this._payload.direction; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| fill() { |
| return this._payload.fill; |
| } |
| |
| /** |
| * @return {!Promise.<!SDK.DOMNode>} |
| */ |
| node() { |
| if (!this._deferredNode) { |
| this._deferredNode = new SDK.DeferredDOMNode(this._animationModel.target(), this.backendNodeId()); |
| } |
| return this._deferredNode.resolvePromise(); |
| } |
| |
| /** |
| * @return {!SDK.DeferredDOMNode} |
| */ |
| deferredNode() { |
| return new SDK.DeferredDOMNode(this._animationModel.target(), this.backendNodeId()); |
| } |
| |
| /** |
| * @return {number} |
| */ |
| backendNodeId() { |
| return /** @type {number} */ (this._payload.backendNodeId); |
| } |
| |
| /** |
| * @return {?Animation.AnimationModel.KeyframesRule} |
| */ |
| keyframesRule() { |
| return this._keyframesRule; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| easing() { |
| return this._payload.easing; |
| } |
| } |
| |
| /** |
| * @unrestricted |
| */ |
| export class KeyframesRule { |
| /** |
| * @param {!Protocol.Animation.KeyframesRule} payload |
| */ |
| constructor(payload) { |
| this._payload = payload; |
| this._keyframes = this._payload.keyframes.map(function(keyframeStyle) { |
| return new KeyframeStyle(keyframeStyle); |
| }); |
| } |
| |
| /** |
| * @param {!Array.<!Protocol.Animation.KeyframeStyle>} payload |
| */ |
| _setKeyframesPayload(payload) { |
| this._keyframes = payload.map(function(keyframeStyle) { |
| return new KeyframeStyle(keyframeStyle); |
| }); |
| } |
| |
| /** |
| * @return {string|undefined} |
| */ |
| name() { |
| return this._payload.name; |
| } |
| |
| /** |
| * @return {!Array.<!Animation.AnimationModel.KeyframeStyle>} |
| */ |
| keyframes() { |
| return this._keyframes; |
| } |
| } |
| |
| /** |
| * @unrestricted |
| */ |
| export class KeyframeStyle { |
| /** |
| * @param {!Protocol.Animation.KeyframeStyle} payload |
| */ |
| constructor(payload) { |
| this._payload = payload; |
| this._offset = this._payload.offset; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| offset() { |
| return this._offset; |
| } |
| |
| /** |
| * @param {number} offset |
| */ |
| setOffset(offset) { |
| this._offset = offset * 100 + '%'; |
| } |
| |
| /** |
| * @return {number} |
| */ |
| offsetAsNumber() { |
| return parseFloat(this._offset) / 100; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| easing() { |
| return this._payload.easing; |
| } |
| } |
| |
| /** |
| * @unrestricted |
| */ |
| export class AnimationGroup { |
| /** |
| * @param {!Animation.AnimationModel} animationModel |
| * @param {string} id |
| * @param {!Array.<!Animation.AnimationModel.Animation>} animations |
| */ |
| constructor(animationModel, id, animations) { |
| this._animationModel = animationModel; |
| this._id = id; |
| this._animations = animations; |
| this._paused = false; |
| this._screenshots = []; |
| this._screenshotImages = []; |
| } |
| |
| /** |
| * @return {string} |
| */ |
| id() { |
| return this._id; |
| } |
| |
| /** |
| * @return {!Array.<!Animation.AnimationModel.Animation>} |
| */ |
| animations() { |
| return this._animations; |
| } |
| |
| release() { |
| this._animationModel._animationGroups.remove(this.id()); |
| this._animationModel._releaseAnimations(this._animationIds()); |
| } |
| |
| /** |
| * @return {!Array.<string>} |
| */ |
| _animationIds() { |
| /** |
| * @param {!Animation.AnimationModel.Animation} animation |
| * @return {string} |
| */ |
| function extractId(animation) { |
| return animation.id(); |
| } |
| |
| return this._animations.map(extractId); |
| } |
| |
| /** |
| * @return {number} |
| */ |
| startTime() { |
| return this._animations[0].startTime(); |
| } |
| |
| /** |
| * @return {number} |
| */ |
| finiteDuration() { |
| let maxDuration = 0; |
| for (let i = 0; i < this._animations.length; ++i) { |
| maxDuration = Math.max(maxDuration, this._animations[i]._finiteDuration()); |
| } |
| return maxDuration; |
| } |
| |
| /** |
| * @param {number} currentTime |
| */ |
| seekTo(currentTime) { |
| this._animationModel._agent.seekAnimations(this._animationIds(), currentTime); |
| } |
| |
| /** |
| * @return {boolean} |
| */ |
| paused() { |
| return this._paused; |
| } |
| |
| /** |
| * @param {boolean} paused |
| */ |
| togglePause(paused) { |
| if (paused === this._paused) { |
| return; |
| } |
| this._paused = paused; |
| this._animationModel._agent.setPaused(this._animationIds(), paused); |
| } |
| |
| /** |
| * @return {!Promise<number>} |
| */ |
| currentTimePromise() { |
| let longestAnim = null; |
| for (const anim of this._animations) { |
| if (!longestAnim || anim.endTime() > longestAnim.endTime()) { |
| longestAnim = anim; |
| } |
| } |
| return this._animationModel._agent.getCurrentTime(longestAnim.id()).then(currentTime => currentTime || 0); |
| } |
| |
| /** |
| * @param {!Animation.AnimationModel.AnimationGroup} group |
| * @return {boolean} |
| */ |
| _matches(group) { |
| /** |
| * @param {!Animation.AnimationModel.Animation} anim |
| * @return {string} |
| */ |
| function extractId(anim) { |
| if (anim.type() === Type.WebAnimation) { |
| return anim.type() + anim.id(); |
| } else { |
| return anim._cssId(); |
| } |
| } |
| |
| if (this._animations.length !== group._animations.length) { |
| return false; |
| } |
| const left = this._animations.map(extractId).sort(); |
| const right = group._animations.map(extractId).sort(); |
| for (let i = 0; i < left.length; i++) { |
| if (left[i] !== right[i]) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| /** |
| * @param {!Animation.AnimationModel.AnimationGroup} group |
| */ |
| _update(group) { |
| this._animationModel._releaseAnimations(this._animationIds()); |
| this._animations = group._animations; |
| } |
| |
| /** |
| * @return {!Array.<!Image>} |
| */ |
| screenshots() { |
| for (let i = 0; i < this._screenshots.length; ++i) { |
| const image = new Image(); |
| image.src = 'data:image/jpeg;base64,' + this._screenshots[i]; |
| this._screenshotImages.push(image); |
| } |
| this._screenshots = []; |
| return this._screenshotImages; |
| } |
| } |
| |
| /** |
| * @implements {Protocol.AnimationDispatcher} |
| * @unrestricted |
| */ |
| export class AnimationDispatcher { |
| constructor(animationModel) { |
| this._animationModel = animationModel; |
| } |
| |
| /** |
| * @override |
| * @param {string} id |
| */ |
| animationCreated(id) { |
| this._animationModel.animationCreated(id); |
| } |
| |
| /** |
| * @override |
| * @param {string} id |
| */ |
| animationCanceled(id) { |
| this._animationModel._animationCanceled(id); |
| } |
| |
| /** |
| * @override |
| * @param {!Protocol.Animation.Animation} payload |
| */ |
| animationStarted(payload) { |
| this._animationModel.animationStarted(payload); |
| } |
| } |
| |
| /** |
| * @unrestricted |
| */ |
| export class ScreenshotCapture { |
| /** |
| * @param {!Animation.AnimationModel} animationModel |
| * @param {!SDK.ScreenCaptureModel} screenCaptureModel |
| */ |
| constructor(animationModel, screenCaptureModel) { |
| /** @type {!Array<!Animation.AnimationModel.ScreenshotCapture.Request>} */ |
| this._requests = []; |
| this._screenCaptureModel = screenCaptureModel; |
| this._animationModel = animationModel; |
| this._animationModel.addEventListener(Events.ModelReset, this._stopScreencast, this); |
| } |
| |
| /** |
| * @param {number} duration |
| * @param {!Array<string>} screenshots |
| */ |
| captureScreenshots(duration, screenshots) { |
| const screencastDuration = Math.min(duration / this._animationModel._playbackRate, 3000); |
| const endTime = screencastDuration + window.performance.now(); |
| this._requests.push({endTime: endTime, screenshots: screenshots}); |
| |
| if (!this._endTime || endTime > this._endTime) { |
| clearTimeout(this._stopTimer); |
| this._stopTimer = setTimeout(this._stopScreencast.bind(this), screencastDuration); |
| this._endTime = endTime; |
| } |
| |
| if (this._capturing) { |
| return; |
| } |
| this._capturing = true; |
| this._screenCaptureModel.startScreencast( |
| 'jpeg', 80, undefined, 300, 2, this._screencastFrame.bind(this), visible => {}); |
| } |
| |
| /** |
| * @param {string} base64Data |
| * @param {!Protocol.Page.ScreencastFrameMetadata} metadata |
| */ |
| _screencastFrame(base64Data, metadata) { |
| /** |
| * @param {!Animation.AnimationModel.ScreenshotCapture.Request} request |
| * @return {boolean} |
| */ |
| function isAnimating(request) { |
| return request.endTime >= now; |
| } |
| |
| if (!this._capturing) { |
| return; |
| } |
| |
| const now = window.performance.now(); |
| this._requests = this._requests.filter(isAnimating); |
| for (const request of this._requests) { |
| request.screenshots.push(base64Data); |
| } |
| } |
| |
| _stopScreencast() { |
| if (!this._capturing) { |
| return; |
| } |
| |
| delete this._stopTimer; |
| delete this._endTime; |
| this._requests = []; |
| this._capturing = false; |
| this._screenCaptureModel.stopScreencast(); |
| } |
| } |
| |
| SDK.SDKModel.register(AnimationModel, SDK.Target.Capability.DOM, false); |
| |
| /* Legacy exported object */ |
| self.Animation = self.Animation || {}; |
| |
| /* Legacy exported object */ |
| Animation = Animation || {}; |
| |
| /** |
| * @constructor |
| */ |
| Animation.AnimationModel = AnimationModel; |
| |
| /** @enum {symbol} */ |
| Animation.AnimationModel.Events = Events; |
| |
| /** |
| * @constructor |
| */ |
| Animation.AnimationModel.Animation = AnimationImpl; |
| |
| /** @enum {string} */ |
| Animation.AnimationModel.Animation.Type = Type; |
| |
| /** |
| * @constructor |
| */ |
| Animation.AnimationModel.AnimationEffect = AnimationEffect; |
| |
| /** |
| * @constructor |
| */ |
| Animation.AnimationModel.KeyframesRule = KeyframesRule; |
| |
| /** |
| * @constructor |
| */ |
| Animation.AnimationModel.KeyframeStyle = KeyframeStyle; |
| |
| /** |
| * @constructor |
| */ |
| Animation.AnimationModel.AnimationGroup = AnimationGroup; |
| |
| /** |
| * @constructor |
| */ |
| Animation.AnimationModel.ScreenshotCapture = ScreenshotCapture; |
| |
| /** @typedef {{ endTime: number, screenshots: !Array.<string>}} */ |
| Animation.AnimationModel.ScreenshotCapture.Request; |
| |
| /** |
| * @constructor |
| */ |
| Animation.AnimationDispatcher = AnimationDispatcher; |