| // 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. |
| |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; |
| import * as Protocol from '../../generated/protocol.js'; |
| |
| export class AnimationModel extends SDK.SDKModel.SDKModel<EventTypes> { |
| readonly runtimeModel: SDK.RuntimeModel.RuntimeModel; |
| readonly agent: ProtocolProxyApi.AnimationApi; |
| #animationsById: Map<string, AnimationImpl>; |
| readonly animationGroups: Map<string, AnimationGroup>; |
| #pendingAnimations: Set<string>; |
| playbackRate: number; |
| readonly #screenshotCapture?: ScreenshotCapture; |
| #enabled?: boolean; |
| |
| constructor(target: SDK.Target.Target) { |
| super(target); |
| this.runtimeModel = (target.model(SDK.RuntimeModel.RuntimeModel) as SDK.RuntimeModel.RuntimeModel); |
| this.agent = target.animationAgent(); |
| target.registerAnimationDispatcher(new AnimationDispatcher(this)); |
| this.#animationsById = new Map(); |
| this.animationGroups = new Map(); |
| this.#pendingAnimations = new Set(); |
| this.playbackRate = 1; |
| const resourceTreeModel = |
| (target.model(SDK.ResourceTreeModel.ResourceTreeModel) as SDK.ResourceTreeModel.ResourceTreeModel); |
| resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.reset, this); |
| const screenCaptureModel = target.model(SDK.ScreenCaptureModel.ScreenCaptureModel); |
| if (screenCaptureModel) { |
| this.#screenshotCapture = new ScreenshotCapture(this, screenCaptureModel); |
| } |
| } |
| |
| private reset(): void { |
| this.#animationsById.clear(); |
| this.animationGroups.clear(); |
| this.#pendingAnimations.clear(); |
| this.dispatchEventToListeners(Events.ModelReset); |
| } |
| |
| animationCreated(id: string): void { |
| this.#pendingAnimations.add(id); |
| } |
| |
| animationCanceled(id: string): void { |
| this.#pendingAnimations.delete(id); |
| this.flushPendingAnimationsIfNeeded(); |
| } |
| |
| animationStarted(payload: Protocol.Animation.Animation): void { |
| // We are not interested in animations without effect or target. |
| if (!payload.source || !payload.source.backendNodeId) { |
| return; |
| } |
| |
| const animation = AnimationImpl.parsePayload(this, payload); |
| if (!animation) { |
| return; |
| } |
| |
| // Ignore Web Animations custom effects & groups. |
| const keyframesRule = animation.source().keyframesRule(); |
| if (animation.type() === 'WebAnimation' && keyframesRule && keyframesRule.keyframes().length === 0) { |
| this.#pendingAnimations.delete(animation.id()); |
| } else { |
| this.#animationsById.set(animation.id(), animation); |
| this.#pendingAnimations.add(animation.id()); |
| } |
| |
| this.flushPendingAnimationsIfNeeded(); |
| } |
| |
| private flushPendingAnimationsIfNeeded(): void { |
| for (const id of this.#pendingAnimations) { |
| if (!this.#animationsById.get(id)) { |
| return; |
| } |
| } |
| |
| while (this.#pendingAnimations.size) { |
| this.matchExistingGroups(this.createGroupFromPendingAnimations()); |
| } |
| } |
| |
| private matchExistingGroups(incomingGroup: AnimationGroup): boolean { |
| let matchedGroup: AnimationGroup|null = 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.screenshotsInternal); |
| } |
| } |
| this.dispatchEventToListeners(Events.AnimationGroupStarted, matchedGroup || incomingGroup); |
| return Boolean(matchedGroup); |
| } |
| |
| private createGroupFromPendingAnimations(): AnimationGroup { |
| console.assert(this.#pendingAnimations.size > 0); |
| const firstAnimationId = this.#pendingAnimations.values().next().value; |
| this.#pendingAnimations.delete(firstAnimationId); |
| |
| const firstAnimation = this.#animationsById.get(firstAnimationId); |
| if (!firstAnimation) { |
| throw new Error('Unable to locate first animation'); |
| } |
| |
| const groupedAnimations = [firstAnimation]; |
| const groupStartTime = firstAnimation.startTime(); |
| const remainingAnimations = new Set<string>(); |
| for (const id of this.#pendingAnimations) { |
| const anim = (this.#animationsById.get(id) as AnimationImpl); |
| if (anim.startTime() === groupStartTime) { |
| groupedAnimations.push(anim); |
| } else { |
| remainingAnimations.add(id); |
| } |
| } |
| this.#pendingAnimations = remainingAnimations; |
| return new AnimationGroup(this, firstAnimationId, groupedAnimations); |
| } |
| |
| setPlaybackRate(playbackRate: number): void { |
| this.playbackRate = playbackRate; |
| void this.agent.invoke_setPlaybackRate({playbackRate}); |
| } |
| |
| releaseAnimations(animations: string[]): void { |
| void this.agent.invoke_releaseAnimations({animations}); |
| } |
| |
| override async suspendModel(): Promise<void> { |
| this.reset(); |
| await this.agent.invoke_disable(); |
| } |
| |
| override async resumeModel(): Promise<void> { |
| if (!this.#enabled) { |
| return; |
| } |
| await this.agent.invoke_enable(); |
| } |
| |
| async ensureEnabled(): Promise<void> { |
| if (this.#enabled) { |
| return; |
| } |
| await this.agent.invoke_enable(); |
| this.#enabled = true; |
| } |
| } |
| |
| // TODO(crbug.com/1167717): Make this a const enum again |
| // eslint-disable-next-line rulesdir/const_enum |
| export enum Events { |
| AnimationGroupStarted = 'AnimationGroupStarted', |
| ModelReset = 'ModelReset', |
| } |
| |
| export type EventTypes = { |
| [Events.AnimationGroupStarted]: AnimationGroup, |
| [Events.ModelReset]: void, |
| }; |
| |
| export class AnimationImpl { |
| readonly #animationModel: AnimationModel; |
| readonly #payloadInternal: Protocol.Animation.Animation; |
| #sourceInternal: AnimationEffect; |
| #playStateInternal?: string; |
| constructor(animationModel: AnimationModel, payload: Protocol.Animation.Animation) { |
| this.#animationModel = animationModel; |
| this.#payloadInternal = payload; |
| this.#sourceInternal = |
| new AnimationEffect(animationModel, (this.#payloadInternal.source as Protocol.Animation.AnimationEffect)); |
| } |
| |
| static parsePayload(animationModel: AnimationModel, payload: Protocol.Animation.Animation): AnimationImpl { |
| return new AnimationImpl(animationModel, payload); |
| } |
| |
| payload(): Protocol.Animation.Animation { |
| return this.#payloadInternal; |
| } |
| |
| id(): string { |
| return this.#payloadInternal.id; |
| } |
| |
| name(): string { |
| return this.#payloadInternal.name; |
| } |
| |
| paused(): boolean { |
| return this.#payloadInternal.pausedState; |
| } |
| |
| playState(): string { |
| return this.#playStateInternal || this.#payloadInternal.playState; |
| } |
| |
| setPlayState(playState: string): void { |
| this.#playStateInternal = playState; |
| } |
| |
| playbackRate(): number { |
| return this.#payloadInternal.playbackRate; |
| } |
| |
| startTime(): number { |
| return this.#payloadInternal.startTime; |
| } |
| |
| endTime(): number { |
| if (!this.source().iterations) { |
| return Infinity; |
| } |
| return this.startTime() + this.source().delay() + this.source().duration() * this.source().iterations() + |
| this.source().endDelay(); |
| } |
| |
| finiteDuration(): number { |
| const iterations = Math.min(this.source().iterations(), 3); |
| return this.source().delay() + this.source().duration() * iterations; |
| } |
| |
| currentTime(): number { |
| return this.#payloadInternal.currentTime; |
| } |
| |
| source(): AnimationEffect { |
| return this.#sourceInternal; |
| } |
| |
| type(): Protocol.Animation.AnimationType { |
| return this.#payloadInternal.type; |
| } |
| |
| overlaps(animation: AnimationImpl): boolean { |
| // 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(); |
| } |
| |
| setTiming(duration: number, delay: number): void { |
| void this.#sourceInternal.node().then(node => { |
| if (!node) { |
| throw new Error('Unable to find node'); |
| } |
| this.updateNodeStyle(duration, delay, node); |
| }); |
| this.#sourceInternal.durationInternal = duration; |
| this.#sourceInternal.delayInternal = delay; |
| void this.#animationModel.agent.invoke_setTiming({animationId: this.id(), duration, delay}); |
| } |
| |
| private updateNodeStyle(duration: number, delay: number, node: SDK.DOMModel.DOMNode): void { |
| let animationPrefix; |
| if (this.type() === Protocol.Animation.AnimationType.CSSTransition) { |
| animationPrefix = 'transition-'; |
| } else if (this.type() === Protocol.Animation.AnimationType.CSSAnimation) { |
| animationPrefix = 'animation-'; |
| } else { |
| return; |
| } |
| |
| if (!node.id) { |
| throw new Error('Node has no id'); |
| } |
| |
| const cssModel = node.domModel().cssModel(); |
| cssModel.setEffectivePropertyValueForNode(node.id, animationPrefix + 'duration', duration + 'ms'); |
| cssModel.setEffectivePropertyValueForNode(node.id, animationPrefix + 'delay', delay + 'ms'); |
| } |
| |
| async remoteObjectPromise(): Promise<SDK.RemoteObject.RemoteObject|null> { |
| const payload = await this.#animationModel.agent.invoke_resolveAnimation({animationId: this.id()}); |
| if (!payload) { |
| return null; |
| } |
| |
| return this.#animationModel.runtimeModel.createRemoteObject(payload.remoteObject); |
| } |
| |
| cssId(): string { |
| return this.#payloadInternal.cssId || ''; |
| } |
| } |
| |
| export class AnimationEffect { |
| #animationModel: AnimationModel; |
| readonly #payload: Protocol.Animation.AnimationEffect; |
| readonly #keyframesRuleInternal: KeyframesRule|undefined; |
| delayInternal: number; |
| durationInternal: number; |
| #deferredNodeInternal?: SDK.DOMModel.DeferredDOMNode; |
| constructor(animationModel: AnimationModel, payload: Protocol.Animation.AnimationEffect) { |
| this.#animationModel = animationModel; |
| this.#payload = payload; |
| if (payload.keyframesRule) { |
| this.#keyframesRuleInternal = new KeyframesRule(payload.keyframesRule); |
| } |
| this.delayInternal = this.#payload.delay; |
| this.durationInternal = this.#payload.duration; |
| } |
| |
| delay(): number { |
| return this.delayInternal; |
| } |
| |
| endDelay(): number { |
| return this.#payload.endDelay; |
| } |
| |
| iterationStart(): number { |
| return this.#payload.iterationStart; |
| } |
| |
| iterations(): number { |
| // 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; |
| } |
| |
| duration(): number { |
| return this.durationInternal; |
| } |
| |
| direction(): string { |
| return this.#payload.direction; |
| } |
| |
| fill(): string { |
| return this.#payload.fill; |
| } |
| |
| node(): Promise<SDK.DOMModel.DOMNode|null> { |
| if (!this.#deferredNodeInternal) { |
| this.#deferredNodeInternal = |
| new SDK.DOMModel.DeferredDOMNode(this.#animationModel.target(), this.backendNodeId()); |
| } |
| return this.#deferredNodeInternal.resolvePromise(); |
| } |
| |
| deferredNode(): SDK.DOMModel.DeferredDOMNode { |
| return new SDK.DOMModel.DeferredDOMNode(this.#animationModel.target(), this.backendNodeId()); |
| } |
| |
| backendNodeId(): Protocol.DOM.BackendNodeId { |
| return this.#payload.backendNodeId as Protocol.DOM.BackendNodeId; |
| } |
| |
| keyframesRule(): KeyframesRule|null { |
| return this.#keyframesRuleInternal || null; |
| } |
| |
| easing(): string { |
| return this.#payload.easing; |
| } |
| } |
| |
| export class KeyframesRule { |
| readonly #payload: Protocol.Animation.KeyframesRule; |
| #keyframesInternal: KeyframeStyle[]; |
| constructor(payload: Protocol.Animation.KeyframesRule) { |
| this.#payload = payload; |
| this.#keyframesInternal = this.#payload.keyframes.map(function(keyframeStyle) { |
| return new KeyframeStyle(keyframeStyle); |
| }); |
| } |
| |
| private setKeyframesPayload(payload: Protocol.Animation.KeyframeStyle[]): void { |
| this.#keyframesInternal = payload.map(function(keyframeStyle) { |
| return new KeyframeStyle(keyframeStyle); |
| }); |
| } |
| |
| name(): string|undefined { |
| return this.#payload.name; |
| } |
| |
| keyframes(): KeyframeStyle[] { |
| return this.#keyframesInternal; |
| } |
| } |
| |
| export class KeyframeStyle { |
| readonly #payload: Protocol.Animation.KeyframeStyle; |
| #offsetInternal: string; |
| constructor(payload: Protocol.Animation.KeyframeStyle) { |
| this.#payload = payload; |
| this.#offsetInternal = this.#payload.offset; |
| } |
| |
| offset(): string { |
| return this.#offsetInternal; |
| } |
| |
| setOffset(offset: number): void { |
| this.#offsetInternal = offset * 100 + '%'; |
| } |
| |
| offsetAsNumber(): number { |
| return parseFloat(this.#offsetInternal) / 100; |
| } |
| |
| easing(): string { |
| return this.#payload.easing; |
| } |
| } |
| |
| export class AnimationGroup { |
| readonly #animationModel: AnimationModel; |
| readonly #idInternal: string; |
| #animationsInternal: AnimationImpl[]; |
| #pausedInternal: boolean; |
| screenshotsInternal: string[]; |
| readonly #screenshotImages: HTMLImageElement[]; |
| constructor(animationModel: AnimationModel, id: string, animations: AnimationImpl[]) { |
| this.#animationModel = animationModel; |
| this.#idInternal = id; |
| this.#animationsInternal = animations; |
| this.#pausedInternal = false; |
| this.screenshotsInternal = []; |
| |
| this.#screenshotImages = []; |
| } |
| |
| id(): string { |
| return this.#idInternal; |
| } |
| |
| animations(): AnimationImpl[] { |
| return this.#animationsInternal; |
| } |
| |
| release(): void { |
| this.#animationModel.animationGroups.delete(this.id()); |
| this.#animationModel.releaseAnimations(this.animationIds()); |
| } |
| |
| private animationIds(): string[] { |
| function extractId(animation: AnimationImpl): string { |
| return animation.id(); |
| } |
| |
| return this.#animationsInternal.map(extractId); |
| } |
| |
| startTime(): number { |
| return this.#animationsInternal[0].startTime(); |
| } |
| |
| finiteDuration(): number { |
| let maxDuration = 0; |
| for (let i = 0; i < this.#animationsInternal.length; ++i) { |
| maxDuration = Math.max(maxDuration, this.#animationsInternal[i].finiteDuration()); |
| } |
| return maxDuration; |
| } |
| |
| seekTo(currentTime: number): void { |
| void this.#animationModel.agent.invoke_seekAnimations({animations: this.animationIds(), currentTime}); |
| } |
| |
| paused(): boolean { |
| return this.#pausedInternal; |
| } |
| |
| togglePause(paused: boolean): void { |
| if (paused === this.#pausedInternal) { |
| return; |
| } |
| this.#pausedInternal = paused; |
| void this.#animationModel.agent.invoke_setPaused({animations: this.animationIds(), paused}); |
| } |
| |
| currentTimePromise(): Promise<number> { |
| let longestAnim: AnimationImpl|null = null; |
| for (const anim of this.#animationsInternal) { |
| if (!longestAnim || anim.endTime() > longestAnim.endTime()) { |
| longestAnim = anim; |
| } |
| } |
| if (!longestAnim) { |
| throw new Error('No longest animation found'); |
| } |
| |
| return this.#animationModel.agent.invoke_getCurrentTime({id: longestAnim.id()}) |
| .then(({currentTime}) => currentTime || 0); |
| } |
| |
| matches(group: AnimationGroup): boolean { |
| function extractId(anim: AnimationImpl): string { |
| if (anim.type() === Protocol.Animation.AnimationType.WebAnimation) { |
| return anim.type() + anim.id(); |
| } |
| return anim.cssId(); |
| } |
| |
| if (this.#animationsInternal.length !== group.#animationsInternal.length) { |
| return false; |
| } |
| const left = this.#animationsInternal.map(extractId).sort(); |
| const right = group.#animationsInternal.map(extractId).sort(); |
| for (let i = 0; i < left.length; i++) { |
| if (left[i] !== right[i]) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| update(group: AnimationGroup): void { |
| this.#animationModel.releaseAnimations(this.animationIds()); |
| this.#animationsInternal = group.#animationsInternal; |
| } |
| |
| screenshots(): HTMLImageElement[] { |
| for (let i = 0; i < this.screenshotsInternal.length; ++i) { |
| const image = new Image(); |
| image.src = 'data:image/jpeg;base64,' + this.screenshotsInternal[i]; |
| this.#screenshotImages.push(image); |
| } |
| this.screenshotsInternal = []; |
| return this.#screenshotImages; |
| } |
| } |
| |
| export class AnimationDispatcher implements ProtocolProxyApi.AnimationDispatcher { |
| readonly #animationModel: AnimationModel; |
| constructor(animationModel: AnimationModel) { |
| this.#animationModel = animationModel; |
| } |
| |
| animationCreated({id}: Protocol.Animation.AnimationCreatedEvent): void { |
| this.#animationModel.animationCreated(id); |
| } |
| |
| animationCanceled({id}: Protocol.Animation.AnimationCanceledEvent): void { |
| this.#animationModel.animationCanceled(id); |
| } |
| |
| animationStarted({animation}: Protocol.Animation.AnimationStartedEvent): void { |
| this.#animationModel.animationStarted(animation); |
| } |
| } |
| |
| export class ScreenshotCapture { |
| #requests: Request[]; |
| readonly #screenCaptureModel: SDK.ScreenCaptureModel.ScreenCaptureModel; |
| readonly #animationModel: AnimationModel; |
| #stopTimer?: number; |
| #endTime?: number; |
| #capturing?: boolean; |
| constructor(animationModel: AnimationModel, screenCaptureModel: SDK.ScreenCaptureModel.ScreenCaptureModel) { |
| this.#requests = []; |
| this.#screenCaptureModel = screenCaptureModel; |
| this.#animationModel = animationModel; |
| this.#animationModel.addEventListener(Events.ModelReset, this.stopScreencast, this); |
| } |
| |
| captureScreenshots(duration: number, screenshots: string[]): void { |
| 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 = window.setTimeout(this.stopScreencast.bind(this), screencastDuration); |
| this.#endTime = endTime; |
| } |
| |
| if (this.#capturing) { |
| return; |
| } |
| this.#capturing = true; |
| this.#screenCaptureModel.startScreencast( |
| Protocol.Page.StartScreencastRequestFormat.Jpeg, 80, undefined, 300, 2, this.screencastFrame.bind(this), |
| _visible => {}); |
| } |
| |
| private screencastFrame(base64Data: string, _metadata: Protocol.Page.ScreencastFrameMetadata): void { |
| function isAnimating(request: Request): boolean { |
| 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); |
| } |
| } |
| |
| private stopScreencast(): void { |
| if (!this.#capturing) { |
| return; |
| } |
| |
| this.#stopTimer = undefined; |
| this.#endTime = undefined; |
| this.#requests = []; |
| this.#capturing = false; |
| this.#screenCaptureModel.stopScreencast(); |
| } |
| } |
| |
| SDK.SDKModel.SDKModel.register(AnimationModel, {capabilities: SDK.Target.Capability.DOM, autostart: false}); |
| export interface Request { |
| endTime: number; |
| screenshots: string[]; |
| } |