Some test text!

menu

Load videos and annotate them in WebViewer using JavaScript

This JavaScript sample illustrates how WebViewer can be used to load videos and annotate frame by frame enhancing the collaboration aspect in a video workflow. To see an example, please visit our Video Demo. Learn more about our JavaScript PDF Library and Video guide.

Get StartedSamplesDownload

To run this sample, get started with a free trial of PDFTron SDK.

JavaScript

HTML

/* eslint-disable */
const viewerElement = document.getElementById('viewer');
const DOCUMENT_ID = 'video';
const DEFAULT_ZOOM = 1.25;

WebViewer(
  {
    path: 'lib',
    css: 'styles.css',
    disabledElements: ['searchButton', 'pageNavOverlay', 'viewControlsButton', 'panToolButton'],
  },
  viewerElement
).then(async instance => {
  instance.setTheme('dark');

  const { docViewer, iframeWindow, setHeaderItems } = instance;
  const annotManager = docViewer.getAnnotationManager();

  const license = `---- Insert commercial license key here after purchase ----`;

  // Extends document class to support documents of type 'video'
  await WebViewer.Video.registerDocument(instance, license);

  // Load a video at a specific url. This file needs to be relative to lib/ui/index.html.
  const videoUrl = 'https://pdftron.s3.amazonaws.com/downloads/pl/video/video.mp4';
  const thumbnail = 'https://pdftron.s3.amazonaws.com/downloads/pl/video/thumbnail.jpg';
  WebViewer.Video.loadDocument(videoUrl, thumbnail);

  // Add save annotations button
  setHeaderItems(header => {
    header.push({
      type: 'actionButton',
      img:
        '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>',
      onClick: async () => {
        // Save annotations when button is clicked
        // widgets and links will remain in the document without changing so it isn't necessary to export them

        // Make a POST request with XFDF string
        const saveXfdfString = (documentId, xfdfString) => {
          return new Promise(resolve => {
            fetch(`/server/annotationHandler.js?documentId=${documentId}`, {
              method: 'POST',
              body: xfdfString,
            }).then(response => {
              if (response.status === 200) {
                resolve();
              }
            });
          });
        };

        const annotations = docViewer
          .getDocument()
          .getVideo()
          .getAllAnnotations();
        var xfdfString = await annotManager.exportAnnotations({ links: false, widgets: false, annotList: annotations });
        await saveXfdfString(DOCUMENT_ID, xfdfString);
        alert('Annotations saved successfully.');
      },
    });
  });

  // Load saved annotations
  docViewer.on('documentLoaded', () => {
    docViewer.zoomTo(DEFAULT_ZOOM);
    const doc = docViewer.getDocument();
    const video = docViewer.getDocument().getVideo();

    // Make a GET request to get XFDF string
    const loadXfdfString = documentId => {
      return new Promise(resolve => {
        fetch(`/server/annotationHandler.js?documentId=${documentId}`, {
          method: 'GET',
        }).then(response => {
          if (response.status === 200) {
            response.text().then(xfdfString => {
              console.log(xfdfString);
              resolve(xfdfString);
            });
          } else if (response.status === 204) {
            console.warn(`Found no content in xfdf file /server/annotationHandler.js?documentId=${documentId}`);
            resolve('');
          } else {
            console.warn(`Something went wrong trying to load xfdf file /server/annotationHandler.js?documentId=${documentId}`);
            console.warn(`Response status ${response.status}`);
            resolve('');
          }
        });
      });
    };

    loadXfdfString(DOCUMENT_ID)
      .then(xfdfString => {
        const annotManager = docViewer.getAnnotationManager();
        return annotManager.importAnnotations(xfdfString);
      })
      .then(() => {
        video.updateAnnotationsToTime(0);
      });
  });

  let once = false;
  // Create the video UI controls
  docViewer.on('pageComplete', (pageIndex, videoContainer) => {
    if (once) return;
    once = true;
    const createElementFromHTML = htmlString => {
      var div = document.createElement('div');
      div.innerHTML = htmlString.trim();

      return div.firstChild;
    };

    const doc = docViewer.getDocument();
    const video = doc.getVideo();
    const controls = createElementFromHTML(`
        <div class="controls">
            <div class="buttons-container">
              <div class="video-buttons">
                <svg id="play" class="icon" width="18px" height="18px" data-state="hidden">
                  <path d="M15.562 8.1L3.87.225c-.818-.562-1.87 0-1.87.9v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z"/>
                </svg>
                <svg id="pause" class="icon" width="18px" height="18px" data-state="hidden">
                  <path d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zm6 0c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1h-3z"/>
                </svg>
                <svg id="volume" class="icon" width="20px" height="18px" data-state="visible">
                  <path d="M15.6 3.3c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4C15.4 5.9 16 7.4 16 9c0 1.6-.6 3.1-1.8 4.3-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7z"/>
                  <path d="M11.282 5.282a.909.909 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.909.909 0 0 0 0 1.316c.145.145.636.262 1.018.156a.725.725 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9c0-.17-.002-.34-.011-.51-.053-.992-.319-2.005-1.522-3.208a.909.909 0 0 0-1.316 0zm-7.496.726H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/>
                </svg>
                <svg id="muted" class="icon" width="20px" height="18px" data-state="visible">
                  <path d="M12.4 12.5l2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/>
                </svg>
              </div>
              <div class="video-buttons">
                <span class="time" id="current-time">00:00:00</span>
                <svg style="display: none;" class="icon" width="18px" height="18px">
                  <path d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997zM9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/>
                </svg>
              </div>
            </div>
            <div class="timeline">
              <div class="markers"></div>
              <progress id="progress">
              </progress>
              <div class="tooltip" id="tooltip">
                00:00:00
              </div>
            </div>
            </div>
        </div>
      `);

    videoContainer.appendChild(controls);
    const play = controls.querySelector('#play');
    const pause = controls.querySelector('#pause');
    const volume = controls.querySelector('#volume');
    const muted = controls.querySelector('#muted');
    const progress = controls.querySelector('#progress');
    const markers = controls.querySelector('.markers');
    const currentTime = controls.querySelector('#current-time');
    const tooltip = controls.querySelector('#tooltip');

    const videoElement = video.getElement();
    const showMarkers = annotations => {
      while (markers.firstChild) {
        markers.removeChild(markers.firstChild);
      }
      annotations.forEach(annotation => {
        if (annotation.startTime <= video.duration) {
          const marker = document.createElement('div');
          marker.className = 'marker';
          const percent = annotation.startTime / video.duration;
          marker.onclick = () => {
            goToTime(annotation.startTime);
          };
          const markerLeft = Math.floor(percent * progress.clientWidth) - 1;
          marker.style = `left: ${markerLeft}px`;
          markers.appendChild(marker);
        }
      });
    };
    annotManager.on('annotationChanged', () => {
      showMarkers(video.getAllAnnotations());
    });
    showMarkers(video.getAllAnnotations());

    play.setAttribute('data-state', 'visible');
    pause.setAttribute('data-state', 'hidden');
    videoElement.addEventListener('play', () => {
      play.setAttribute('data-state', 'hidden');
      pause.setAttribute('data-state', 'visible');
    });
    videoElement.addEventListener('pause', () => {
      play.setAttribute('data-state', 'visible');
      pause.setAttribute('data-state', 'hidden');
    });
    play.addEventListener('click', () => {
      if (videoElement.paused || videoElement.ended) {
        videoElement.play();
      }
    });
    pause.addEventListener('click', () => {
      videoElement.pause();
    });

    volume.setAttribute('data-state', 'visible');
    muted.setAttribute('data-state', 'hidden');
    videoElement.addEventListener('volumechange', e => {
      if (videoElement.muted) {
        volume.setAttribute('data-state', 'hidden');
        muted.setAttribute('data-state', 'visible');
      } else {
        volume.setAttribute('data-state', 'visible');
        muted.setAttribute('data-state', 'hidden');
      }
    });
    volume.addEventListener('click', () => {
      videoElement.muted = true;
    });
    muted.addEventListener('click', () => {
      videoElement.muted = false;
    });

    progress.setAttribute('max', video.duration);

    const timelineFrames = createElementFromHTML(`
        <div id="timeline-frames" class="timeline-frames">
      `);

    let frameCanvases = {};
    const onScroll = async () => {
      const { x: timelineX, width: timelineWidth } = timelineFrames.getBoundingClientRect();

      const innerFrames = Array.from(timelineFrames.getElementsByClassName('innerFrame')).filter(innerFrame => {
        const { x: frameX, width: frameWidth } = innerFrame.getBoundingClientRect();
        const isVisible = frameX + frameWidth > timelineX && frameX < timelineX + timelineWidth;
        return innerFrame.children.length === 0 && isVisible;
      });
      const frameNumbers = innerFrames.map(innerFrame => innerFrame.frameNumber).filter(frameNumber => !frameCanvases[frameNumber]);

      frameCanvases = { ...frameCanvases, ...(await video.extractFrames(frameNumbers)) };
      innerFrames.forEach(innerFrame => {
        const frame = frameCanvases[innerFrame.frameNumber];
        if (frame) {
          innerFrame.appendChild(frame);
        } else {
          console.warn(`Failed to get canvas for frame: ${innerFrame.frameNumber}`);
        }
      });
    };

    timelineFrames.addEventListener('scroll', _.debounce(onScroll, 250));

    const marginRight = 8;
    const widthFrameContainer = 92.5;
    const timelineHeight = 99;
    const timelineWidth = 770 / docViewer.getZoom();

    const totalFrames = video.getTotalFrames();
    let selectedFrameContainer;
    HyperList.create(timelineFrames, {
      height: timelineHeight,
      width: timelineWidth,
      horizontal: true,
      itemHeight: marginRight + widthFrameContainer,
      total: totalFrames,
      generate(index) {
        const frameNumber = index + 1;
        const frameContainer = document.createElement('div');
        frameContainer.id = `frame${frameNumber}`;
        frameContainer.className = 'frame';
        const innerFrameContainer = document.createElement('div');
        innerFrameContainer.id = `innerFrame${frameNumber}`;
        innerFrameContainer.frameNumber = frameNumber;
        innerFrameContainer.className = 'innerFrame';
        innerFrameContainer.style = `min-width: ${88}px; min-height: ${50}px;`;

        frameContainer.onclick = () => {
          goToTime(video.getTimeFromFrame(frameNumber));
        };

        const frameFooter = createElementFromHTML(`
            <div class="frameFooter">
              <div class="frameNumber">
                <span>${video.getFormattedTime(video.getTimeFromFrame(frameNumber))}</span>
              </div>
            </div>
          `);

        // if (video.hasAnnotation(frameNumber)) {
        //   const pencilIcon = createElementFromHTML(`
        //     <svg width="18px" height="18px" viewBox="0 0 24 24">
        //       <use xlink:href="./plyr/plyr.svg#pencil"></use>
        //     </svg>
        //   `);
        //   frameFooter.insertBefore(pencilIcon, frameFooter.firstChild);
        // }

        const frame = frameCanvases[frameNumber];
        if (frame && frame !== 'inprogress') {
          innerFrameContainer.appendChild(frame);
        }

        frameContainer.appendChild(innerFrameContainer);
        frameContainer.appendChild(frameFooter);

        const currentFrameNumber = video.getFrameFromTime(videoElement.currentTime);

        if (frameNumber === currentFrameNumber) {
          frameContainer.setAttribute('data-state', 'selected');
          selectedFrameContainer = frameContainer;
        }
        return frameContainer;
      },
    });

    controls.appendChild(timelineFrames);
    videoElement.addEventListener('timeupdate', async () => {
      doc.getVideo().updateAnnotationsToTime(videoElement.currentTime);

      if (selectedFrameContainer) {
        selectedFrameContainer.removeAttribute('data-state');
      }

      const currentFrameNumber = video.getFrameFromTime(videoElement.currentTime);
      selectedFrameContainer = iframeWindow.document.getElementById(`frame${currentFrameNumber}`);
      if (selectedFrameContainer) {
        selectedFrameContainer.setAttribute('data-state', 'selected');
      }
    });

    const goToTime = time => {
      videoElement.currentTime = time;

      progress.value = videoElement.currentTime;

      currentTime.innerHTML = `${video.getFormattedCurrentTime()}`;
      const currentFrameNumber = video.getFrameFromTime(videoElement.currentTime);

      const rightmostPosition = (currentFrameNumber - 1) * (marginRight + widthFrameContainer);
      const scrollLeft = rightmostPosition - timelineFrames.clientWidth / 2 + widthFrameContainer / 2;
      if (Math.abs(timelineFrames.scrollLeft - scrollLeft) < 800) {
        timelineFrames.scroll({
          left: scrollLeft,
          behavior: 'smooth',
        });
      } else {
        timelineFrames.scrollLeft = scrollLeft;
      }
    };

    let stateCurrentTime;
    progress.addEventListener('click', e => {
      goToTime(stateCurrentTime);
    });

    tooltip.setAttribute('data-state', 'hidden');
    progress.addEventListener('mousemove', e => {
      const { width } = progress.getBoundingClientRect();
      const percentage = (e.offsetX * docViewer.getZoom()) / width;
      const newTime = percentage * progress.max;
      stateCurrentTime = newTime;

      tooltip.setAttribute('data-state', 'visible');
      tooltip.style = `left: ${e.offsetX}px;`;
      tooltip.innerHTML = `${video.getFormattedTime(newTime)}`;
    });
    progress.addEventListener('mouseout', e => {
      tooltip.setAttribute('data-state', 'hidden');
    });

    onScroll();
  });
});
close

Free Trial

Get unlimited trial usage of PDFTron SDK to bring accurate, reliable, and fast document processing capabilities to any application or workflow.

Select a platform to get started with your free trial.

Unlimited usage. No email address required.

Join our upcoming webinar to learn about how to collaborate on videos frame by frame directly in your browser

Save your seat
close