Closed howardbaik closed 1 year ago
This app.R
script adds an "Add Subtitles" toggle. If turned on, it will create a subtitle file (.srt) within ari_spin()
, and burn the subtitle file into the video with ari_burn_subtitles()
, then shows the mp4 video with subtitles in the shiny app, and downloads/sends an email of the video with subtitles.
Copy-paste this code once https://github.com/jhudsl/ari/pull/46 is merged, which would mean the burn-subtitles
branch (a sub-branch of the ariExtra-immigration
branch) would be merged into main.
app.R
# Packages
library(shiny)
library(shinyjs)
library(shinyWidgets)
library(shinyFeedback)
library(ari)
library(dplyr)
library(readr)
library(pdftools)
library(blastula)
library(googlesheets4)
library(promises)
library(future)
library(ipc)
# Options
options("future.rng.onMisuse" = "ignore")
plan(multisession, workers = 25)
# Voice Data
voices_coqui <- read_csv("data/voices-coqui.csv", show_col_types = FALSE) %>%
# Remove after testing
filter(language == "en",
dataset %in% c("ljspeech", "jenny"),
model_name %in% c("tacotron2-DDC_ph", "jenny"))
# Don't need Paid Voices for now
# voices_amazon <- read_csv("data/voices-amazon.csv", show_col_types = FALSE)
# voices_google <- read_csv("data/voices-google.csv", show_col_types = FALSE) %>%
# filter(!is.na(language))
# voices_ms <- read_csv("data/voices-ms.csv", show_col_types = FALSE)
# names(voices_ms) <- tolower(names(voices_ms))
# images for pickerInput stored in www/i/ from the root app directory
imgs <- c("i/img/coqui.png", "i/img/aws.jpeg", "i/img/google.png", "i/img/ms.jpeg")
img_name <- c("Coqui TTS", "Amazon Polly",
"Google Cloud Text-to-Speech", "Microsoft Cognitive Services Text-to-Speech")
# Select image
select_choice_img <- function(img, text) {
shiny::HTML(paste(
tags$img(src=img, width=25, height=22),
text
))
}
# Check if email is valid
is_valid_email <- function(x) {
grepl("([_+a-z0-9-]+(\\.[_+a-z0-9-]+)*@[a-z0-9-]+(\\.[a-z0-9-]+)*(\\.[a-z]{2,14}))", x)
}
# Start of Shiny app
ui <- fluidPage(
useShinyjs(),
useShinyFeedback(),
# css
tags$head(
tags$link(rel = "stylesheet", type = "text/css", href = "i/hutch_theme.css")
),
# favicon
tags$head(tags$link(rel="shortcut icon", href="i/img/favicon.ico")),
# css to center the progress bar
# https://stackoverflow.com/a/52585505/14804653
tags$head(
tags$style(
HTML(".shiny-notification {
height: 100px;
width: 800px;
position:fixed;
top: calc(50% - 50px);
left: calc(50% - 400px);
font-size: 250%;
font-family: Times;
color: #1c3b61;
text-align: center;
}
"
)
)
),
titlePanel(tagList(
"Loqui",
span(
actionButton("demo",
label = "Demo",
icon = icon("youtube"),
onclick ="window.open(`https://youtu.be/G7JyvCAxg40`, '_blank')"),
actionButton("help",
label = "Help",
icon = icon("circle-exclamation"),
width = "77px",
onclick ="window.open(`https://github.com/FredHutch/loqui#getting-help`, '_blank')"),
actionButton("github",
label = "Code",
icon = icon("github"),
width = "77px",
onclick ="window.open(`https://github.com/FredHutch/loqui`, '_blank')"),
style = "position:absolute;right:2em;"
)
),
windowTitle = "Loqui"),
hr(),
sidebarLayout(
sidebarPanel(
textInput("email", "Email Address (where video should be sent)"),
div(
prettySwitch("auto_email", "Once video finishes rendering, send email automatically",
status = "success", fill = TRUE),
style = "color: #1c3b61;"
),
radioButtons("presentation_tool", "Presentation Tool",
c("Google Slides" = "google_slides",
"Powerpoint" = "powerpoint")
),
div(
prettySwitch("burn_subtitle", "Add Subtitles",
status = "success", fill = TRUE),
style = "color: #1c3b61;"
),
uiOutput("user_input"),
shinyWidgets::pickerInput("service",
label = "Text-to-Speech Service",
choices = c("Coqui TTS" = "coqui"),
choicesOpt = list(content = purrr::map2(imgs, img_name, select_choice_img)[[1]])),
# "Amazon Polly" = "amazon",
# "Google Cloud Text-to-Speech" = "google",
# "Microsoft Cognitive Services Text-to-Speech" = "ms"),
# choicesOpt = list(content = purrr::map2(imgs, img_name, select_choice_img))),
uiOutput("voice_options"),
actionButton("generate", "Generate", icon = icon("person-running")),
br(),
br(),
h5("Built with",
img(src = "https://www.rstudio.com/wp-content/uploads/2014/04/shiny.png", height = "30px"),
"by",
img(src = "i/img/posit.jpeg", height = "30px")
),
tags$img(src = "i/img/logo.png", width = "90%")
),
mainPanel(
tabsetPanel(id = "inTabset",
tabPanel(
title = div("About",
style = "font-family: Arial; color: #1c3b61; font-weight: bold"),
value = "about",
h3("Introducing Loqui: A Shiny app for Creating Automated Videos"),
div(
p("Loqui is an open source web application that enables the creation of automated videos using ari,
an R package for generating videos from text and images. Loqui takes as input a Google Slides URL,
extracts the speaker notes from the slides, and converts them into an audio file.
Then, it converts the Google Slides to images and ultimately,
generates an mp4 video file where each image is presented with its corresponding audio."),
p("The functionality of Loqui relies on two R packages, namely ari and text2speech, which run in the background.
Although it is certainly possible to go directly to these packages and run their functions for course generation,
we realize that not everyone feels comfortable programming in R. This web application offers an intuitive and user-friendly
interface allowing individuals to effortlessly create automated videos without the need for programming skills."),
uiOutput("loqui_demo"),
em("Privacy Policy: We only collect the date and time of usage, duration of the generated video, and the provided email address."),
h5("This initiative is funded by the following grant: National Cancer Institute (NCI) UE5 CA254170"),
style = "font-family: Arial; color: #1c3b61"),
actionButton("show_example", "Show Example", icon = icon("magnifying-glass"),
style = "font-family: Arial; font-weight: bold; color: #F4F4F4; background-color: #0A799A; border-radius: 12px;")
),
tabPanel(
title = div("Examples",
style = "font-family: Arial; color: #1c3b61; font-weight: bold"),
value = "loqui_example",
br(),
h3("DeepPhe: Natural Language Processing Tool"),
uiOutput("loqui_example_ui_1"),
br(),
h3("EMERSE: Electronic Medical Record Search Engine"),
uiOutput("loqui_example_ui_2"),
br(),
h3("pVACtools: Cancer Immunotherapy Suite"),
uiOutput("loqui_example_ui_3")
),
tabPanel(
title = div("Rendered Video",
style = "font-family: Arial; color: #1c3b61; font-weight: bold"),
value = "rendered_video",
br(),
uiOutput("video_ui"),
br(),
fluidRow(column(11, htmlOutput("video_info"))),
fluidRow(uiOutput("video_btn"))
)
)
)
)
)
server <- function(input, output, session) {
# Disable buttons when email is not provided
observe({
toggleState("generate",
!is.null(input$email) && input$email != "" && is_valid_email(input$email))
toggleState("download_btn",
!is.null(input$email) && input$email != "" && is_valid_email(input$email))
toggleState("send_email",
!is.null(input$email) && input$email != "" && is_valid_email(input$email))
})
# Display feedback message when email address is not valid
observeEvent(input$email, {
if (input$email != "" & !is_valid_email(input$email)) {
showFeedbackWarning(
inputId = "email",
text = "Invalid email. Please try again."
)
} else {
hideFeedback("email")
}
})
# Select Google Slides or PowerPoint
output$user_input <- renderUI({
if (input$presentation_tool == "google_slides") {
textInput("gs_url",
label = "Google Slides URL (Enable Link Sharing)",
value = "",
placeholder = "Paste a URL")
} else {
# PowerPoint
# shinyFiles::shinyFilesButton("pptx_file", "File select",
# "Upload PowerPoint Presentation",
# FALSE)
fileInput("pptx_file", NULL, accept = ".pptx",
buttonLabel = "Upload .pptx")
}
})
# observeEvent(input$upload, {
# file.rename(from = input$upload$datapath, to = "www/slides.pptx")
# })
# Switch tabs when "Get Started" is clicked
observeEvent(input$generate, {
updateTabsetPanel(session, "inTabset", selected = "rendered_video")
})
# Switch tabs when "Show Example" is clicked
observeEvent(input$show_example, {
updateTabsetPanel(session, "inTabset", selected = "loqui_example")
})
# Video without subtitles
video_name <- eventReactive(input$generate, {
# create unique name for video file
current_time <- Sys.time()
current_time <- format(current_time, "%Y-%m-%d-%H-%M-%S")
unique_file_name <- paste0("www/ari-video-", current_time, ".mp4")
unique_file_name
})
# Video with subtitles
video_name_subtitle <- eventReactive(input$burn_subtitle, {
# create unique name for video file
current_time <- Sys.time()
current_time <- format(current_time, "%Y-%m-%d-%H-%M-%S")
unique_file_name <- paste0("www/subtitle-ari-video-", current_time, ".mp4")
unique_file_name
})
# Show videos when "Show Examples" is clicked
output$loqui_demo <- renderUI({
tags$video(src = "i/video/loqui.mp4",
type = "video/mp4",
height ="480px",
width="790px",
controls = TRUE)
})
# Show videos when "Show Examples" is clicked
output$loqui_example_ui_1 <- renderUI({
tags$video(src = "i/video/deepphe.mp4",
type = "video/mp4",
height ="480px",
width="854px",
controls = TRUE)
})
output$loqui_example_ui_2 <- renderUI({
tags$video(src = "i/video/emerse.mp4",
type = "video/mp4",
height ="480px",
width="854px",
controls = TRUE)
})
output$loqui_example_ui_3 <- renderUI({
tags$video(src = "i/video/pVACtools.mp4",
type = "video/mp4",
height ="480px",
width="854px",
controls = TRUE)
})
# Voice Options
output$voice_options <- renderUI({
if (input$service == "coqui") {
tagList(
selectInput("coqui_model_name", "Select Model Name (Voice)",
choices = unique(voices_coqui$model_name))
# TODO: Refactor after ITCR PI Meeting 08/04/2023
# selectInput("coqui_lang", "Select Language", choices = unique(voices_coqui$language)),
# selectInput("coqui_dataset", "Select Dataset", choices = NULL),
# selectInput("coqui_model_name", "Select Model Name", choices = NULL),
# selectInput("coqui_vocoder_name", "Select Vocoder Name",
# choices = NULL)
# Remove after testing
# c("libri-tts/wavegrad",
# "libri-tts/fullband-melgan",
# "ek1/wavegrad",
# "ljspeech/multiband-melgan",
# "ljspeech/hifigan_v2",
# "ljspeech/univnet",
# "blizzard2013/hifigan_v2",
# "vctk/hifigan_v2",
# "sam/hifigan_v2")
)
# Remove when paid services are good2go
} else if (input$service == "amazon") {
# tagList(
# selectInput("amazon_lang", "Select Language",
# choices = unique(voices_amazon$language)),
# selectInput("amazon_gender", "Select Gender", choices = NULL),
# selectInput("amazon_voice", "Select Voice", choices = NULL)
# )
} else if (input$service == "google") {
# Remove when paid services are good2go
# tagList(
# selectInput("google_lang", "Select Language",
# choices = unique(voices_google$language)),
# selectInput("google_gender", "Select Gender", choices = NULL),
# selectInput("google_voice", "Select Voice", choices = NULL)
# )
} else {
# Remove when paid services are good2go
# tagList(
# selectInput("ms_locale", "Select Language",
# choices = unique(voices_ms$locale)),
# selectInput("ms_gender", "Select Gender", choices = NULL),
# selectInput("ms_voice", "Select Voice", choices = NULL)
# )
}
})
# TODO: Refactor after ITCR PI Meeting 08/04/2023
# Coqui
# voices_coqui_reactive <- reactive({
# filter(voices_coqui, language == input$coqui_lang)
# })
# observeEvent(input$coqui_lang, {
# freezeReactiveValue(input, "coqui_dataset")
# choices <- unique(voices_coqui_reactive()$dataset)
# updateSelectInput(inputId = "coqui_dataset", choices = choices)
# })
# voices_coqui_dataset_reactive <- reactive({
# req(input$coqui_dataset)
# filter(voices_coqui_reactive(), dataset == input$coqui_dataset)
# })
# observeEvent(input$coqui_dataset, {
# freezeReactiveValue(input, "coqui_model_name")
# choices <- unique(voices_coqui_dataset_reactive()$model_name)
# updateSelectInput(inputId = "coqui_model_name", choices = choices)
# })
# voices_coqui_model_reactive <- reactive({
# req(input$coqui_model_name)
# filter(voices_coqui_dataset_reactive(), dataset == input$coqui_model_name)
# })
# observeEvent(input$coqui_model_name, {
# freezeReactiveValue(input, "coqui_vocoder_name")
# choices <- ifelse(input$coqui_model_name == "jenny", "jenny", "ljspeech/univnet")
# updateSelectInput(inputId = "coqui_vocoder_name", choices = choices)
# })
# Remove after testing
# # Amazon
# voices_amazon_reactive <- reactive({
# filter(voices_amazon, language == input$amazon_lang)
# })
# observeEvent(input$amazon_lang, {
# freezeReactiveValue(input, "amazon_gender")
# choices <- unique(voices_amazon_reactive()$gender)
# updateSelectInput(inputId = "amazon_gender", choices = choices)
# })
# voices_amazon_gender_reactive <- reactive({
# req(input$amazon_gender)
# filter(voices_amazon_reactive(), gender == input$amazon_gender)
# })
# observeEvent(input$amazon_gender, {
# freezeReactiveValue(input, "amazon_voice")
# choices <- unique(voices_amazon_gender_reactive()$voice)
# updateSelectInput(inputId = "amazon_voice", choices = choices)
# })
#
# # Google
# voices_google_reactive <- reactive({
# filter(voices_google, language == input$google_lang)
# })
# observeEvent(input$google_lang, {
# freezeReactiveValue(input, "google_gender")
# choices <- unique(voices_google_reactive()$gender)
# updateSelectInput(inputId = "google_gender", choices = choices)
# })
# voices_google_gender_reactive <- reactive({
# req(input$google_gender)
# filter(voices_google_reactive(), gender == input$google_gender)
# })
# observeEvent(input$google_gender, {
# freezeReactiveValue(input, "google_voice")
# choices <- unique(voices_google_gender_reactive()$voice)
# updateSelectInput(inputId = "google_voice", choices = choices)
# })
#
# # Microsoft
# voices_ms_reactive <- reactive({
# filter(voices_ms, locale == input$ms_locale)
# })
# observeEvent(input$ms_locale, {
# freezeReactiveValue(input, "ms_gender")
# choices <- unique(voices_ms_reactive()$gender)
# updateSelectInput(inputId = "ms_gender", choices = choices)
# })
# voices_ms_gender_reactive <- reactive({
# req(input$ms_gender)
# filter(voices_ms_reactive(), gender == input$ms_gender)
# })
# observeEvent(input$ms_gender, {
# freezeReactiveValue(input, "ms_voice")
# choices <- unique(voices_ms_gender_reactive()$name)
# updateSelectInput(inputId = "ms_voice", choices = choices)
# })
#
# shinyFiles::shinyFileChoose(input, "pptx_file", session = session,
# roots=c(wd='.'))
# Main function
observeEvent(input$generate, {
# Create a progress bar
progress <- AsyncProgress$new(message = "Processing...")
# Inputs used inside future_promise()
service <- input$service
coqui_model_name <- input$coqui_model_name
coqui_vocoder_name <- ifelse(coqui_model_name == "jenny",
"jenny",
"ljspeech/univnet")
which_tool <- input$presentation_tool
burn_subtitle <- input$burn_subtitle
gs_url <- input$gs_url
pptx_upload_datapath <- input$pptx_file$datapath
# inFile <- shinyFiles::parseFilePaths(roots=c(wd='.'), input$pptx_file)
# pptx_upload_datapath <- inFile$datapath
user_email <- input$email
auto_email <- input$auto_email
video_name <- video_name()
video_name_subtitle <- video_name_subtitle()
app_url <- "https://loqui.fredhutch.org"
res <- reactiveVal()
future_promise({
# extract speaker notes
progress$inc(amount = 0, message = "Processing takes a few minutes...")
# download google slides as pptx
if(which_tool == "google_slides") {
pptx_path <- ari::download_gs_file(gs_url, out_type = "pptx")
} else {
# or fetch path to pptx on server
pptx_path <- pptx_upload_datapath
}
progress$inc(amount = 1/5, message = "Processing...")
pptx_notes_vector <- ari::pptx_notes(pptx_path)
progress$inc(amount = 1/5, message = "Processing...")
# download as pdf
progress$inc(amount = 0, message = "Processing takes a few minutes...")
# download google slides as pdf
if (which_tool == "google_slides") {
pdf_path <- ari::download_gs_file(gs_url, out_type = "pdf")
} else {
# convert pptx slides to pdf
if (Sys.info()['sysname'] == "Linux") {
Sys.setenv(LD_LIBRARY_PATH="")
}
pdf_path <- ari::pptx_to_pdf(pptx_upload_datapath)
}
progress$inc(amount = 1/5, message = "Processing...")
# convert to png
progress$inc(amount = 0, message = "Processing takes a few minutes...")
image_path <- ari::pdf_to_pngs(pdf_path)
progress$inc(amount = 1/5, message = "Processing...")
progress$inc(amount = 0, message = "This step requires a few minutes...")
Sys.sleep(2)
progress$inc(amount = 0, message = "Processing takes a few minutes...")
# combine images and notes with ari_spin()
switch(service,
coqui = ari::ari_spin(images = image_path,
paragraphs = pptx_notes_vector,
output = video_name,
tts_engine = text2speech::tts,
tts_engine_args = list(service = "coqui",
voice = NULL,
model_name = coqui_model_name,
vocoder_name = coqui_vocoder_name),
subtitles = burn_subtitle),
# TODO: once paid services are enabled, change the arguments in the below functions
amazon = ari::ari_spin(images = image_path,
paragraphs = pptx_notes_vector,
service = "amazon",
voice = input$amazon_voice,
subtitles = burn_subtitle,
output = video_name),
google = ari::ari_spin(images = image_path,
paragraphs = pptx_notes_vector,
service = "google",
voice = input$google_voice,
subtitles = burn_subtitle,
output = video_name),
ms = ari::ari_spin(images = image_path,
paragraphs = pptx_notes_vector,
service = "microsoft",
voice = input$ms_voice,
subtitles = burn_subtitle,
output = video_name)
)
if (burn_subtitle) {
srt_file <- paste0(tools::file_path_sans_ext(video_name), ".srt")
ari_burn_subtitles(video_name, srt_file, video_name_subtitle)
}
progress$inc(amount = 1/5, message = "Processing...Done!", detail = "100%")
Sys.sleep(3)
progress$close()
# Email
if (auto_email) {
if (burn_subtitle) {
video_name <- video_name_subtitle
}
# Video Link
video_name_processed <- gsub("www/", "", video_name)
video_link <- paste0(app_url, "/", "i", "/", video_name_processed)
# Date/Time
date_time <- blastula::add_readable_time()
# Compose Email
email <- blastula::compose_email(
body = blastula::md(glue::glue(
"Dear Loqui User,
To access the video, simply click on the following link: [{video_link}]({video_link}). To download the video, click the three
vertical dots and select 'Download'.
We also invite you to visit our website at [https://hutchdatascience.org](https://hutchdatascience.org)
to explore a wealth of valuable resources and stay updated on the latest news from
the Fred Hutch Data Science Lab (DaSL).
Feel free to reach out to us with any questions at howardbaek@fredhutch.org or by filing a [GitHub issue](https://github.com/FredHutch/loqui/issues).
We will respond to your inquiries as soon as possible.
Howard Baek
")),
footer = md(glue::glue("Email automatically sent on {date_time}."))
)
# Send email
email %>%
smtp_send(
from = "loqui-noreply@fredhutch.org",
to = user_email,
subject = "Video Generated by Loqui from Fred Hutch Data Science Lab (DaSL)",
credentials = creds_anonymous(host = "mx.fhcrc.org", port = 25)
)
}
# Google Sheets
ffmpeg_cmd <- paste0("-i", " ", video_name, " ", "2>&1 | grep \"Duration\"")
duration_raw <- system2("ffmpeg", ffmpeg_cmd, stdout = TRUE)
duration_raw <- regmatches(duration_raw, regexpr("Duration: (\\d{2}:\\d{2}:\\d{2}\\.\\d{2})", duration_raw))
video_duration <- sub("Duration: ([0-9:.]+)", "\\1", duration_raw)
date_time <- add_readable_time()
# Authorize
gs4_auth(cache=".secrets", email="howardbaek.fh@gmail.com")
# Append
gs_url <- "https://docs.google.com/spreadsheets/d/1G_HTU-bv2k5txExP8EH3ScUfGqtW1P3syThD84Z-g9k/edit?usp=sharing"
sheet_append(gs_url,
data.frame(date_time = date_time,
video_duration = video_duration,
email = user_email))
# Final output
# Replace "www" with "i"
if (burn_subtitle) {
gsub("www", "i", video_name_subtitle)
} else {
gsub("www", "i", video_name)
}
}) %...>% res
# Show video when "Generate" is clicked
output$video_ui <- renderUI({
res <- res()
tags$video(src = res,
type = "video/mp4",
height ="480px",
width="854px",
autoplay = TRUE,
controls = TRUE)
})
# Extract video info
if (which_tool == "google_slides") {
pdf_path <- ari::download_gs_file(gs_url, "pdf")
} else {
# convert pptx slides to pdf
if (Sys.info()['sysname'] == "Linux") {
Sys.setenv(LD_LIBRARY_PATH="")
}
pdf_path <- ari::pptx_to_pdf(pptx_upload_datapath)
}
pdf_info <- pdftools::pdf_info(pdf = pdf_path)
# Show video title
output$video_info <- renderUI({
span(textOutput("video_title"),
style = "font-weight: bold;
font-family: Arial;
font-size: 25px;
color: #1c3b61")
output$video_title <- renderText({
pdf_info$keys$Title
})
})
# Show video buttons (download/send email)
output$video_btn <- renderUI({
column(12,
downloadButton("download_btn"),
actionButton("send_email", "Email", icon = icon("inbox")),
align = "left"
)
})
})
# Download rendered video
# Source: https://stackoverflow.com/questions/33416557/r-shiny-download-existing-file
output$download_btn <- downloadHandler(
filename = "loqui_video.mp4",
content = function(file) {
if (input$burn_subtitle) {
video_name <- video_name_subtitle()
} else {
video_name <- video_name()
}
file.copy(video_name, file)
},
contentType = "video/mp4"
)
# Send email
observeEvent(input$send_email, {
# Dialog Box
showModal(modalDialog(
title = span(h4("Success message:"), style = "color: #1c3b61;font-family:Times;font-weight: bold;"),
span(paste0("Email with the video file has been sent to ", input$email, "."), style = "color: #1c3b61;font-family:Arial")
))
if (input$burn_subtitle) {
video_name <- video_name()
} else {
video_name <- video_name_subtitle()
}
# Video Link
video_name_processed <- gsub("www/", "", video_name)
# Video Link
app_url <- "https://loqui.fredhutch.org"
video_link <- paste0(app_url, "/", "i", "/", video_name_processed)
# Date/Time
date_time <- blastula::add_readable_time()
# Compose Email
email <- blastula::compose_email(
body = blastula::md(glue::glue(
"Dear Loqui User,
To access the video, simply click on the following link: [{video_link}]({video_link}). To download the video, click the three
vertical dots and select 'Download'.
We also invite you to visit our website at [https://hutchdatascience.org](https://hutchdatascience.org)
to explore a wealth of valuable resources and stay updated on the latest news from
the Fred Hutch Data Science Lab (DaSL).
Feel free to reach out to us with any questions at howardbaek@fredhutch.org or by filing a [GitHub issue](https://github.com/FredHutch/loqui/issues).
We will respond to your inquiries as soon as possible.
Howard Baek
")),
footer = md(glue::glue("Email sent on {date_time}."))
)
# Send email
email %>%
smtp_send(
from = "loqui-noreply@fredhutch.org",
to = input$email,
subject = "Video Generated by Loqui from Fred Hutch Data Science Lab (DaSL)",
credentials = creds_anonymous(host = "mx.fhcrc.org", port = 25)
)
})
}
# Code for Deployment to Hutch servers
addResourcePath("/i", file.path(getwd(), "www"))
options <- list()
if (!interactive()) {
options$port = 3838
options$launch.browser = FALSE
options$host = "0.0.0.0"
}
shinyApp(ui, server, options=options)
Once https://github.com/jhudsl/ari/pull/46 is merged, add feature to burn subtitles into video using
ari::ari_subtitles()
andari::ari_burn_subtitles()
.