Path: blob/main/src/resources/extensions/quarto/video/video.lua
12923 views
-- === Utils ===1-- from http://lua-users.org/wiki/StringInterpolation2local interpolate = function(str, vars)3-- Allow replace_vars{str, vars} syntax as well as replace_vars(str, {vars})4if not vars then5vars = str6str = vars[1]7end8return (string.gsub(str, "({([^}]+)})",9function(whole, i)10return vars[i] or whole11end))12end1314local function splitString (toSplit, delimiter)15delimiter = delimiter or "%s"1617local t={}18for str in string.gmatch(toSplit, "([^".. delimiter .."]+)") do19table.insert(t, str)20end21return t22end2324local function isEmpty(s)25return s == nil or s == ''26end2728local isResponsive = function(width, height)29return isEmpty(height) and isEmpty(width)30end3132VIDEO_SHORTCODE_NUM_VIDEOJS = 03334local VIDEO_TYPES = {35YOUTUBE = "YOUTUBE",36BRIGHTCOVE = "BRIGHTCOVE",37VIMEO = "VIMEO",38VIDEOJS = "VIDEOJS"39}4041local ASPECT_RATIOS = {42["1x1"] = "ratio-1x1",43["4x3"] = "ratio-4x3",44["16x9"] = "ratio-16x9",45["21x9"] = "ratio-21x9"46}4748local DEFAULT_ASPECT_RATIO = ASPECT_RATIOS["16x9"]4950local wrapWithDiv = function(toWrap, aspectRatio, shouldAddResponsiveClasses)51local ratioClass = aspectRatio and ASPECT_RATIOS[aspectRatio] or DEFAULT_ASPECT_RATIO52local responsiveClasses = shouldAddResponsiveClasses and ' ratio ' .. ratioClass53wrapper = [[<div class="quarto-video{responsiveClasses}">{toWrap}</div>]]5455return interpolate {56wrapper,57toWrap = toWrap,58responsiveClasses = responsiveClasses or '' }59end6061local replaceCommonAttributes = function(snippet, params)62result = interpolate {63snippet,64src = params.src,65height = params.height and ' height="' .. params.height .. '"' or '',66width = params.width and ' width="' .. params.width .. '"' or '',67title = params.title or '',68ariaLabel = params.ariaLabel and ' aria-label="' .. params.ariaLabel .. '"' or '',69}70return result71end7273local checkMatchStart = function(value, matcherFront)74return string.match(value, '^' .. matcherFront .. '(.-)$')75end7677local youTubeBuilder = function(params)78if not (params and params.src) then return nil end79local src = params.src80match = checkMatchStart(src, 'https://www.youtube.com/embed/')81match = match or checkMatchStart(src, 'https://www.youtube.com/shorts/')82match = match or checkMatchStart(src, 'https://www.youtube%-nocookie.com/embed/')83match = match or checkMatchStart(src, 'https://youtu.be/')84match = match or string.match(src, '%?v=(.-)&')85match = match or string.match(src, '%?v=(.-)$')8687if not match then return nil end8889local YOUTUBE_EMBED = 'https://www.youtube.com/embed/'90params.src = YOUTUBE_EMBED .. match9192local SNIPPET = [[<iframe data-external="1" src="{src}{start}"{width}{height} title="{title}"{ariaLabel} frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>]]93snippet = replaceCommonAttributes(SNIPPET, params)9495local result = {}9697result.snippet = interpolate {98snippet,99start = params.start and '?start=' .. params.start or ''100}101result.type = VIDEO_TYPES.YOUTUBE102result.src = params.src103result.videoId = match104105return result106end107108local brightcoveBuilder = function(params)109if not (params and params.src) then return nil end110local src = params.src111local isBrightcove = function()112return string.find(src, 'https://players.brightcove.net')113end114115if not isBrightcove() then return nil end116117local result = {}118119local SNIPPET = [[<iframe data-external="1" src="{src}"{width}{height} allowfullscreen="" title="{title}"{ariaLabel} allow="encrypted-media"></iframe>]]120result.snippet = replaceCommonAttributes(SNIPPET, params)121result.type = VIDEO_TYPES.BRIGHTCOVE122result.src = params.src123return result124end125126local vimeoBuilder = function(params)127if not (params and params.src) then return nil end128129local VIMEO_STANDARD = 'https://vimeo.com/'130local match = checkMatchStart(params.src, VIMEO_STANDARD)131if not match then return nil end132133-- Internal Links134-- bug/5390-vimeo-newlink135if string.find(match, '/') then136local internalMatch = string.gsub(match, "?(.*)", "" )137videoId = splitString(internalMatch, '/')[1]138privacyHash = splitString(internalMatch, '/')[2]139params.src = 'https://player.vimeo.com/video/' .. videoId .. '?h=' .. privacyHash140else141videoId = match142params.src = 'https://player.vimeo.com/video/' .. videoId143end144145146local SNIPPET = [[<iframe data-external="1" src="{src}"{width}{height} frameborder="0" title="{title}"{ariaLabel} allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>]]147148local result = {}149150result.snippet = replaceCommonAttributes(SNIPPET, params)151result.type = VIDEO_TYPES.VIMEO152result.src = params.src153result.videoId = videoId154155return result156end157158local videoJSBuilder = function(params)159if not (params and params.src) then return nil end160VIDEO_SHORTCODE_NUM_VIDEOJS = VIDEO_SHORTCODE_NUM_VIDEOJS + 1161local id = "video_shortcode_videojs_video" .. VIDEO_SHORTCODE_NUM_VIDEOJS162163local SNIPPET = [[<video id="{id}"{width}{height} class="video-js vjs-default-skin vjs-big-play-centered {fluid}" controls preload="auto" data-setup='{}' title="{title}"{ariaLabel}><source src="{src}"></video>]]164local snippet = params.snippet or SNIPPET165snippet = replaceCommonAttributes(snippet, params)166snippet = interpolate {167snippet,168id = id,169fluid = isResponsive(params.width, params.height) and 'vjs-fluid' or ''170}171172local result = {}173result.snippet = snippet174result.type = VIDEO_TYPES.VIDEOJS175result.src = params.src176result.id = id177return result178end179local getSnippetFromBuilders = function(src, height, width, title, start, ariaLabel)180local builderList = {181youTubeBuilder,182brightcoveBuilder,183vimeoBuilder,184videoJSBuilder}185186local params = { src = src, height = height, width = width, title = title, start = start, ariaLabel = ariaLabel }187188for i = 1, #builderList do189local builtSnippet = builderList[i](params)190if (builtSnippet) then191return builtSnippet192end193end194end195196local helpers = {197["checkMatchStart"] = checkMatchStart,198["youTubeBuilder"] = youTubeBuilder,199["brightcoveBuilder"] = brightcoveBuilder,200["vimeoBuilder"] = vimeoBuilder,201["videoJSBuilder"] = videoJSBuilder,202["wrapWithDiv"] = wrapWithDiv,203["VIDEO_TYPES"] = VIDEO_TYPES,204["VIDEO_SHORTCODE_NUM_VIDEOJS"] = VIDEO_SHORTCODE_NUM_VIDEOJS,205["getSnippetFromBuilders"] = getSnippetFromBuilders206}207208-- makes an asciidoc video raw block209-- see https://docs.asciidoctor.org/asciidoc/latest/macros/audio-and-video/210function formatAsciiDocVideo(src, type)211return 'video::' .. src .. '[' .. type .. ']'212end213214local function asciidocVideo(src, height, width, title, start, _aspectRatio, ariaLabel)215local asciiDocVideoRawBlock = function(src, type)216return pandoc.RawBlock("asciidoc", formatAsciiDocVideo(src, type) .. '\n\n')217end218219local videoSnippetAndType = getSnippetFromBuilders(src, height, width, title, start, ariaLabel)220if videoSnippetAndType.type == VIDEO_TYPES.YOUTUBE then221-- Use the video id to form an asciidoc video222if videoSnippetAndType.videoId ~= nil then223return asciiDocVideoRawBlock(videoSnippetAndType.videoId, 'youtube');224end225elseif videoSnippetAndType.type == VIDEO_TYPES.VIMEO then226return asciiDocVideoRawBlock(videoSnippetAndType.videoId, 'vimeo');227elseif videoSnippetAndType.type == VIDEO_TYPES.VIDEOJS then228return asciiDocVideoRawBlock(videoSnippetAndType.src, '');229else230-- this is not a local or supported video type for asciidoc231-- we should just emit a hyper link232end233234end235236function htmlVideo(src, height, width, title, start, aspectRatio, ariaLabel)237238-- https://github.com/quarto-dev/quarto-cli/issues/6833239-- handle partially-specified width, height, and aspectRatio240if aspectRatio then241-- https://github.com/quarto-dev/quarto-cli/issues/11699#issuecomment-2549219533242-- we remove quotes as a243-- local workaround for inconsistent shortcode argument parsing on our end.244--245-- removing quotes in general is not a good idea, but the246-- only acceptable values for aspectRatio are 4x3, 16x9, 21x9, 1x1247-- and so we can safely remove quotes in this context.248local strs = splitString(aspectRatio:gsub('"', ''):gsub("'", ''), 'x')249local wr = tonumber(strs[1])250local hr = tonumber(strs[2])251local aspectRatioNum = wr / hr252if height and not width then253width = math.floor(height * aspectRatioNum + 0.5)254elseif width and not height then255height = math.floor(width / aspectRatioNum + 0.5)256end257end258259local videoSnippetAndType = getSnippetFromBuilders(src, height, width, title, start, ariaLabel)260local videoSnippet261262videoSnippet = videoSnippetAndType.snippet263264if (videoSnippetAndType.type == VIDEO_TYPES.VIDEOJS) then265-- Can this be bundled with the VideoJS dependency266-- Avoid disjointed combination?267quarto.doc.add_html_dependency({268name = 'videojs',269scripts = { 'resources/videojs/video.min.js' },270stylesheets = { 'resources/videojs/video-js.css' }271})272local id = videoSnippetAndType.id or ''273local scriptTag = "<script>videojs(" .. id .. ");</script>"274quarto.doc.include_text("after-body", scriptTag)275end276277local isVideoJS = function()278return videoSnippetAndType.type == VIDEO_TYPES.VIDEOJS279end280281local isRevealJS = function()282return quarto.doc.is_format('revealjs')283end284285local shouldAddResponsiveClasses = false286if isResponsive(width, height)287and not isRevealJS()288and not isVideoJS() then289if (not quarto.doc.has_bootstrap()) then290quarto.doc.add_html_dependency({291name = 'bootstrap-responsive',292stylesheets = { 'resources/bootstrap/bootstrap-responsive-ratio.css' }293})294end295shouldAddResponsiveClasses = true296end297298if not isRevealJS() then299videoSnippet = wrapWithDiv(300videoSnippet,301aspectRatio,302shouldAddResponsiveClasses303)304end305306-- inject the rendering code307return pandoc.RawBlock('html', videoSnippet)308end309-- return a table containing shortcode definitions310-- defining shortcodes this way allows us to create helper311-- functions that are not themselves considered shortcodes312return {313["video"] = function(args, kwargs, _meta, raw_args)314checkArg = function(toCheck, key)315value = pandoc.utils.stringify(toCheck[key])316if not isEmpty(value) then317return value318else319return nil320end321end322323local srcValue = checkArg(kwargs, 'src')324local titleValue = checkArg(kwargs, 'title')325local startValue = checkArg(kwargs, 'start')326local heightValue = checkArg(kwargs, 'height')327local widthValue = checkArg(kwargs, 'width')328local aspectRatio = checkArg(kwargs, 'aspectRatio')329local ariaLabelValue = checkArg(kwargs, 'aria-label')330331if isEmpty(aspectRatio) then332aspectRatio = checkArg(kwargs, 'aspect-ratio')333end334335if isEmpty(srcValue) then336337if #raw_args > 0 then338srcValue = pandoc.utils.stringify(raw_args[1])339else340-- luacov: disable341fail("No video source specified for video shortcode")342-- luacov: enable343end344end345346if quarto.doc.is_format("html:js") then347return htmlVideo(srcValue, heightValue, widthValue, titleValue, startValue, aspectRatio, ariaLabelValue)348elseif quarto.doc.is_format("asciidoc") then349return asciidocVideo(srcValue, heightValue, widthValue, titleValue, startValue, aspectRatio, ariaLabelValue)350elseif quarto.doc.is_format("markdown") then351if srcValue:sub(1, 4) == "http" then352-- For remote videos, we can emit a link353return pandoc.Link(srcValue, titleValue or srcValue)354else355-- For local356-- use an image to allow markdown previewers to show video357return pandoc.Image(quarto.utils.as_inlines(titleValue), srcValue)358end359else360-- Fall-back to a link of the source361return pandoc.Link(srcValue, srcValue)362end363364end,365["video-helpers"] = helpers,366}367368369