diff --git a/src/plugins/fresco/ImagePool.js b/src/plugins/fresco/ImagePool.js new file mode 100644 index 000000000..d358a4023 --- /dev/null +++ b/src/plugins/fresco/ImagePool.js @@ -0,0 +1,75 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type {ImageId, ImageData} from './api.js'; + +export type ImagesMap = {[imageId: ImageId]: ImageData}; + +const maxInflightRequests = 10; + +export default class ImagePool { + cache: ImagesMap = {}; + requested: {[imageId: ImageId]: boolean} = {}; + queued: Array = []; + inFlightRequests: number = 0; + fetchImage: (imageId: ImageId) => void; + updateNotificationScheduled: boolean = false; + onPoolUpdated: (images: ImagesMap) => void; + + constructor( + fetchImage: (imageId: ImageId) => void, + onPoolUpdated: (images: ImagesMap) => void, + ) { + this.fetchImage = fetchImage; + this.onPoolUpdated = onPoolUpdated; + } + + getImages(): ImagesMap { + return {...this.cache}; + } + + fetchImages(ids: Array) { + for (const id of ids) { + if (!this.cache[id] && !this.requested[id]) { + this.requested[id] = true; + + if (this.inFlightRequests < maxInflightRequests) { + this.inFlightRequests++; + this.fetchImage(id); + } else { + this.queued.unshift(id); + } + } + } + } + + clear() { + this.cache = {}; + this.requested = {}; + } + + _fetchCompleted(image: ImageData): void { + this.cache[image.imageId] = image; + delete this.requested[image.imageId]; + + if (this.queued.length > 0) { + this.fetchImage(this.queued.pop()); + } else { + this.inFlightRequests--; + } + + if (!this.updateNotificationScheduled) { + this.updateNotificationScheduled = true; + window.setTimeout(this._notify, 1000); + } + } + + _notify = () => { + this.updateNotificationScheduled = false; + this.onPoolUpdated(this.getImages()); + }; +} diff --git a/src/plugins/fresco/ImagesCacheOverview.js b/src/plugins/fresco/ImagesCacheOverview.js new file mode 100644 index 000000000..524169cd5 --- /dev/null +++ b/src/plugins/fresco/ImagesCacheOverview.js @@ -0,0 +1,404 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type {ImageId, ImageData, ImagesList} from './api.js'; +import type {ImageEventWithId} from './index.js'; + +import { + Toolbar, + Button, + Spacer, + colors, + FlexBox, + FlexRow, + FlexColumn, + LoadingIndicator, + styled, +} from 'flipper'; +import type {ImagesMap} from './ImagePool.js'; +import {clipboard} from 'electron'; +import {PureComponent} from 'react'; + +function formatMB(bytes: number) { + return Math.floor(bytes / (1024 * 1024)) + 'MB'; +} + +function formatKB(bytes: number) { + return Math.floor(bytes / 1024) + 'KB'; +} + +type ImagesCacheOverviewProps = { + images: ImagesList, + onClear: (type: string) => void, + onTrimMemory: () => void, + onRefresh: () => void, + onEnableDebugOverlay: (enabled: boolean) => void, + isDebugOverlayEnabled: boolean, + onEnableAutoRefresh: (enabled: boolean) => void, + isAutoRefreshEnabled: boolean, + onImageSelected: (selectedImage: ImageId) => void, + imagesMap: ImagesMap, + events: Array, +}; + +type ImagesCacheOverviewState = {| + selectedImage: ?ImageId, + size: number, +|}; + +export default class ImagesCacheOverview extends PureComponent< + ImagesCacheOverviewProps, + ImagesCacheOverviewState, +> { + static Container = styled(FlexColumn)({ + backgroundColor: colors.white, + }); + + static Content = styled(FlexColumn)({ + flex: 1, + overflow: 'auto', + }); + + static Empty = styled(FlexBox)({ + alignItems: 'center', + height: '100%', + justifyContent: 'center', + width: '100%', + }); + + state = { + selectedImage: undefined, + size: 150, + }; + + onImageSelected = (selectedImage: ImageId) => { + this.setState({selectedImage}); + this.props.onImageSelected(selectedImage); + }; + + onKeyDown = (e: SyntheticKeyboardEvent<*>) => { + const selectedImage = this.state.selectedImage; + const imagesMap = this.props.imagesMap; + + if (selectedImage) { + if ((e.ctrlKey || e.metaKey) && e.key === 'c') { + clipboard.writeText(String(imagesMap[selectedImage])); + e.preventDefault(); + } + } + }; + + onEnableDebugOverlayToggled = () => { + this.props.onEnableDebugOverlay(!this.props.isDebugOverlayEnabled); + }; + + onEnableAutoRefreshToggled = () => { + this.props.onEnableAutoRefresh(!this.props.isAutoRefreshEnabled); + }; + + onChangeSize = (e: SyntheticInputEvent) => + this.setState({size: parseInt(e.target.value, 10)}); + + render() { + const hasImages = + this.props.images.reduce( + (c, cacheInfo) => c + cacheInfo.imageIds.length, + 0, + ) > 0; + if (!hasImages) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + {this.props.images.map(data => { + const maxSize = data.maxSizeBytes; + const subtitle = maxSize + ? formatMB(data.sizeBytes) + ' / ' + formatMB(maxSize) + : formatMB(data.sizeBytes); + const onClear = data.clearKey + ? () => this.props.onClear(data.clearKey) + : null; + return ( + + ); + })} + + + ); + } +} + +class ImageGrid extends PureComponent<{ + title: string, + subtitle: string, + images: Array, + selectedImage: ?ImageId, + onImageSelected: (image: ImageId) => void, + onClear: ?() => void, + imagesMap: ImagesMap, + size: number, + events: Array, +}> { + static Content = styled('div')({ + paddingLeft: 15, + }); + + render() { + const {images, onImageSelected, selectedImage} = this.props; + + if (images.length === 0) { + return null; + } + + return [ + , + + {images.map(imageId => ( + e.imageIds.includes(imageId)).length + } + /> + ))} + , + ]; + } +} + +class ImageGridHeader extends PureComponent<{ + title: string, + subtitle: string, + onClear: ?() => void, +}> { + static Container = styled(FlexRow)({ + color: colors.dark70, + paddingTop: 10, + paddingBottom: 10, + marginLeft: 15, + marginRight: 15, + marginBottom: 15, + borderBottom: `1px solid ${colors.light10}`, + flexShrink: 0, + alignItems: 'center', + position: 'sticky', + top: 0, + left: 0, + right: 0, + backgroundColor: colors.white, + zIndex: 3, + }); + + static Heading = styled('span')({ + fontSize: 22, + fontWeight: 600, + }); + + static Subtitle = styled('span')({ + fontSize: 22, + fontWeight: 300, + marginLeft: 15, + }); + + static ClearButton = styled(Button)({ + alignSelf: 'center', + height: 30, + marginLeft: 'auto', + width: 100, + }); + + render() { + return ( + + {this.props.title} + + {this.props.subtitle} + + + {this.props.onClear ? ( + + Clear Cache + + ) : null} + + ); + } +} + +class ImageItem extends PureComponent<{ + imageId: ImageId, + image: ?ImageData, + selected: boolean, + onSelected: (image: ImageId) => void, + size: number, + numberOfRequests: number, +}> { + static Container = styled(FlexBox)(({size}) => ({ + float: 'left', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + height: size, + width: size, + borderRadius: 4, + marginRight: 15, + marginBottom: 15, + backgroundColor: colors.light02, + })); + + static Image = styled('img')({ + borderRadius: 4, + maxHeight: '100%', + maxWidth: '100%', + objectFit: 'contain', + }); + + static Loading = styled('span')({ + padding: '0 0', + }); + + static SelectedHighlight = styled('div')(props => ({ + borderColor: colors.highlight, + borderStyle: 'solid', + borderWidth: props.selected ? 3 : 0, + borderRadius: 4, + boxShadow: props.selected ? `inset 0 0 0 1px ${colors.white}` : 'none', + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + })); + + static HoverOverlay = styled(FlexColumn)(props => ({ + alignItems: 'center', + backgroundColor: colors.whiteAlpha80, + bottom: props.selected ? 4 : 0, + fontSize: props.size > 100 ? 16 : 11, + justifyContent: 'center', + left: props.selected ? 4 : 0, + opacity: 0, + position: 'absolute', + right: props.selected ? 4 : 0, + top: props.selected ? 4 : 0, + overflow: 'hidden', + transition: '.1s opacity', + '&:hover': { + opacity: 1, + }, + })); + + static MemoryLabel = styled('span')({ + fontWeight: 600, + marginBottom: 6, + }); + + static SizeLabel = styled('span')({ + fontWeight: 300, + }); + + static Events = styled('div')({ + position: 'absolute', + top: -5, + right: -5, + color: colors.white, + backgroundColor: colors.highlight, + fontWeight: 600, + borderRadius: 10, + fontSize: '0.85em', + zIndex: 2, + lineHeight: '20px', + width: 20, + textAlign: 'center', + }); + + static defaultProps = { + size: 150, + }; + + onClick = () => { + this.props.onSelected(this.props.imageId); + }; + + render() { + const {image, selected, size, numberOfRequests} = this.props; + + return ( + + {numberOfRequests > 0 && + image != null && ( + {numberOfRequests} + )} + {image != null ? ( + + ) : ( + + )} + + {image != null && ( + + + {formatKB(image.sizeBytes)} + + + {image.width}×{image.height} + + + )} + + ); + } +} diff --git a/src/plugins/fresco/ImagesSidebar.js b/src/plugins/fresco/ImagesSidebar.js new file mode 100644 index 000000000..20b47c6cb --- /dev/null +++ b/src/plugins/fresco/ImagesSidebar.js @@ -0,0 +1,160 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type {ImageData} from './api.js'; +import type {ImageEventWithId} from './index.js'; +import { + Component, + DataDescription, + Text, + Panel, + ManagedDataInspector, + colors, + styled, +} from 'flipper'; + +type ImagesSidebarProps = { + image: ?ImageData, + events: Array, +}; + +type ImagesSidebarState = {}; + +const DataDescriptionKey = styled('span')({ + color: colors.grapeDark1, +}); + +export default class ImagesSidebar extends Component< + ImagesSidebarProps, + ImagesSidebarState, +> { + render() { + return ( +
+ {this.renderUri()} + {this.props.events.map(e => )} +
+ ); + } + + renderUri() { + if (!this.props.image) { + return null; + } + if (!this.props.image.uri) { + return null; + } + return ( +

+ URI + : + , val: any) {}} + /> +

+ ); + } +} + +class EventDetails extends Component<{ + event: ImageEventWithId, +}> { + static Container = styled(Panel)({ + flexShrink: 0, + marginTop: '15px', + }); + + render() { + const {event} = this.props; + + return ( + } + floating={false} + padded={false} + grow={false} + collapsed={false}> +

+ Attribution + : + +

+

+ Time start + : + , val: any) {}} + /> +

+

+ Time end + : + , val: any) {}} + /> +

+

+ Source + : + , val: any) {}} + /> +

+ {this.renderViewportData()} +
+ ); + } + + renderViewportData() { + const viewport = this.props.event.viewport; + if (!viewport) { + return null; + } + return ( +

+ Viewport + : + , val: any) {}} + /> +

+ ); + // TODO (t31947746): grey box time, n-th scan time + } +} + +class RequestHeader extends Component<{ + event: ImageEventWithId, +}> { + dateString = timestamp => { + const date = new Date(timestamp); + return `${date.toTimeString().split(' ')[0]}.${( + '000' + date.getMilliseconds() + ).substr(-3)}`; + }; + + render() { + const {event} = this.props; + const durationMs = event.endTime - event.startTime; + return ( + + {event.viewport ? 'Request' : 'Prefetch'} at{' '} + {this.dateString(event.startTime)} ({durationMs}ms) + + ); + } +} diff --git a/src/plugins/fresco/api.js b/src/plugins/fresco/api.js new file mode 100644 index 000000000..9d0bfb373 --- /dev/null +++ b/src/plugins/fresco/api.js @@ -0,0 +1,67 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +export type ImageId = string; + +// Listing images + +export type CacheInfo = {| + cacheType: string, + clearKey?: null, // set this if this cache level supports clear() + sizeBytes: number, + maxSizeBytes?: number, + imageIds: Array, +|}; + +export type ImagesList = Array; + +// The iOS Flipper api does not support a top-level array, so we wrap it in an object +export type ImagesListResponse = {| + levels: ImagesList, +|}; + +// listImages() -> ImagesListResponse + +// Getting details on a specific image + +export type ImageBytes = string; + +export type ImageData = {| + imageId: ImageId, + uri?: string, + width: number, + height: number, + sizeBytes: number, + data: ImageBytes, +|}; + +// getImage({imageId: string}) -> ImageData + +// Subscribing to image events (requests and prefetches) + +export type Timestamp = number; + +export type ViewportData = {| + width: number, + height: number, + scanDisplayTime: {[scan_number: number]: Timestamp}, +|}; + +export type ImageEvent = { + imageIds: Array, + attribution: Array, + startTime: Timestamp, + endTime: Timestamp, + source: string, + viewport?: ViewportData, // not set for prefetches +}; + +// Misc + +export type FrescoDebugOverlayEvent = {| + enabled: boolean, +|}; diff --git a/src/plugins/fresco/index.js b/src/plugins/fresco/index.js new file mode 100644 index 000000000..397a17d52 --- /dev/null +++ b/src/plugins/fresco/index.js @@ -0,0 +1,192 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type { + ImageId, + ImageData, + ImagesList, + ImagesListResponse, + ImageEvent, + FrescoDebugOverlayEvent, +} from './api.js'; +import type {ImagesMap} from './ImagePool.js'; + +import React from 'react'; +import ImagesCacheOverview from './ImagesCacheOverview.js'; +import { + FlipperPlugin, + FlexRow, + Text, + DetailSidebar, + colors, + styled, +} from 'flipper'; +import ImagesSidebar from './ImagesSidebar.js'; +import ImagePool from './ImagePool.js'; + +export type ImageEventWithId = ImageEvent & {eventId: number}; + +type PluginState = { + images: ImagesList, + isDebugOverlayEnabled: boolean, + isAutoRefreshEnabled: boolean, + events: Array, + imagesMap: ImagesMap, + selectedImage: ?ImageId, +}; + +const EmptySidebar = styled(FlexRow)({ + alignItems: 'center', + justifyContent: 'center', + color: colors.light30, + padding: 15, + fontSize: 16, +}); + +const DEBUG = false; + +export default class extends FlipperPlugin { + state: PluginState; + imagePool: ImagePool; + nextEventId: number = 1; + + state = { + images: [], + events: [], + selectedImage: null, + isDebugOverlayEnabled: false, + isAutoRefreshEnabled: false, + imagesMap: {}, + }; + + init() { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('init()'); + } + this.updateCaches('init'); + this.client.subscribe('events', (event: ImageEvent) => { + this.setState({ + events: [{eventId: this.nextEventId, ...event}, ...this.state.events], + }); + this.nextEventId++; + }); + this.client.subscribe( + 'debug_overlay_event', + (event: FrescoDebugOverlayEvent) => { + this.setState({isDebugOverlayEnabled: event.enabled}); + }, + ); + + this.imagePool = new ImagePool(this.getImage, (images: ImagesMap) => + this.setState({imagesMap: images}), + ); + } + + teardown() { + this.imagePool.clear(); + } + + updateCaches = (reason: string) => { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('Requesting images list (reason=' + reason + ')'); + } + this.client.call('listImages').then((response: ImagesListResponse) => { + response.levels.forEach(data => + this.imagePool.fetchImages(data.imageIds), + ); + this.setState({images: response.levels}); + }); + }; + + onClear = (type: string) => { + this.client.call('clear', {type}); + setTimeout(() => this.updateCaches('onClear'), 1000); + }; + + onTrimMemory = () => { + this.client.call('trimMemory', {}); + setTimeout(() => this.updateCaches('onTrimMemory'), 1000); + }; + + onEnableDebugOverlay = (enabled: boolean) => { + this.client.call('enableDebugOverlay', {enabled}); + }; + + onEnableAutoRefresh = (enabled: boolean) => { + this.setState({isAutoRefreshEnabled: enabled}); + if (enabled) { + // Delay the call just enough to allow the state change to complete. + setTimeout(() => this.onAutoRefresh()); + } + }; + + onAutoRefresh = () => { + this.updateCaches('auto-refresh'); + if (this.state.isAutoRefreshEnabled) { + setTimeout(() => this.onAutoRefresh(), 1000); + } + }; + + getImage = (imageId: string) => { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('<- getImage requested for ' + imageId); + } + this.client.call('getImage', {imageId}).then((image: ImageData) => { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('-> getImage ' + imageId + ' returned'); + } + this.imagePool._fetchCompleted(image); + }); + }; + + onImageSelected = (selectedImage: ImageId) => this.setState({selectedImage}); + + renderSidebar = () => { + const {selectedImage} = this.state; + + if (selectedImage == null) { + return ( + + + Select an image to see the events associated with it. + + + ); + } + + const maybeImage = this.state.imagesMap[selectedImage]; + const events = this.state.events.filter(e => + e.imageIds.includes(selectedImage), + ); + return ; + }; + + render() { + return ( + + this.updateCaches('refresh')} + onEnableDebugOverlay={this.onEnableDebugOverlay} + onEnableAutoRefresh={this.onEnableAutoRefresh} + isDebugOverlayEnabled={this.state.isDebugOverlayEnabled} + isAutoRefreshEnabled={this.state.isAutoRefreshEnabled} + onImageSelected={this.onImageSelected} + imagesMap={this.state.imagesMap} + events={this.state.events} + /> + {this.renderSidebar()} + + ); + } +} diff --git a/src/plugins/fresco/package.json b/src/plugins/fresco/package.json new file mode 100644 index 000000000..0801e147c --- /dev/null +++ b/src/plugins/fresco/package.json @@ -0,0 +1,11 @@ +{ + "name": "Fresco", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "title": "Images", + "icon": "profile", + "bugs": { + "email": "oncall+fresco@xmail.facebook.com" + } +} diff --git a/src/plugins/fresco/yarn.lock b/src/plugins/fresco/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/src/plugins/fresco/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +