render-media
Version:
Intelligently render media files in the browser
400 lines (339 loc) • 10.5 kB
JavaScript
/*! render-media. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
exports.render = render
exports.append = append
exports.mime = require('./lib/mime.json')
const debug = require('debug')('render-media')
const isAscii = require('is-ascii')
const MediaElementWrapper = require('mediasource')
const path = require('path')
const streamToBlobURL = require('stream-to-blob-url')
const VideoStream = require('videostream')
// Note: Everything listed in VIDEOSTREAM_EXTS should also appear in either
// MEDIASOURCE_VIDEO_EXTS or MEDIASOURCE_AUDIO_EXTS.
const VIDEOSTREAM_EXTS = [
'.m4a',
'.m4b',
'.m4p',
'.m4v',
'.mp4'
]
const MEDIASOURCE_VIDEO_EXTS = [
'.m4v',
'.mkv',
'.mp4',
'.webm'
]
const MEDIASOURCE_AUDIO_EXTS = [
'.m4a',
'.m4b',
'.m4p',
'.mp3'
]
const MEDIASOURCE_EXTS = [].concat(
MEDIASOURCE_VIDEO_EXTS,
MEDIASOURCE_AUDIO_EXTS
)
const VIDEO_EXTS = [
'.mov',
'.ogv'
]
const AUDIO_EXTS = [
'.aac',
'.oga',
'.ogg',
'.wav',
'.flac'
]
const IMAGE_EXTS = [
'.bmp',
'.gif',
'.jpeg',
'.jpg',
'.png',
'.svg'
]
const IFRAME_EXTS = [
'.css',
'.html',
'.js',
'.md',
'.pdf',
'.srt',
'.txt'
]
// Maximum file length for which the Blob URL strategy will be attempted
// See: https://github.com/feross/render-media/issues/18
const MAX_BLOB_LENGTH = 200 * 1000 * 1000 // 200 MB
const MediaSource = typeof window !== 'undefined' && window.MediaSource
function render (file, elem, opts, cb) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}
if (!opts) opts = {}
if (!cb) cb = () => {}
validateFile(file)
parseOpts(opts)
if (typeof elem === 'string') elem = document.querySelector(elem)
renderMedia(file, tagName => {
if (elem.nodeName !== tagName.toUpperCase()) {
const extname = path.extname(file.name).toLowerCase()
throw new Error(
`Cannot render "${extname}" inside a "${elem.nodeName.toLowerCase()}" element, expected "${tagName}"`
)
}
if (tagName === 'video' || tagName === 'audio') setMediaOpts(elem, opts)
return elem
}, opts, cb)
}
function append (file, rootElem, opts, cb) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}
if (!opts) opts = {}
if (!cb) cb = () => {}
validateFile(file)
parseOpts(opts)
if (typeof rootElem === 'string') rootElem = document.querySelector(rootElem)
if (rootElem && (rootElem.nodeName === 'VIDEO' || rootElem.nodeName === 'AUDIO')) {
throw new Error(
'Invalid video/audio node argument. Argument must be root element that ' +
'video/audio tag will be appended to.'
)
}
renderMedia(file, getElem, opts, done)
function getElem (tagName) {
if (tagName === 'video' || tagName === 'audio') return createMedia(tagName)
else return createElem(tagName)
}
function createMedia (tagName) {
const elem = createElem(tagName)
setMediaOpts(elem, opts)
rootElem.appendChild(elem)
return elem
}
function createElem (tagName) {
const elem = document.createElement(tagName)
rootElem.appendChild(elem)
return elem
}
function done (err, elem) {
if (err && elem) elem.remove()
cb(err, elem)
}
}
function renderMedia (file, getElem, opts, cb) {
const extname = path.extname(file.name).toLowerCase()
let currentTime = 0
let elem
if (MEDIASOURCE_EXTS.includes(extname)) {
renderMediaSource()
} else if (VIDEO_EXTS.includes(extname)) {
renderMediaElement('video')
} else if (AUDIO_EXTS.includes(extname)) {
renderMediaElement('audio')
} else if (IMAGE_EXTS.includes(extname)) {
renderImage()
} else if (IFRAME_EXTS.includes(extname)) {
renderIframe()
} else {
tryRenderIframe()
}
function renderMediaSource () {
const tagName = MEDIASOURCE_VIDEO_EXTS.includes(extname) ? 'video' : 'audio'
if (MediaSource) {
if (VIDEOSTREAM_EXTS.includes(extname)) {
useVideostream()
} else {
useMediaSource()
}
} else {
useBlobURL()
}
function useVideostream () {
debug(`Use \`videostream\` package for ${file.name}`)
prepareElem()
elem.addEventListener('error', fallbackToMediaSource)
elem.addEventListener('loadstart', onLoadStart)
elem.addEventListener('loadedmetadata', onLoadedMetadata)
new VideoStream(file, elem) /* eslint-disable-line no-new */
}
function useMediaSource () {
debug(`Use MediaSource API for ${file.name}`)
prepareElem()
elem.addEventListener('error', fallbackToBlobURL)
elem.addEventListener('loadstart', onLoadStart)
elem.addEventListener('loadedmetadata', onLoadedMetadata)
const wrapper = new MediaElementWrapper(elem)
const writable = wrapper.createWriteStream(getCodec(file.name))
file.createReadStream().pipe(writable)
if (currentTime) elem.currentTime = currentTime
}
function useBlobURL () {
debug(`Use Blob URL for ${file.name}`)
prepareElem()
elem.addEventListener('error', fatalError)
elem.addEventListener('loadstart', onLoadStart)
elem.addEventListener('loadedmetadata', onLoadedMetadata)
getBlobURL(file, (err, url) => {
if (err) return fatalError(err)
elem.src = url
if (currentTime) elem.currentTime = currentTime
})
}
function fallbackToMediaSource (err) {
debug('videostream error: fallback to MediaSource API: %o', err.message || err)
elem.removeEventListener('error', fallbackToMediaSource)
elem.removeEventListener('loadedmetadata', onLoadedMetadata)
useMediaSource()
}
function fallbackToBlobURL (err) {
debug('MediaSource API error: fallback to Blob URL: %o', err.message || err)
if (!checkBlobLength()) return
elem.removeEventListener('error', fallbackToBlobURL)
elem.removeEventListener('loadedmetadata', onLoadedMetadata)
useBlobURL()
}
function prepareElem () {
if (!elem) {
elem = getElem(tagName)
elem.addEventListener('progress', () => {
currentTime = elem.currentTime
})
}
}
}
function checkBlobLength () {
if (typeof file.length === 'number' && file.length > opts.maxBlobLength) {
debug(
'File length too large for Blob URL approach: %d (max: %d)',
file.length, opts.maxBlobLength
)
fatalError(new Error(
`File length too large for Blob URL approach: ${file.length} (max: ${opts.maxBlobLength})`
))
return false
}
return true
}
function renderMediaElement (type) {
if (!checkBlobLength()) return
elem = getElem(type)
getBlobURL(file, (err, url) => {
if (err) return fatalError(err)
elem.addEventListener('error', fatalError)
elem.addEventListener('loadstart', onLoadStart)
elem.addEventListener('loadedmetadata', onLoadedMetadata)
elem.src = url
})
}
function onLoadStart () {
elem.removeEventListener('loadstart', onLoadStart)
if (opts.autoplay) {
const playPromise = elem.play()
if (typeof playPromise !== 'undefined') playPromise.catch(fatalError)
}
}
function onLoadedMetadata () {
elem.removeEventListener('loadedmetadata', onLoadedMetadata)
cb(null, elem)
}
function renderImage () {
elem = getElem('img')
getBlobURL(file, (err, url) => {
if (err) return fatalError(err)
elem.src = url
elem.alt = file.name
cb(null, elem)
})
}
function renderIframe () {
getBlobURL(file, (err, url) => {
if (err) return fatalError(err)
if (extname !== '.pdf') {
// Render iframe
elem = getElem('iframe')
elem.sandbox = 'allow-forms allow-scripts'
elem.src = url
} else {
// Render .pdf
elem = getElem('object')
// Firefox-only: `typemustmatch` keeps the embedded file from running unless
// its content type matches the specified `type` attribute
elem.setAttribute('typemustmatch', true)
elem.setAttribute('type', 'application/pdf')
elem.setAttribute('data', url)
}
cb(null, elem)
})
}
function tryRenderIframe () {
debug('Unknown file extension "%s" - will attempt to render into iframe', extname)
let str = ''
file.createReadStream({ start: 0, end: 1000 })
.setEncoding('utf8')
.on('data', chunk => {
str += chunk
})
.on('end', done)
.on('error', cb)
function done () {
if (isAscii(str)) {
debug('File extension "%s" appears ascii, so will render.', extname)
renderIframe()
} else {
debug('File extension "%s" appears non-ascii, will not render.', extname)
cb(new Error(`Unsupported file type "${extname}": Cannot append to DOM`))
}
}
}
function fatalError (err) {
err.message = `Error rendering file "${file.name}": ${err.message}`
debug(err.message)
cb(err)
}
}
function getBlobURL (file, cb) {
const extname = path.extname(file.name).toLowerCase()
streamToBlobURL(file.createReadStream(), exports.mime[extname])
.then(
blobUrl => cb(null, blobUrl),
err => cb(err)
)
}
function validateFile (file) {
if (file == null) {
throw new Error('file cannot be null or undefined')
}
if (typeof file.name !== 'string') {
throw new Error('missing or invalid file.name property')
}
if (typeof file.createReadStream !== 'function') {
throw new Error('missing or invalid file.createReadStream property')
}
}
function getCodec (name) {
const extname = path.extname(name).toLowerCase()
return {
'.m4a': 'audio/mp4; codecs="mp4a.40.5"',
'.m4b': 'audio/mp4; codecs="mp4a.40.5"',
'.m4p': 'audio/mp4; codecs="mp4a.40.5"',
'.m4v': 'video/mp4; codecs="avc1.640029, mp4a.40.5"',
'.mkv': 'video/webm; codecs="avc1.640029, mp4a.40.5"',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4; codecs="avc1.640029, mp4a.40.5"',
'.webm': 'video/webm; codecs="vorbis, vp8"'
}[extname]
}
function parseOpts (opts) {
if (opts.autoplay == null) opts.autoplay = false
if (opts.muted == null) opts.muted = false
if (opts.controls == null) opts.controls = true
if (opts.maxBlobLength == null) opts.maxBlobLength = MAX_BLOB_LENGTH
}
function setMediaOpts (elem, opts) {
elem.autoplay = !!opts.autoplay
elem.muted = !!opts.muted
elem.controls = !!opts.controls
}