はじまりの大地

このコミットが含まれているのは:
2024-07-15 09:14:04 +09:00
コミット 6632905f32
3501個のファイルの変更1439465行の追加0行の削除
+19
ファイルの表示
@@ -0,0 +1,19 @@
{
"name": "@peertube/peertube-core-utils",
"private": true,
"version": "0.0.0",
"main": "dist/index.js",
"files": [ "dist" ],
"exports": {
"types": "./dist/index.d.ts",
"peertube:tsx": "./src/index.ts",
"default": "./dist/index.js"
},
"type": "module",
"devDependencies": {},
"scripts": {
"build": "tsc",
"watch": "tsc -w"
},
"dependencies": {}
}
+14
ファイルの表示
@@ -0,0 +1,14 @@
import { AbusePredefinedReasons, AbusePredefinedReasonsString, AbusePredefinedReasonsType } from '@peertube/peertube-models'
export const abusePredefinedReasonsMap: {
[key in AbusePredefinedReasonsString]: AbusePredefinedReasonsType
} = {
violentOrRepulsive: AbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
hatefulOrAbusive: AbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
spamOrMisleading: AbusePredefinedReasons.SPAM_OR_MISLEADING,
privacy: AbusePredefinedReasons.PRIVACY,
rights: AbusePredefinedReasons.RIGHTS,
serverRules: AbusePredefinedReasons.SERVER_RULES,
thumbnails: AbusePredefinedReasons.THUMBNAILS,
captions: AbusePredefinedReasons.CAPTIONS
} as const
+1
ファイルの表示
@@ -0,0 +1 @@
export * from './abuse-predefined-reasons.js'
+65
ファイルの表示
@@ -0,0 +1,65 @@
export function findCommonElement <T> (array1: T[], array2: T[]) {
for (const a of array1) {
for (const b of array2) {
if (a === b) return a
}
}
return null
}
// Avoid conflict with other toArray() functions
export function arrayify <T> (element: T | T[]) {
if (Array.isArray(element)) return element
return [ element ]
}
// Avoid conflict with other uniq() functions
export function uniqify <T> (elements: T[]) {
return Array.from(new Set(elements))
}
// Thanks: https://stackoverflow.com/a/12646864
export function shuffle <T> (elements: T[]) {
const shuffled = [ ...elements ]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[ shuffled[i], shuffled[j] ] = [ shuffled[j], shuffled[i] ]
}
return shuffled
}
export function sortBy (obj: any[], key1: string, key2?: string) {
return obj.sort((a, b) => {
const elem1 = key2 ? a[key1][key2] : a[key1]
const elem2 = key2 ? b[key1][key2] : b[key1]
if (elem1 < elem2) return -1
if (elem1 === elem2) return 0
return 1
})
}
export function maxBy <T> (arr: T[], property: keyof T) {
let result: T
for (const obj of arr) {
if (!result || result[property] < obj[property]) result = obj
}
return result
}
export function minBy <T> (arr: T[], property: keyof T) {
let result: T
for (const obj of arr) {
if (!result || result[property] > obj[property]) result = obj
}
return result
}
+168
ファイルの表示
@@ -0,0 +1,168 @@
function isToday (d: Date) {
const today = new Date()
return areDatesEqual(d, today)
}
function isYesterday (d: Date) {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
return areDatesEqual(d, yesterday)
}
function isThisWeek (d: Date) {
const minDateOfThisWeek = new Date()
minDateOfThisWeek.setHours(0, 0, 0)
// getDay() -> Sunday - Saturday : 0 - 6
// We want to start our week on Monday
let dayOfWeek = minDateOfThisWeek.getDay() - 1
if (dayOfWeek < 0) dayOfWeek = 6 // Sunday
minDateOfThisWeek.setDate(minDateOfThisWeek.getDate() - dayOfWeek)
return d >= minDateOfThisWeek
}
function isThisMonth (d: Date) {
const thisMonth = new Date().getMonth()
return d.getMonth() === thisMonth
}
function isLastMonth (d: Date) {
const now = new Date()
return getDaysDifferences(now, d) <= 30
}
function isLastWeek (d: Date) {
const now = new Date()
return getDaysDifferences(now, d) <= 7
}
// ---------------------------------------------------------------------------
export const timecodeRegexString = `(\\d+[h:])?(\\d+[m:])?\\d+s?`
function timeToInt (time: number | string) {
if (!time) return 0
if (typeof time === 'number') return Math.floor(time)
// Try with 00h00m00s format first
const reg = new RegExp(`^((?<hours>\\d+)h)?((?<minutes>\\d+)m)?((?<seconds>\\d+)s?)?$`)
const matches = time.match(reg)
if (matches) {
const hours = parseInt(matches.groups['hours'] || '0', 10)
const minutes = parseInt(matches.groups['minutes'] || '0', 10)
const seconds = parseInt(matches.groups['seconds'] || '0', 10)
return hours * 3600 + minutes * 60 + seconds
}
// ':' format fallback
const parts = time.split(':').reverse()
const iMultiplier = {
0: 1,
1: 60,
2: 3600
}
let result = 0
for (let i = 0; i < parts.length; i++) {
const partInt = parseInt(parts[i], 10)
if (isNaN(partInt)) return 0
result += iMultiplier[i] * partInt
}
return result
}
function secondsToTime (options: {
seconds: number
format: 'short' | 'full' | 'locale-string' // default 'short'
symbol?: string
} | number) {
let seconds: number
let format: 'short' | 'full' | 'locale-string' = 'short'
let symbol: string
if (typeof options === 'number') {
seconds = options
} else {
seconds = options.seconds
format = options.format ?? 'short'
symbol = options.symbol
}
let time = ''
if (seconds === 0 && format !== 'full') return '0s'
const formatNumber = (value: number) => {
if (format === 'locale-string') return value.toLocaleString()
return value
}
const hourSymbol = (symbol || 'h')
const minuteSymbol = (symbol || 'm')
const secondsSymbol = format === 'full' ? '' : 's'
const hours = Math.floor(seconds / 3600)
if (hours >= 1 && hours < 10 && format === 'full') time = '0' + hours + hourSymbol
else if (hours >= 1) time = formatNumber(hours) + hourSymbol
else if (format === 'full') time = '00' + hourSymbol
seconds %= 3600
const minutes = Math.floor(seconds / 60)
if (minutes >= 1 && minutes < 10 && format === 'full') time += '0' + minutes + minuteSymbol
else if (minutes >= 1) time += formatNumber(minutes) + minuteSymbol
else if (format === 'full') time += '00' + minuteSymbol
seconds = Math.round(seconds) % 60
if (seconds >= 1 && seconds < 10 && format === 'full') time += '0' + seconds + secondsSymbol
else if (seconds >= 1) time += formatNumber(seconds) + secondsSymbol
else if (format === 'full') time += '00'
return time
}
function millisecondsToTime (options: {
seconds: number
format: 'short' | 'full' | 'locale-string' // default 'short'
symbol?: string
} | number) {
return secondsToTime(typeof options === 'number' ? options / 1000 : { ...options, seconds: options.seconds / 1000 })
}
// ---------------------------------------------------------------------------
export {
isYesterday,
isThisWeek,
isThisMonth,
isToday,
isLastMonth,
isLastWeek,
timeToInt,
secondsToTime,
millisecondsToTime
}
// ---------------------------------------------------------------------------
function areDatesEqual (d1: Date, d2: Date) {
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
}
function getDaysDifferences (d1: Date, d2: Date) {
return (d1.getTime() - d2.getTime()) / (86400000)
}
+10
ファイルの表示
@@ -0,0 +1,10 @@
export * from './array.js'
export * from './random.js'
export * from './date.js'
export * from './number.js'
export * from './object.js'
export * from './regexp.js'
export * from './time.js'
export * from './promises.js'
export * from './url.js'
export * from './version.js'
+13
ファイルの表示
@@ -0,0 +1,13 @@
export function forceNumber (value: any) {
return parseInt(value + '')
}
export function isOdd (num: number) {
return (num % 2) !== 0
}
export function toEven (num: number) {
if (isOdd(num)) return num + 1
return num
}
+86
ファイルの表示
@@ -0,0 +1,86 @@
function pick <O extends object, K extends keyof O> (object: O, keys: K[]): Pick<O, K> {
const result: any = {}
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(object, key)) {
result[key] = object[key]
}
}
return result
}
function omit <O extends object, K extends keyof O> (object: O, keys: K[]): Exclude<O, K> {
const result: any = {}
const keysSet = new Set(keys) as Set<string>
for (const [ key, value ] of Object.entries(object)) {
if (keysSet.has(key)) continue
result[key] = value
}
return result
}
function objectKeysTyped <O extends object, K extends keyof O> (object: O): K[] {
return (Object.keys(object) as K[])
}
function getKeys <O extends object, K extends keyof O> (object: O, keys: K[]): K[] {
return (Object.keys(object) as K[]).filter(k => keys.includes(k))
}
function hasKey <T extends object> (obj: T, k: keyof any): k is keyof T {
return k in obj
}
function sortObjectComparator (key: string, order: 'asc' | 'desc') {
return (a: any, b: any) => {
if (a[key] < b[key]) {
return order === 'asc' ? -1 : 1
}
if (a[key] > b[key]) {
return order === 'asc' ? 1 : -1
}
return 0
}
}
function shallowCopy <T> (o: T): T {
return Object.assign(Object.create(Object.getPrototypeOf(o)), o)
}
function simpleObjectsDeepEqual (a: any, b: any) {
if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
for (const key of keysA) {
if (!keysB.includes(key)) return false
if (!simpleObjectsDeepEqual(a[key], b[key])) return false
}
return true
}
export {
pick,
omit,
objectKeysTyped,
getKeys,
hasKey,
shallowCopy,
sortObjectComparator,
simpleObjectsDeepEqual
}
+58
ファイルの表示
@@ -0,0 +1,58 @@
export function isPromise <T = unknown> (value: T | Promise<T>): value is Promise<T> {
return value && typeof (value as Promise<T>).then === 'function'
}
export function isCatchable (value: any) {
return value && typeof value.catch === 'function'
}
export function timeoutPromise <T> (promise: Promise<T>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout>
return Promise.race([
promise,
new Promise((_res, rej) => {
timer = setTimeout(() => rej(new Error('Timeout')), timeoutMs)
})
]).finally(() => clearTimeout(timer))
}
export function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
return function promisified (): Promise<A> {
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
// eslint-disable-next-line no-useless-call
func.apply(null, [ (err: any, res: A) => err ? reject(err) : resolve(res) ])
})
}
}
// Thanks to https://gist.github.com/kumasento/617daa7e46f13ecdd9b2
export function promisify1<T, A> (func: (arg: T, cb: (err: any, result: A) => void) => void): (arg: T) => Promise<A> {
return function promisified (arg: T): Promise<A> {
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
// eslint-disable-next-line no-useless-call
func.apply(null, [ arg, (err: any, res: A) => err ? reject(err) : resolve(res) ])
})
}
}
// eslint-disable-next-line max-len
export function promisify2<T, U, A> (func: (arg1: T, arg2: U, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U) => Promise<A> {
return function promisified (arg1: T, arg2: U): Promise<A> {
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
// eslint-disable-next-line no-useless-call
func.apply(null, [ arg1, arg2, (err: any, res: A) => err ? reject(err) : resolve(res) ])
})
}
}
// eslint-disable-next-line max-len
export function promisify3<T, U, V, A> (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise<A> {
return function promisified (arg1: T, arg2: U, arg3: V): Promise<A> {
return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
// eslint-disable-next-line no-useless-call
func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ])
})
}
}
+4
ファイルの表示
@@ -0,0 +1,4 @@
// high excluded
export function randomInt (low: number, high: number) {
return Math.floor(Math.random() * (high - low) + low)
}
+5
ファイルの表示
@@ -0,0 +1,5 @@
export const uuidRegex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
export function removeFragmentedMP4Ext (path: string) {
return path.replace(/-fragmented.mp4$/i, '')
}
+7
ファイルの表示
@@ -0,0 +1,7 @@
function wait (milliseconds: number) {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}
export {
wait
}
+167
ファイルの表示
@@ -0,0 +1,167 @@
import { Video, VideoPlaylist } from '@peertube/peertube-models'
import { secondsToTime } from './date.js'
function addQueryParams (url: string, params: { [ id: string ]: string }) {
const objUrl = new URL(url)
for (const key of Object.keys(params)) {
objUrl.searchParams.append(key, params[key])
}
return objUrl.toString()
}
function removeQueryParams (url: string) {
const objUrl = new URL(url)
objUrl.searchParams.forEach((_v, k) => objUrl.searchParams.delete(k))
return objUrl.toString()
}
function queryParamsToObject (entries: any) {
const result: { [ id: string ]: string | number | boolean } = {}
for (const [ key, value ] of entries) {
result[key] = value
}
return result
}
// ---------------------------------------------------------------------------
function buildPlaylistLink (playlist: Pick<VideoPlaylist, 'shortUUID'>, base?: string) {
return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist)
}
function buildPlaylistWatchPath (playlist: Pick<VideoPlaylist, 'shortUUID'>) {
return '/w/p/' + playlist.shortUUID
}
function buildVideoWatchPath (video: Pick<Video, 'shortUUID'>) {
return '/w/' + video.shortUUID
}
function buildVideoLink (video: Pick<Video, 'shortUUID'>, base?: string) {
return (base ?? window.location.origin) + buildVideoWatchPath(video)
}
function buildPlaylistEmbedPath (playlist: Pick<VideoPlaylist, 'uuid'>) {
return '/video-playlists/embed/' + playlist.uuid
}
function buildPlaylistEmbedLink (playlist: Pick<VideoPlaylist, 'uuid'>, base?: string) {
return (base ?? window.location.origin) + buildPlaylistEmbedPath(playlist)
}
function buildVideoEmbedPath (video: Pick<Video, 'uuid'>) {
return '/videos/embed/' + video.uuid
}
function buildVideoEmbedLink (video: Pick<Video, 'uuid'>, base?: string) {
return (base ?? window.location.origin) + buildVideoEmbedPath(video)
}
function decorateVideoLink (options: {
url: string
startTime?: number
stopTime?: number
subtitle?: string
loop?: boolean
autoplay?: boolean
muted?: boolean
// Embed options
title?: boolean
warningTitle?: boolean
controls?: boolean
controlBar?: boolean
peertubeLink?: boolean
p2p?: boolean
api?: boolean
}) {
const { url } = options
const params = new URLSearchParams()
if (options.startTime !== undefined && options.startTime !== null) {
const startTimeInt = Math.floor(options.startTime)
params.set('start', secondsToTime(startTimeInt))
}
if (options.stopTime) {
const stopTimeInt = Math.floor(options.stopTime)
params.set('stop', secondsToTime(stopTimeInt))
}
if (options.subtitle) params.set('subtitle', options.subtitle)
if (options.loop === true) params.set('loop', '1')
if (options.autoplay === true) params.set('autoplay', '1')
if (options.muted === true) params.set('muted', '1')
if (options.title === false) params.set('title', '0')
if (options.warningTitle === false) params.set('warningTitle', '0')
if (options.controls === false) params.set('controls', '0')
if (options.controlBar === false) params.set('controlBar', '0')
if (options.peertubeLink === false) params.set('peertubeLink', '0')
if (options.p2p !== undefined) params.set('p2p', options.p2p ? '1' : '0')
if (options.api !== undefined) params.set('api', options.api ? '1' : '0')
return buildUrl(url, params)
}
function decoratePlaylistLink (options: {
url: string
playlistPosition?: number
}) {
const { url } = options
const params = new URLSearchParams()
if (options.playlistPosition) params.set('playlistPosition', '' + options.playlistPosition)
return buildUrl(url, params)
}
// ---------------------------------------------------------------------------
export {
addQueryParams,
removeQueryParams,
queryParamsToObject,
buildPlaylistLink,
buildVideoLink,
buildVideoWatchPath,
buildPlaylistWatchPath,
buildPlaylistEmbedPath,
buildVideoEmbedPath,
buildPlaylistEmbedLink,
buildVideoEmbedLink,
decorateVideoLink,
decoratePlaylistLink
}
function buildUrl (url: string, params: URLSearchParams) {
let hasParams = false
params.forEach(() => { hasParams = true })
if (hasParams) return url + '?' + params.toString()
return url
}
+11
ファイルの表示
@@ -0,0 +1,11 @@
// Thanks https://gist.github.com/iwill/a83038623ba4fef6abb9efca87ae9ccb
function compareSemVer (a: string, b: string) {
if (a.startsWith(b + '-')) return -1
if (b.startsWith(a + '-')) return 1
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' })
}
export {
compareSemVer
}
+121
ファイルの表示
@@ -0,0 +1,121 @@
export const LOCALE_FILES = [ 'player', 'server' ]
export const I18N_LOCALES = {
// Always first to avoid issues when using express acceptLanguages function when no accept language header is set
'en-US': 'English',
// Keep it alphabetically sorted
'ar': 'العربية',
'ca-ES': 'Català',
'cs-CZ': 'Čeština',
'de-DE': 'Deutsch',
'el-GR': 'ελληνικά',
'eo': 'Esperanto',
'es-ES': 'Español',
'eu-ES': 'Euskara',
'fa-IR': 'فارسی',
'fi-FI': 'Suomi',
'fr-FR': 'Français',
'gd': 'Gàidhlig',
'gl-ES': 'Galego',
'hr': 'Hrvatski',
'hu-HU': 'Magyar',
'is': 'Íslenska',
'it-IT': 'Italiano',
'ja-JP': '日本語',
'kab': 'Taqbaylit',
'nb-NO': 'Norsk bokmål',
'nl-NL': 'Nederlands',
'nn': 'Norsk nynorsk',
'oc': 'Occitan',
'pl-PL': 'Polski',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
'ru-RU': 'Pусский',
'sq': 'Shqip',
'sv-SE': 'Svenska',
'th-TH': 'ไทย',
'tok': 'Toki Pona',
'tr-TR': 'Türkçe',
'uk-UA': 'украї́нська мо́ва',
'vi-VN': 'Tiếng Việt',
'zh-Hans-CN': '简体中文(中国)',
'zh-Hant-TW': '繁體中文(台灣)'
}
// Keep it alphabetically sorted
const I18N_LOCALE_ALIAS = {
'ar-001': 'ar',
'ca': 'ca-ES',
'cs': 'cs-CZ',
'de': 'de-DE',
'el': 'el-GR',
'en': 'en-US',
'es': 'es-ES',
'eu': 'eu-ES',
'fa': 'fa-IR',
'fi': 'fi-FI',
'fr': 'fr-FR',
'gl': 'gl-ES',
'hu': 'hu-HU',
'it': 'it-IT',
'ja': 'ja-JP',
'nb': 'nb-NO',
'nl': 'nl-NL',
'pl': 'pl-PL',
'pt': 'pt-BR',
'ru': 'ru-RU',
'sv': 'sv-SE',
'th': 'th-TH',
'tr': 'tr-TR',
'uk': 'uk-UA',
'vi': 'vi-VN',
'zh-CN': 'zh-Hans-CN',
'zh-Hans': 'zh-Hans-CN',
'zh-Hant': 'zh-Hant-TW',
'zh-TW': 'zh-Hant-TW',
'zh': 'zh-Hans-CN'
}
export const POSSIBLE_LOCALES = (Object.keys(I18N_LOCALES) as string[]).concat(Object.keys(I18N_LOCALE_ALIAS))
export function getDefaultLocale () {
return 'en-US'
}
export function isDefaultLocale (locale: string) {
return getCompleteLocale(locale) === getCompleteLocale(getDefaultLocale())
}
export function peertubeTranslate (str: string, translations?: { [ id: string ]: string }) {
if (!translations?.[str]) return str
return translations[str]
}
const possiblePaths = POSSIBLE_LOCALES.map(l => '/' + l)
export function is18nPath (path: string) {
return possiblePaths.includes(path)
}
export function is18nLocale (locale: string) {
return POSSIBLE_LOCALES.includes(locale)
}
export function getCompleteLocale (locale: string) {
if (!locale) return locale
const found = (I18N_LOCALE_ALIAS as any)[locale] as string
return found || locale
}
export function getShortLocale (locale: string) {
if (locale.includes('-') === false) return locale
return locale.split('-')[0]
}
export function buildFileLocale (locale: string) {
return getCompleteLocale(locale)
}
+1
ファイルの表示
@@ -0,0 +1 @@
export * from './i18n.js'
+8
ファイルの表示
@@ -0,0 +1,8 @@
export * from './abuse/index.js'
export * from './common/index.js'
export * from './i18n/index.js'
export * from './plugins/index.js'
export * from './renderer/index.js'
export * from './users/index.js'
export * from './videos/index.js'
export * from './string/index.js'
+60
ファイルの表示
@@ -0,0 +1,60 @@
import { HookType, HookType_Type, RegisteredExternalAuthConfig } from '@peertube/peertube-models'
import { isCatchable, isPromise } from '../common/promises.js'
function getHookType (hookName: string) {
if (hookName.startsWith('filter:')) return HookType.FILTER
if (hookName.startsWith('action:')) return HookType.ACTION
return HookType.STATIC
}
async function internalRunHook <T> (options: {
handler: Function
hookType: HookType_Type
result: T
params: any
onError: (err: Error) => void
}) {
const { handler, hookType, result, params, onError } = options
try {
if (hookType === HookType.FILTER) {
const p = handler(result, params)
const newResult = isPromise(p)
? await p
: p
return newResult
}
// Action/static hooks do not have result value
const p = handler(params)
if (hookType === HookType.STATIC) {
if (isPromise(p)) await p
return undefined
}
if (hookType === HookType.ACTION) {
if (isCatchable(p)) p.catch((err: any) => onError(err))
return undefined
}
} catch (err) {
onError(err)
}
return result
}
function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) {
return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
}
export {
getHookType,
internalRunHook,
getExternalAuthHref
}
+1
ファイルの表示
@@ -0,0 +1 @@
export * from './hooks.js'
+77
ファイルの表示
@@ -0,0 +1,77 @@
export function getDefaultSanitizeOptions () {
return {
allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
allowedSchemes: [ 'http', 'https' ],
allowedAttributes: {
'a': [ 'href', 'class', 'target', 'rel' ],
'*': [ 'data-*' ]
},
transformTags: {
a: (tagName: string, attribs: any) => {
let rel = 'noopener noreferrer'
if (attribs.rel === 'me') rel += ' me'
return {
tagName,
attribs: Object.assign(attribs, {
target: '_blank',
rel
})
}
}
}
}
}
export function getTextOnlySanitizeOptions () {
return {
allowedTags: [] as string[]
}
}
export function getCustomMarkupSanitizeOptions (additionalAllowedTags: string[] = []) {
const base = getDefaultSanitizeOptions()
return {
allowedTags: [
...base.allowedTags,
...additionalAllowedTags,
'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'img'
],
allowedSchemes: [
...base.allowedSchemes,
'mailto'
],
allowedAttributes: {
...base.allowedAttributes,
'img': [ 'src', 'alt' ],
'*': [ 'data-*', 'style' ]
}
}
}
// Thanks: https://stackoverflow.com/a/12034334
export function escapeHTML (stringParam: string) {
if (!stringParam) return ''
const entityMap: { [id: string ]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
}
return String(stringParam).replace(/[&<>"'`=/]/g, s => entityMap[s])
}
export function escapeAttribute (value: string) {
if (!value) return ''
return String(value).replace(/"/g, '&quot;')
}
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './markdown.js'
export * from './html.js'
+24
ファイルの表示
@@ -0,0 +1,24 @@
export const TEXT_RULES = [
'linkify',
'autolink',
'emphasis',
'link',
'newline',
'entity',
'list'
]
export const TEXT_WITH_HTML_RULES = TEXT_RULES.concat([
'html_inline',
'html_block'
])
export const ENHANCED_RULES = TEXT_RULES.concat([ 'image' ])
export const ENHANCED_WITH_HTML_RULES = TEXT_WITH_HTML_RULES.concat([ 'image' ])
export const COMPLETE_RULES = ENHANCED_WITH_HTML_RULES.concat([
'block',
'inline',
'heading',
'paragraph'
])
+35
ファイルの表示
@@ -0,0 +1,35 @@
import { timeToInt, timecodeRegexString } from '../common/date.js'
const timecodeRegex = new RegExp(`^(${timecodeRegexString})\\s`)
export function parseChapters (text: string, maxTitleLength: number) {
if (!text) return []
const lines = text.split(/\r?\n|\r|\n/g)
let foundChapters = false
const chapters: { timecode: number, title: string }[] = []
for (const line of lines) {
const matched = line.match(timecodeRegex)
if (!matched) {
// Stop chapters parsing
if (foundChapters) break
continue
}
foundChapters = true
const timecodeText = matched[1]
const timecode = timeToInt(timecodeText)
const title = line.replace(matched[0], '')
chapters.push({ timecode, title: title.slice(0, maxTitleLength) })
}
// Only consider chapters if there are more than one
if (chapters.length > 1) return chapters
return []
}
+1
ファイルの表示
@@ -0,0 +1 @@
export * from './chapters.js'
+1
ファイルの表示
@@ -0,0 +1 @@
export * from './user-role.js'
+39
ファイルの表示
@@ -0,0 +1,39 @@
import { UserRight, UserRightType, UserRole, UserRoleType } from '@peertube/peertube-models'
export const USER_ROLE_LABELS: { [ id in UserRoleType ]: string } = {
[UserRole.USER]: 'User',
[UserRole.MODERATOR]: 'Moderator',
[UserRole.ADMINISTRATOR]: 'Administrator'
}
const userRoleRights: { [ id in UserRoleType ]: UserRightType[] } = {
[UserRole.ADMINISTRATOR]: [
UserRight.ALL
],
[UserRole.MODERATOR]: [
UserRight.MANAGE_VIDEO_BLACKLIST,
UserRight.MANAGE_ABUSES,
UserRight.MANAGE_ANY_VIDEO_CHANNEL,
UserRight.REMOVE_ANY_VIDEO,
UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
UserRight.MANAGE_ANY_VIDEO_COMMENT,
UserRight.UPDATE_ANY_VIDEO,
UserRight.SEE_ALL_VIDEOS,
UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
UserRight.MANAGE_SERVERS_BLOCKLIST,
UserRight.MANAGE_USERS,
UserRight.SEE_ALL_COMMENTS,
UserRight.MANAGE_REGISTRATIONS,
UserRight.MANAGE_INSTANCE_WATCHED_WORDS,
UserRight.MANAGE_INSTANCE_AUTO_TAGS
],
[UserRole.USER]: []
}
export function hasUserRight (userRole: UserRoleType, userRight: UserRightType) {
const userRights = userRoleRights[userRole]
return userRights.includes(UserRight.ALL) || userRights.includes(userRight)
}
+118
ファイルの表示
@@ -0,0 +1,118 @@
import { VideoResolution, VideoResolutionType } from '@peertube/peertube-models'
type BitPerPixel = { [ id in VideoResolutionType ]: number }
// https://bitmovin.com/video-bitrate-streaming-hls-dash/
const minLimitBitPerPixel: BitPerPixel = {
[VideoResolution.H_NOVIDEO]: 0,
[VideoResolution.H_144P]: 0.02,
[VideoResolution.H_240P]: 0.02,
[VideoResolution.H_360P]: 0.02,
[VideoResolution.H_480P]: 0.02,
[VideoResolution.H_720P]: 0.02,
[VideoResolution.H_1080P]: 0.02,
[VideoResolution.H_1440P]: 0.02,
[VideoResolution.H_4K]: 0.02
}
const averageBitPerPixel: BitPerPixel = {
[VideoResolution.H_NOVIDEO]: 0,
[VideoResolution.H_144P]: 0.19,
[VideoResolution.H_240P]: 0.17,
[VideoResolution.H_360P]: 0.15,
[VideoResolution.H_480P]: 0.12,
[VideoResolution.H_720P]: 0.11,
[VideoResolution.H_1080P]: 0.10,
[VideoResolution.H_1440P]: 0.09,
[VideoResolution.H_4K]: 0.08
}
const maxBitPerPixel: BitPerPixel = {
[VideoResolution.H_NOVIDEO]: 0,
[VideoResolution.H_144P]: 0.32,
[VideoResolution.H_240P]: 0.29,
[VideoResolution.H_360P]: 0.26,
[VideoResolution.H_480P]: 0.22,
[VideoResolution.H_720P]: 0.19,
[VideoResolution.H_1080P]: 0.17,
[VideoResolution.H_1440P]: 0.16,
[VideoResolution.H_4K]: 0.14
}
function getAverageTheoreticalBitrate (options: {
resolution: number
ratio: number
fps: number
}) {
const targetBitrate = calculateBitrate({ ...options, bitPerPixel: averageBitPerPixel })
if (!targetBitrate) return 192 * 1000
return targetBitrate
}
function getMaxTheoreticalBitrate (options: {
resolution: number
ratio: number
fps: number
}) {
const targetBitrate = calculateBitrate({ ...options, bitPerPixel: maxBitPerPixel })
if (!targetBitrate) return 256 * 1000
return targetBitrate
}
function getMinTheoreticalBitrate (options: {
resolution: number
ratio: number
fps: number
}) {
const minLimitBitrate = calculateBitrate({ ...options, bitPerPixel: minLimitBitPerPixel })
if (!minLimitBitrate) return 10 * 1000
return minLimitBitrate
}
// ---------------------------------------------------------------------------
export {
getAverageTheoreticalBitrate,
getMaxTheoreticalBitrate,
getMinTheoreticalBitrate
}
// ---------------------------------------------------------------------------
function calculateBitrate (options: {
bitPerPixel: BitPerPixel
resolution: number
ratio: number
fps: number
}) {
const { bitPerPixel, resolution, ratio, fps } = options
const resolutionsOrder = [
VideoResolution.H_4K,
VideoResolution.H_1440P,
VideoResolution.H_1080P,
VideoResolution.H_720P,
VideoResolution.H_480P,
VideoResolution.H_360P,
VideoResolution.H_240P,
VideoResolution.H_144P,
VideoResolution.H_NOVIDEO
]
const size1 = resolution
const size2 = ratio < 1 && ratio > 0
? resolution / ratio // Portrait mode
: resolution * ratio
for (const toTestResolution of resolutionsOrder) {
if (toTestResolution <= resolution) {
return Math.floor(size1 * size2 * fps * bitPerPixel[toTestResolution])
}
}
throw new Error('Unknown resolution ' + resolution)
}
+25
ファイルの表示
@@ -0,0 +1,25 @@
import { VideoDetails, VideoPrivacy, VideoStreamingPlaylistType } from '@peertube/peertube-models'
export function getAllPrivacies () {
return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ]
}
export function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) {
const files = video.files
const hls = getHLS(video)
if (hls) return files.concat(hls.files)
return files
}
export function getHLS (video: Partial<Pick<VideoDetails, 'streamingPlaylists'>>) {
return video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
}
export function buildAspectRatio (options: { width: number, height: number }) {
const { width, height } = options
if (!width || !height) return null
return Math.round((width / height) * 10000) / 10000 // 4 decimals precision
}
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './bitrate.js'
export * from './common.js'
+11
ファイルの表示
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"references": [
{ "path": "../models" }
]
}
+19
ファイルの表示
@@ -0,0 +1,19 @@
{
"name": "@peertube/peertube-ffmpeg",
"private": true,
"version": "0.0.0",
"main": "dist/index.js",
"files": [ "dist" ],
"exports": {
"types": "./dist/index.d.ts",
"peertube:tsx": "./src/index.ts",
"default": "./dist/index.js"
},
"type": "module",
"devDependencies": {},
"scripts": {
"build": "tsc",
"watch": "tsc -w"
},
"dependencies": {}
}
+257
ファイルの表示
@@ -0,0 +1,257 @@
import { pick, promisify0 } from '@peertube/peertube-core-utils'
import {
AvailableEncoders,
EncoderOptionsBuilder,
EncoderOptionsBuilderParams,
EncoderProfile,
SimpleLogger
} from '@peertube/peertube-models'
import { MutexInterface } from 'async-mutex'
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
export interface FFmpegCommandWrapperOptions {
availableEncoders?: AvailableEncoders
profile?: string
niceness: number
tmpDirectory: string
threads: number
logger: SimpleLogger
lTags?: { tags: string[] }
updateJobProgress?: (progress?: number) => void
onEnd?: () => void
onError?: (err: Error) => void
}
export class FFmpegCommandWrapper {
private static supportedEncoders: Map<string, boolean>
private readonly availableEncoders: AvailableEncoders
private readonly profile: string
private readonly niceness: number
private readonly tmpDirectory: string
private readonly threads: number
private readonly logger: SimpleLogger
private readonly lTags: { tags: string[] }
private readonly updateJobProgress: (progress?: number) => void
private readonly onEnd?: () => void
private readonly onError?: (err: Error) => void
private command: FfmpegCommand
constructor (options: FFmpegCommandWrapperOptions) {
this.availableEncoders = options.availableEncoders
this.profile = options.profile
this.niceness = options.niceness
this.tmpDirectory = options.tmpDirectory
this.threads = options.threads
this.logger = options.logger
this.lTags = options.lTags || { tags: [] }
this.updateJobProgress = options.updateJobProgress
this.onEnd = options.onEnd
this.onError = options.onError
}
getAvailableEncoders () {
return this.availableEncoders
}
getProfile () {
return this.profile
}
getCommand () {
return this.command
}
// ---------------------------------------------------------------------------
debugLog (msg: string, meta: any = {}) {
this.logger.debug(msg, { ...meta, ...this.lTags })
}
// ---------------------------------------------------------------------------
resetCommand () {
this.command = undefined
}
buildCommand (input: string, inputFileMutexReleaser?: MutexInterface.Releaser) {
if (this.command) throw new Error('Command is already built')
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
this.command = ffmpeg(input, {
niceness: this.niceness,
cwd: this.tmpDirectory
})
if (this.threads > 0) {
// If we don't set any threads ffmpeg will chose automatically
this.command.outputOption('-threads ' + this.threads)
}
if (inputFileMutexReleaser) {
this.command.on('start', () => {
setTimeout(() => inputFileMutexReleaser(), 1000)
})
}
return this.command
}
async runCommand (options: {
silent?: boolean // false by default
} = {}) {
const { silent = false } = options
return new Promise<void>((res, rej) => {
let shellCommand: string
this.command.on('start', cmdline => { shellCommand = cmdline })
this.command.on('error', (err, stdout, stderr) => {
if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
if (this.onError) this.onError(err)
rej(err)
})
this.command.on('end', (stdout, stderr) => {
this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags })
if (this.onEnd) this.onEnd()
res()
})
if (this.updateJobProgress) {
this.command.on('progress', progress => {
if (!progress.percent) return
// Sometimes ffmpeg returns an invalid progress
let percent = Math.round(progress.percent)
if (percent < 0) percent = 0
if (percent > 100) percent = 100
this.updateJobProgress(percent)
})
}
this.command.run()
})
}
// ---------------------------------------------------------------------------
static resetSupportedEncoders () {
FFmpegCommandWrapper.supportedEncoders = undefined
}
// Run encoder builder depending on available encoders
// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
// If the default one does not exist, check the next encoder
async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
streamType: 'video' | 'audio'
input: string
videoType: 'vod' | 'live'
}) {
if (!this.availableEncoders) {
throw new Error('There is no available encoders')
}
const { streamType, videoType } = options
const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType]
const encoders = this.availableEncoders.available[videoType]
for (const encoder of encodersToTry) {
if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) {
this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags)
continue
}
if (!encoders[encoder]) {
this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags)
continue
}
// An object containing available profiles for this encoder
const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
let builder = builderProfiles[this.profile]
if (!builder) {
this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags)
builder = builderProfiles.default
if (!builder) {
this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags)
continue
}
}
const result = await builder(
pick(options, [
'input',
'canCopyAudio',
'canCopyVideo',
'resolution',
'inputBitrate',
'inputProbe',
'fps',
'inputRatio',
'streamNum'
])
)
return {
result,
// If we don't have output options, then copy the input stream
encoder: result.copy === true
? 'copy'
: encoder
}
}
return null
}
// Detect supported encoders by ffmpeg
private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
if (FFmpegCommandWrapper.supportedEncoders !== undefined) {
return FFmpegCommandWrapper.supportedEncoders
}
const getAvailableEncodersPromise = promisify0(ffmpeg.getAvailableEncoders)
const availableFFmpegEncoders = await getAvailableEncodersPromise()
const searchEncoders = new Set<string>()
for (const type of [ 'live', 'vod' ]) {
for (const streamType of [ 'audio', 'video' ]) {
for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
searchEncoders.add(encoder)
}
}
}
const supportedEncoders = new Map<string, boolean>()
for (const searchEncoder of searchEncoders) {
supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
}
this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags })
FFmpegCommandWrapper.supportedEncoders = supportedEncoders
return supportedEncoders
}
}
+184
ファイルの表示
@@ -0,0 +1,184 @@
import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, getMinTheoreticalBitrate } from '@peertube/peertube-core-utils'
import {
buildStreamSuffix,
getAudioStream,
getMaxAudioBitrate,
getVideoStream,
getVideoStreamBitrate,
getVideoStreamDimensionsInfo,
getVideoStreamFPS
} from '@peertube/peertube-ffmpeg'
import { EncoderOptionsBuilder, EncoderOptionsBuilderParams } from '@peertube/peertube-models'
import { FfprobeData } from 'fluent-ffmpeg'
const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
const { fps, inputRatio, inputBitrate, resolution } = options
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
return {
outputOptions: [
...getCommonOutputOptions(targetBitrate),
`-r ${fps}`
]
}
}
const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
const { streamNum, fps, inputBitrate, inputRatio, resolution } = options
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
return {
outputOptions: [
...getCommonOutputOptions(targetBitrate, streamNum),
`${buildStreamSuffix('-r:v', streamNum)} ${fps}`,
`${buildStreamSuffix('-b:v', streamNum)} ${targetBitrate}`
]
}
}
const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio, inputProbe }) => {
if (canCopyAudio && await canDoQuickAudioTranscode(input, inputProbe)) {
return { copy: true, outputOptions: [ ] }
}
const parsedAudio = await getAudioStream(input, inputProbe)
// We try to reduce the ceiling bitrate by making rough matches of bitrates
// Of course this is far from perfect, but it might save some space in the end
const audioCodecName = parsedAudio.audioStream['codec_name']
const bitrate = getMaxAudioBitrate(audioCodecName, parsedAudio.bitrate)
// Force stereo as it causes some issues with HLS playback in Chrome
const base = [ '-channel_layout', 'stereo' ]
if (bitrate !== -1) {
return { outputOptions: base.concat([ buildStreamSuffix('-b:a', streamNum), bitrate + 'k' ]) }
}
return { outputOptions: base }
}
const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum }) => {
return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
}
export function getDefaultAvailableEncoders () {
return {
vod: {
libx264: {
default: defaultX264VODOptionsBuilder
},
aac: {
default: defaultAACOptionsBuilder
},
libfdk_aac: {
default: defaultLibFDKAACVODOptionsBuilder
}
},
live: {
libx264: {
default: defaultX264LiveOptionsBuilder
},
aac: {
default: defaultAACOptionsBuilder
}
}
}
}
export function getDefaultEncodersToTry () {
return {
vod: {
video: [ 'libx264' ],
audio: [ 'libfdk_aac', 'aac' ]
},
live: {
video: [ 'libx264' ],
audio: [ 'libfdk_aac', 'aac' ]
}
}
}
export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
const parsedAudio = await getAudioStream(path, probe)
if (!parsedAudio.audioStream) return true
if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
const audioBitrate = parsedAudio.bitrate
if (!audioBitrate) return false
const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
const channelLayout = parsedAudio.audioStream['channel_layout']
// Causes playback issues with Chrome
if (!channelLayout || channelLayout === 'unknown' || channelLayout === 'quad') return false
return true
}
export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
const videoStream = await getVideoStream(path, probe)
const fps = await getVideoStreamFPS(path, probe)
const bitRate = await getVideoStreamBitrate(path, probe)
const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
// If ffprobe did not manage to guess the bitrate
if (!bitRate) return false
// check video params
if (!videoStream) return false
if (videoStream['codec_name'] !== 'h264') return false
if (videoStream['pix_fmt'] !== 'yuv420p') return false
if (fps < 2 || fps > 65) return false
if (bitRate > getMaxTheoreticalBitrate({ ...resolutionData, fps })) return false
return true
}
// ---------------------------------------------------------------------------
function getTargetBitrate (options: {
inputBitrate: number
resolution: number
ratio: number
fps: number
}) {
const { inputBitrate, resolution, ratio, fps } = options
const capped = capBitrate(inputBitrate, getAverageTheoreticalBitrate({ resolution, fps, ratio }))
const limit = getMinTheoreticalBitrate({ resolution, fps, ratio })
return Math.max(limit, capped)
}
function capBitrate (inputBitrate: number, targetBitrate: number) {
if (!inputBitrate) return targetBitrate
// Add 30% margin to input bitrate
const inputBitrateWithMargin = inputBitrate + (inputBitrate * 0.3)
return Math.min(targetBitrate, inputBitrateWithMargin)
}
function getCommonOutputOptions (targetBitrate: number, streamNum?: number) {
return [
`-preset veryfast`,
`${buildStreamSuffix('-maxrate:v', streamNum)} ${targetBitrate}`,
`${buildStreamSuffix('-bufsize:v', streamNum)} ${targetBitrate * 2}`,
// NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
`-b_strategy 1`,
// NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
`-bf 16`
]
}
+239
ファイルの表示
@@ -0,0 +1,239 @@
import { FilterSpecification } from 'fluent-ffmpeg'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { presetVOD } from './shared/presets.js'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js'
export class FFmpegEdition {
private readonly commandWrapper: FFmpegCommandWrapper
constructor (options: FFmpegCommandWrapperOptions) {
this.commandWrapper = new FFmpegCommandWrapper(options)
}
async cutVideo (options: {
inputPath: string
outputPath: string
start?: number
end?: number
}) {
const { inputPath, outputPath } = options
const mainProbe = await ffprobePromise(inputPath)
const fps = await getVideoStreamFPS(inputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
const command = this.commandWrapper.buildCommand(inputPath)
.output(outputPath)
await presetVOD({
commandWrapper: this.commandWrapper,
input: inputPath,
resolution,
fps,
canCopyAudio: false,
canCopyVideo: false
})
if (options.start) {
command.outputOption('-ss ' + options.start)
}
if (options.end) {
command.outputOption('-to ' + options.end)
}
await this.commandWrapper.runCommand()
}
async addWatermark (options: {
inputPath: string
watermarkPath: string
outputPath: string
videoFilters: {
watermarkSizeRatio: number
horitonzalMarginRatio: number
verticalMarginRatio: number
}
}) {
const { watermarkPath, inputPath, outputPath, videoFilters } = options
const videoProbe = await ffprobePromise(inputPath)
const fps = await getVideoStreamFPS(inputPath, videoProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
const command = this.commandWrapper.buildCommand(inputPath)
.output(outputPath)
command.input(watermarkPath)
await presetVOD({
commandWrapper: this.commandWrapper,
input: inputPath,
resolution,
fps,
canCopyAudio: true,
canCopyVideo: false
})
const complexFilter: FilterSpecification[] = [
// Scale watermark
{
inputs: [ '[1]', '[0]' ],
filter: 'scale2ref',
options: {
w: 'oh*mdar',
h: `ih*${videoFilters.watermarkSizeRatio}`
},
outputs: [ '[watermark]', '[video]' ]
},
{
inputs: [ '[video]', '[watermark]' ],
filter: 'overlay',
options: {
x: `main_w - overlay_w - (main_h * ${videoFilters.horitonzalMarginRatio})`,
y: `main_h * ${videoFilters.verticalMarginRatio}`
}
}
]
command.complexFilter(complexFilter)
await this.commandWrapper.runCommand()
}
async addIntroOutro (options: {
inputPath: string
introOutroPath: string
outputPath: string
type: 'intro' | 'outro'
}) {
const { introOutroPath, inputPath, outputPath, type } = options
const mainProbe = await ffprobePromise(inputPath)
const fps = await getVideoStreamFPS(inputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
const introOutroProbe = await ffprobePromise(introOutroPath)
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
const command = this.commandWrapper.buildCommand(inputPath)
.output(outputPath)
command.input(introOutroPath)
if (!introOutroHasAudio && mainHasAudio) {
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
command.input('anullsrc')
command.withInputFormat('lavfi')
command.withInputOption('-t ' + duration)
}
await presetVOD({
commandWrapper: this.commandWrapper,
input: inputPath,
resolution,
fps,
canCopyAudio: false,
canCopyVideo: false
})
// Add black background to correctly scale intro/outro with padding
const complexFilter: FilterSpecification[] = [
{
inputs: [ '1', '0' ],
filter: 'scale2ref',
options: {
w: 'iw',
h: `ih`
},
outputs: [ 'intro-outro', 'main' ]
},
{
inputs: [ 'intro-outro', 'main' ],
filter: 'scale2ref',
options: {
w: 'iw',
h: `ih`
},
outputs: [ 'to-scale', 'main' ]
},
{
inputs: 'to-scale',
filter: 'drawbox',
options: {
t: 'fill'
},
outputs: [ 'to-scale-bg' ]
},
{
inputs: [ '1', 'to-scale-bg' ],
filter: 'scale2ref',
options: {
w: 'iw',
h: 'ih',
force_original_aspect_ratio: 'decrease',
flags: 'spline'
},
outputs: [ 'to-scale', 'to-scale-bg' ]
},
{
inputs: [ 'to-scale-bg', 'to-scale' ],
filter: 'overlay',
options: {
x: '(main_w - overlay_w)/2',
y: '(main_h - overlay_h)/2'
},
outputs: 'intro-outro-resized'
}
]
const concatFilter = {
inputs: [],
filter: 'concat',
options: {
n: 2,
v: 1,
unsafe: 1
},
outputs: [ 'v' ]
}
const introOutroFilterInputs = [ 'intro-outro-resized' ]
const mainFilterInputs = [ 'main' ]
if (mainHasAudio) {
mainFilterInputs.push('0:a')
if (introOutroHasAudio) {
introOutroFilterInputs.push('1:a')
} else {
// Silent input
introOutroFilterInputs.push('2:a')
}
}
if (type === 'intro') {
concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
} else {
concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
}
if (mainHasAudio) {
concatFilter.options['a'] = 1
concatFilter.outputs.push('a')
command.outputOption('-map [a]')
}
command.outputOption('-map [v]')
complexFilter.push(concatFilter)
command.complexFilter(complexFilter)
await this.commandWrapper.runCommand()
}
}
+141
ファイルの表示
@@ -0,0 +1,141 @@
import { MutexInterface } from 'async-mutex'
import { FfprobeData } from 'fluent-ffmpeg'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { getVideoStreamDuration } from './ffprobe.js'
export class FFmpegImage {
private readonly commandWrapper: FFmpegCommandWrapper
constructor (options: FFmpegCommandWrapperOptions) {
this.commandWrapper = new FFmpegCommandWrapper(options)
}
convertWebPToJPG (options: {
path: string
destination: string
}): Promise<void> {
const { path, destination } = options
this.commandWrapper.buildCommand(path)
.output(destination)
return this.commandWrapper.runCommand({ silent: true })
}
processGIF (options: {
path: string
destination: string
newSize: { width: number, height: number }
}): Promise<void> {
const { path, destination, newSize } = options
this.commandWrapper.buildCommand(path)
.fps(20)
.size(`${newSize.width}x${newSize.height}`)
.output(destination)
return this.commandWrapper.runCommand()
}
// ---------------------------------------------------------------------------
async generateThumbnailFromVideo (options: {
fromPath: string
output: string
framesToAnalyze: number
scale?: {
width: number
height: number
}
ffprobe?: FfprobeData
}) {
const { fromPath, ffprobe } = options
let duration = await getVideoStreamDuration(fromPath, ffprobe)
if (isNaN(duration)) duration = 0
this.buildGenerateThumbnailFromVideo(options)
.seekInput(duration / 2)
try {
return await this.commandWrapper.runCommand()
} catch (err) {
this.commandWrapper.debugLog('Cannot generate thumbnail from video using seek input, fallback to no seek', { err })
this.commandWrapper.resetCommand()
this.buildGenerateThumbnailFromVideo(options)
return this.commandWrapper.runCommand()
}
}
private buildGenerateThumbnailFromVideo (options: {
fromPath: string
output: string
framesToAnalyze: number
scale?: {
width: number
height: number
}
}) {
const { fromPath, output, framesToAnalyze, scale } = options
const command = this.commandWrapper.buildCommand(fromPath)
.videoFilter('thumbnail=' + framesToAnalyze)
.outputOption('-frames:v 1')
.outputOption('-q:v 5')
.outputOption('-abort_on empty_output')
.output(output)
if (scale) {
command.videoFilter(`scale=${scale.width}x${scale.height}:force_original_aspect_ratio=decrease`)
}
return command
}
// ---------------------------------------------------------------------------
async generateStoryboardFromVideo (options: {
path: string
destination: string
// Will be released after the ffmpeg started
inputFileMutexReleaser: MutexInterface.Releaser
sprites: {
size: {
width: number
height: number
}
count: {
width: number
height: number
}
duration: number
}
}) {
const { path, destination, sprites } = options
const command = this.commandWrapper.buildCommand(path)
const filter = [
// Fix "t" variable with some videos
`setpts='N/FRAME_RATE/TB'`,
// First frame or the time difference between the last and the current frame is enough for our sprite interval
`select='isnan(prev_selected_t)+gte(t-prev_selected_t,${options.sprites.duration})'`,
`scale=${sprites.size.width}:${sprites.size.height}`,
`tile=layout=${sprites.count.width}x${sprites.count.height}`
].join(',')
command.outputOption('-filter_complex', filter)
command.outputOption('-frames:v', '1')
command.outputOption('-q:v', '2')
command.output(destination)
return this.commandWrapper.runCommand()
}
}
+189
ファイルの表示
@@ -0,0 +1,189 @@
import { pick } from '@peertube/peertube-core-utils'
import { FfprobeData, FilterSpecification } from 'fluent-ffmpeg'
import { join } from 'path'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { StreamType, buildStreamSuffix, getScaleFilter } from './ffmpeg-utils.js'
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js'
export class FFmpegLive {
private readonly commandWrapper: FFmpegCommandWrapper
constructor (options: FFmpegCommandWrapperOptions) {
this.commandWrapper = new FFmpegCommandWrapper(options)
}
async getLiveTranscodingCommand (options: {
inputUrl: string
outPath: string
masterPlaylistName: string
toTranscode: {
resolution: number
fps: number
}[]
// Input information
bitrate: number
ratio: number
hasAudio: boolean
probe: FfprobeData
segmentListSize: number
segmentDuration: number
}) {
const {
inputUrl,
outPath,
toTranscode,
bitrate,
masterPlaylistName,
ratio,
hasAudio,
probe
} = options
const command = this.commandWrapper.buildCommand(inputUrl)
const varStreamMap: string[] = []
const complexFilter: FilterSpecification[] = [
{
inputs: '[v:0]',
filter: 'split',
options: toTranscode.length,
outputs: toTranscode.map(t => `vtemp${t.resolution}`)
}
]
command.outputOption('-sc_threshold 0')
addDefaultEncoderGlobalParams(command)
for (let i = 0; i < toTranscode.length; i++) {
const streamMap: string[] = []
const { resolution, fps } = toTranscode[i]
const baseEncoderBuilderParams = {
input: inputUrl,
canCopyAudio: true,
canCopyVideo: true,
inputBitrate: bitrate,
inputRatio: ratio,
inputProbe: probe,
resolution,
fps,
streamNum: i,
videoType: 'live' as 'live'
}
{
const streamType: StreamType = 'video'
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
if (!builderResult) {
throw new Error('No available live video encoder found')
}
command.outputOption(`-map [vout${resolution}]`)
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
this.commandWrapper.debugLog(
`Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
{ builderResult, fps, toTranscode }
)
command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
applyEncoderOptions(command, builderResult.result)
complexFilter.push({
inputs: `vtemp${resolution}`,
filter: getScaleFilter(builderResult.result),
options: `w=-2:h=${resolution}`,
outputs: `vout${resolution}`
})
streamMap.push(`v:${i}`)
}
if (hasAudio) {
const streamType: StreamType = 'audio'
const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
if (!builderResult) {
throw new Error('No available live audio encoder found')
}
command.outputOption('-map a:0')
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
this.commandWrapper.debugLog(
`Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
{ builderResult, fps, resolution }
)
command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
applyEncoderOptions(command, builderResult.result)
streamMap.push(`a:${i}`)
}
varStreamMap.push(streamMap.join(','))
}
command.complexFilter(complexFilter)
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
command.outputOption('-var_stream_map', varStreamMap.join(' '))
return command
}
getLiveMuxingCommand (options: {
inputUrl: string
outPath: string
masterPlaylistName: string
segmentListSize: number
segmentDuration: number
}) {
const { inputUrl, outPath, masterPlaylistName } = options
const command = this.commandWrapper.buildCommand(inputUrl)
command.outputOption('-c:v copy')
command.outputOption('-c:a copy')
command.outputOption('-map 0:a?')
command.outputOption('-map 0:v?')
this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
return command
}
private addDefaultLiveHLSParams (options: {
outPath: string
masterPlaylistName: string
segmentListSize: number
segmentDuration: number
}) {
const { outPath, masterPlaylistName, segmentListSize, segmentDuration } = options
const command = this.commandWrapper.getCommand()
command.outputOption('-hls_time ' + segmentDuration)
command.outputOption('-hls_list_size ' + segmentListSize)
command.outputOption('-hls_flags delete_segments+independent_segments+program_date_time')
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
command.outputOption('-master_pl_name ' + masterPlaylistName)
command.outputOption(`-f hls`)
command.output(join(outPath, '%v.m3u8'))
}
}
+17
ファイルの表示
@@ -0,0 +1,17 @@
import { EncoderOptions } from '@peertube/peertube-models'
export type StreamType = 'audio' | 'video'
export function buildStreamSuffix (base: string, streamNum?: number) {
if (streamNum !== undefined) {
return `${base}:${streamNum}`
}
return base
}
export function getScaleFilter (options: EncoderOptions): string {
if (options.scaleFilter) return options.scaleFilter.name
return 'scale'
}
+23
ファイルの表示
@@ -0,0 +1,23 @@
import { exec } from 'child_process'
import ffmpeg from 'fluent-ffmpeg'
/**
* @returns FFmpeg version string. Usually a semver string, but may vary when depending on installation method.
*/
export function getFFmpegVersion () {
return new Promise<string>((res, rej) => {
(ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
if (err) return rej(err)
if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
return exec(`${ffmpegPath} -version`, (err, stdout) => {
if (err) return rej(err)
const parsed = stdout.match(/(?<=ffmpeg version )[a-zA-Z\d.-]+/)
if (!parsed) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
res(parsed[0])
})
})
})
}
+245
ファイルの表示
@@ -0,0 +1,245 @@
import { pick } from '@peertube/peertube-core-utils'
import { VideoResolution } from '@peertube/peertube-models'
import { MutexInterface } from 'async-mutex'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { readFile, writeFile } from 'fs/promises'
import { dirname } from 'path'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js'
import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js'
export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio'
export interface BaseTranscodeVODOptions {
type: TranscodeVODOptionsType
inputPath: string
outputPath: string
// Will be released after the ffmpeg started
// To prevent a bug where the input file does not exist anymore when running ffmpeg
inputFileMutexReleaser: MutexInterface.Releaser
resolution: number
fps: number
}
export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
type: 'hls'
copyCodecs: boolean
hlsPlaylist: {
videoFilename: string
}
}
export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
type: 'hls-from-ts'
isAAC: boolean
hlsPlaylist: {
videoFilename: string
}
}
export interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
type: 'quick-transcode'
}
export interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
type: 'video'
}
export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
type: 'merge-audio'
audioPath: string
}
export type TranscodeVODOptions =
HLSTranscodeOptions
| HLSFromTSTranscodeOptions
| VideoTranscodeOptions
| MergeAudioTranscodeOptions
| QuickTranscodeOptions
// ---------------------------------------------------------------------------
export class FFmpegVOD {
private readonly commandWrapper: FFmpegCommandWrapper
private ended = false
constructor (options: FFmpegCommandWrapperOptions) {
this.commandWrapper = new FFmpegCommandWrapper(options)
}
async transcode (options: TranscodeVODOptions) {
const builders: {
[ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise<void> | void
} = {
'quick-transcode': this.buildQuickTranscodeCommand.bind(this),
'hls': this.buildHLSVODCommand.bind(this),
'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
'merge-audio': this.buildAudioMergeCommand.bind(this),
'video': this.buildWebVideoCommand.bind(this)
}
this.commandWrapper.debugLog('Will run transcode.', { options })
this.commandWrapper.buildCommand(options.inputPath, options.inputFileMutexReleaser)
.output(options.outputPath)
await builders[options.type](options)
await this.commandWrapper.runCommand()
await this.fixHLSPlaylistIfNeeded(options)
this.ended = true
}
isEnded () {
return this.ended
}
private async buildWebVideoCommand (options: TranscodeVODOptions & { canCopyAudio?: boolean, canCopyVideo?: boolean }) {
const { resolution, fps, inputPath, canCopyAudio = true, canCopyVideo = true } = options
if (resolution === VideoResolution.H_NOVIDEO) {
presetOnlyAudio(this.commandWrapper)
return
}
let scaleFilterValue: string
if (resolution !== undefined) {
const probe = await ffprobePromise(inputPath)
const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)
scaleFilterValue = videoStreamInfo?.isPortraitMode === true
? `w=${resolution}:h=-2`
: `w=-2:h=${resolution}`
}
await presetVOD({
commandWrapper: this.commandWrapper,
resolution,
input: inputPath,
canCopyAudio,
canCopyVideo,
fps,
scaleFilterValue
})
}
private buildQuickTranscodeCommand (_options: TranscodeVODOptions) {
const command = this.commandWrapper.getCommand()
presetCopy(this.commandWrapper)
command.outputOption('-map_metadata -1') // strip all metadata
.outputOption('-movflags faststart')
}
// ---------------------------------------------------------------------------
// Audio transcoding
// ---------------------------------------------------------------------------
private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) {
const command = this.commandWrapper.getCommand()
command.loop(undefined)
await presetVOD({
...pick(options, [ 'resolution' ]),
commandWrapper: this.commandWrapper,
input: options.audioPath,
canCopyAudio: true,
canCopyVideo: true,
fps: options.fps,
scaleFilterValue: this.getMergeAudioScaleFilterValue()
})
command.outputOption('-preset:v veryfast')
command.input(options.audioPath)
.outputOption('-tune stillimage')
.outputOption('-shortest')
}
// Avoid "height not divisible by 2" error
private getMergeAudioScaleFilterValue () {
return 'trunc(iw/2)*2:trunc(ih/2)*2'
}
// ---------------------------------------------------------------------------
// HLS transcoding
// ---------------------------------------------------------------------------
private async buildHLSVODCommand (options: HLSTranscodeOptions) {
const command = this.commandWrapper.getCommand()
const videoPath = this.getHLSVideoPath(options)
if (options.copyCodecs) {
presetCopy(this.commandWrapper)
} else if (options.resolution === VideoResolution.H_NOVIDEO) {
presetOnlyAudio(this.commandWrapper)
} else {
// If we cannot copy codecs, we do not copy them at all to prevent issues like audio desync
// See for example https://github.com/Chocobozzz/PeerTube/issues/6438
await this.buildWebVideoCommand({ ...options, canCopyAudio: false, canCopyVideo: false })
}
this.addCommonHLSVODCommandOptions(command, videoPath)
}
private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
const command = this.commandWrapper.getCommand()
const videoPath = this.getHLSVideoPath(options)
command.outputOption('-c copy')
if (options.isAAC) {
// Required for example when copying an AAC stream from an MPEG-TS
// Since it's a bitstream filter, we don't need to reencode the audio
command.outputOption('-bsf:a aac_adtstoasc')
}
this.addCommonHLSVODCommandOptions(command, videoPath)
}
private addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
return command.outputOption('-hls_time 4')
.outputOption('-hls_list_size 0')
.outputOption('-hls_playlist_type vod')
.outputOption('-hls_segment_filename ' + outputPath)
.outputOption('-hls_segment_type fmp4')
.outputOption('-f hls')
.outputOption('-hls_flags single_file')
}
private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
const fileContent = await readFile(options.outputPath)
const videoFileName = options.hlsPlaylist.videoFilename
const videoFilePath = this.getHLSVideoPath(options)
// Fix wrong mapping with some ffmpeg versions
const newContent = fileContent.toString()
.replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
await writeFile(options.outputPath, newContent)
}
private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
}
}
+213
ファイルの表示
@@ -0,0 +1,213 @@
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'
import { buildAspectRatio, forceNumber } from '@peertube/peertube-core-utils'
import { VideoResolution } from '@peertube/peertube-models'
/**
*
* Helpers to run ffprobe and extract data from the JSON output
*
*/
function ffprobePromise (path: string) {
return new Promise<FfprobeData>((res, rej) => {
ffmpeg.ffprobe(path, [ '-show_chapters' ], (err, data) => {
if (err) return rej(err)
return res(data)
})
})
}
// ---------------------------------------------------------------------------
// Audio
// ---------------------------------------------------------------------------
const imageCodecs = new Set([
'ansi', 'apng', 'bintext', 'bmp', 'brender_pix', 'dpx', 'exr', 'fits', 'gem', 'gif', 'jpeg2000', 'jpgls', 'mjpeg', 'mjpegb', 'msp2',
'pam', 'pbm', 'pcx', 'pfm', 'pgm', 'pgmyuv', 'pgx', 'photocd', 'pictor', 'png', 'ppm', 'psd', 'sgi', 'sunrast', 'svg', 'targa', 'tiff',
'txd', 'webp', 'xbin', 'xbm', 'xface', 'xpm', 'xwd'
])
async function isAudioFile (path: string, existingProbe?: FfprobeData) {
const videoStream = await getVideoStream(path, existingProbe)
if (!videoStream) return true
if (imageCodecs.has(videoStream.codec_name)) return true
return false
}
async function hasAudioStream (path: string, existingProbe?: FfprobeData) {
const { audioStream } = await getAudioStream(path, existingProbe)
return !!audioStream
}
async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) {
// without position, ffprobe considers the last input only
// we make it consider the first input only
// if you pass a file path to pos, then ffprobe acts on that file directly
const data = existingProbe || await ffprobePromise(videoPath)
if (Array.isArray(data.streams)) {
const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio')
if (audioStream) {
return {
absolutePath: data.format.filename,
audioStream,
bitrate: forceNumber(audioStream['bit_rate'])
}
}
}
return { absolutePath: data.format.filename }
}
function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) {
const maxKBitrate = 384
const kToBits = (kbits: number) => kbits * 1000
// If we did not manage to get the bitrate, use an average value
if (!bitrate) return 256
if (type === 'aac') {
switch (true) {
case bitrate > kToBits(maxKBitrate):
return maxKBitrate
default:
return -1 // we interpret it as a signal to copy the audio stream as is
}
}
/*
a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
That's why, when using aac, we can go to lower kbit/sec. The equivalences
made here are not made to be accurate, especially with good mp3 encoders.
*/
switch (true) {
case bitrate <= kToBits(192):
return 128
case bitrate <= kToBits(384):
return 256
default:
return maxKBitrate
}
}
// ---------------------------------------------------------------------------
// Video
// ---------------------------------------------------------------------------
async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) {
const videoStream = await getVideoStream(path, existingProbe)
if (!videoStream) {
return {
width: 0,
height: 0,
ratio: 0,
resolution: VideoResolution.H_NOVIDEO,
isPortraitMode: false
}
}
if (videoStream.rotation === '90' || videoStream.rotation === '-90') {
const width = videoStream.width
videoStream.width = videoStream.height
videoStream.height = width
}
return {
width: videoStream.width,
height: videoStream.height,
ratio: buildAspectRatio({ width: videoStream.width, height: videoStream.height }),
resolution: Math.min(videoStream.height, videoStream.width),
isPortraitMode: videoStream.height > videoStream.width
}
}
async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) {
const videoStream = await getVideoStream(path, existingProbe)
if (!videoStream) return 0
for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
const valuesText: string = videoStream[key]
if (!valuesText) continue
const [ frames, seconds ] = valuesText.split('/')
if (!frames || !seconds) continue
const result = parseInt(frames, 10) / parseInt(seconds, 10)
if (result > 0) return Math.round(result)
}
return 0
}
async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
const metadata = existingProbe || await ffprobePromise(path)
let bitrate = metadata.format.bit_rate
if (bitrate && !isNaN(bitrate)) return bitrate
const videoStream = await getVideoStream(path, existingProbe)
if (!videoStream) return undefined
bitrate = forceNumber(videoStream?.bit_rate)
if (bitrate && !isNaN(bitrate)) return bitrate
return undefined
}
async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
const metadata = existingProbe || await ffprobePromise(path)
return Math.round(metadata.format.duration)
}
async function getVideoStream (path: string, existingProbe?: FfprobeData) {
const metadata = existingProbe || await ffprobePromise(path)
return metadata.streams.find(s => s.codec_type === 'video')
}
// ---------------------------------------------------------------------------
// Chapters
// ---------------------------------------------------------------------------
async function getChaptersFromContainer (options: {
path: string
maxTitleLength: number
ffprobe?: FfprobeData
}) {
const { path, maxTitleLength, ffprobe } = options
const metadata = ffprobe || await ffprobePromise(path)
if (!Array.isArray(metadata?.chapters)) return []
return metadata.chapters
.map(c => ({
timecode: Math.round(c.start_time),
title: (c['TAG:title'] || '').slice(0, maxTitleLength)
}))
}
// ---------------------------------------------------------------------------
export {
getVideoStreamDimensionsInfo,
getChaptersFromContainer,
getMaxAudioBitrate,
getVideoStream,
getVideoStreamDuration,
getAudioStream,
getVideoStreamFPS,
isAudioFile,
ffprobePromise,
getVideoStreamBitrate,
hasAudioStream
}
+9
ファイルの表示
@@ -0,0 +1,9 @@
export * from './ffmpeg-command-wrapper.js'
export * from './ffmpeg-default-transcoding-profile.js'
export * from './ffmpeg-edition.js'
export * from './ffmpeg-images.js'
export * from './ffmpeg-live.js'
export * from './ffmpeg-utils.js'
export * from './ffmpeg-version.js'
export * from './ffmpeg-vod.js'
export * from './ffprobe.js'
+36
ファイルの表示
@@ -0,0 +1,36 @@
import { FfmpegCommand } from 'fluent-ffmpeg'
import { EncoderOptions } from '@peertube/peertube-models'
import { buildStreamSuffix } from '../ffmpeg-utils.js'
export function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
// avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
command.outputOption('-max_muxing_queue_size 1024')
// strip all metadata
.outputOption('-map_metadata -1')
// allows import of source material with incompatible pixel formats (e.g. MJPEG video)
.outputOption('-pix_fmt yuv420p')
}
export function addDefaultEncoderParams (options: {
command: FfmpegCommand
encoder: 'libx264' | string
fps: number
streamNum?: number
}) {
const { command, encoder, fps, streamNum } = options
if (encoder === 'libx264') {
if (fps) {
// Keyframe interval of 2 seconds for faster seeking and resolution switching.
// https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
// https://superuser.com/a/908325
command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
}
}
}
export function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions) {
command.inputOptions(options.inputOptions ?? [])
.outputOptions(options.outputOptions ?? [])
}
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './encoder-options.js'
export * from './presets.js'
+94
ファイルの表示
@@ -0,0 +1,94 @@
import { pick } from '@peertube/peertube-core-utils'
import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper.js'
import { getScaleFilter, StreamType } from '../ffmpeg-utils.js'
import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe.js'
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options.js'
export async function presetVOD (options: {
commandWrapper: FFmpegCommandWrapper
input: string
canCopyAudio: boolean
canCopyVideo: boolean
resolution: number
fps: number
scaleFilterValue?: string
}) {
const { commandWrapper, input, resolution, fps, scaleFilterValue } = options
const command = commandWrapper.getCommand()
command.format('mp4')
.outputOption('-movflags faststart')
addDefaultEncoderGlobalParams(command)
const probe = await ffprobePromise(input)
// Audio encoder
const bitrate = await getVideoStreamBitrate(input, probe)
const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
if (!await hasAudioStream(input, probe)) {
command.noAudio()
streamsToProcess = [ 'video' ]
}
for (const streamType of streamsToProcess) {
const builderResult = await commandWrapper.getEncoderBuilderResult({
...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
input,
inputBitrate: bitrate,
inputRatio: videoStreamDimensions?.ratio || 0,
inputProbe: probe,
resolution,
fps,
streamType,
videoType: 'vod' as 'vod'
})
if (!builderResult) {
throw new Error('No available encoder found for stream ' + streamType)
}
commandWrapper.debugLog(
`Apply ffmpeg params from ${builderResult.encoder} for ${streamType} ` +
`stream of input ${input} using ${commandWrapper.getProfile()} profile.`,
{ builderResult, resolution, fps }
)
if (streamType === 'video') {
command.videoCodec(builderResult.encoder)
if (scaleFilterValue) {
command.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
}
} else if (streamType === 'audio') {
command.audioCodec(builderResult.encoder)
}
applyEncoderOptions(command, builderResult.result)
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps })
}
}
export function presetCopy (commandWrapper: FFmpegCommandWrapper) {
commandWrapper.getCommand()
.format('mp4')
.videoCodec('copy')
.audioCodec('copy')
}
export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) {
commandWrapper.getCommand()
.format('mp4')
.audioCodec('copy')
.noVideo()
}
+12
ファイルの表示
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"references": [
{ "path": "../models" },
{ "path": "../core-utils" }
]
}
+19
ファイルの表示
@@ -0,0 +1,19 @@
{
"name": "@peertube/peertube-models",
"private": true,
"version": "0.0.0",
"main": "dist/index.js",
"type": "module",
"files": [ "dist" ],
"exports": {
"types": "./dist/index.d.ts",
"peertube:tsx": "./src/index.ts",
"default": "./dist/index.js"
},
"devDependencies": {},
"scripts": {
"build": "tsc",
"watch": "tsc -w"
},
"dependencies": {}
}
+156
ファイルの表示
@@ -0,0 +1,156 @@
import { ActivityPubActor } from './activitypub-actor.js'
import { ActivityPubSignature } from './activitypub-signature.js'
import {
ActivityFlagReasonObject,
ActivityObject,
APObjectId,
CacheFileObject,
PlaylistObject,
VideoCommentObject,
VideoObject,
WatchActionObject
} from './objects/index.js'
export type ActivityUpdateObject =
Extract<ActivityObject, VideoObject | CacheFileObject | PlaylistObject | ActivityPubActor | string> | ActivityPubActor
// Cannot Extract from Activity because of circular reference
export type ActivityUndoObject =
ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate<CacheFileObject | string> | ActivityAnnounce
export type ActivityCreateObject =
Extract<ActivityObject, VideoObject | CacheFileObject | WatchActionObject | VideoCommentObject | PlaylistObject | string>
export type Activity =
ActivityCreate<ActivityCreateObject> |
ActivityUpdate<ActivityUpdateObject> |
ActivityDelete |
ActivityFollow |
ActivityAccept |
ActivityAnnounce |
ActivityUndo<ActivityUndoObject> |
ActivityLike |
ActivityReject |
ActivityView |
ActivityDislike |
ActivityFlag |
ActivityApproveReply |
ActivityRejectReply
export type ActivityType =
'Create' |
'Update' |
'Delete' |
'Follow' |
'Accept' |
'Announce' |
'Undo' |
'Like' |
'Reject' |
'View' |
'Dislike' |
'Flag' |
'ApproveReply' |
'RejectReply'
export interface ActivityAudience {
to: string[]
cc: string[]
}
export interface BaseActivity {
'@context'?: any[]
id: string
to?: string[]
cc?: string[]
actor: string | ActivityPubActor
type: ActivityType
signature?: ActivityPubSignature
}
export interface ActivityCreate <T extends ActivityCreateObject> extends BaseActivity {
type: 'Create'
object: T
}
export interface ActivityUpdate <T extends ActivityUpdateObject> extends BaseActivity {
type: 'Update'
object: T
}
export interface ActivityDelete extends BaseActivity {
type: 'Delete'
object: APObjectId
}
export interface ActivityFollow extends BaseActivity {
type: 'Follow'
object: string
}
export interface ActivityAccept extends BaseActivity {
type: 'Accept'
object: ActivityFollow
}
export interface ActivityApproveReply extends BaseActivity {
type: 'ApproveReply'
object: string
inReplyTo: string
}
export interface ActivityRejectReply extends BaseActivity {
type: 'RejectReply'
object: string
inReplyTo: string
}
export interface ActivityReject extends BaseActivity {
type: 'Reject'
object: ActivityFollow
}
export interface ActivityAnnounce extends BaseActivity {
type: 'Announce'
object: APObjectId
}
export interface ActivityUndo <T extends ActivityUndoObject> extends BaseActivity {
type: 'Undo'
object: T
}
export interface ActivityLike extends BaseActivity {
type: 'Like'
object: APObjectId
}
export interface ActivityView extends BaseActivity {
type: 'View'
actor: string
object: APObjectId
// If sending a "viewer" event
expires?: string
result?: {
type: 'InteractionCounter'
interactionType: 'WatchAction'
userInteractionCount: number
}
}
export interface ActivityDislike extends BaseActivity {
id: string
type: 'Dislike'
actor: string
object: APObjectId
}
export interface ActivityFlag extends BaseActivity {
type: 'Flag'
content: string
object: APObjectId | APObjectId[]
tag?: ActivityFlagReasonObject[]
startAt?: number
endAt?: number
}
+41
ファイルの表示
@@ -0,0 +1,41 @@
import { ActivityIconObject, ActivityPubAttributedTo } from './objects/common-objects.js'
export type ActivityPubActorType = 'Person' | 'Application' | 'Group' | 'Service' | 'Organization'
export interface ActivityPubActor {
'@context': any[]
type: ActivityPubActorType
id: string
following: string
followers: string
playlists?: string
inbox: string
outbox: string
preferredUsername: string
url: string
name: string
endpoints: {
sharedInbox: string
}
summary: string
attributedTo?: ActivityPubAttributedTo[]
support?: string
publicKey: {
id: string
owner: string
publicKeyPem: string
}
// Lemmy attribute for groups
postingRestrictedToMods?: boolean
image?: ActivityIconObject | ActivityIconObject[]
icon?: ActivityIconObject | ActivityIconObject[]
published?: string
// For export
likes?: string
dislikes?: string
}
+9
ファイルの表示
@@ -0,0 +1,9 @@
import { Activity } from './activity.js'
export interface ActivityPubCollection {
'@context': any[]
type: 'Collection' | 'CollectionPage'
totalItems: number
partOf?: string
items: Activity[]
}
+12
ファイルの表示
@@ -0,0 +1,12 @@
export interface ActivityPubOrderedCollection<T> {
id: string
'@context': any[]
type: 'OrderedCollection' | 'OrderedCollectionPage'
totalItems: number
orderedItems: T[]
partOf?: string
next?: string
first?: string
}
+5
ファイルの表示
@@ -0,0 +1,5 @@
import { Activity } from './activity.js'
import { ActivityPubCollection } from './activitypub-collection.js'
import { ActivityPubOrderedCollection } from './activitypub-ordered-collection.js'
export type RootActivity = Activity | ActivityPubCollection | ActivityPubOrderedCollection<Activity>
+6
ファイルの表示
@@ -0,0 +1,6 @@
export interface ActivityPubSignature {
type: string
created: Date
creator: string
signatureValue: string
}
+19
ファイルの表示
@@ -0,0 +1,19 @@
export type ContextType =
'Video' |
'Comment' |
'Playlist' |
'Follow' |
'Reject' |
'Accept' |
'View' |
'Announce' |
'CacheFile' |
'Delete' |
'Rate' |
'Flag' |
'Actor' |
'Collection' |
'WatchAction' |
'Chapters' |
'ApproveReply' |
'RejectReply'
+9
ファイルの表示
@@ -0,0 +1,9 @@
export * from './objects/index.js'
export * from './activity.js'
export * from './activitypub-actor.js'
export * from './activitypub-collection.js'
export * from './activitypub-ordered-collection.js'
export * from './activitypub-root.js'
export * from './activitypub-signature.js'
export * from './context.js'
export * from './webfinger.js'
+15
ファイルの表示
@@ -0,0 +1,15 @@
import { ActivityFlagReasonObject } from './common-objects.js'
export interface AbuseObject {
type: 'Flag'
content: string
mediaType: 'text/markdown'
object: string | string[]
tag?: ActivityFlagReasonObject[]
startAt?: number
endAt?: number
}
+17
ファイルの表示
@@ -0,0 +1,17 @@
import { AbuseObject } from './abuse-object.js'
import { CacheFileObject } from './cache-file-object.js'
import { PlaylistObject } from './playlist-object.js'
import { VideoCommentObject } from './video-comment-object.js'
import { VideoObject } from './video-object.js'
import { WatchActionObject } from './watch-action-object.js'
export type ActivityObject =
VideoObject |
AbuseObject |
VideoCommentObject |
CacheFileObject |
PlaylistObject |
WatchActionObject |
string
export type APObjectId = string | { id: string }
+9
ファイルの表示
@@ -0,0 +1,9 @@
import { ActivityVideoUrlObject, ActivityPlaylistUrlObject } from './common-objects.js'
export interface CacheFileObject {
id: string
type: 'CacheFile'
object: string
expires: string
url: ActivityVideoUrlObject | ActivityPlaylistUrlObject
}
+136
ファイルの表示
@@ -0,0 +1,136 @@
import { AbusePredefinedReasonsString } from '../../moderation/abuse/abuse-reason.model.js'
export interface ActivityIdentifierObject {
identifier: string
name: string
url?: string
}
export interface ActivityIconObject {
type: 'Image'
url: string
mediaType: string
width: number
height: number | null
}
export type ActivityVideoUrlObject = {
type: 'Link'
mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4'
href: string
height: number
width: number | null
size: number
fps: number
}
export type ActivityPlaylistSegmentHashesObject = {
type: 'Link'
name: 'sha256'
mediaType: 'application/json'
href: string
}
export type ActivityVideoFileMetadataUrlObject = {
type: 'Link'
rel: [ 'metadata', any ]
mediaType: 'application/json'
height: number
width: number | null
href: string
fps: number
}
export type ActivityTrackerUrlObject = {
type: 'Link'
rel: [ 'tracker', 'websocket' | 'http' ]
name: string
href: string
}
export type ActivityStreamingPlaylistInfohashesObject = {
type: 'Infohash'
name: string
}
export type ActivityPlaylistUrlObject = {
type: 'Link'
mediaType: 'application/x-mpegURL'
href: string
tag?: ActivityTagObject[]
}
export type ActivityBitTorrentUrlObject = {
type: 'Link'
mediaType: 'application/x-bittorrent' | 'application/x-bittorrent;x-scheme-handler/magnet'
href: string
height: number
width: number | null
fps: number | null
}
export type ActivityMagnetUrlObject = {
type: 'Link'
mediaType: 'application/x-bittorrent;x-scheme-handler/magnet'
href: string
height: number
width: number | null
fps: number | null
}
export type ActivityHtmlUrlObject = {
type: 'Link'
mediaType: 'text/html'
href: string
}
export interface ActivityHashTagObject {
type: 'Hashtag'
href?: string
name: string
}
export interface ActivityMentionObject {
type: 'Mention'
href?: string
name: string
}
export interface ActivityFlagReasonObject {
type: 'Hashtag'
name: AbusePredefinedReasonsString
}
export type ActivityTagObject =
ActivityPlaylistSegmentHashesObject
| ActivityStreamingPlaylistInfohashesObject
| ActivityVideoUrlObject
| ActivityHashTagObject
| ActivityMentionObject
| ActivityBitTorrentUrlObject
| ActivityMagnetUrlObject
| ActivityVideoFileMetadataUrlObject
export type ActivityUrlObject =
ActivityVideoUrlObject
| ActivityPlaylistUrlObject
| ActivityBitTorrentUrlObject
| ActivityMagnetUrlObject
| ActivityHtmlUrlObject
| ActivityVideoFileMetadataUrlObject
| ActivityTrackerUrlObject
export type ActivityPubAttributedTo = { type: 'Group' | 'Person', id: string } | string
export interface ActivityTombstoneObject {
'@context'?: any
id: string
url?: string
type: 'Tombstone'
name?: string
formerType?: string
inReplyTo?: string
published: string
updated: string
deleted: string
}
+11
ファイルの表示
@@ -0,0 +1,11 @@
export * from './abuse-object.js'
export * from './activitypub-object.js'
export * from './cache-file-object.js'
export * from './common-objects.js'
export * from './playlist-element-object.js'
export * from './playlist-object.js'
export * from './video-caption-object.js'
export * from './video-chapters-object.js'
export * from './video-comment-object.js'
export * from './video-object.js'
export * from './watch-action-object.js'
+10
ファイルの表示
@@ -0,0 +1,10 @@
export interface PlaylistElementObject {
id: string
type: 'PlaylistElement'
url: string
position: number
startTimestamp?: number
stopTimestamp?: number
}
+29
ファイルの表示
@@ -0,0 +1,29 @@
import { ActivityIconObject, ActivityPubAttributedTo } from './common-objects.js'
export interface PlaylistObject {
id: string
type: 'Playlist'
name: string
content: string
mediaType: 'text/markdown'
uuid: string
totalItems: number
attributedTo: ActivityPubAttributedTo[]
icon?: ActivityIconObject
published: string
updated: string
orderedItems?: string[]
partOf?: string
next?: string
first?: string
to?: string[]
}
+5
ファイルの表示
@@ -0,0 +1,5 @@
import { ActivityIdentifierObject } from './common-objects.js'
export interface VideoCaptionObject extends ActivityIdentifierObject {
automaticallyGenerated: boolean
}
+11
ファイルの表示
@@ -0,0 +1,11 @@
export interface VideoChaptersObject {
id: string
hasPart: VideoChapterObject[]
}
// Same as https://schema.org/hasPart
export interface VideoChapterObject {
name: string
startOffset: number
endOffset: number
}
+21
ファイルの表示
@@ -0,0 +1,21 @@
import { ActivityPubAttributedTo, ActivityTagObject } from './common-objects.js'
export interface VideoCommentObject {
type: 'Note'
id: string
content: string
mediaType: 'text/markdown'
inReplyTo: string
published: string
updated: string
url: string
attributedTo: ActivityPubAttributedTo
tag: ActivityTagObject[]
replyApproval: string | null
to?: string[]
cc?: string[]
}
+93
ファイルの表示
@@ -0,0 +1,93 @@
import { LiveVideoLatencyModeType, VideoCommentPolicyType, VideoStateType } from '../../videos/index.js'
import {
ActivityIconObject,
ActivityIdentifierObject,
ActivityPubAttributedTo,
ActivityTagObject,
ActivityUrlObject
} from './common-objects.js'
import { VideoCaptionObject } from './video-caption-object.js'
import { VideoChapterObject } from './video-chapters-object.js'
export interface VideoObject {
type: 'Video'
id: string
name: string
duration: string
uuid: string
tag: ActivityTagObject[]
category: ActivityIdentifierObject
licence: ActivityIdentifierObject
language: ActivityIdentifierObject
subtitleLanguage: VideoCaptionObject[]
views: number
sensitive: boolean
isLiveBroadcast: boolean
liveSaveReplay: boolean
permanentLive: boolean
latencyMode: LiveVideoLatencyModeType
commentsEnabled?: boolean
commentsPolicy: VideoCommentPolicyType
canReply: 'as:Public' | 'https://www.w3.org/ns/activitystreams#Public'
downloadEnabled: boolean
waitTranscoding: boolean
state: VideoStateType
published: string
originallyPublishedAt: string
updated: string
uploadDate: string
mediaType: 'text/markdown'
content: string
support: string
aspectRatio: number
icon: ActivityIconObject[]
url: ActivityUrlObject[]
likes: string
dislikes: string
shares: string
comments: string
hasParts: string | VideoChapterObject[]
attributedTo: ActivityPubAttributedTo[]
preview?: ActivityPubStoryboard[]
to?: string[]
cc?: string[]
// For export
attachment?: {
type: 'Video'
url: string
mediaType: string
height: number
size: number
fps: number
}[]
}
export interface ActivityPubStoryboard {
type: 'Image'
rel: [ 'storyboard' ]
url: {
href: string
mediaType: string
width: number
height: number
tileWidth: number
tileHeight: number
tileDuration: string
}[]
}
+23
ファイルの表示
@@ -0,0 +1,23 @@
export interface WatchActionObject {
id: string
type: 'WatchAction'
startTime: string
endTime: string
location?: {
addressCountry: string
addressRegion: string
}
uuid: string
object: string
actionStatus: 'CompletedActionStatus'
duration: string
watchSections: {
startTimestamp: number
endTimestamp: number
}[]
}
+9
ファイルの表示
@@ -0,0 +1,9 @@
export interface WebFingerData {
subject: string
aliases: string[]
links: {
rel: 'self'
type: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
href: string
}[]
}
+22
ファイルの表示
@@ -0,0 +1,22 @@
import { ActorImage } from './actor-image.model.js'
import { Actor } from './actor.model.js'
export interface Account extends Actor {
displayName: string
description: string
avatars: ActorImage[]
updatedAt: Date | string
userId?: number
}
export interface AccountSummary {
id: number
name: string
displayName: string
url: string
host: string
avatars: ActorImage[]
}
+9
ファイルの表示
@@ -0,0 +1,9 @@
export interface ActorImage {
width: number
path: string
url?: string
createdAt: Date | string
updatedAt: Date | string
}
+6
ファイルの表示
@@ -0,0 +1,6 @@
export const ActorImageType = {
AVATAR: 1,
BANNER: 2
} as const
export type ActorImageType_Type = typeof ActorImageType[keyof typeof ActorImageType]
+13
ファイルの表示
@@ -0,0 +1,13 @@
import { ActorImage } from './actor-image.model.js'
export interface Actor {
id: number
url: string
name: string
host: string
followingCount: number
followersCount: number
createdAt: Date | string
avatars: ActorImage[]
}
+3
ファイルの表示
@@ -0,0 +1,3 @@
export interface CustomPage {
content: string
}
+13
ファイルの表示
@@ -0,0 +1,13 @@
import { Actor } from './actor.model.js'
export type FollowState = 'pending' | 'accepted' | 'rejected'
export interface ActorFollow {
id: number
follower: Actor & { hostRedundancyAllowed: boolean }
following: Actor & { hostRedundancyAllowed: boolean }
score: number
state: FollowState
createdAt: Date
updatedAt: Date
}
+6
ファイルの表示
@@ -0,0 +1,6 @@
export * from './account.model.js'
export * from './actor-image.model.js'
export * from './actor-image.type.js'
export * from './actor.model.js'
export * from './custom-page.model.js'
export * from './follow.model.js'
+4
ファイルの表示
@@ -0,0 +1,4 @@
export interface BulkRemoveCommentsOfBody {
accountName: string
scope: 'my-videos' | 'instance'
}
+1
ファイルの表示
@@ -0,0 +1 @@
export * from './bulk-remove-comments-of-body.model.js'
+6
ファイルの表示
@@ -0,0 +1,6 @@
export const FileStorage = {
FILE_SYSTEM: 0,
OBJECT_STORAGE: 1
} as const
export type FileStorageType = typeof FileStorage[keyof typeof FileStorage]
+3
ファイルの表示
@@ -0,0 +1,3 @@
export * from './file-storage.enum.js'
export * from './result-list.model.js'
export * from './simple-logger.model.js'
+8
ファイルの表示
@@ -0,0 +1,8 @@
export interface ResultList<T> {
total: number
data: T[]
}
export interface ThreadsResultList <T> extends ResultList <T> {
totalNotDeletedComments: number
}
+6
ファイルの表示
@@ -0,0 +1,6 @@
export type SimpleLogger = {
info: (msg: string, obj?: object) => void
debug: (msg: string, obj?: object) => void
warn: (msg: string, obj?: object) => void
error: (msg: string, obj?: object) => void
}
+68
ファイルの表示
@@ -0,0 +1,68 @@
type StringBoolean = 'true' | 'false'
export type EmbedMarkupData = {
// Video or playlist uuid
uuid: string
}
export type VideoMiniatureMarkupData = {
// Video uuid
uuid: string
onlyDisplayTitle?: StringBoolean
}
export type PlaylistMiniatureMarkupData = {
// Playlist uuid
uuid: string
}
export type ChannelMiniatureMarkupData = {
// Channel name (username)
name: string
displayLatestVideo?: StringBoolean
displayDescription?: StringBoolean
}
export type VideosListMarkupData = {
onlyDisplayTitle?: StringBoolean
maxRows?: string // number
sort?: string
count?: string // number
categoryOneOf?: string // coma separated values, number[]
languageOneOf?: string // coma separated values
channelHandle?: string
accountHandle?: string
isLive?: string // number
onlyLocal?: StringBoolean
}
export type ButtonMarkupData = {
theme: 'primary' | 'secondary'
href: string
label: string
blankTarget?: StringBoolean
}
export type ContainerMarkupData = {
width?: string
title?: string
description?: string
layout?: 'row' | 'column'
justifyContent?: 'space-between' | 'normal' // default to 'space-between'
}
export type InstanceBannerMarkupData = {
revertHomePaddingTop?: StringBoolean // default to 'true'
}
export type InstanceAvatarMarkupData = {
size: string // size in pixels
}
+1
ファイルの表示
@@ -0,0 +1 @@
export * from './custom-markup-data.model.js'
+7
ファイルの表示
@@ -0,0 +1,7 @@
export const FeedFormat = {
RSS: 'xml',
ATOM: 'atom',
JSON: 'json'
} as const
export type FeedFormatType = typeof FeedFormat[keyof typeof FeedFormat]
+1
ファイルの表示
@@ -0,0 +1 @@
export * from './feed-format.enum.js'
+23
ファイルの表示
@@ -0,0 +1,23 @@
/** HTTP request method to indicate the desired action to be performed for a given resource. */
export const HttpMethod = {
/** The CONNECT method establishes a tunnel to the server identified by the target resource. */
CONNECT: 'CONNECT',
/** The DELETE method deletes the specified resource. */
DELETE: 'DELETE',
/** The GET method requests a representation of the specified resource. Requests using GET should only retrieve data. */
GET: 'GET',
/** The HEAD method asks for a response identical to that of a GET request, but without the response body. */
HEAD: 'HEAD',
/** The OPTIONS method is used to describe the communication options for the target resource. */
OPTIONS: 'OPTIONS',
/** The PATCH method is used to apply partial modifications to a resource. */
PATCH: 'PATCH',
/** The POST method is used to submit an entity to the specified resource */
POST: 'POST',
/** The PUT method replaces all current representations of the target resource with the request payload. */
PUT: 'PUT',
/** The TRACE method performs a message loop-back test along the path to the target resource. */
TRACE: 'TRACE'
} as const
export type HttpMethodType = typeof HttpMethod[keyof typeof HttpMethod]
+366
ファイルの表示
@@ -0,0 +1,366 @@
/**
* Hypertext Transfer Protocol (HTTP) response status codes.
* @see {@link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes}
*
* WebDAV and other codes useless with regards to PeerTube are not listed.
*/
export const HttpStatusCode = {
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.1
*
* The server has received the request headers and the client should proceed to send the request body
* (in the case of a request for which a body needs to be sent; for example, a POST request).
* Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient.
* To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request
* and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates
* the request should not be continued.
*/
CONTINUE_100: 100,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.2
*
* This code is sent in response to an Upgrade request header by the client, and indicates the protocol the server is switching too.
*/
SWITCHING_PROTOCOLS_101: 101,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.1
*
* Standard response for successful HTTP requests. The actual response will depend on the request method used:
* GET: The resource has been fetched and is transmitted in the message body.
* HEAD: The entity headers are in the message body.
* POST: The resource describing the result of the action is transmitted in the message body.
* TRACE: The message body contains the request message as received by the server
*/
OK_200: 200,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.2
*
* The request has been fulfilled, resulting in the creation of a new resource, typically after a PUT.
*/
CREATED_201: 201,
/**
* The request has been accepted for processing, but the processing has not been completed.
* The request might or might not be eventually acted upon, and may be disallowed when processing occurs.
*/
ACCEPTED_202: 202,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.5
*
* There is no content to send for this request, but the headers may be useful.
* The user-agent may update its cached headers for this resource with the new ones.
*/
NO_CONTENT_204: 204,
/**
* The server successfully processed the request, but is not returning any content.
* Unlike a 204 response, this response requires that the requester reset the document view.
*/
RESET_CONTENT_205: 205,
/**
* The server is delivering only part of the resource (byte serving) due to a range header sent by the client.
* The range header is used by HTTP clients to enable resuming of interrupted downloads,
* or split a download into multiple simultaneous streams.
*/
PARTIAL_CONTENT_206: 206,
/**
* Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation).
* For example, this code could be used to present multiple video format options,
* to list files with different filename extensions, or to suggest word-sense disambiguation.
*/
MULTIPLE_CHOICES_300: 300,
/**
* This and all future requests should be directed to the given URI.
*/
MOVED_PERMANENTLY_301: 301,
/**
* This is an example of industry practice contradicting the standard.
* The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect
* (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302
* with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307
* to distinguish between the two behaviours. However, some Web applications and frameworks
* use the 302 status code as if it were the 303.
*/
FOUND_302: 302,
/**
* SINCE HTTP/1.1
* The response to the request can be found under another URI using a GET method.
* When received in response to a POST (or PUT/DELETE), the client should presume that
* the server has received the data and should issue a redirect with a separate GET message.
*/
SEE_OTHER_303: 303,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.1
*
* Indicates that the resource has not been modified since the version specified by the request headers
* `If-Modified-Since` or `If-None-Match`.
* In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy.
*/
NOT_MODIFIED_304: 304,
/**
* SINCE HTTP/1.1
* In this case, the request should be repeated with another URI; however, future requests should still use the original URI.
* In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the
* original request.
* For example, a POST request should be repeated using another POST request.
*/
TEMPORARY_REDIRECT_307: 307,
/**
* The request and all future requests should be repeated using another URI.
* 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change.
* So, for example, submitting a form to a permanently redirected resource may continue smoothly.
*/
PERMANENT_REDIRECT_308: 308,
/**
* The server cannot or will not process the request due to an apparent client error
* (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing).
*/
BAD_REQUEST_400: 400,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.1
*
* Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet
* been provided. The response must include a `WWW-Authenticate` header field containing a challenge applicable to the
* requested resource. See Basic access authentication and Digest access authentication. 401 semantically means
* "unauthenticated",i.e. the user does not have the necessary credentials.
*/
UNAUTHORIZED_401: 401,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.2
*
* Reserved for future use. The original intention was that this code might be used as part of some form of digital
* cash or micro payment scheme, but that has not happened, and this code is not usually used.
* Google Developers API uses this status if a particular developer has exceeded the daily limit on requests.
*/
PAYMENT_REQUIRED_402: 402,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.3
*
* The client does not have access rights to the content, i.e. they are unauthorized, so server is rejecting to
* give proper response. Unlike 401, the client's identity is known to the server.
*/
FORBIDDEN_403: 403,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2
*
* The requested resource could not be found but may be available in the future.
* Subsequent requests by the client are permissible.
*/
NOT_FOUND_404: 404,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.5
*
* A request method is not supported for the requested resource;
* for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource.
*/
METHOD_NOT_ALLOWED_405: 405,
/**
* The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request.
*/
NOT_ACCEPTABLE_406: 406,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.7
*
* This response is sent on an idle connection by some servers, even without any previous request by the client.
* It means that the server would like to shut down this unused connection. This response is used much more since
* some browsers, like Chrome, Firefox 27+, or IE9, use HTTP pre-connection mechanisms to speed up surfing. Also
* note that some servers merely shut down the connection without sending this message.
*
* @
*/
REQUEST_TIMEOUT_408: 408,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.8
*
* Indicates that the request could not be processed because of conflict in the request,
* such as an edit conflict between multiple simultaneous updates.
*
* @see HttpStatusCode.UNPROCESSABLE_ENTITY_422 to denote a disabled feature
*/
CONFLICT_409: 409,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.9
*
* Indicates that the resource requested is no longer available and will not be available again.
* This should be used when a resource has been intentionally removed and the resource should be purged.
* Upon receiving a 410 status code, the client should not request the resource in the future.
* Clients such as search engines should remove the resource from their indices.
* Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead.
*/
GONE_410: 410,
/**
* The request did not specify the length of its content, which is required by the requested resource.
*/
LENGTH_REQUIRED_411: 411,
/**
* The server does not meet one of the preconditions that the requester put on the request.
*/
PRECONDITION_FAILED_412: 412,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.11
*
* The request is larger than the server is willing or able to process ; the server might close the connection
* or return an Retry-After header field.
* Previously called "Request Entity Too Large".
*/
PAYLOAD_TOO_LARGE_413: 413,
/**
* The URI provided was too long for the server to process. Often the result of too much data being encoded as a
* query-string of a GET request, in which case it should be converted to a POST request.
* Called "Request-URI Too Long" previously.
*/
URI_TOO_LONG_414: 414,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.13
*
* The request entity has a media type which the server or resource does not support.
* For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format.
*/
UNSUPPORTED_MEDIA_TYPE_415: 415,
/**
* The client has asked for a portion of the file (byte serving), but the server cannot supply that portion.
* For example, if the client asked for a part of the file that lies beyond the end of the file.
* Called "Requested Range Not Satisfiable" previously.
*/
RANGE_NOT_SATISFIABLE_416: 416,
/**
* The server cannot meet the requirements of the `Expect` request-header field.
*/
EXPECTATION_FAILED_417: 417,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2324
*
* This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol,
* and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by
* teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including PeerTube instances ;-).
*/
I_AM_A_TEAPOT_418: 418,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.3
*
* The request was well-formed but was unable to be followed due to semantic errors.
* The server understands the content type of the request entity (hence a 415 (Unsupported Media Type) status code is inappropriate),
* and the syntax of the request entity is correct (thus a 400 (Bad Request) status code is inappropriate) but was unable to process
* the contained instructions. For example, this error condition may occur if an JSON request body contains well-formed (i.e.,
* syntactically correct), but semantically erroneous, JSON instructions.
*
* Can also be used to denote disabled features (akin to disabled syntax).
*
* @see HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415 if the `Content-Type` was not supported.
* @see HttpStatusCode.BAD_REQUEST_400 if the request was not parsable (broken JSON, XML)
*/
UNPROCESSABLE_ENTITY_422: 422,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc4918#section-11.3
*
* The resource that is being accessed is locked. WebDAV-specific but used by some HTTP services.
*
* @deprecated use `If-Match` / `If-None-Match` instead
* @see {@link https://evertpot.com/http/423-locked}
*/
LOCKED_423: 423,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc6585#section-4
*
* The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes.
*/
TOO_MANY_REQUESTS_429: 429,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc6585#section-5
*
* The server is unwilling to process the request because either an individual header field,
* or all the header fields collectively, are too large.
*/
REQUEST_HEADER_FIELDS_TOO_LARGE_431: 431,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7725
*
* A server operator has received a legal demand to deny access to a resource or to a set of resources
* that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451.
*/
UNAVAILABLE_FOR_LEGAL_REASONS_451: 451,
/**
* A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
*/
INTERNAL_SERVER_ERROR_500: 500,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2
*
* The server either does not recognize the request method, or it lacks the ability to fulfill the request.
* Usually this implies future availability (e.g., a new feature of a web-service API).
*/
NOT_IMPLEMENTED_501: 501,
/**
* The server was acting as a gateway or proxy and received an invalid response from the upstream server.
*/
BAD_GATEWAY_502: 502,
/**
* The server is currently unavailable (because it is overloaded or down for maintenance).
* Generally, this is a temporary state.
*/
SERVICE_UNAVAILABLE_503: 503,
/**
* The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.
*/
GATEWAY_TIMEOUT_504: 504,
/**
* The server does not support the HTTP protocol version used in the request
*/
HTTP_VERSION_NOT_SUPPORTED_505: 505,
/**
* Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6
*
* The 507 (Insufficient Storage) status code means the method could not be performed on the resource because the
* server is unable to store the representation needed to successfully complete the request. This condition is
* considered to be temporary. If the request which received this status code was the result of a user action,
* the request MUST NOT be repeated until it is requested by a separate user action.
*
* @see HttpStatusCode.PAYLOAD_TOO_LARGE_413 for quota errors
*/
INSUFFICIENT_STORAGE_507: 507
} as const
export type HttpStatusCodeType = typeof HttpStatusCode[keyof typeof HttpStatusCode]
+2
ファイルの表示
@@ -0,0 +1,2 @@
export * from './http-status-codes.js'
export * from './http-methods.js'
+9
ファイルの表示
@@ -0,0 +1,9 @@
export * from './peertube-export-format/index.js'
export * from './user-export-request-result.model.js'
export * from './user-export-request.model.js'
export * from './user-export-state.enum.js'
export * from './user-export.model.js'
export * from './user-import.model.js'
export * from './user-import-state.enum.js'
export * from './user-import-result.model.js'
export * from './user-import-upload-result.model.js'
@@ -0,0 +1,18 @@
import { UserActorImageJSON } from './actor-export.model.js'
export interface AccountExportJSON {
url: string
name: string
displayName: string
description: string
updatedAt: string
createdAt: string
avatars: UserActorImageJSON[]
archiveFiles: {
avatar: string | null
}
}
@@ -0,0 +1,6 @@
export interface UserActorImageJSON {
width: number
url: string
createdAt: string
updatedAt: string
}
@@ -0,0 +1,5 @@
export interface AutoTagPoliciesJSON {
reviewComments: {
name: string
}[]
}
@@ -0,0 +1,9 @@
export interface BlocklistExportJSON {
instances: {
host: string
}[]
actors: {
handle: string
}[]
}
@@ -0,0 +1,23 @@
import { UserActorImageJSON } from './actor-export.model.js'
export interface ChannelExportJSON {
channels: {
url: string
name: string
displayName: string
description: string
support: string
updatedAt: string
createdAt: string
avatars: UserActorImageJSON[]
banners: UserActorImageJSON[]
archiveFiles: {
avatar: string | null
banner: string | null
}
}[]
}
@@ -0,0 +1,12 @@
export interface CommentsExportJSON {
comments: {
url: string
text: string
createdAt: string
videoUrl: string
inReplyToCommentUrl?: string
archiveFiles?: never
}[]
}
@@ -0,0 +1,8 @@
export interface DislikesExportJSON {
dislikes: {
videoUrl: string
createdAt: string
archiveFiles?: never
}[]
}
@@ -0,0 +1,9 @@
export interface FollowersExportJSON {
followers: {
handle: string
createdAt: string
targetHandle: string
archiveFiles?: never
}[]
}
@@ -0,0 +1,9 @@
export interface FollowingExportJSON {
following: {
handle: string
targetHandle: string
createdAt: string
archiveFiles?: never
}[]
}
+15
ファイルの表示
@@ -0,0 +1,15 @@
export * from './account-export.model.js'
export * from './actor-export.model.js'
export * from './auto-tag-policies-export.js'
export * from './blocklist-export.model.js'
export * from './channel-export.model.js'
export * from './comments-export.model.js'
export * from './dislikes-export.model.js'
export * from './followers-export.model.js'
export * from './following-export.model.js'
export * from './likes-export.model.js'
export * from './user-settings-export.model.js'
export * from './user-video-history-export.js'
export * from './video-export.model.js'
export * from './video-playlists-export.model.js'
export * from './watched-words-lists-export.js'
@@ -0,0 +1,8 @@
export interface LikesExportJSON {
likes: {
videoUrl: string
createdAt: string
archiveFiles?: never
}[]
}
@@ -0,0 +1,26 @@
import { UserNotificationSetting } from '../../users/user-notification-setting.model.js'
import { NSFWPolicyType } from '../../videos/nsfw-policy.type.js'
export interface UserSettingsExportJSON {
email: string
emailPublic: boolean
nsfwPolicy: NSFWPolicyType
autoPlayVideo: boolean
autoPlayNextVideo: boolean
autoPlayNextVideoPlaylist: boolean
p2pEnabled: boolean
videosHistoryEnabled: boolean
videoLanguages: string[]
theme: string
createdAt: Date
notificationSettings: UserNotificationSetting
archiveFiles?: never
}

変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示