[Video] Download videos (#4886)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
zio/stable
Hailey 2024-08-15 11:23:48 -07:00 committed by GitHub
parent b9975697e2
commit 11061b628e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 747 additions and 3 deletions

View File

@ -256,6 +256,9 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
// video download
e.GET("/video-download", server.WebGeneric)
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)

View File

@ -7,3 +7,4 @@
# be ok.
User-Agent: *
Allow: /
Disallow: /video-download

View File

@ -0,0 +1,35 @@
package expo.modules.blueskyswissarmy.hlsdownload
import android.net.Uri
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoHLSDownloadModule : Module() {
override fun definition() =
ModuleDefinition {
Name("ExpoHLSDownload")
Function("isAvailable") {
return@Function true
}
View(HLSDownloadView::class) {
Events(
arrayOf(
"onStart",
"onError",
"onProgress",
"onSuccess",
),
)
Prop("downloaderUrl") { view: HLSDownloadView, downloaderUrl: Uri ->
view.downloaderUrl = downloaderUrl
}
AsyncFunction("startDownloadAsync") { view: HLSDownloadView, sourceUrl: Uri ->
view.startDownload(sourceUrl)
}
}
}
}

View File

@ -0,0 +1,141 @@
package expo.modules.blueskyswissarmy.hlsdownload
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.util.Base64
import android.util.Log
import android.webkit.DownloadListener
import android.webkit.JavascriptInterface
import android.webkit.WebView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.viewevent.ViewEventCallback
import expo.modules.kotlin.views.ExpoView
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.URI
import java.util.UUID
class HLSDownloadView(
context: Context,
appContext: AppContext,
) : ExpoView(context, appContext),
DownloadListener {
private val webView = WebView(context)
var downloaderUrl: Uri? = null
private val onStart by EventDispatcher()
private val onError by EventDispatcher()
private val onProgress by EventDispatcher()
private val onSuccess by EventDispatcher()
init {
this.setupWebView()
this.addView(this.webView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
val webSettings = this.webView.settings
webSettings.javaScriptEnabled = true
webSettings.domStorageEnabled = true
webView.setDownloadListener(this)
webView.addJavascriptInterface(WebAppInterface(this.onProgress, this.onError), "AndroidInterface")
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
this.webView.stopLoading()
this.webView.clearHistory()
this.webView.removeAllViews()
this.webView.destroy()
}
fun startDownload(sourceUrl: Uri) {
if (this.downloaderUrl == null) {
this.onError(mapOf(ERROR_KEY to "Downloader URL is not set."))
return
}
val url = URI("${this.downloaderUrl}?videoUrl=$sourceUrl")
this.webView.loadUrl(url.toString())
this.onStart(mapOf())
}
override fun onDownloadStart(
url: String?,
userAgent: String?,
contentDisposition: String?,
mimeType: String?,
contentLength: Long,
) {
if (url == null) {
this.onError(mapOf(ERROR_KEY to "Failed to retrieve download URL from webview."))
return
}
val tempDir = context.cacheDir
val fileName = "${UUID.randomUUID()}.mp4"
val file = File(tempDir, fileName)
val base64 = url.split(",")[1]
val bytes = Base64.decode(base64, Base64.DEFAULT)
val fos = FileOutputStream(file)
try {
fos.write(bytes)
} catch (e: Exception) {
Log.e("FileDownload", "Error downloading file", e)
this.onError(mapOf(ERROR_KEY to e.message.toString()))
return
} finally {
fos.close()
}
val uri = Uri.fromFile(file)
this.onSuccess(mapOf("uri" to uri.toString()))
}
companion object {
const val ERROR_KEY = "message"
}
}
public class WebAppInterface(
val onProgress: ViewEventCallback<Map<String, Any>>,
val onError: ViewEventCallback<Map<String, Any>>,
) {
@JavascriptInterface
public fun onMessage(message: String) {
val jsonObject = JSONObject(message)
val action = jsonObject.getString("action")
when (action) {
"error" -> {
val messageStr = jsonObject.get("messageStr")
if (messageStr !is String) {
this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message."))
return
}
this.onError(mapOf(ERROR_KEY to messageStr))
}
"progress" -> {
val messageFloat = jsonObject.get("messageFloat")
if (messageFloat !is Number) {
this.onError(mapOf(ERROR_KEY to "Failed to decode JSON post message."))
return
}
this.onProgress(mapOf(PROGRESS_KEY to messageFloat))
}
}
}
companion object {
const val PROGRESS_KEY = "progress"
const val ERROR_KEY = "message"
}
}

View File

@ -5,6 +5,7 @@
"ExpoBlueskySharedPrefsModule",
"ExpoBlueskyReferrerModule",
"ExpoBlueskyVisibilityViewModule",
"ExpoHLSDownloadModule",
"ExpoPlatformInfoModule"
]
},
@ -13,7 +14,8 @@
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule",
"expo.modules.blueskyswissarmy.hlsdownload.ExpoHLSDownloadModule"
]
}
}

View File

@ -1,7 +1,15 @@
import HLSDownloadView from './src/HLSDownload'
import * as PlatformInfo from './src/PlatformInfo'
import {AudioCategory} from './src/PlatformInfo/types'
import * as Referrer from './src/Referrer'
import * as SharedPrefs from './src/SharedPrefs'
import VisibilityView from './src/VisibilityView'
export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView}
export {
AudioCategory,
HLSDownloadView,
PlatformInfo,
Referrer,
SharedPrefs,
VisibilityView,
}

View File

@ -0,0 +1,31 @@
import ExpoModulesCore
public class ExpoHLSDownloadModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoHLSDownload")
Function("isAvailable") {
if #available(iOS 14.5, *) {
return true
}
return false
}
View(HLSDownloadView.self) {
Events([
"onStart",
"onError",
"onProgress",
"onSuccess"
])
Prop("downloaderUrl") { (view: HLSDownloadView, downloaderUrl: URL) in
view.downloaderUrl = downloaderUrl
}
AsyncFunction("startDownloadAsync") { (view: HLSDownloadView, sourceUrl: URL) in
view.startDownload(sourceUrl: sourceUrl)
}
}
}
}

View File

@ -0,0 +1,148 @@
import ExpoModulesCore
import WebKit
class HLSDownloadView: ExpoView, WKScriptMessageHandler, WKNavigationDelegate, WKDownloadDelegate {
var webView: WKWebView!
var downloaderUrl: URL?
private var onStart = EventDispatcher()
private var onError = EventDispatcher()
private var onProgress = EventDispatcher()
private var onSuccess = EventDispatcher()
private var outputUrl: URL?
public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
// controller for post message api
let contentController = WKUserContentController()
contentController.add(self, name: "onMessage")
let configuration = WKWebViewConfiguration()
configuration.userContentController = contentController
// create webview
let webView = WKWebView(frame: .zero, configuration: configuration)
// Use these for debugging, to see the webview itself
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
webView.layer.masksToBounds = false
webView.backgroundColor = .clear
webView.contentMode = .scaleToFill
webView.navigationDelegate = self
self.addSubview(webView)
self.webView = webView
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - view functions
func startDownload(sourceUrl: URL) {
guard let downloaderUrl = self.downloaderUrl,
let url = URL(string: "\(downloaderUrl.absoluteString)?videoUrl=\(sourceUrl.absoluteString)") else {
self.onError([
"message": "Downloader URL is not set."
])
return
}
self.onStart()
self.webView.load(URLRequest(url: url))
}
// webview message handling
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let response = message.body as? String,
let data = response.data(using: .utf8),
let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else {
self.onError([
"message": "Failed to decode JSON post message."
])
return
}
switch payload.action {
case .progress:
guard let progress = payload.messageFloat else {
self.onError([
"message": "Failed to decode JSON post message."
])
return
}
self.onProgress([
"progress": progress
])
case .error:
guard let messageStr = payload.messageStr else {
self.onError([
"message": "Failed to decode JSON post message."
])
return
}
self.onError([
"message": messageStr
])
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
guard #available(iOS 14.5, *) else {
return .cancel
}
if navigationAction.shouldPerformDownload {
return .download
} else {
return .allow
}
}
// MARK: - wkdownloaddelegate
@available(iOS 14.5, *)
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
download.delegate = self
}
@available(iOS 14.5, *)
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
download.delegate = self
}
@available(iOS 14.5, *)
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
let directory = NSTemporaryDirectory()
let fileName = "\(NSUUID().uuidString).mp4"
let url = NSURL.fileURL(withPathComponents: [directory, fileName])
self.outputUrl = url
completionHandler(url)
}
@available(iOS 14.5, *)
func downloadDidFinish(_ download: WKDownload) {
guard let url = self.outputUrl else {
return
}
self.onSuccess([
"uri": url.absoluteString
])
self.outputUrl = nil
}
}
struct WebViewActionPayload: Decodable {
enum Action: String, Decodable {
case progress, error
}
let action: Action
let messageStr: String?
let messageFloat: Float?
}

View File

@ -0,0 +1,39 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {HLSDownloadViewProps} from './types'
const NativeModule = requireNativeModule('ExpoHLSDownload')
const NativeView: React.ComponentType<
HLSDownloadViewProps & {
ref: React.RefObject<any>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('ExpoHLSDownload')
export default class HLSDownloadView extends React.PureComponent<HLSDownloadViewProps> {
private nativeRef: React.RefObject<any> = React.createRef()
constructor(props: HLSDownloadViewProps) {
super(props)
}
static isAvailable(): boolean {
return NativeModule.isAvailable()
}
async startDownloadAsync(sourceUrl: string): Promise<void> {
return await this.nativeRef.current.startDownloadAsync(sourceUrl)
}
render() {
return (
<NativeView
ref={this.nativeRef}
style={{height: 0, width: 0}}
{...this.props}
/>
)
}
}

View File

@ -0,0 +1,22 @@
import React from 'react'
import {NotImplementedError} from '../NotImplemented'
import {HLSDownloadViewProps} from './types'
export default class HLSDownloadView extends React.PureComponent<HLSDownloadViewProps> {
constructor(props: HLSDownloadViewProps) {
super(props)
}
static isAvailable(): boolean {
return false
}
async startDownloadAsync(sourceUrl: string): Promise<void> {
throw new NotImplementedError({sourceUrl})
}
render() {
return null
}
}

View File

@ -0,0 +1,10 @@
import {NativeSyntheticEvent} from 'react-native'
export interface HLSDownloadViewProps {
downloaderUrl: string
onSuccess: (e: NativeSyntheticEvent<{uri: string}>) => void
onStart?: () => void
onError?: (e: NativeSyntheticEvent<{message: string}>) => void
onProgress?: (e: NativeSyntheticEvent<{progress: number}>) => void
}

View File

@ -59,6 +59,8 @@
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.4.2",
"@expo/webpack-config": "^19.0.0",
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
"@formatjs/intl-locale": "^4.0.0",
@ -143,6 +145,7 @@
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls-parser": "^0.13.3",
"hls.js": "^1.5.11",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
@ -224,6 +227,7 @@
"@testing-library/react-native": "^11.5.2",
"@tsconfig/react-native": "^2.0.3",
"@types/he": "^1.1.2",
"@types/hls-parser": "^0.8.7",
"@types/jest": "^29.4.0",
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.debounce": "^4.0.7",

View File

@ -50,6 +50,7 @@ import {
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {VideoDownloadScreen} from '#/components/VideoDownloadScreen'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
@ -364,6 +365,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => Wizard}
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
/>
<Stack.Screen
name="VideoDownload"
getComponent={() => VideoDownloadScreen}
options={{title: title(msg`Download video`)}}
/>
</>
)
}

View File

@ -0,0 +1,4 @@
export function VideoDownloadScreen() {
// @TODO redirect
return null
}

View File

@ -0,0 +1,215 @@
import React from 'react'
import {parse} from 'hls-parser'
import {MasterPlaylist, MediaPlaylist, Variant} from 'hls-parser/types'
interface PostMessageData {
action: 'progress' | 'error'
messageStr?: string
messageFloat?: number
}
function postMessage(data: PostMessageData) {
// @ts-expect-error safari webview only
if (window?.webkit) {
// @ts-expect-error safari webview only
window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(data))
// @ts-expect-error android webview only
} else if (AndroidInterface) {
// @ts-expect-error android webview only
AndroidInterface.onMessage(JSON.stringify(data))
}
}
function createSegementUrl(originalUrl: string, newFile: string) {
const parts = originalUrl.split('/')
parts[parts.length - 1] = newFile
return parts.join('/')
}
export function VideoDownloadScreen() {
const ffmpegRef = React.useRef<any>(null)
const fetchFileRef = React.useRef<any>(null)
const [dataUrl, setDataUrl] = React.useState<any>(null)
const load = React.useCallback(async () => {
const ffmpegLib = await import('@ffmpeg/ffmpeg')
const ffmpeg = new ffmpegLib.FFmpeg()
ffmpegRef.current = ffmpeg
const ffmpegUtilLib = await import('@ffmpeg/util')
fetchFileRef.current = ffmpegUtilLib.fetchFile
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'
await ffmpeg.load({
coreURL: await ffmpegUtilLib.toBlobURL(
`${baseURL}/ffmpeg-core.js`,
'text/javascript',
),
wasmURL: await ffmpegUtilLib.toBlobURL(
`${baseURL}/ffmpeg-core.wasm`,
'application/wasm',
),
})
}, [])
const createMp4 = React.useCallback(async (videoUrl: string) => {
// Get the master playlist and find the best variant
const masterPlaylistRes = await fetch(videoUrl)
const masterPlaylistText = await masterPlaylistRes.text()
const masterPlaylist = parse(masterPlaylistText) as MasterPlaylist
// If URL given is not a master playlist, we probably cannot handle this.
if (!masterPlaylist.isMasterPlaylist) {
postMessage({
action: 'error',
messageStr: 'A master playlist was not found in the provided playlist.',
})
return
}
// Figure out what the best quality is. These should generally be in order, but we'll check them all just in case
let bestVariant: Variant | undefined
for (const variant of masterPlaylist.variants) {
if (!bestVariant || variant.bandwidth > bestVariant.bandwidth) {
bestVariant = variant
}
}
// Should only happen if there was no variants at all given to us. Mostly for types.
if (!bestVariant) {
postMessage({
action: 'error',
messageStr: 'No variants were found in the provided master playlist.',
})
return
}
const urlParts = videoUrl.split('/')
urlParts[urlParts.length - 1] = bestVariant?.uri
const bestVariantUrl = urlParts.join('/')
// Download and parse m3u8
const hlsFileRes = await fetch(bestVariantUrl)
const hlsPlainText = await hlsFileRes.text()
const playlist = parse(hlsPlainText) as MediaPlaylist
// This one shouldn't be a master playlist - again just for types really
if (playlist.isMasterPlaylist) {
postMessage({
action: 'error',
messageStr: 'An unknown error has occurred.',
})
return
}
const ffmpeg = ffmpegRef.current
// Get the correctly ordered file names. We need to remove the tracking info from the end of the file name
const segments = playlist.segments.map(segment => {
return segment.uri.split('?')[0]
})
// Download each segment
let error: string | null = null
let completed = 0
await Promise.all(
playlist.segments.map(async segment => {
const uri = createSegementUrl(bestVariantUrl, segment.uri)
const filename = segment.uri.split('?')[0]
const res = await fetch(uri)
if (!res.ok) {
error = 'Failed to download playlist segment.'
}
const blob = await res.blob()
try {
await ffmpeg.writeFile(filename, await fetchFileRef.current(blob))
} catch (e: unknown) {
error = 'Failed to write file.'
} finally {
completed++
const progress = completed / playlist.segments.length
postMessage({
action: 'progress',
messageFloat: progress,
})
}
}),
)
// Do something if there was an error
if (error) {
postMessage({
action: 'error',
messageStr: error,
})
return
}
// Put the segments together
await ffmpeg.exec([
'-i',
`concat:${segments.join('|')}`,
'-c:v',
'copy',
'output.mp4',
])
const fileData = await ffmpeg.readFile('output.mp4')
const blob = new Blob([fileData.buffer], {type: 'video/mp4'})
const dataUrl = await new Promise<string | null>(resolve => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = () => resolve(null)
reader.readAsDataURL(blob)
})
return dataUrl
}, [])
const download = React.useCallback(
async (videoUrl: string) => {
await load()
const mp4Res = await createMp4(videoUrl)
if (!mp4Res) {
postMessage({
action: 'error',
messageStr: 'An error occurred while creating the MP4.',
})
return
}
setDataUrl(mp4Res)
},
[createMp4, load],
)
React.useEffect(() => {
const url = new URL(window.location.href)
const videoUrl = url.searchParams.get('videoUrl')
if (!videoUrl) {
postMessage({action: 'error', messageStr: 'No video URL provided'})
} else {
setDataUrl(null)
download(videoUrl)
}
}, [download])
if (!dataUrl) return null
return (
<div>
<a
href={dataUrl}
ref={el => {
el?.click()
}}
download="video.mp4"
/>
</div>
)
}

View File

@ -50,6 +50,7 @@ export type CommonNavigatorParams = {
StarterPackShort: {code: string}
StarterPackWizard: undefined
StarterPackEdit: {rkey?: string}
VideoDownload: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {

View File

@ -48,4 +48,5 @@ export const router = new Router({
StarterPack: '/starter-pack/:name/:rkey',
StarterPackShort: '/starter-pack-short/:code',
StarterPackWizard: '/starter-pack/create',
VideoDownload: '/video-download',
})

View File

@ -1,12 +1,17 @@
import React from 'react'
import {ScrollView, View} from 'react-native'
import {deleteAsync} from 'expo-file-system'
import {saveToLibraryAsync} from 'expo-media-library'
import {useSetThemePrefs} from '#/state/shell'
import {isWeb} from 'platform/detection'
import {useVideoLibraryPermission} from 'lib/hooks/usePermissions'
import {isIOS, isWeb} from 'platform/detection'
import {CenteredView} from '#/view/com/util/Views'
import * as Toast from 'view/com/util/Toast'
import {ListContained} from 'view/screens/Storybook/ListContained'
import {atoms as a, ThemeProvider, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {HLSDownloadView} from '../../../../modules/expo-bluesky-swiss-army'
import {Breakpoints} from './Breakpoints'
import {Buttons} from './Buttons'
import {Dialogs} from './Dialogs'
@ -33,10 +38,49 @@ function StorybookInner() {
const t = useTheme()
const {setColorMode, setDarkTheme} = useSetThemePrefs()
const [showContainedList, setShowContainedList] = React.useState(false)
const hlsDownloadRef = React.useRef<HLSDownloadView>(null)
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
return (
<CenteredView style={[t.atoms.bg]}>
<View style={[a.p_xl, a.gap_5xl, {paddingBottom: 100}]}>
<HLSDownloadView
ref={hlsDownloadRef}
downloaderUrl={
isIOS
? 'http://localhost:19006/video-download'
: 'http://10.0.2.2:19006/video-download'
}
onSuccess={async e => {
const uri = e.nativeEvent.uri
const permsRes = await requestVideoAccessIfNeeded()
if (!permsRes) return
await saveToLibraryAsync(uri)
try {
deleteAsync(uri)
} catch (err) {
console.error('Failed to delete file', err)
}
Toast.show('Video saved to library')
}}
onStart={() => console.log('Download is starting')}
onError={e => console.log(e.nativeEvent.message)}
onProgress={e => console.log(e.nativeEvent.progress)}
/>
<Button
variant="solid"
color="primary"
size="small"
onPress={async () => {
hlsDownloadRef.current?.startDownloadAsync(
'https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?download=true',
)
}}
label="Video download test">
<ButtonText>Video download test</ButtonText>
</Button>
{!showContainedList ? (
<>
<View style={[a.flex_row, a.align_start, a.gap_md]}>

View File

@ -3925,6 +3925,23 @@
resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a"
integrity sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==
"@ffmpeg/ffmpeg@^0.12.10":
version "0.12.10"
resolved "https://registry.yarnpkg.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.10.tgz#e3cce21f21f11f33dfc1ec1d5ad5694f4a3073c9"
integrity sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ==
dependencies:
"@ffmpeg/types" "^0.12.2"
"@ffmpeg/types@^0.12.2":
version "0.12.2"
resolved "https://registry.yarnpkg.com/@ffmpeg/types/-/types-0.12.2.tgz#bc7eef321ae50225c247091f1f23fd3087c6aa1d"
integrity sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA==
"@ffmpeg/util@^0.12.1":
version "0.12.1"
resolved "https://registry.yarnpkg.com/@ffmpeg/util/-/util-0.12.1.tgz#98afa20d7b4c0821eebdb205ddcfa5d07b0a4f53"
integrity sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==
"@floating-ui/core@^1.0.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1"
@ -8007,6 +8024,13 @@
resolved "https://registry.yarnpkg.com/@types/he/-/he-1.2.0.tgz#3845193e597d943bab4e61ca5d7f3d8fc3d572a3"
integrity sha512-uH2smqTN4uGReAiKedIVzoLUAXIYLBTbSofhx3hbNqj74Ua6KqFsLYszduTrLCMEAEAozF73DbGi/SC1bzQq4g==
"@types/hls-parser@^0.8.7":
version "0.8.7"
resolved "https://registry.yarnpkg.com/@types/hls-parser/-/hls-parser-0.8.7.tgz#26360493231ed8606ebe995976c63c69c3982657"
integrity sha512-3ry9V6i/uhSbNdvBUENAqt2p5g+xKIbjkr5Qv4EaXe7eIJnaGQntFZalRLQlKoEop381a0LwUr2qNKKlxQC4TQ==
dependencies:
"@types/node" "*"
"@types/html-minifier-terser@^6.0.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
@ -13456,6 +13480,11 @@ history@^5.3.0:
dependencies:
"@babel/runtime" "^7.7.6"
hls-parser@^0.13.3:
version "0.13.3"
resolved "https://registry.yarnpkg.com/hls-parser/-/hls-parser-0.13.3.tgz#5f7a305629cf462bbf16a4d080e03e0be714f1fe"
integrity sha512-DXqW7bwx9j2qFcAXS/LBJTDJWitxknb6oUnsnTvECHrecPvPbhRgIu45OgNDUU6gpwKxMJx40SHRRUUhdIM2gA==
hls.js@^1.5.11:
version "1.5.11"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826"