Skip to content
Snippets Groups Projects
Select Git revision
  • 7942ec9af4b09c992fad6d066984deb2d6b83d17
  • mui5-tetras-main-stable default protected
  • mui5-tetras-main-old-stable
  • preprod protected
  • 75-dernieres-ameliorations-avant-workshop-du-7-02
  • wip-fix-xywh
  • wip-positionement-annot
  • wip-surface-transformer
  • uploads-file
  • 69-la-video-demare-quand-on-fait-glisser-le-slider-et-le-clic-creer-un-decalage-entre-le-player
  • 61-recettage-des-outils-d-annotation
  • gestion_multiple_ouverture_pannel_annotation
  • autorisation_un_pannel_annotation
  • autorisation_un_pannel_edition_annotation
  • récupération_temps_video
  • save-shapes-and-position
  • fix-error-create-annotation-pannel
  • time-saving-on-annotation
  • tetras-main protected
  • fix-poc-mirador
  • tetras-antho-test
21 results

miradorAnnotationPlugin.test.js

Blame
  • AnnotationsOverlayVideo.js 22.12 KiB
    import React, { Component } from 'react';
    import PropTypes from 'prop-types';
    import isEqual from 'lodash/isEqual';
    import debounce from 'lodash/debounce';
    import flatten from 'lodash/flatten';
    import sortBy from 'lodash/sortBy';
    import xor from 'lodash/xor';
    import ResizeObserver from 'react-resize-observer';
    import CircularProgress from '@material-ui/core/CircularProgress';
    import CanvasOverlayVideo from '../lib/CanvasOverlayVideo';
    import CanvasWorld from '../lib/CanvasWorld';
    import CanvasAnnotationDisplay from '../lib/CanvasAnnotationDisplay';
    import { VideosReferences } from '../plugins/VideosReferences';
    
    /** AnnotationsOverlayVideo - based on AnnotationsOverlay */
    export class AnnotationsOverlayVideo extends Component {
      /**
       * annotationsMatch - compares previous annotations to current to determine
       * whether to add a new updateCanvas method to draw annotations
       * @param  {Array} currentAnnotations
       * @param  {Array} prevAnnotations
       * @return {Boolean}
       */
      static annotationsMatch(currentAnnotations, prevAnnotations) {
        if (!currentAnnotations && !prevAnnotations) return true;
        if (
          (currentAnnotations && !prevAnnotations)
          || (!currentAnnotations && prevAnnotations)
        ) return false;
    
        if (currentAnnotations.length === 0 && prevAnnotations.length === 0) return true;
        if (currentAnnotations.length !== prevAnnotations.length) return false;
        return currentAnnotations.every((annotation, index) => {
          const newIds = annotation.resources.map(r => r.id);
          const prevIds = prevAnnotations[index].resources.map(r => r.id);
          if (newIds.length === 0 && prevIds.length === 0) return true;
          if (newIds.length !== prevIds.length) return false;
    
          if ((annotation.id === prevAnnotations[index].id) && (isEqual(newIds, prevIds))) {
            return true;
          }
          return false;
        });
      }
    
      /** @private */
      static isAnnotaionInTemporalSegment(resource, time) {
        const temporalfragment = resource.temporalfragmentSelector;
        if (temporalfragment && temporalfragment.length > 0) {
          const start = temporalfragment[0] || 0;
          const end = (temporalfragment.length > 1) ? temporalfragment[1] : Number.MAX_VALUE;
          if (start <= time && time < end) {
            //
          } else {
            return false;
          }
        }
        return true;
      }
    
      /**
       * @param {Object} props
       */
      constructor(props) {
        super(props);
    
        this.ref = React.createRef();
        VideosReferences.set(props.windowId, this);
        this.canvasOverlay = null;
        // An initial value for the updateCanvas method
        this.updateCanvas = () => {};
        this.onCanvasClick = this.onCanvasClick.bind(this);
        this.onCanvasMouseMove = debounce(this.onCanvasMouseMove.bind(this), 10);
        this.onCanvasExit = this.onCanvasExit.bind(this);
    
        this.onVideoTimeUpdate = this.onVideoTimeUpdate.bind(this);
        this.onVideoLoadedMetadata = this.onVideoLoadedMetadata.bind(this);
        this.onVideoWaiting = this.onVideoWaiting.bind(this);
        this.onVideoPlaying = this.onVideoPlaying.bind(this);
    
        this.onCanvasResize = this.onCanvasResize.bind(this);
    
        this.imagesLoading = [];
        this.imagesReady = [];
    
        const { videoTarget: temporalfragment } = this.props;
        if (temporalfragment && temporalfragment.length > 0) {
          this.temporalOffset = temporalfragment[0] || 0;
        } else {
          this.temporalOffset = 0;
        }
        this.currentTimeNearestAnnotationId = null;
    
        this.state = {
          showProgress: false,
        };
      }
    
      /**
       * React lifecycle event
       */
      componentDidMount() {
        this.initializeViewer();
      }
    
      /** */
      componentDidUpdate(prevProps) {
        const {
          canvasWorld,
          currentTime,
          drawAnnotations,
          drawSearchAnnotations,
          annotations, searchAnnotations,
          hoveredAnnotationIds, selectedAnnotationId,
          highlightAllAnnotations,
          paused,
          seekToTime,
        } = this.props;
    
        this.initializeViewer();
    
        let prevVideoPausedState;
        if (this.video) {
          prevVideoPausedState = this.video.paused;
          if (this.video.paused && !paused) {
            const promise = this.video.play();
            if (promise !== undefined) {
              promise.catch((e) => {});
            }
          } else if (!this.video.paused && paused) {
            this.video.pause();
          }
          if (seekToTime !== prevProps.seekToTime) {
            if (seekToTime !== undefined) {
              this.seekTo(seekToTime, true);
              return;
            }
          }
          if (this.video.seeking) {
            return;
          }
          if (currentTime !== prevProps.currentTime) {
            if (paused && this.video.paused) {
              this.video.currentTime = currentTime - this.temporalOffset;
            }
          }
        }
    
        const annotationsUpdated = !AnnotationsOverlayVideo.annotationsMatch(
          annotations, prevProps.annotations,
        );
        const searchAnnotationsUpdated = !AnnotationsOverlayVideo.annotationsMatch(
          searchAnnotations, prevProps.searchAnnotations,
        );
    
        const hoveredAnnotationsUpdated = (
          xor(hoveredAnnotationIds, prevProps.hoveredAnnotationIds).length > 0
        );
    
        if (this.canvasOverlay && this.canvasOverlay.canvas && hoveredAnnotationsUpdated) {
          if (hoveredAnnotationIds.length > 0) {
            this.canvasOverlay.canvas.style.cursor = 'pointer';
          } else {
            this.canvasOverlay.canvas.style.cursor = '';
          }
        }
    
        const selectedAnnotationsUpdated = selectedAnnotationId !== prevProps.selectedAnnotationId;
        if (selectedAnnotationsUpdated && selectedAnnotationId) {
          if (this.currentTimeNearestAnnotationId
            && this.currentTimeNearestAnnotationId === selectedAnnotationId) {
            // go through
          } else {
            annotations.forEach((annotation) => {
              annotation.resources.forEach((resource) => {
                if (resource.id !== selectedAnnotationId) return;
                if (!canvasWorld.canvasIds.includes(resource.targetId)) return;
                if (!AnnotationsOverlayVideo.isAnnotaionInTemporalSegment(resource, currentTime)) {
                  const temporalfragment = resource.temporalfragmentSelector;
                  if (temporalfragment && temporalfragment.length > 0 && this.video) {
                    const seekto = temporalfragment[0] || 0;
                    this.seekTo(seekto, !prevVideoPausedState);
                  }
                }
              });
            });
          }
        }
    
        // auto scroll
        if (this.video && !this.video.paused) {
          let minElapsedTimeAfterStart = Number.MAX_VALUE;
          let candidateAnnotation;
          annotations.forEach((annotation) => {
            annotation.resources.forEach((resource) => {
              if (!canvasWorld.canvasIds.includes(resource.targetId)) return;
              if (AnnotationsOverlayVideo.isAnnotaionInTemporalSegment(resource, currentTime)) {
                const temporalfragment = resource.temporalfragmentSelector;
                if (temporalfragment && temporalfragment.length > 0 && this.video) {
                  const seekto = temporalfragment[0] || 0;
                  const elapsedTimeAfterStart = currentTime - seekto;
                  if (elapsedTimeAfterStart >= 0 && elapsedTimeAfterStart < minElapsedTimeAfterStart) {
                    minElapsedTimeAfterStart = elapsedTimeAfterStart;
                    candidateAnnotation = resource.resource;
                  }
                }
              }
            });
          });
          if (candidateAnnotation) {
            if (candidateAnnotation.id !== prevProps.selectedAnnotationId) {
              const {
                selectAnnotation,
                windowId,
              } = this.props;
              if (selectedAnnotationId !== candidateAnnotation.id) {
                selectAnnotation(windowId, candidateAnnotation.id);
              }
              this.currentTimeNearestAnnotationId = candidateAnnotation.id;
            }
          }
        }
    
        const redrawAnnotations = drawAnnotations !== prevProps.drawAnnotations
          || drawSearchAnnotations !== prevProps.drawSearchAnnotations
          || highlightAllAnnotations !== prevProps.highlightAllAnnotations;
    
        if (
          searchAnnotationsUpdated
          || annotationsUpdated
          || selectedAnnotationsUpdated
          || hoveredAnnotationsUpdated
          || redrawAnnotations
        ) {
          this.updateCanvas = this.canvasUpdateCallback();
          this.updateCanvas();
        }
      }
    
      /**
       */
      componentWillUnmount() {
        if (this.resizeObserver) {
          this.resizeObserver.disconnect();
        }
        if (this.video) {
          this.video.removeEventListener('timeupdate', this.onVideoTimeUpdate);
          this.video.removeEventListener('loadedmetadata', this.onVideoLoadedMetadata);
          this.video.removeEventListener('waiting', this.onVideoWaiting);
          this.video.removeEventListener('playing', this.onVideoPlaying);
          this.video.removeEventListener('seeked', this.onVideoPlaying);
        }
        if (this.canvasOverlay && this.canvasOverlay.canvas) {
          this.canvasOverlay.canvas.removeEventListener('click', this.onCanvasClick);
          this.canvasOverlay.canvas.removeEventListener('mouseleave', this.onCanvasExit);
          this.canvasOverlay.canvas.removeEventListener('mousemove', this.onCanvasMouseMove);
        }
      }
    
      /** */
      onVideoTimeUpdate(event) {
        this.updateCanvas();
      }
    
      /** */
      onVideoLoadedMetadata(event) {
        if (this.video) {
          const { currentTime } = this.props;
          const { temporalOffset } = this;
          this.video.currentTime = currentTime - temporalOffset;
        }
      }
    
      /** */
      onVideoPlaying(event) {
        if (this.video && this.video.currentTime !== 0) {
          const { currentTime, seekToTime } = this.props;
          const currentTimeToVideoTime = currentTime - this.temporalOffset;
          const diff = Math.abs(currentTimeToVideoTime - this.video.currentTime);
          const acceptableDiff = 1; // sec.
          if (diff > acceptableDiff && seekToTime === undefined) {
            this.seekTo(this.video.currentTime + this.temporalOffset, true);
          }
        }
        this.setState({ showProgress: false });
      }
    
      /** */
      onVideoWaiting(event) {
        this.setState({ showProgress: true });
      }
    
      /** */
      onCanvasClick(event) {
        const { canvas: canvas_, canvasWorld, currentTime } = this.props;
    
        const scale = (this.canvasOverlay ? this.canvasOverlay.scale : 1) || 1;
        const point = { x: event.layerX / scale, y: event.layerY / scale };
    
        const canvas = this.isCanvasSizeSpecified() ? canvasWorld.canvasAtPoint(point) : canvas_;
        if (!canvas) return;
    
        // get all the annotations that contain the click
        // const currentTime = this.video ? this.video.currentTime : undefined;
        const annos = this.annotationsAtPoint(canvas, point, currentTime);
    
        if (annos.length > 0) {
          event.preventDefaultAction = true; // eslint-disable-line no-param-reassign
        }
    
        if (annos.length === 1) {
          this.toggleAnnotation(annos[0].id);
        } else if (annos.length > 0) {
          /**
           * Try to find the "right" annotation to select after a click.
           *
           * This is perhaps a naive method, but seems to deal with rectangles and SVG shapes:
           *
           * - figure out how many points around a circle are inside the annotation shape
           * - if there's a shape with the fewest interior points, it's probably the one
           *       with the closest boundary?
           * - if there's a tie, make the circle bigger and try again.
           */
          const annosWithClickScore = (radius) => {
            const degreesToRadians = Math.PI / 180;
    
            return (anno) => {
              let score = 0;
              for (let degrees = 0; degrees < 360; degrees += 1) {
                const x = Math.cos(degrees * degreesToRadians) * radius + point.x;
                const y = Math.sin(degrees * degreesToRadians) * radius + point.y;
    
                if (this.isAnnotationAtPoint(anno, canvas, { x, y })) score += 1;
              }
    
              return { anno, score };
            };
          };
    
          let annosWithScore = [];
          let radius = 1;
          annosWithScore = sortBy(annos.map(annosWithClickScore(radius)), 'score');
    
          const { width: canvasWidth, height: canvasHeight } = this.getCurrentCanvasSize();
          while (radius < Math.max(canvasWidth, canvasHeight)
            && annosWithScore[0].score === annosWithScore[1].score) {
            radius *= 2;
            annosWithScore = sortBy(annos.map(annosWithClickScore(radius)), 'score');
          }
    
          this.toggleAnnotation(annosWithScore[0].anno.id);
        }
      }
    
      /** */
      onCanvasMouseMove(event) {
        const {
          annotations,
          canvas: canvas_,
          canvasWorld,
          currentTime,
          hoverAnnotation,
          hoveredAnnotationIds,
          searchAnnotations,
          windowId,
        } = this.props;
    
        if (annotations.length === 0 && searchAnnotations.length === 0) return;
    
        const scale = (this.canvasOverlay ? this.canvasOverlay.scale : 1) || 1;
        const point = { x: event.layerX / scale, y: event.layerY / scale };
    
        const canvas = this.isCanvasSizeSpecified() ? canvasWorld.canvasAtPoint(point) : canvas_;
        if (!canvas) {
          hoverAnnotation(windowId, []);
          return;
        }
    
        // const currentTime = this.video ? this.video.currentTime : undefined;
        const annos = this.annotationsAtPoint(canvas, point, currentTime);
    
        if (xor(hoveredAnnotationIds, annos.map(a => a.id)).length > 0) {
          hoverAnnotation(windowId, annos.map(a => a.id));
        }
      }
    
      /** If the cursor leaves the canvas, wipe out highlights */
      onCanvasExit(event) {
        const { hoverAnnotation, windowId } = this.props;
    
        // a move event may be queued up by the debouncer
        this.onCanvasMouseMove.cancel();
        hoverAnnotation(windowId, []);
      }
    
      /**
       * Make sure that the annotation position on the Canvas is correct even when the video
       * display size is changed by opening and closing the side panel.
      */
      onCanvasResize(event) {
        this.updateCanvas();
      }
    
      /** @private */
      getCurrentCanvasSize() {
        const { canvas, canvasWorld } = this.props;
        const [
          _canvasX, _canvasY, _canvasWidth, _canvasHeight, // eslint-disable-line no-unused-vars
        ] = canvasWorld.canvasToWorldCoordinates(canvas.id);
        if (_canvasWidth && _canvasHeight) {
          return { height: _canvasHeight, width: _canvasWidth };
        }
        if (this.video) {
          const { videoWidth, videoHeight } = this.video;
          return { height: videoHeight, width: videoWidth };
        }
        return { height: 0, width: 0 };
      }
    
      /** @private */
      getResourceImage(resource) {
        let imageSource;
        if (resource.body && resource.body.length > 0 && resource.body[0].type === 'Image') {
          const src = resource.body[0].id;
          if (this.imagesReady[src]) {
            imageSource = this.imagesReady[src];
          } else if (!this.imagesLoading.includes(src)) {
            this.imagesLoading.push(src);
            const img = new Image();
            img.addEventListener('load', () => {
              this.imagesReady[src] = img;
            }, false);
            img.src = src;
          }
        }
        return imageSource;
      }
    
      /** @private */
      seekTo(seekTo, resume) {
        const { setCurrentTime, setPaused } = this.props;
        setPaused(true);
        setCurrentTime(seekTo);
        this.video.addEventListener('seeked', function seeked(event) {
          event.currentTarget.removeEventListener(event.type, seeked);
          if (resume) {
            setPaused(false);
          }
        });
      }
    
      /** @private */
      isCanvasSizeSpecified() {
        const { canvas, canvasWorld } = this.props;
        const [
          _canvasX, _canvasY, _canvasWidth, _canvasHeight, // eslint-disable-line no-unused-vars
        ] = canvasWorld.canvasToWorldCoordinates(canvas.id);
        return _canvasWidth && _canvasHeight;
      }
    
      /** @private */
      initializeViewer() {
        if (this.canvasOverlay && this.canvasOverlay.canvas) {
          this.canvasOverlay.canvas.addEventListener('click', this.onCanvasClick);
          this.canvasOverlay.canvas.addEventListener('mouseleave', this.onCanvasExit);
          this.canvasOverlay.canvas.addEventListener('mousemove', this.onCanvasMouseMove);
        }
        if (this.canvasOverlay) return;
    
        const { videoRef } = this.props;
        if (!videoRef.current) return;
        this.video = videoRef.current;
        this.video.addEventListener('timeupdate', this.onVideoTimeUpdate);
        this.video.addEventListener('loadedmetadata', this.onVideoLoadedMetadata);
        this.video.addEventListener('waiting', this.onVideoWaiting);
        this.video.addEventListener('playing', this.onVideoPlaying);
        this.video.addEventListener('seeked', this.onVideoPlaying);
    
        const { canvas, canvasWorld } = this.props;
        const canvasSize = canvasWorld.canvasToWorldCoordinates(canvas.id);
        this.canvasOverlay = new CanvasOverlayVideo(this.video, this.ref, canvasSize);
    
        this.updateCanvas = this.canvasUpdateCallback();
    
        // Prefetch annotation images
        const { annotations } = this.props;
        annotations.forEach((annotation) => {
          annotation.resources.forEach((resource) => {
            if (!canvasWorld.canvasIds.includes(resource.targetId)) return;
            this.getResourceImage(resource);
          });
        });
      }
    
      /** */
      canvasUpdateCallback() {
        return () => {
          this.canvasOverlay.clear();
          this.canvasOverlay.resize();
          this.canvasOverlay.canvasUpdate(this.renderAnnotations.bind(this));
        };
      }
    
      /** @private */
      isAnnotationAtPoint(resource, canvas, point, time) {
        const { canvasWorld } = this.props;
    
        const [canvasX, canvasY] = canvasWorld.canvasToWorldCoordinates(canvas.id);
        const relativeX = point.x - canvasX;
        const relativeY = point.y - canvasY;
    
        if (resource.temporalfragmentSelector) {
          if (!AnnotationsOverlayVideo.isAnnotaionInTemporalSegment(resource, time)) {
            return false;
          }
        }
    
        if (resource.svgSelector) {
          const context = this.canvasOverlay.context2d;
          const { svgPaths } = new CanvasAnnotationDisplay({ resource });
          return [...svgPaths].some(path => (
            context.isPointInPath(new Path2D(path.attributes.d.nodeValue), relativeX, relativeY)
          ));
        }
    
        if (resource.fragmentSelector) {
          const [x, y, w, h] = resource.fragmentSelector;
          return (x <= relativeX && relativeX <= (x + w) && y <= relativeY && relativeY <= (y + h));
        }
    
        // If there is no svgSelector or fragmentSelector, assume that the target is the entire canvas.
        return true;
      }
    
      /** @private */
      annotationsAtPoint(canvas, point, time) {
        const { annotations, searchAnnotations } = this.props;
    
        const lists = [...annotations, ...searchAnnotations];
        const annos = flatten(lists.map(l => l.resources)).filter((resource) => {
          if (canvas.id !== resource.targetId) return false;
    
          return this.isAnnotationAtPoint(resource, canvas, point, time);
        });
    
        return annos;
      }
    
      /** */
      toggleAnnotation(id) {
        const {
          selectedAnnotationId,
          selectAnnotation,
          deselectAnnotation,
          windowId,
        } = this.props;
    
        if (selectedAnnotationId === id) {
          deselectAnnotation(windowId, id);
        } else {
          selectAnnotation(windowId, id);
        }
      }
    
      /**
       * annotationsToContext - converts anontations to a canvas context
       */
      annotationsToContext(annotations, palette) {
        const {
          highlightAllAnnotations, hoveredAnnotationIds, selectedAnnotationId, canvasWorld, currentTime,
        } = this.props;
        const context = this.canvasOverlay.context2d;
        const zoomRatio = 1;
        annotations.forEach((annotation) => {
          annotation.resources.forEach((resource) => {
            if (!canvasWorld.canvasIds.includes(resource.targetId)) return;
            if (!AnnotationsOverlayVideo.isAnnotaionInTemporalSegment(resource, currentTime)) return;
    
            const imageSource = this.getResourceImage(resource);
            const offset = canvasWorld.offsetByCanvas(resource.targetId);
            const canvasSize = canvasWorld.canvasToWorldCoordinates(resource.targetId);
            const canvasAnnotationDisplay = new CanvasAnnotationDisplay({
              canvasSize,
              hovered: hoveredAnnotationIds.includes(resource.id),
              imageSource,
              offset,
              palette: {
                ...palette,
                default: {
                  ...palette.default,
                  ...(!highlightAllAnnotations && palette.hidden),
                },
              },
              resource,
              selected: selectedAnnotationId === resource.id,
              zoomRatio,
            });
            canvasAnnotationDisplay.toContext(context);
          });
        });
      }
    
      /** */
      renderAnnotations() {
        const {
          annotations,
          drawAnnotations,
          drawSearchAnnotations,
          searchAnnotations,
          palette,
        } = this.props;
    
        if (drawSearchAnnotations) {
          this.annotationsToContext(searchAnnotations, palette.search);
        }
    
        if (drawAnnotations) {
          this.annotationsToContext(annotations, palette.annotations);
        }
      }
    
      /**
       * Renders things
       */
      render() {
        const { showProgress } = this.state;
        const circularProgress = (<CircularProgress style={{ left: '50%', position: 'absolute', top: '50%' }} />);
        return (
          <>
            <canvas ref={this.ref} style={{ left: 0, position: 'absolute', top: 0 }} />
            <ResizeObserver onResize={this.onCanvasResize} />
            { showProgress && circularProgress }
          </>
        );
      }
    }
    
    AnnotationsOverlayVideo.defaultProps = {
      annotations: [],
      canvas: {},
      currentTime: 0,
      deselectAnnotation: () => {},
      drawAnnotations: true,
      drawSearchAnnotations: true,
      highlightAllAnnotations: false,
      hoverAnnotation: () => {},
      hoveredAnnotationIds: [],
      palette: {},
      paused: true,
      searchAnnotations: [],
      seekToTime: undefined,
      selectAnnotation: () => {},
      selectedAnnotationId: undefined,
      setCurrentTime: () => {},
      setPaused: () => {},
      videoTarget: [],
    };
    
    AnnotationsOverlayVideo.propTypes = {
      annotations: PropTypes.arrayOf(PropTypes.object),
      canvas: PropTypes.object, // eslint-disable-line react/forbid-prop-types
      canvasWorld: PropTypes.instanceOf(CanvasWorld).isRequired,
      currentTime: PropTypes.number,
      deselectAnnotation: PropTypes.func,
      drawAnnotations: PropTypes.bool,
      drawSearchAnnotations: PropTypes.bool,
      highlightAllAnnotations: PropTypes.bool,
      hoverAnnotation: PropTypes.func,
      hoveredAnnotationIds: PropTypes.arrayOf(PropTypes.string),
      palette: PropTypes.object, // eslint-disable-line react/forbid-prop-types
      paused: PropTypes.bool,
      searchAnnotations: PropTypes.arrayOf(PropTypes.object),
      seekToTime: PropTypes.number,
      selectAnnotation: PropTypes.func,
      selectedAnnotationId: PropTypes.string,
      setCurrentTime: PropTypes.func,
      setPaused: PropTypes.func,
      videoRef: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
      videoTarget: PropTypes.arrayOf(PropTypes.number),
      windowId: PropTypes.string.isRequired,
    };