mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 17:59:37 +02:00
Feature for runners - handle storyboard-generation-job (#7191)
* Implement processing storyboards by runners * Fixed storyboard generation by runners * use common code patterns * fix import * improve debug logging for storyboard generation * config option for storyboard processing with remote-runners * refactor repetitive pattern * refactor storyboard related code to share common utlities * Fix test * Fix storyboard generation config logic * Improve logging * Added tests for storyboard generation with runners * Refactor PR --------- Co-authored-by: ilfarpro <ilfarpro@ya.ru> Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
parent
e74bf8ae2a
commit
dd52e8b89e
50 changed files with 973 additions and 248 deletions
|
@ -7,7 +7,13 @@ import {
|
||||||
RunnerJobVODWebVideoTranscodingPayload
|
RunnerJobVODWebVideoTranscodingPayload
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { logger } from '../../shared/index.js'
|
import { logger } from '../../shared/index.js'
|
||||||
import { processAudioMergeTranscoding, processHLSTranscoding, ProcessOptions, processWebVideoTranscoding } from './shared/index.js'
|
import {
|
||||||
|
processAudioMergeTranscoding,
|
||||||
|
processGenerateStoryboard,
|
||||||
|
processHLSTranscoding,
|
||||||
|
ProcessOptions,
|
||||||
|
processWebVideoTranscoding
|
||||||
|
} from './shared/index.js'
|
||||||
import { ProcessLiveRTMPHLSTranscoding } from './shared/process-live.js'
|
import { ProcessLiveRTMPHLSTranscoding } from './shared/process-live.js'
|
||||||
import { processStudioTranscoding } from './shared/process-studio.js'
|
import { processStudioTranscoding } from './shared/process-studio.js'
|
||||||
import { processVideoTranscription } from './shared/process-transcription.js'
|
import { processVideoTranscription } from './shared/process-transcription.js'
|
||||||
|
@ -42,6 +48,10 @@ export async function processJob (options: ProcessOptions) {
|
||||||
await processVideoTranscription(options as ProcessOptions<RunnerJobTranscriptionPayload>)
|
await processVideoTranscription(options as ProcessOptions<RunnerJobTranscriptionPayload>)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'generate-video-storyboard':
|
||||||
|
await processGenerateStoryboard(options as any)
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.error(`Unknown job ${job.type} to process`)
|
logger.error(`Unknown job ${job.type} to process`)
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
import { pick } from '@peertube/peertube-core-utils'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
import { FFmpegEdition, FFmpegLive, FFmpegVOD, getDefaultAvailableEncoders, getDefaultEncodersToTry } from '@peertube/peertube-ffmpeg'
|
import {
|
||||||
|
FFmpegEdition,
|
||||||
|
FFmpegImage,
|
||||||
|
FFmpegLive,
|
||||||
|
FFmpegVOD,
|
||||||
|
getDefaultAvailableEncoders,
|
||||||
|
getDefaultEncodersToTry
|
||||||
|
} from '@peertube/peertube-ffmpeg'
|
||||||
import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models'
|
import { RunnerJob, RunnerJobPayload } from '@peertube/peertube-models'
|
||||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
import { PeerTubeServer } from '@peertube/peertube-server-commands'
|
import { PeerTubeServer } from '@peertube/peertube-server-commands'
|
||||||
|
@ -8,9 +15,9 @@ import { join } from 'path'
|
||||||
import { ConfigManager, downloadFile, logger } from '../../../shared/index.js'
|
import { ConfigManager, downloadFile, logger } from '../../../shared/index.js'
|
||||||
import { getWinstonLogger } from './winston-logger.js'
|
import { getWinstonLogger } from './winston-logger.js'
|
||||||
|
|
||||||
export type JobWithToken <T extends RunnerJobPayload = RunnerJobPayload> = RunnerJob<T> & { jobToken: string }
|
export type JobWithToken<T extends RunnerJobPayload = RunnerJobPayload> = RunnerJob<T> & { jobToken: string }
|
||||||
|
|
||||||
export type ProcessOptions <T extends RunnerJobPayload = RunnerJobPayload> = {
|
export type ProcessOptions<T extends RunnerJobPayload = RunnerJobPayload> = {
|
||||||
server: PeerTubeServer
|
server: PeerTubeServer
|
||||||
job: JobWithToken<T>
|
job: JobWithToken<T>
|
||||||
runnerToken: string
|
runnerToken: string
|
||||||
|
@ -108,6 +115,10 @@ export function buildFFmpegEdition () {
|
||||||
return new FFmpegEdition(getCommonFFmpegOptions())
|
return new FFmpegEdition(getCommonFFmpegOptions())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildFFmpegImage () {
|
||||||
|
return new FFmpegImage(getCommonFFmpegOptions())
|
||||||
|
}
|
||||||
|
|
||||||
function getCommonFFmpegOptions () {
|
function getCommonFFmpegOptions () {
|
||||||
const config = ConfigManager.Instance.getConfig()
|
const config = ConfigManager.Instance.getConfig()
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './common.js'
|
export * from './common.js'
|
||||||
export * from './process-vod.js'
|
export * from './process-vod.js'
|
||||||
export * from './winston-logger.js'
|
export * from './winston-logger.js'
|
||||||
|
export * from './process-storyboard.js'
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { RunnerJobGenerateStoryboardPayload, GenerateStoryboardSuccess } from '@peertube/peertube-models'
|
||||||
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
|
import { remove } from 'fs-extra/esm'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { ConfigManager } from '../../../shared/config-manager.js'
|
||||||
|
import { logger } from '../../../shared/index.js'
|
||||||
|
import { buildFFmpegImage, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common.js'
|
||||||
|
|
||||||
|
export async function processGenerateStoryboard (options: ProcessOptions<RunnerJobGenerateStoryboardPayload>) {
|
||||||
|
const { server, job, runnerToken } = options
|
||||||
|
|
||||||
|
const payload = job.payload
|
||||||
|
|
||||||
|
let ffmpegProgress: number
|
||||||
|
let videoInputPath: string
|
||||||
|
|
||||||
|
const outputPath = join(ConfigManager.Instance.getStoryboardDirectory(), `storyboard-${buildUUID()}.jpg`)
|
||||||
|
|
||||||
|
const updateProgressInterval = scheduleTranscodingProgress({
|
||||||
|
job,
|
||||||
|
server,
|
||||||
|
runnerToken,
|
||||||
|
progressGetter: () => ffmpegProgress
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Downloading input file ${payload.input.videoFileUrl} for storyboard job ${job.jobToken}`)
|
||||||
|
|
||||||
|
videoInputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
|
||||||
|
|
||||||
|
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Generating storyboard.`)
|
||||||
|
|
||||||
|
const ffmpegImage = buildFFmpegImage()
|
||||||
|
|
||||||
|
await ffmpegImage.generateStoryboardFromVideo({
|
||||||
|
path: videoInputPath,
|
||||||
|
destination: outputPath,
|
||||||
|
inputFileMutexReleaser: () => {},
|
||||||
|
sprites: payload.sprites
|
||||||
|
})
|
||||||
|
|
||||||
|
const successBody: GenerateStoryboardSuccess = {
|
||||||
|
storyboardFile: outputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.runnerJobs.success({
|
||||||
|
jobToken: job.jobToken,
|
||||||
|
jobUUID: job.uuid,
|
||||||
|
runnerToken,
|
||||||
|
payload: successBody,
|
||||||
|
reqPayload: payload
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (videoInputPath) await remove(videoInputPath)
|
||||||
|
if (outputPath) await remove(outputPath)
|
||||||
|
if (updateProgressInterval) clearInterval(updateProgressInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -74,9 +74,11 @@ export class RunnerServer {
|
||||||
|
|
||||||
// Process jobs
|
// Process jobs
|
||||||
await ensureDir(ConfigManager.Instance.getTranscodingDirectory())
|
await ensureDir(ConfigManager.Instance.getTranscodingDirectory())
|
||||||
|
await ensureDir(ConfigManager.Instance.getStoryboardDirectory())
|
||||||
await this.cleanupTMP()
|
await this.cleanupTMP()
|
||||||
|
|
||||||
logger.info(`Using ${ConfigManager.Instance.getTranscodingDirectory()} for transcoding directory`)
|
logger.info(`Using ${ConfigManager.Instance.getTranscodingDirectory()} for transcoding directory`)
|
||||||
|
logger.info(`Using ${ConfigManager.Instance.getStoryboardDirectory()} for storyboard directory`)
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
await this.checkAvailableJobs()
|
await this.checkAvailableJobs()
|
||||||
|
|
|
@ -7,7 +7,8 @@ import {
|
||||||
RunnerJobVODAudioMergeTranscodingPayload,
|
RunnerJobVODAudioMergeTranscodingPayload,
|
||||||
RunnerJobVODHLSTranscodingPayload,
|
RunnerJobVODHLSTranscodingPayload,
|
||||||
RunnerJobVODWebVideoTranscodingPayload,
|
RunnerJobVODWebVideoTranscodingPayload,
|
||||||
VideoStudioTaskPayload
|
VideoStudioTaskPayload,
|
||||||
|
RunnerJobGenerateStoryboardPayload
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
|
|
||||||
const supportedMatrix: { [ id in RunnerJobType ]: (payload: RunnerJobPayload) => boolean } = {
|
const supportedMatrix: { [ id in RunnerJobType ]: (payload: RunnerJobPayload) => boolean } = {
|
||||||
|
@ -33,7 +34,8 @@ const supportedMatrix: { [ id in RunnerJobType ]: (payload: RunnerJobPayload) =>
|
||||||
},
|
},
|
||||||
'video-transcription': (_payload: RunnerJobTranscriptionPayload) => {
|
'video-transcription': (_payload: RunnerJobTranscriptionPayload) => {
|
||||||
return true
|
return true
|
||||||
}
|
},
|
||||||
|
'generate-video-storyboard': (_payload: RunnerJobGenerateStoryboardPayload) => true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isJobSupported (job: { type: RunnerJobType, payload: RunnerJobPayload }, enabledJobs?: Set<RunnerJobType>) {
|
export function isJobSupported (job: { type: RunnerJobType, payload: RunnerJobPayload }, enabledJobs?: Set<RunnerJobType>) {
|
||||||
|
|
|
@ -108,6 +108,10 @@ export class ConfigManager {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getStoryboardDirectory () {
|
||||||
|
return join(paths.cache, this.id, 'storyboard')
|
||||||
|
}
|
||||||
|
|
||||||
getTranscodingDirectory () {
|
getTranscodingDirectory () {
|
||||||
return join(paths.cache, this.id, 'transcoding')
|
return join(paths.cache, this.id, 'transcoding')
|
||||||
}
|
}
|
||||||
|
|
|
@ -395,6 +395,20 @@
|
||||||
<ng-container ngProjectAs="description">
|
<ng-container ngProjectAs="description">
|
||||||
<span i18n>Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video</span>
|
<span i18n>Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video</span>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container ngProjectAs="extra">
|
||||||
|
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getStoryboardRunnerDisabledClass()">
|
||||||
|
<my-peertube-checkbox
|
||||||
|
inputName="storyboardsRemoteRunnersEnabled" formControlName="enabled"
|
||||||
|
i18n-labelText labelText="Enable remote runners to generate storyboards"
|
||||||
|
>
|
||||||
|
<ng-container ngProjectAs="description">
|
||||||
|
<div i18n>Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to generate storyboards.</div>
|
||||||
|
<div i18n>Remote runners have to register on your instance first.</div>
|
||||||
|
</ng-container>
|
||||||
|
</my-peertube-checkbox>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -416,10 +430,8 @@
|
||||||
i18n-labelText labelText="Enable remote runners for transcription"
|
i18n-labelText labelText="Enable remote runners for transcription"
|
||||||
>
|
>
|
||||||
<ng-container ngProjectAs="description">
|
<ng-container ngProjectAs="description">
|
||||||
<span i18n>
|
<div i18n>Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process transcription tasks.</div>
|
||||||
Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process transcription tasks.
|
<div i18n>Remote runners have to register on your instance first.</div>
|
||||||
Remote runners has to register on your instance first.
|
|
||||||
</span>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -182,6 +182,10 @@ type Form = {
|
||||||
|
|
||||||
storyboards: FormGroup<{
|
storyboards: FormGroup<{
|
||||||
enabled: FormControl<boolean>
|
enabled: FormControl<boolean>
|
||||||
|
|
||||||
|
remoteRunners: FormGroup<{
|
||||||
|
enabled: FormControl<boolean>
|
||||||
|
}>
|
||||||
}>
|
}>
|
||||||
|
|
||||||
defaults: FormGroup<{
|
defaults: FormGroup<{
|
||||||
|
@ -423,7 +427,10 @@ export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanCompon
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
storyboards: {
|
storyboards: {
|
||||||
enabled: null
|
enabled: null,
|
||||||
|
remoteRunners: {
|
||||||
|
enabled: null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
publish: {
|
publish: {
|
||||||
|
@ -536,6 +543,16 @@ export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanCompon
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
isStoryboardEnabled () {
|
||||||
|
return this.form.value.storyboards.enabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
getStoryboardRunnerDisabledClass () {
|
||||||
|
return { 'disabled-checkbox-extra': !this.isStoryboardEnabled() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
isAutoFollowIndexEnabled () {
|
isAutoFollowIndexEnabled () {
|
||||||
return this.form.value.followings.instance.autoFollowIndex.enabled === true
|
return this.form.value.followings.instance.autoFollowIndex.enabled === true
|
||||||
}
|
}
|
||||||
|
|
|
@ -167,10 +167,8 @@
|
||||||
i18n-labelText labelText="Enable remote runners for lives"
|
i18n-labelText labelText="Enable remote runners for lives"
|
||||||
>
|
>
|
||||||
<ng-container ngProjectAs="description">
|
<ng-container ngProjectAs="description">
|
||||||
<span i18n>
|
<div i18n>Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process live transcoding.</div>
|
||||||
Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process live transcoding.
|
<div i18n>Remote runners have to register on your instance first.</div>
|
||||||
Remote runners has to register on your instance first.
|
|
||||||
</span>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -185,10 +185,8 @@
|
||||||
i18n-labelText labelText="Enable remote runners for VOD"
|
i18n-labelText labelText="Enable remote runners for VOD"
|
||||||
>
|
>
|
||||||
<ng-container ngProjectAs="description">
|
<ng-container ngProjectAs="description">
|
||||||
<span i18n>
|
<div i18n>Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process VOD transcoding.</div>
|
||||||
Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process VOD transcoding.
|
<div i18n>Remote runners have to register on your instance first.</div>
|
||||||
Remote runners has to register on your instance first.
|
|
||||||
</span>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
@ -262,19 +260,19 @@
|
||||||
<ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
|
<ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
|
||||||
<span i18n>⚠️ You need to enable transcoding first to enable video studio</span>
|
<span i18n>⚠️ You need to enable transcoding first to enable video studio</span>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</my-peertube-checkbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getStudioDisabledClass()">
|
<ng-container ngProjectAs="extra">
|
||||||
<my-peertube-checkbox
|
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getStudioRunnerDisabledClass()">
|
||||||
inputName="videoStudioRemoteRunnersEnabled" formControlName="enabled"
|
<my-peertube-checkbox
|
||||||
i18n-labelText labelText="Enable remote runners for studio"
|
inputName="videoStudioRemoteRunnersEnabled" formControlName="enabled"
|
||||||
>
|
i18n-labelText labelText="Enable remote runners for studio"
|
||||||
<ng-container ngProjectAs="description">
|
>
|
||||||
<span i18n>
|
<ng-container ngProjectAs="description">
|
||||||
Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process studio transcoding tasks.
|
<div i18n>Use <a routerLink="/admin/settings/system/runners/runners-list">remote runners</a> to process studio transcoding tasks.</div>
|
||||||
Remote runners has to register on your instance first.
|
<div i18n>Remote runners have to register on your instance first.</div>
|
||||||
</span>
|
</ng-container>
|
||||||
|
</my-peertube-checkbox>
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -226,7 +226,7 @@ export class AdminConfigVODComponent implements OnInit, OnDestroy, CanComponentD
|
||||||
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
|
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
|
||||||
}
|
}
|
||||||
|
|
||||||
getStudioDisabledClass () {
|
getStudioRunnerDisabledClass () {
|
||||||
return { 'disabled-checkbox-extra': !this.isStudioEnabled() }
|
return { 'disabled-checkbox-extra': !this.isStudioEnabled() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1137,6 +1137,10 @@ storyboards:
|
||||||
# Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video
|
# Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
remote_runners:
|
||||||
|
# Use remote runners to generate storyboards instead of processing them locally
|
||||||
|
enabled: false
|
||||||
|
|
||||||
# Update default PeerTube values
|
# Update default PeerTube values
|
||||||
# Set by API when the field is not provided and put as default value in client
|
# Set by API when the field is not provided and put as default value in client
|
||||||
defaults:
|
defaults:
|
||||||
|
|
|
@ -340,6 +340,9 @@ security:
|
||||||
powered_by_header:
|
powered_by_header:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
http_server:
|
||||||
|
request_timeout: '5 minutes'
|
||||||
|
|
||||||
tracker:
|
tracker:
|
||||||
# If you disable the tracker, you disable the P2P on your PeerTube instance
|
# If you disable the tracker, you disable the P2P on your PeerTube instance
|
||||||
enabled: true
|
enabled: true
|
||||||
|
@ -1144,6 +1147,10 @@ storyboards:
|
||||||
# Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video
|
# Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
remote_runners:
|
||||||
|
# Use remote runners to generate storyboards instead of processing them locally
|
||||||
|
enabled: false
|
||||||
|
|
||||||
# Update default PeerTube values
|
# Update default PeerTube values
|
||||||
# Set by API when the field is not provided and put as default value in client
|
# Set by API when the field is not provided and put as default value in client
|
||||||
defaults:
|
defaults:
|
||||||
|
|
|
@ -14,7 +14,8 @@ export type RunnerJobPayload =
|
||||||
RunnerJobVODPayload |
|
RunnerJobVODPayload |
|
||||||
RunnerJobLiveRTMPHLSTranscodingPayload |
|
RunnerJobLiveRTMPHLSTranscodingPayload |
|
||||||
RunnerJobStudioTranscodingPayload |
|
RunnerJobStudioTranscodingPayload |
|
||||||
RunnerJobTranscriptionPayload
|
RunnerJobTranscriptionPayload |
|
||||||
|
RunnerJobGenerateStoryboardPayload
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -90,6 +91,24 @@ export interface RunnerJobTranscriptionPayload {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunnerJobGenerateStoryboardPayload {
|
||||||
|
input: {
|
||||||
|
videoFileUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed server-side for consistency across runners
|
||||||
|
sprites: {
|
||||||
|
size: { width: number, height: number }
|
||||||
|
count: { width: number, height: number }
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
output: {
|
||||||
|
// To upload on an external URL
|
||||||
|
storyboardFileCustomUpload?: RunnerJobCustomUpload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function isAudioMergeTranscodingPayload (payload: RunnerJobPayload): payload is RunnerJobVODAudioMergeTranscodingPayload {
|
export function isAudioMergeTranscodingPayload (payload: RunnerJobPayload): payload is RunnerJobVODAudioMergeTranscodingPayload {
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import { VideoStudioTaskPayload } from '../../server/index.js'
|
import { VideoStudioTaskPayload } from '../../server/index.js'
|
||||||
|
|
||||||
export type RunnerJobVODPrivatePayload =
|
export type RunnerJobVODPrivatePayload =
|
||||||
RunnerJobVODWebVideoTranscodingPrivatePayload |
|
| RunnerJobVODWebVideoTranscodingPrivatePayload
|
||||||
RunnerJobVODAudioMergeTranscodingPrivatePayload |
|
| RunnerJobVODAudioMergeTranscodingPrivatePayload
|
||||||
RunnerJobVODHLSTranscodingPrivatePayload
|
| RunnerJobVODHLSTranscodingPrivatePayload
|
||||||
|
|
||||||
export type RunnerJobPrivatePayload =
|
export type RunnerJobPrivatePayload =
|
||||||
RunnerJobVODPrivatePayload |
|
| RunnerJobVODPrivatePayload
|
||||||
RunnerJobLiveRTMPHLSTranscodingPrivatePayload |
|
| RunnerJobLiveRTMPHLSTranscodingPrivatePayload
|
||||||
RunnerJobVideoStudioTranscodingPrivatePayload |
|
| RunnerJobVideoStudioTranscodingPrivatePayload
|
||||||
RunnerJobTranscriptionPrivatePayload
|
| RunnerJobTranscriptionPrivatePayload
|
||||||
|
| RunnerJobGenerateStoryboardPrivatePayload
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -52,3 +53,7 @@ export interface RunnerJobVideoStudioTranscodingPrivatePayload {
|
||||||
export interface RunnerJobTranscriptionPrivatePayload {
|
export interface RunnerJobTranscriptionPrivatePayload {
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunnerJobGenerateStoryboardPrivatePayload {
|
||||||
|
videoUUID: string
|
||||||
|
}
|
||||||
|
|
|
@ -13,7 +13,8 @@ export type RunnerJobSuccessPayload =
|
||||||
VODAudioMergeTranscodingSuccess |
|
VODAudioMergeTranscodingSuccess |
|
||||||
LiveRTMPHLSTranscodingSuccess |
|
LiveRTMPHLSTranscodingSuccess |
|
||||||
VideoStudioTranscodingSuccess |
|
VideoStudioTranscodingSuccess |
|
||||||
TranscriptionSuccess
|
TranscriptionSuccess |
|
||||||
|
GenerateStoryboardSuccess
|
||||||
|
|
||||||
export interface VODWebVideoTranscodingSuccess {
|
export interface VODWebVideoTranscodingSuccess {
|
||||||
videoFile: Blob | string
|
videoFile: Blob | string
|
||||||
|
@ -42,6 +43,10 @@ export interface TranscriptionSuccess {
|
||||||
vttFile: Blob | string
|
vttFile: Blob | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateStoryboardSuccess {
|
||||||
|
storyboardFile: Blob | string
|
||||||
|
}
|
||||||
|
|
||||||
export function isWebVideoOrAudioMergeTranscodingPayloadSuccess (
|
export function isWebVideoOrAudioMergeTranscodingPayloadSuccess (
|
||||||
payload: RunnerJobSuccessPayload
|
payload: RunnerJobSuccessPayload
|
||||||
): payload is VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess {
|
): payload is VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess {
|
||||||
|
@ -55,3 +60,7 @@ export function isHLSTranscodingPayloadSuccess (payload: RunnerJobSuccessPayload
|
||||||
export function isTranscriptionPayloadSuccess (payload: RunnerJobSuccessPayload): payload is TranscriptionSuccess {
|
export function isTranscriptionPayloadSuccess (payload: RunnerJobSuccessPayload): payload is TranscriptionSuccess {
|
||||||
return !!(payload as TranscriptionSuccess)?.vttFile
|
return !!(payload as TranscriptionSuccess)?.vttFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isGenerateStoryboardSuccess (payload: RunnerJobSuccessPayload): payload is GenerateStoryboardSuccess {
|
||||||
|
return !!(payload as GenerateStoryboardSuccess)?.storyboardFile
|
||||||
|
}
|
||||||
|
|
|
@ -4,4 +4,5 @@ export type RunnerJobType =
|
||||||
'vod-audio-merge-transcoding' |
|
'vod-audio-merge-transcoding' |
|
||||||
'live-rtmp-hls-transcoding' |
|
'live-rtmp-hls-transcoding' |
|
||||||
'video-studio-transcoding' |
|
'video-studio-transcoding' |
|
||||||
'video-transcription'
|
'video-transcription' |
|
||||||
|
'generate-video-storyboard'
|
||||||
|
|
|
@ -345,6 +345,9 @@ export interface CustomConfig {
|
||||||
|
|
||||||
storyboards: {
|
storyboards: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
remoteRunners: {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaults: {
|
defaults: {
|
||||||
|
|
|
@ -418,6 +418,9 @@ export interface ServerConfig {
|
||||||
|
|
||||||
storyboards: {
|
storyboards: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
remoteRunners: {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
videoTranscription: {
|
videoTranscription: {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
AcceptRunnerJobBody,
|
AcceptRunnerJobBody,
|
||||||
AcceptRunnerJobResult,
|
AcceptRunnerJobResult,
|
||||||
ErrorRunnerJobBody,
|
ErrorRunnerJobBody,
|
||||||
|
GenerateStoryboardSuccess,
|
||||||
HttpStatusCode,
|
HttpStatusCode,
|
||||||
ListRunnerJobsQuery,
|
ListRunnerJobsQuery,
|
||||||
RequestRunnerJobBody,
|
RequestRunnerJobBody,
|
||||||
|
@ -11,6 +12,7 @@ import {
|
||||||
ResultList,
|
ResultList,
|
||||||
RunnerJobAdmin,
|
RunnerJobAdmin,
|
||||||
RunnerJobCustomUpload,
|
RunnerJobCustomUpload,
|
||||||
|
RunnerJobGenerateStoryboardPayload,
|
||||||
RunnerJobLiveRTMPHLSTranscodingPayload,
|
RunnerJobLiveRTMPHLSTranscodingPayload,
|
||||||
RunnerJobPayload,
|
RunnerJobPayload,
|
||||||
RunnerJobState,
|
RunnerJobState,
|
||||||
|
@ -26,6 +28,7 @@ import {
|
||||||
TranscriptionSuccess,
|
TranscriptionSuccess,
|
||||||
VODHLSTranscodingSuccess,
|
VODHLSTranscodingSuccess,
|
||||||
VODWebVideoTranscodingSuccess,
|
VODWebVideoTranscodingSuccess,
|
||||||
|
isGenerateStoryboardSuccess,
|
||||||
isHLSTranscodingPayloadSuccess,
|
isHLSTranscodingPayloadSuccess,
|
||||||
isLiveRTMPHLSTranscodingUpdatePayload,
|
isLiveRTMPHLSTranscodingUpdatePayload,
|
||||||
isTranscriptionPayloadSuccess,
|
isTranscriptionPayloadSuccess,
|
||||||
|
@ -265,6 +268,22 @@ export class RunnerJobsCommand extends AbstractCommand {
|
||||||
payloadWithoutFiles = omit(payloadWithoutFiles as TranscriptionSuccess, [ 'vttFile' ])
|
payloadWithoutFiles = omit(payloadWithoutFiles as TranscriptionSuccess, [ 'vttFile' ])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate storyboard success payload contains a storyboard image file
|
||||||
|
if (isGenerateStoryboardSuccess(payload) && payload.storyboardFile) {
|
||||||
|
const reqPayload = options.reqPayload as RunnerJobGenerateStoryboardPayload
|
||||||
|
|
||||||
|
this.updateUploadPayloads({
|
||||||
|
attachesStore: attaches,
|
||||||
|
customUploadsStore: customUploads,
|
||||||
|
|
||||||
|
file: payload.storyboardFile,
|
||||||
|
attachName: 'storyboardFile',
|
||||||
|
customUpload: reqPayload?.output?.storyboardFileCustomUpload
|
||||||
|
})
|
||||||
|
|
||||||
|
payloadWithoutFiles = omit(payloadWithoutFiles as GenerateStoryboardSuccess, [ 'storyboardFile' ])
|
||||||
|
}
|
||||||
|
|
||||||
return this.uploadRunnerJobRequest({
|
return this.uploadRunnerJobRequest({
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
|
@ -367,12 +386,17 @@ export class RunnerJobsCommand extends AbstractCommand {
|
||||||
|
|
||||||
if (!jobUUID) {
|
if (!jobUUID) {
|
||||||
const { availableJobs } = await this.request({ runnerToken })
|
const { availableJobs } = await this.request({ runnerToken })
|
||||||
jobUUID = availableJobs[0].uuid
|
// Find a web video transcoding job specifically
|
||||||
|
const webVideoJob = availableJobs.find(j => j.type === 'vod-web-video-transcoding')
|
||||||
|
if (!webVideoJob) throw new Error('No web video transcoding jobs available')
|
||||||
|
|
||||||
|
jobUUID = webVideoJob.uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
const { job } = await this.accept({ runnerToken, jobUUID })
|
const { job } = await this.accept({ runnerToken, jobUUID })
|
||||||
const jobToken = job.jobToken
|
const jobToken = job.jobToken
|
||||||
|
|
||||||
|
// Use a proper fixture file path for testing
|
||||||
const payload: RunnerJobSuccessPayload = { videoFile: 'video_short.mp4' }
|
const payload: RunnerJobSuccessPayload = { videoFile: 'video_short.mp4' }
|
||||||
await this.success({ runnerToken, jobUUID, jobToken, payload, reqPayload: undefined })
|
await this.success({ runnerToken, jobUUID, jobToken, payload, reqPayload: undefined })
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export * from './runner-common.js'
|
export * from './runner-common.js'
|
||||||
|
export * from './runner-generate-storyboard.js'
|
||||||
export * from './runner-live-transcoding.js'
|
export * from './runner-live-transcoding.js'
|
||||||
export * from './runner-socket.js'
|
export * from './runner-socket.js'
|
||||||
export * from './runner-studio-transcoding.js'
|
export * from './runner-studio-transcoding.js'
|
||||||
|
|
92
packages/tests/src/api/runners/runner-generate-storyboard.ts
Normal file
92
packages/tests/src/api/runners/runner-generate-storyboard.ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { GenerateStoryboardSuccess, RunnerJobGenerateStoryboardPayload } from '@peertube/peertube-models'
|
||||||
|
import {
|
||||||
|
PeerTubeServer,
|
||||||
|
cleanupTests,
|
||||||
|
createMultipleServers,
|
||||||
|
doubleFollow,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
setDefaultVideoChannel,
|
||||||
|
waitJobs
|
||||||
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { testImage } from '@tests/shared/checks.js'
|
||||||
|
import { checkPersistentTmpIsEmpty } from '@tests/shared/directories.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
describe('Test runner generate storyboard', function () {
|
||||||
|
let servers: PeerTubeServer[] = []
|
||||||
|
let runnerToken: string
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(120_000)
|
||||||
|
|
||||||
|
servers = await createMultipleServers(2)
|
||||||
|
|
||||||
|
await setAccessTokensToServers(servers)
|
||||||
|
await setDefaultVideoChannel(servers)
|
||||||
|
|
||||||
|
await doubleFollow(servers[0], servers[1])
|
||||||
|
|
||||||
|
await servers[0].config.updateExistingConfig({
|
||||||
|
newConfig: {
|
||||||
|
storyboards: {
|
||||||
|
enabled: true,
|
||||||
|
remoteRunners: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
runnerToken = await servers[0].runners.autoRegisterRunner()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function upload () {
|
||||||
|
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'video', language: undefined } })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
const { availableJobs } = await servers[0].runnerJobs.request({ runnerToken })
|
||||||
|
expect(availableJobs).to.have.lengthOf(1)
|
||||||
|
|
||||||
|
const jobUUID = availableJobs[0].uuid
|
||||||
|
|
||||||
|
const { job } = await servers[0].runnerJobs.accept<RunnerJobGenerateStoryboardPayload>({ runnerToken, jobUUID })
|
||||||
|
return { uuid, job }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should execute a remote generate storyboard job', async function () {
|
||||||
|
this.timeout(240_000)
|
||||||
|
|
||||||
|
const { uuid, job } = await upload()
|
||||||
|
|
||||||
|
expect(job.type === 'generate-video-storyboard')
|
||||||
|
expect(job.payload.input.videoFileUrl).to.exist
|
||||||
|
|
||||||
|
// Check video input file
|
||||||
|
{
|
||||||
|
await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken: job.jobToken, runnerToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: GenerateStoryboardSuccess = {
|
||||||
|
storyboardFile: 'banner.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].runnerJobs.success({ runnerToken, jobUUID: job.uuid, jobToken: job.jobToken, payload })
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const { storyboards } = await server.storyboard.list({ id: uuid })
|
||||||
|
|
||||||
|
expect(storyboards).to.have.lengthOf(1)
|
||||||
|
|
||||||
|
await testImage({ name: 'banner.jpg', url: storyboards[0].fileUrl })
|
||||||
|
}
|
||||||
|
await checkPersistentTmpIsEmpty(servers[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests(servers)
|
||||||
|
})
|
||||||
|
})
|
|
@ -443,7 +443,10 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
storyboards: {
|
storyboards: {
|
||||||
enabled: false
|
enabled: false,
|
||||||
|
remoteRunners: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
export: {
|
export: {
|
||||||
users: {
|
users: {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import './video-schedule-update.js'
|
||||||
import './video-source.js'
|
import './video-source.js'
|
||||||
import './video-static-file-privacy.js'
|
import './video-static-file-privacy.js'
|
||||||
import './video-storyboard.js'
|
import './video-storyboard.js'
|
||||||
|
import './video-storyboard-remote-runner.js'
|
||||||
import './video-transcription.js'
|
import './video-transcription.js'
|
||||||
import './videos-common-filters.js'
|
import './videos-common-filters.js'
|
||||||
import './videos-history.js'
|
import './videos-history.js'
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { expect } from 'chai'
|
import { VideoPrivacy } from '@peertube/peertube-models'
|
||||||
import { readdir } from 'fs/promises'
|
|
||||||
import { basename } from 'path'
|
|
||||||
import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
|
|
||||||
import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils'
|
import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
|
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
createMultipleServers,
|
createMultipleServers,
|
||||||
doubleFollow,
|
doubleFollow,
|
||||||
makeGetRequest,
|
|
||||||
makeRawRequest,
|
|
||||||
PeerTubeServer,
|
PeerTubeServer,
|
||||||
sendRTMPStream,
|
sendRTMPStream,
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
|
@ -19,44 +13,11 @@ import {
|
||||||
stopFfmpeg,
|
stopFfmpeg,
|
||||||
waitJobs
|
waitJobs
|
||||||
} from '@peertube/peertube-server-commands'
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
|
||||||
async function checkStoryboard (options: {
|
import { checkStoryboard } from '@tests/shared/storyboard.js'
|
||||||
server: PeerTubeServer
|
import { expect } from 'chai'
|
||||||
uuid: string
|
import { readdir } from 'fs/promises'
|
||||||
spriteHeight?: number
|
import { basename } from 'path'
|
||||||
spriteWidth?: number
|
|
||||||
tilesCount?: number
|
|
||||||
minSize?: number
|
|
||||||
spriteDuration?: number
|
|
||||||
}) {
|
|
||||||
const { server, uuid, tilesCount, spriteDuration = 1, spriteHeight = 108, spriteWidth = 192, minSize = 1000 } = options
|
|
||||||
|
|
||||||
const { storyboards } = await server.storyboard.list({ id: uuid })
|
|
||||||
|
|
||||||
expect(storyboards).to.have.lengthOf(1)
|
|
||||||
|
|
||||||
const storyboard = storyboards[0]
|
|
||||||
|
|
||||||
expect(storyboard.spriteDuration).to.equal(spriteDuration)
|
|
||||||
expect(storyboard.spriteHeight).to.equal(spriteHeight)
|
|
||||||
expect(storyboard.spriteWidth).to.equal(spriteWidth)
|
|
||||||
expect(storyboard.storyboardPath).to.exist
|
|
||||||
|
|
||||||
if (tilesCount) {
|
|
||||||
expect(storyboard.totalWidth).to.equal(spriteWidth * Math.min(tilesCount, 11))
|
|
||||||
expect(storyboard.totalHeight).to.equal(spriteHeight * Math.max((tilesCount / 11), 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const { body } = await makeGetRequest({ url: server.url, path: storyboard.storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
expect(body.length).to.be.above(minSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const { body } = await makeRawRequest({ url: storyboard.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
|
||||||
expect(body.length).to.be.above(minSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Test video storyboard', function () {
|
describe('Test video storyboard', function () {
|
||||||
let servers: PeerTubeServer[]
|
let servers: PeerTubeServer[]
|
||||||
|
@ -205,16 +166,16 @@ describe('Test video storyboard', function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const storyboads = await listFiles()
|
const storyboards = await listFiles()
|
||||||
expect(storyboads).to.include(storyboardName)
|
expect(storyboards).to.include(storyboardName)
|
||||||
}
|
}
|
||||||
|
|
||||||
await servers[0].videos.remove({ id: baseUUID })
|
await servers[0].videos.remove({ id: baseUUID })
|
||||||
await waitJobs(servers)
|
await waitJobs(servers)
|
||||||
|
|
||||||
{
|
{
|
||||||
const storyboads = await listFiles()
|
const storyboards = await listFiles()
|
||||||
expect(storyboads).to.not.include(storyboardName)
|
expect(storyboards).to.not.include(storyboardName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
189
packages/tests/src/peertube-runner/generate-storyboard.ts
Normal file
189
packages/tests/src/peertube-runner/generate-storyboard.ts
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { wait } from '@peertube/peertube-core-utils'
|
||||||
|
import { RunnerJobState, VideoPrivacy } from '@peertube/peertube-models'
|
||||||
|
import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils'
|
||||||
|
import {
|
||||||
|
PeerTubeServer,
|
||||||
|
cleanupTests,
|
||||||
|
createMultipleServers,
|
||||||
|
doubleFollow,
|
||||||
|
sendRTMPStream,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
setDefaultVideoChannel,
|
||||||
|
stopFfmpeg,
|
||||||
|
waitJobs
|
||||||
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { checkPeerTubeRunnerCacheIsEmpty } from '@tests/shared/directories.js'
|
||||||
|
import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
|
||||||
|
import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js'
|
||||||
|
import { checkNoStoryboard, checkStoryboard } from '@tests/shared/storyboard.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
describe('Test generate storyboard in peertube-runner program', function () {
|
||||||
|
let servers: PeerTubeServer[] = []
|
||||||
|
let peertubeRunner: PeerTubeRunnerProcess
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(120_000)
|
||||||
|
|
||||||
|
servers = await createMultipleServers(2)
|
||||||
|
|
||||||
|
await setAccessTokensToServers(servers)
|
||||||
|
await setDefaultVideoChannel(servers)
|
||||||
|
|
||||||
|
await doubleFollow(servers[0], servers[1])
|
||||||
|
|
||||||
|
await servers[0].config.updateExistingConfig({
|
||||||
|
newConfig: {
|
||||||
|
storyboards: {
|
||||||
|
enabled: true,
|
||||||
|
remoteRunners: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken()
|
||||||
|
|
||||||
|
peertubeRunner = new PeerTubeRunnerProcess(servers[0])
|
||||||
|
await peertubeRunner.runServer({ jobType: 'generate-video-storyboard' })
|
||||||
|
await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Running storyboard generation', function () {
|
||||||
|
describe('Common on filesystem', function () {
|
||||||
|
it('Should run generate storyboard on classic file without transcoding', async function () {
|
||||||
|
this.timeout(360000)
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
|
||||||
|
await waitJobs(servers, { runnerJobs: true })
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid, tilesCount: 5 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should run generate storyboard on classic file with transcoding', async function () {
|
||||||
|
this.timeout(360000)
|
||||||
|
|
||||||
|
await servers[0].config.enableMinimumTranscoding()
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
|
||||||
|
await waitJobs(servers, { runnerJobs: true })
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid, tilesCount: 5 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await servers[0].config.disableTranscoding()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after HTTP import', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
if (areHttpImportTestsDisabled()) return
|
||||||
|
|
||||||
|
// 3s video
|
||||||
|
const { video } = await servers[0].videoImports.importVideo({
|
||||||
|
attributes: {
|
||||||
|
targetUrl: FIXTURE_URLS.goodVideo,
|
||||||
|
channelId: servers[0].store.channel.id,
|
||||||
|
privacy: VideoPrivacy.PUBLIC
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers, { runnerJobs: true })
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid: video.uuid, spriteHeight: 144, tilesCount: 3 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after torrent import', async function () {
|
||||||
|
this.timeout(240000)
|
||||||
|
|
||||||
|
if (areHttpImportTestsDisabled()) return
|
||||||
|
|
||||||
|
// 10s video
|
||||||
|
const { video } = await servers[0].videoImports.importVideo({
|
||||||
|
attributes: {
|
||||||
|
magnetUri: FIXTURE_URLS.magnet,
|
||||||
|
channelId: servers[0].store.channel.id,
|
||||||
|
privacy: VideoPrivacy.PUBLIC
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers, { runnerJobs: true })
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid: video.uuid, tilesCount: 10 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after a live', async function () {
|
||||||
|
this.timeout(240000)
|
||||||
|
|
||||||
|
await servers[0].config.enableMinimumTranscoding()
|
||||||
|
await servers[0].config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
|
||||||
|
|
||||||
|
const { live, video } = await servers[0].live.quickCreate({
|
||||||
|
saveReplay: true,
|
||||||
|
permanentLive: false,
|
||||||
|
privacy: VideoPrivacy.PUBLIC
|
||||||
|
})
|
||||||
|
|
||||||
|
const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
|
||||||
|
await servers[0].live.waitUntilPublished({ videoId: video.id })
|
||||||
|
|
||||||
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
|
||||||
|
await servers[0].live.waitUntilReplacedByReplay({ videoId: video.id })
|
||||||
|
await waitJobs(servers, { runnerJobs: true })
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid: video.uuid })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When generate storyboard is not enabled in runner', function () {
|
||||||
|
before(async function () {
|
||||||
|
await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' })
|
||||||
|
peertubeRunner.kill()
|
||||||
|
await wait(500)
|
||||||
|
|
||||||
|
const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken()
|
||||||
|
await peertubeRunner.runServer({ jobType: 'live-rtmp-hls-transcoding' })
|
||||||
|
await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not run generate storyboard', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
await wait(2000)
|
||||||
|
|
||||||
|
const { data } = await servers[0].runnerJobs.list({ stateOneOf: [ RunnerJobState.PENDING ] })
|
||||||
|
expect(data.some(j => j.type === 'generate-video-storyboard')).to.be.true
|
||||||
|
|
||||||
|
await checkNoStoryboard({ server: servers[0], uuid })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Check cleanup', function () {
|
||||||
|
it('Should have an empty cache directory', async function () {
|
||||||
|
await checkPeerTubeRunnerCacheIsEmpty(peertubeRunner, 'storyboard')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
if (peertubeRunner) {
|
||||||
|
await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' })
|
||||||
|
peertubeRunner.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupTests(servers)
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './client-cli.js'
|
export * from './client-cli.js'
|
||||||
export * from './custom-upload.js'
|
export * from './custom-upload.js'
|
||||||
|
export * from './generate-storyboard.js'
|
||||||
export * from './live-transcoding.js'
|
export * from './live-transcoding.js'
|
||||||
export * from './replace-file.js'
|
export * from './replace-file.js'
|
||||||
export * from './shutdown.js'
|
export * from './shutdown.js'
|
||||||
|
|
|
@ -32,7 +32,10 @@ export async function checkDirectoryIsEmpty (server: PeerTubeServer, directory:
|
||||||
expect(filtered).to.have.lengthOf(0)
|
expect(filtered).to.have.lengthOf(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkPeerTubeRunnerCacheIsEmpty (runner: PeerTubeRunnerProcess, subDir: 'transcoding' | 'transcription') {
|
export async function checkPeerTubeRunnerCacheIsEmpty (
|
||||||
|
runner: PeerTubeRunnerProcess,
|
||||||
|
subDir: 'transcoding' | 'transcription' | 'storyboard'
|
||||||
|
) {
|
||||||
const directoryPath = join(homedir(), '.cache', 'peertube-runner-nodejs', runner.getId(), subDir)
|
const directoryPath = join(homedir(), '.cache', 'peertube-runner-nodejs', runner.getId(), subDir)
|
||||||
|
|
||||||
const directoryExists = await pathExists(directoryPath)
|
const directoryExists = await pathExists(directoryPath)
|
||||||
|
|
53
packages/tests/src/shared/storyboard.ts
Normal file
53
packages/tests/src/shared/storyboard.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||||
|
import { makeGetRequest, makeRawRequest, PeerTubeServer } from '@peertube/peertube-server-commands'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
export async function checkStoryboard (options: {
|
||||||
|
server: PeerTubeServer
|
||||||
|
uuid: string
|
||||||
|
spriteHeight?: number
|
||||||
|
spriteWidth?: number
|
||||||
|
tilesCount?: number
|
||||||
|
minSize?: number
|
||||||
|
spriteDuration?: number
|
||||||
|
}) {
|
||||||
|
const { server, uuid, tilesCount, spriteDuration = 1, spriteHeight = 108, spriteWidth = 192, minSize = 1000 } = options
|
||||||
|
|
||||||
|
const { storyboards } = await server.storyboard.list({ id: uuid })
|
||||||
|
expect(storyboards).to.have.lengthOf(1)
|
||||||
|
|
||||||
|
const storyboard = storyboards[0]
|
||||||
|
|
||||||
|
expect(storyboard.spriteDuration).to.equal(spriteDuration)
|
||||||
|
expect(storyboard.spriteHeight).to.equal(spriteHeight)
|
||||||
|
expect(storyboard.spriteWidth).to.equal(spriteWidth)
|
||||||
|
expect(storyboard.storyboardPath).to.exist
|
||||||
|
|
||||||
|
if (tilesCount) {
|
||||||
|
expect(storyboard.totalWidth).to.equal(spriteWidth * Math.min(tilesCount, 11))
|
||||||
|
expect(storyboard.totalHeight).to.equal(spriteHeight * Math.max(tilesCount / 11, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { body } = await makeGetRequest({ url: server.url, path: storyboard.storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
expect(body.length).to.be.above(minSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { body } = await makeRawRequest({ url: storyboard.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
expect(body.length).to.be.above(minSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkNoStoryboard (options: {
|
||||||
|
server: PeerTubeServer
|
||||||
|
uuid: string
|
||||||
|
}) {
|
||||||
|
const { server, uuid } = options
|
||||||
|
|
||||||
|
const { storyboards } = await server.storyboard.list({ id: uuid })
|
||||||
|
|
||||||
|
expect(storyboards).to.have.lengthOf(0)
|
||||||
|
}
|
|
@ -582,7 +582,10 @@ function customConfig (): CustomConfig {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
storyboards: {
|
storyboards: {
|
||||||
enabled: CONFIG.STORYBOARDS.ENABLED
|
enabled: CONFIG.STORYBOARDS.ENABLED,
|
||||||
|
remoteRunners: {
|
||||||
|
enabled: CONFIG.STORYBOARDS.REMOTE_RUNNERS.ENABLED
|
||||||
|
}
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
publish: {
|
publish: {
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
RunnerJobUpdatePayload,
|
RunnerJobUpdatePayload,
|
||||||
ServerErrorCode,
|
ServerErrorCode,
|
||||||
TranscriptionSuccess,
|
TranscriptionSuccess,
|
||||||
|
GenerateStoryboardSuccess,
|
||||||
UserRight,
|
UserRight,
|
||||||
VODAudioMergeTranscodingSuccess,
|
VODAudioMergeTranscodingSuccess,
|
||||||
VODHLSTranscodingSuccess,
|
VODHLSTranscodingSuccess,
|
||||||
|
@ -56,8 +57,13 @@ import { RunnerModel } from '@server/models/runner/runner.js'
|
||||||
import express, { UploadFiles } from 'express'
|
import express, { UploadFiles } from 'express'
|
||||||
|
|
||||||
const postRunnerJobSuccessVideoFiles = createReqFiles(
|
const postRunnerJobSuccessVideoFiles = createReqFiles(
|
||||||
[ 'payload[videoFile]', 'payload[resolutionPlaylistFile]', 'payload[vttFile]' ],
|
[ 'payload[videoFile]', 'payload[resolutionPlaylistFile]', 'payload[vttFile]', 'payload[storyboardFile]' ],
|
||||||
{ ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.M3U8.MIMETYPE_EXT, ...MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT }
|
{
|
||||||
|
...MIMETYPES.VIDEO.MIMETYPE_EXT,
|
||||||
|
...MIMETYPES.M3U8.MIMETYPE_EXT,
|
||||||
|
...MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT,
|
||||||
|
...MIMETYPES.IMAGE.MIMETYPE_EXT
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const runnerJobUpdateVideoFiles = createReqFiles(
|
const runnerJobUpdateVideoFiles = createReqFiles(
|
||||||
|
@ -384,6 +390,14 @@ const jobSuccessPayloadBuilders: {
|
||||||
|
|
||||||
vttFile: files['payload[vttFile]'][0].path
|
vttFile: files['payload[vttFile]'][0].path
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'generate-video-storyboard': (payload: GenerateStoryboardSuccess, files) => {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
|
||||||
|
storyboardFile: files['payload[storyboardFile]'][0].path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { setupUploadResumableRoutes } from '@server/lib/uploadx.js'
|
||||||
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
|
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
|
||||||
import { regenerateTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
|
import { regenerateTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
|
||||||
import { buildNewFile, createVideoSource } from '@server/lib/video-file.js'
|
import { buildNewFile, createVideoSource } from '@server/lib/video-file.js'
|
||||||
import { buildMoveVideoJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
|
import { addRemoteStoryboardJobIfNeeded, buildLocalStoryboardJobIfNeeded, buildMoveVideoJob } from '@server/lib/video-jobs.js'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { buildNextVideoState } from '@server/lib/video-state.js'
|
import { buildNextVideoState } from '@server/lib/video-state.js'
|
||||||
import { openapiOperationDoc } from '@server/middlewares/doc.js'
|
import { openapiOperationDoc } from '@server/middlewares/doc.js'
|
||||||
|
@ -172,7 +172,7 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
buildStoryboardJobIfNeeded({ video, federate: false }),
|
buildLocalStoryboardJobIfNeeded({ video, federate: false }),
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'federate-video' as const,
|
type: 'federate-video' as const,
|
||||||
|
@ -201,6 +201,7 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide
|
||||||
|
|
||||||
await JobQueue.Instance.createSequentialJobFlow(...jobs)
|
await JobQueue.Instance.createSequentialJobFlow(...jobs)
|
||||||
|
|
||||||
|
await addRemoteStoryboardJobIfNeeded(video)
|
||||||
await regenerateTranscriptionTaskIfNeeded(video)
|
await regenerateTranscriptionTaskIfNeeded(video)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,8 @@ import {
|
||||||
VODAudioMergeTranscodingSuccess,
|
VODAudioMergeTranscodingSuccess,
|
||||||
VODHLSTranscodingSuccess,
|
VODHLSTranscodingSuccess,
|
||||||
VODWebVideoTranscodingSuccess,
|
VODWebVideoTranscodingSuccess,
|
||||||
VideoStudioTranscodingSuccess
|
VideoStudioTranscodingSuccess,
|
||||||
|
GenerateStoryboardSuccess
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants.js'
|
import { CONSTRAINTS_FIELDS, RUNNER_JOB_STATES } from '@server/initializers/constants.js'
|
||||||
import { UploadFilesForCheck } from 'express'
|
import { UploadFilesForCheck } from 'express'
|
||||||
|
@ -16,7 +17,15 @@ import { exists, isArray, isFileValid, isSafeFilename } from '../misc.js'
|
||||||
|
|
||||||
const RUNNER_JOBS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.RUNNER_JOBS
|
const RUNNER_JOBS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.RUNNER_JOBS
|
||||||
|
|
||||||
const runnerJobTypes = new Set([ 'vod-hls-transcoding', 'vod-web-video-transcoding', 'vod-audio-merge-transcoding' ])
|
const runnerJobTypes = new Set([
|
||||||
|
'vod-hls-transcoding',
|
||||||
|
'vod-web-video-transcoding',
|
||||||
|
'vod-audio-merge-transcoding',
|
||||||
|
'live-rtmp-hls-transcoding',
|
||||||
|
'video-studio-transcoding',
|
||||||
|
'video-transcription',
|
||||||
|
'generate-video-storyboard'
|
||||||
|
])
|
||||||
export function isRunnerJobTypeValid (value: RunnerJobType) {
|
export function isRunnerJobTypeValid (value: RunnerJobType) {
|
||||||
return runnerJobTypes.has(value)
|
return runnerJobTypes.has(value)
|
||||||
}
|
}
|
||||||
|
@ -27,7 +36,8 @@ export function isRunnerJobSuccessPayloadValid (value: RunnerJobSuccessPayload,
|
||||||
isRunnerJobVODAudioMergeResultPayloadValid(value as VODHLSTranscodingSuccess, type, files) ||
|
isRunnerJobVODAudioMergeResultPayloadValid(value as VODHLSTranscodingSuccess, type, files) ||
|
||||||
isRunnerJobLiveRTMPHLSResultPayloadValid(value as LiveRTMPHLSTranscodingSuccess, type) ||
|
isRunnerJobLiveRTMPHLSResultPayloadValid(value as LiveRTMPHLSTranscodingSuccess, type) ||
|
||||||
isRunnerJobVideoStudioResultPayloadValid(value as VideoStudioTranscodingSuccess, type, files) ||
|
isRunnerJobVideoStudioResultPayloadValid(value as VideoStudioTranscodingSuccess, type, files) ||
|
||||||
isRunnerJobTranscriptionResultPayloadValid(value as TranscriptionSuccess, type, files)
|
isRunnerJobTranscriptionResultPayloadValid(value as TranscriptionSuccess, type, files) ||
|
||||||
|
isRunnerJobGenerateStoryboardResultPayloadValid(value as GenerateStoryboardSuccess, type, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -42,7 +52,8 @@ export function isRunnerJobUpdatePayloadValid (value: RunnerJobUpdatePayload, ty
|
||||||
isRunnerJobVideoStudioUpdatePayloadValid(value, type, files) ||
|
isRunnerJobVideoStudioUpdatePayloadValid(value, type, files) ||
|
||||||
isRunnerJobVODAudioMergeUpdatePayloadValid(value, type, files) ||
|
isRunnerJobVODAudioMergeUpdatePayloadValid(value, type, files) ||
|
||||||
isRunnerJobLiveRTMPHLSUpdatePayloadValid(value, type, files) ||
|
isRunnerJobLiveRTMPHLSUpdatePayloadValid(value, type, files) ||
|
||||||
isRunnerJobTranscriptionUpdatePayloadValid(value, type, files)
|
isRunnerJobTranscriptionUpdatePayloadValid(value, type, files) ||
|
||||||
|
isRunnerJobGenerateStoryboardUpdatePayloadValid(value, type, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -103,7 +114,7 @@ function isRunnerJobLiveRTMPHLSResultPayloadValid (
|
||||||
value: LiveRTMPHLSTranscodingSuccess,
|
value: LiveRTMPHLSTranscodingSuccess,
|
||||||
type: RunnerJobType
|
type: RunnerJobType
|
||||||
) {
|
) {
|
||||||
return type === 'live-rtmp-hls-transcoding' && (!value || (typeof value === 'object' && Object.keys(value).length === 0))
|
return type === 'live-rtmp-hls-transcoding' && isEmptyUpdatePayload(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRunnerJobVideoStudioResultPayloadValid (
|
function isRunnerJobVideoStudioResultPayloadValid (
|
||||||
|
@ -124,6 +135,15 @@ function isRunnerJobTranscriptionResultPayloadValid (
|
||||||
isFileValid({ files, field: 'payload[vttFile]', mimeTypeRegex: null, maxSize: null })
|
isFileValid({ files, field: 'payload[vttFile]', mimeTypeRegex: null, maxSize: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRunnerJobGenerateStoryboardResultPayloadValid (
|
||||||
|
value: GenerateStoryboardSuccess,
|
||||||
|
type: RunnerJobType,
|
||||||
|
files: UploadFilesForCheck
|
||||||
|
) {
|
||||||
|
return type === 'generate-video-storyboard' &&
|
||||||
|
isFileValid({ files, field: 'payload[storyboardFile]', mimeTypeRegex: null, maxSize: null })
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function isRunnerJobVODWebVideoUpdatePayloadValid (
|
function isRunnerJobVODWebVideoUpdatePayloadValid (
|
||||||
|
@ -131,8 +151,7 @@ function isRunnerJobVODWebVideoUpdatePayloadValid (
|
||||||
type: RunnerJobType,
|
type: RunnerJobType,
|
||||||
_files: UploadFilesForCheck
|
_files: UploadFilesForCheck
|
||||||
) {
|
) {
|
||||||
return type === 'vod-web-video-transcoding' &&
|
return type === 'vod-web-video-transcoding' && isEmptyUpdatePayload(value)
|
||||||
(!value || (typeof value === 'object' && Object.keys(value).length === 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRunnerJobVODHLSUpdatePayloadValid (
|
function isRunnerJobVODHLSUpdatePayloadValid (
|
||||||
|
@ -140,8 +159,7 @@ function isRunnerJobVODHLSUpdatePayloadValid (
|
||||||
type: RunnerJobType,
|
type: RunnerJobType,
|
||||||
_files: UploadFilesForCheck
|
_files: UploadFilesForCheck
|
||||||
) {
|
) {
|
||||||
return type === 'vod-hls-transcoding' &&
|
return type === 'vod-hls-transcoding' && isEmptyUpdatePayload(value)
|
||||||
(!value || (typeof value === 'object' && Object.keys(value).length === 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRunnerJobVODAudioMergeUpdatePayloadValid (
|
function isRunnerJobVODAudioMergeUpdatePayloadValid (
|
||||||
|
@ -149,8 +167,7 @@ function isRunnerJobVODAudioMergeUpdatePayloadValid (
|
||||||
type: RunnerJobType,
|
type: RunnerJobType,
|
||||||
_files: UploadFilesForCheck
|
_files: UploadFilesForCheck
|
||||||
) {
|
) {
|
||||||
return type === 'vod-audio-merge-transcoding' &&
|
return type === 'vod-audio-merge-transcoding' && isEmptyUpdatePayload(value)
|
||||||
(!value || (typeof value === 'object' && Object.keys(value).length === 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRunnerJobTranscriptionUpdatePayloadValid (
|
function isRunnerJobTranscriptionUpdatePayloadValid (
|
||||||
|
@ -158,8 +175,7 @@ function isRunnerJobTranscriptionUpdatePayloadValid (
|
||||||
type: RunnerJobType,
|
type: RunnerJobType,
|
||||||
_files: UploadFilesForCheck
|
_files: UploadFilesForCheck
|
||||||
) {
|
) {
|
||||||
return type === 'video-transcription' &&
|
return type === 'video-transcription' && isEmptyUpdatePayload(value)
|
||||||
(!value || (typeof value === 'object' && Object.keys(value).length === 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRunnerJobLiveRTMPHLSUpdatePayloadValid (
|
function isRunnerJobLiveRTMPHLSUpdatePayloadValid (
|
||||||
|
@ -201,6 +217,17 @@ function isRunnerJobVideoStudioUpdatePayloadValid (
|
||||||
type: RunnerJobType,
|
type: RunnerJobType,
|
||||||
_files: UploadFilesForCheck
|
_files: UploadFilesForCheck
|
||||||
) {
|
) {
|
||||||
return type === 'video-studio-transcoding' &&
|
return type === 'video-studio-transcoding' && isEmptyUpdatePayload(value)
|
||||||
(!value || (typeof value === 'object' && Object.keys(value).length === 0))
|
}
|
||||||
|
|
||||||
|
function isRunnerJobGenerateStoryboardUpdatePayloadValid (
|
||||||
|
value: RunnerJobUpdatePayload,
|
||||||
|
type: RunnerJobType,
|
||||||
|
_files: UploadFilesForCheck
|
||||||
|
) {
|
||||||
|
return type === 'generate-video-storyboard' && isEmptyUpdatePayload(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyUpdatePayload (value: any): boolean {
|
||||||
|
return !value || (typeof value === 'object' && Object.keys(value).length === 0)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1099,6 +1099,11 @@ const CONFIG = {
|
||||||
STORYBOARDS: {
|
STORYBOARDS: {
|
||||||
get ENABLED () {
|
get ENABLED () {
|
||||||
return config.get<boolean>('storyboards.enabled')
|
return config.get<boolean>('storyboards.enabled')
|
||||||
|
},
|
||||||
|
REMOTE_RUNNERS: {
|
||||||
|
get ENABLED () {
|
||||||
|
return config.get<boolean>('storyboards.remote_runners.enabled')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
EMAIL: {
|
EMAIL: {
|
||||||
|
|
|
@ -291,6 +291,7 @@ export const REPEAT_JOBS: { [id in JobType]?: RepeatOptions } = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const JOB_PRIORITY = {
|
export const JOB_PRIORITY = {
|
||||||
|
STORYBOARD: 95,
|
||||||
TRANSCODING: 100,
|
TRANSCODING: 100,
|
||||||
VIDEO_STUDIO: 150,
|
VIDEO_STUDIO: 150,
|
||||||
TRANSCRIPTION: 200
|
TRANSCRIPTION: 200
|
||||||
|
|
|
@ -1,25 +1,20 @@
|
||||||
import { FFmpegImage, ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
import { FFmpegImage } from '@peertube/peertube-ffmpeg'
|
||||||
import { GenerateStoryboardPayload, VideoFileStream } from '@peertube/peertube-models'
|
import { GenerateStoryboardPayload, VideoFileStream } from '@peertube/peertube-models'
|
||||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
|
||||||
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
|
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
|
||||||
import { generateImageFilename } from '@server/helpers/image-utils.js'
|
import { generateImageFilename } from '@server/helpers/image-utils.js'
|
||||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||||
import { deleteFileAndCatch } from '@server/helpers/utils.js'
|
|
||||||
import { CONFIG } from '@server/initializers/config.js'
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { STORYBOARD } from '@server/initializers/constants.js'
|
import { STORYBOARD } from '@server/initializers/constants.js'
|
||||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
|
||||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
|
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { getImageSizeFromWorker } from '@server/lib/worker/parent-process.js'
|
import { getImageSizeFromWorker } from '@server/lib/worker/parent-process.js'
|
||||||
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
|
||||||
import { VideoModel } from '@server/models/video/video.js'
|
import { VideoModel } from '@server/models/video/video.js'
|
||||||
import { MVideo } from '@server/types/models/index.js'
|
|
||||||
import { Job } from 'bullmq'
|
import { Job } from 'bullmq'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { buildSpriteSize, buildTotalSprites, findGridSize, insertStoryboardInDatabase } from '../../storyboard.js'
|
||||||
|
|
||||||
const lTagsBase = loggerTagsFactory('storyboard')
|
const lTagsBase = loggerTagsFactory('storyboard')
|
||||||
|
|
||||||
async function processGenerateStoryboard (job: Job): Promise<void> {
|
export async function processGenerateStoryboard (job: Job): Promise<void> {
|
||||||
const payload = job.data as GenerateStoryboardPayload
|
const payload = job.data as GenerateStoryboardPayload
|
||||||
const lTags = lTagsBase(payload.videoUUID)
|
const lTags = lTagsBase(payload.videoUUID)
|
||||||
|
|
||||||
|
@ -46,26 +41,9 @@ async function processGenerateStoryboard (job: Job): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => {
|
await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => {
|
||||||
const probe = await ffprobePromise(videoPath)
|
const { spriteHeight, spriteWidth } = await buildSpriteSize(videoPath)
|
||||||
|
|
||||||
const videoStreamInfo = await getVideoStreamDimensionsInfo(videoPath, probe)
|
const { totalSprites, spriteDuration } = buildTotalSprites(video)
|
||||||
let spriteHeight: number
|
|
||||||
let spriteWidth: number
|
|
||||||
|
|
||||||
if (videoStreamInfo.isPortraitMode) {
|
|
||||||
spriteHeight = STORYBOARD.SPRITE_MAX_SIZE
|
|
||||||
spriteWidth = Math.round(spriteHeight * videoStreamInfo.ratio)
|
|
||||||
} else {
|
|
||||||
spriteWidth = STORYBOARD.SPRITE_MAX_SIZE
|
|
||||||
spriteHeight = Math.round(spriteWidth / videoStreamInfo.ratio)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ffmpeg = new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail'))
|
|
||||||
|
|
||||||
const filename = generateImageFilename()
|
|
||||||
const destination = join(CONFIG.STORAGE.STORYBOARDS_DIR, filename)
|
|
||||||
|
|
||||||
const { totalSprites, spriteDuration } = buildSpritesMetadata({ video })
|
|
||||||
if (totalSprites === 0) {
|
if (totalSprites === 0) {
|
||||||
logger.info(`Do not generate a storyboard of ${payload.videoUUID} because the video is not long enough`, lTags)
|
logger.info(`Do not generate a storyboard of ${payload.videoUUID} because the video is not long enough`, lTags)
|
||||||
return
|
return
|
||||||
|
@ -76,11 +54,16 @@ async function processGenerateStoryboard (job: Job): Promise<void> {
|
||||||
maxEdgeCount: STORYBOARD.SPRITES_MAX_EDGE_COUNT
|
maxEdgeCount: STORYBOARD.SPRITES_MAX_EDGE_COUNT
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filename = generateImageFilename()
|
||||||
|
const destination = join(CONFIG.STORAGE.STORYBOARDS_DIR, filename)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Generating storyboard from video of ${video.uuid} to ${destination}`,
|
`Generating storyboard from video of ${video.uuid} to ${destination}`,
|
||||||
{ ...lTags, totalSprites, spritesCount, spriteDuration, videoDuration: video.duration, spriteHeight, spriteWidth }
|
{ ...lTags, totalSprites, spritesCount, spriteDuration, videoDuration: video.duration, spriteHeight, spriteWidth }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ffmpeg = new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail'))
|
||||||
|
|
||||||
await ffmpeg.generateStoryboardFromVideo({
|
await ffmpeg.generateStoryboardFromVideo({
|
||||||
destination,
|
destination,
|
||||||
path: videoPath,
|
path: videoPath,
|
||||||
|
@ -95,78 +78,23 @@ async function processGenerateStoryboard (job: Job): Promise<void> {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const imageSize = await getImageSizeFromWorker(destination)
|
await insertStoryboardInDatabase({
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
lTags,
|
||||||
|
|
||||||
await retryTransactionWrapper(() => {
|
filename,
|
||||||
return sequelizeTypescript.transaction(async transaction => {
|
destination,
|
||||||
const videoStillExists = await VideoModel.load(video.id, transaction)
|
|
||||||
if (!videoStillExists) {
|
|
||||||
logger.info(`Video ${payload.videoUUID} does not exist anymore, skipping storyboard generation.`, lTags)
|
|
||||||
deleteFileAndCatch(destination)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await StoryboardModel.loadByVideo(video.id, transaction)
|
imageSize: await getImageSizeFromWorker(destination),
|
||||||
if (existing) await existing.destroy({ transaction })
|
|
||||||
|
|
||||||
await StoryboardModel.create({
|
spriteHeight,
|
||||||
filename,
|
spriteWidth,
|
||||||
totalHeight: imageSize.height,
|
spriteDuration,
|
||||||
totalWidth: imageSize.width,
|
|
||||||
spriteHeight,
|
|
||||||
spriteWidth,
|
|
||||||
spriteDuration,
|
|
||||||
videoId: video.id
|
|
||||||
}, { transaction })
|
|
||||||
|
|
||||||
logger.info(`Storyboard generation ${destination} ended for video ${video.uuid}.`, lTags)
|
federate: payload.federate
|
||||||
|
|
||||||
if (payload.federate) {
|
|
||||||
await federateVideoIfNeeded(video, false, transaction)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
inputFileMutexReleaser()
|
inputFileMutexReleaser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export {
|
|
||||||
processGenerateStoryboard
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSpritesMetadata (options: {
|
|
||||||
video: MVideo
|
|
||||||
}) {
|
|
||||||
const { video } = options
|
|
||||||
|
|
||||||
if (video.duration < 3) return { spriteDuration: undefined, totalSprites: 0 }
|
|
||||||
|
|
||||||
const maxSprites = Math.min(Math.ceil(video.duration), STORYBOARD.SPRITES_MAX_EDGE_COUNT * STORYBOARD.SPRITES_MAX_EDGE_COUNT)
|
|
||||||
|
|
||||||
const spriteDuration = Math.ceil(video.duration / maxSprites)
|
|
||||||
const totalSprites = Math.ceil(video.duration / spriteDuration)
|
|
||||||
|
|
||||||
// We can generate a single line so we don't need a prime number
|
|
||||||
if (totalSprites <= STORYBOARD.SPRITES_MAX_EDGE_COUNT) return { spriteDuration, totalSprites }
|
|
||||||
|
|
||||||
return { spriteDuration, totalSprites }
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGridSize (options: {
|
|
||||||
toFind: number
|
|
||||||
maxEdgeCount: number
|
|
||||||
}) {
|
|
||||||
const { toFind, maxEdgeCount } = options
|
|
||||||
|
|
||||||
for (let i = 1; i <= maxEdgeCount; i++) {
|
|
||||||
for (let j = i; j <= maxEdgeCount; j++) {
|
|
||||||
if (toFind <= i * j) return { width: j, height: i }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Could not find grid size (to find: ${toFind}, max edge count: ${maxEdgeCount}`)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import { buildAspectRatio } from '@peertube/peertube-core-utils'
|
import { buildAspectRatio } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import { ffprobePromise, getChaptersFromContainer, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
|
||||||
ffprobePromise,
|
|
||||||
getChaptersFromContainer, getVideoStreamDuration
|
|
||||||
} from '@peertube/peertube-ffmpeg'
|
|
||||||
import {
|
import {
|
||||||
ThumbnailType,
|
ThumbnailType,
|
||||||
ThumbnailType_Type,
|
ThumbnailType_Type,
|
||||||
|
@ -12,7 +9,8 @@ import {
|
||||||
VideoImportTorrentPayload,
|
VideoImportTorrentPayload,
|
||||||
VideoImportTorrentPayloadType,
|
VideoImportTorrentPayloadType,
|
||||||
VideoImportYoutubeDLPayload,
|
VideoImportYoutubeDLPayload,
|
||||||
VideoImportYoutubeDLPayloadType, VideoState
|
VideoImportYoutubeDLPayloadType,
|
||||||
|
VideoState
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||||
import { YoutubeDLWrapper } from '@server/helpers/youtube-dl/index.js'
|
import { YoutubeDLWrapper } from '@server/helpers/youtube-dl/index.js'
|
||||||
|
@ -27,7 +25,7 @@ import { isUserQuotaValid } from '@server/lib/user.js'
|
||||||
import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
|
import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
|
||||||
import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
|
import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
|
||||||
import { buildNewFile } from '@server/lib/video-file.js'
|
import { buildNewFile } from '@server/lib/video-file.js'
|
||||||
import { buildMoveVideoJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
|
import { addLocalOrRemoteStoryboardJobIfNeeded, buildMoveVideoJob } from '@server/lib/video-jobs.js'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { buildNextVideoState } from '@server/lib/video-state.js'
|
import { buildNextVideoState } from '@server/lib/video-state.js'
|
||||||
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
|
||||||
|
@ -305,7 +303,7 @@ async function afterImportSuccess (options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the storyboard in the job queue, and don't forget to federate an update after
|
// Generate the storyboard in the job queue, and don't forget to federate an update after
|
||||||
await JobQueue.Instance.createJob(buildStoryboardJobIfNeeded({ video, federate: true }))
|
await addLocalOrRemoteStoryboardJobIfNeeded({ video, federate: true })
|
||||||
|
|
||||||
if (await VideoCaptionModel.hasVideoCaption(video.id) !== true && generateTranscription === true) {
|
if (await VideoCaptionModel.hasVideoCaption(video.id) !== true && generateTranscription === true) {
|
||||||
await createTranscriptionTaskIfNeeded(video)
|
await createTranscriptionTaskIfNeeded(video)
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {
|
||||||
import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded, updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
|
import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded, updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
|
||||||
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding.js'
|
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding.js'
|
||||||
import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
|
import { createTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
|
||||||
import { buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
|
import { addLocalOrRemoteStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
import { isVideoInPublicDirectory } from '@server/lib/video-privacy.js'
|
import { isVideoInPublicDirectory } from '@server/lib/video-privacy.js'
|
||||||
import { moveToNextState } from '@server/lib/video-state.js'
|
import { moveToNextState } from '@server/lib/video-state.js'
|
||||||
|
@ -41,7 +41,6 @@ import { pathExists, remove } from 'fs-extra/esm'
|
||||||
import { readdir } from 'fs/promises'
|
import { readdir } from 'fs/promises'
|
||||||
import { isAbsolute, join } from 'path'
|
import { isAbsolute, join } from 'path'
|
||||||
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
|
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
|
||||||
import { JobQueue } from '../job-queue.js'
|
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('live', 'job')
|
const lTags = loggerTagsFactory('live', 'job')
|
||||||
|
|
||||||
|
@ -362,7 +361,7 @@ async function cleanupLiveAndFederate (options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStoryboardJob (video: MVideo) {
|
function createStoryboardJob (video: MVideo) {
|
||||||
return JobQueue.Instance.createJob(buildStoryboardJobIfNeeded({ video, federate: true }))
|
return addLocalOrRemoteStoryboardJobIfNeeded({ video, federate: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasReplayFiles (replayDirectory: string) {
|
async function hasReplayFiles (replayDirectory: string) {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { pick } from '@peertube/peertube-core-utils'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
import {
|
import {
|
||||||
|
RunnerJobGenerateStoryboardPayload,
|
||||||
|
RunnerJobGenerateStoryboardPrivatePayload,
|
||||||
RunnerJobLiveRTMPHLSTranscodingPayload,
|
RunnerJobLiveRTMPHLSTranscodingPayload,
|
||||||
RunnerJobLiveRTMPHLSTranscodingPrivatePayload,
|
RunnerJobLiveRTMPHLSTranscodingPrivatePayload,
|
||||||
RunnerJobState,
|
RunnerJobState,
|
||||||
|
@ -28,50 +30,56 @@ import { setAsUpdated } from '@server/models/shared/update.js'
|
||||||
import { MRunnerJob } from '@server/types/models/runners/index.js'
|
import { MRunnerJob } from '@server/types/models/runners/index.js'
|
||||||
|
|
||||||
type CreateRunnerJobArg =
|
type CreateRunnerJobArg =
|
||||||
{
|
| {
|
||||||
type: Extract<RunnerJobType, 'vod-web-video-transcoding'>
|
type: Extract<RunnerJobType, 'vod-web-video-transcoding'>
|
||||||
payload: RunnerJobVODWebVideoTranscodingPayload
|
payload: RunnerJobVODWebVideoTranscodingPayload
|
||||||
privatePayload: RunnerJobVODWebVideoTranscodingPrivatePayload
|
privatePayload: RunnerJobVODWebVideoTranscodingPrivatePayload
|
||||||
} |
|
}
|
||||||
{
|
| {
|
||||||
type: Extract<RunnerJobType, 'vod-hls-transcoding'>
|
type: Extract<RunnerJobType, 'vod-hls-transcoding'>
|
||||||
payload: RunnerJobVODHLSTranscodingPayload
|
payload: RunnerJobVODHLSTranscodingPayload
|
||||||
privatePayload: RunnerJobVODHLSTranscodingPrivatePayload
|
privatePayload: RunnerJobVODHLSTranscodingPrivatePayload
|
||||||
} |
|
}
|
||||||
{
|
| {
|
||||||
type: Extract<RunnerJobType, 'vod-audio-merge-transcoding'>
|
type: Extract<RunnerJobType, 'vod-audio-merge-transcoding'>
|
||||||
payload: RunnerJobVODAudioMergeTranscodingPayload
|
payload: RunnerJobVODAudioMergeTranscodingPayload
|
||||||
privatePayload: RunnerJobVODAudioMergeTranscodingPrivatePayload
|
privatePayload: RunnerJobVODAudioMergeTranscodingPrivatePayload
|
||||||
} |
|
}
|
||||||
{
|
| {
|
||||||
type: Extract<RunnerJobType, 'live-rtmp-hls-transcoding'>
|
type: Extract<RunnerJobType, 'live-rtmp-hls-transcoding'>
|
||||||
payload: RunnerJobLiveRTMPHLSTranscodingPayload
|
payload: RunnerJobLiveRTMPHLSTranscodingPayload
|
||||||
privatePayload: RunnerJobLiveRTMPHLSTranscodingPrivatePayload
|
privatePayload: RunnerJobLiveRTMPHLSTranscodingPrivatePayload
|
||||||
} |
|
}
|
||||||
{
|
| {
|
||||||
type: Extract<RunnerJobType, 'video-studio-transcoding'>
|
type: Extract<RunnerJobType, 'video-studio-transcoding'>
|
||||||
payload: RunnerJobStudioTranscodingPayload
|
payload: RunnerJobStudioTranscodingPayload
|
||||||
privatePayload: RunnerJobVideoStudioTranscodingPrivatePayload
|
privatePayload: RunnerJobVideoStudioTranscodingPrivatePayload
|
||||||
} |
|
}
|
||||||
{
|
| {
|
||||||
|
type: Extract<RunnerJobType, 'generate-video-storyboard'>
|
||||||
|
payload: RunnerJobGenerateStoryboardPayload
|
||||||
|
privatePayload: RunnerJobGenerateStoryboardPrivatePayload
|
||||||
|
}
|
||||||
|
| {
|
||||||
type: Extract<RunnerJobType, 'video-transcription'>
|
type: Extract<RunnerJobType, 'video-transcription'>
|
||||||
payload: RunnerJobTranscriptionPayload
|
payload: RunnerJobTranscriptionPayload
|
||||||
privatePayload: RunnerJobTranscriptionPrivatePayload
|
privatePayload: RunnerJobTranscriptionPrivatePayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class AbstractJobHandler <C, U extends RunnerJobUpdatePayload, S extends RunnerJobSuccessPayload> {
|
export abstract class AbstractJobHandler<C, U extends RunnerJobUpdatePayload, S extends RunnerJobSuccessPayload> {
|
||||||
|
|
||||||
protected readonly lTags = loggerTagsFactory('runner')
|
protected readonly lTags = loggerTagsFactory('runner')
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
abstract create (options: C): Promise<MRunnerJob>
|
abstract create (options: C): Promise<MRunnerJob>
|
||||||
|
|
||||||
protected async createRunnerJob (options: CreateRunnerJobArg & {
|
protected async createRunnerJob (
|
||||||
jobUUID: string
|
options: CreateRunnerJobArg & {
|
||||||
priority: number
|
jobUUID: string
|
||||||
dependsOnRunnerJob?: MRunnerJob
|
priority: number
|
||||||
}): Promise<MRunnerJob> {
|
dependsOnRunnerJob?: MRunnerJob
|
||||||
|
}
|
||||||
|
): Promise<MRunnerJob> {
|
||||||
const { priority, dependsOnRunnerJob } = options
|
const { priority, dependsOnRunnerJob } = options
|
||||||
|
|
||||||
logger.debug('Creating runner job', { options, dependsOnRunnerJob, ...this.lTags(options.type) })
|
logger.debug('Creating runner job', { options, dependsOnRunnerJob, ...this.lTags(options.type) })
|
||||||
|
|
|
@ -6,3 +6,4 @@ export * from './video-studio-transcoding-job-handler.js'
|
||||||
export * from './vod-audio-merge-transcoding-job-handler.js'
|
export * from './vod-audio-merge-transcoding-job-handler.js'
|
||||||
export * from './vod-hls-transcoding-job-handler.js'
|
export * from './vod-hls-transcoding-job-handler.js'
|
||||||
export * from './vod-web-video-transcoding-job-handler.js'
|
export * from './vod-web-video-transcoding-job-handler.js'
|
||||||
|
export * from './video-storyboard-job-handler.js'
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { VideoStudioTranscodingJobHandler } from './video-studio-transcoding-job
|
||||||
import { VODAudioMergeTranscodingJobHandler } from './vod-audio-merge-transcoding-job-handler.js'
|
import { VODAudioMergeTranscodingJobHandler } from './vod-audio-merge-transcoding-job-handler.js'
|
||||||
import { VODHLSTranscodingJobHandler } from './vod-hls-transcoding-job-handler.js'
|
import { VODHLSTranscodingJobHandler } from './vod-hls-transcoding-job-handler.js'
|
||||||
import { VODWebVideoTranscodingJobHandler } from './vod-web-video-transcoding-job-handler.js'
|
import { VODWebVideoTranscodingJobHandler } from './vod-web-video-transcoding-job-handler.js'
|
||||||
|
import { VideoStoryboardJobHandler } from './video-storyboard-job-handler.js'
|
||||||
|
|
||||||
const processors: Record<RunnerJobType, new() => AbstractJobHandler<unknown, RunnerJobUpdatePayload, RunnerJobSuccessPayload>> = {
|
const processors: Record<RunnerJobType, new() => AbstractJobHandler<unknown, RunnerJobUpdatePayload, RunnerJobSuccessPayload>> = {
|
||||||
'vod-web-video-transcoding': VODWebVideoTranscodingJobHandler,
|
'vod-web-video-transcoding': VODWebVideoTranscodingJobHandler,
|
||||||
|
@ -14,7 +15,8 @@ const processors: Record<RunnerJobType, new() => AbstractJobHandler<unknown, Run
|
||||||
'vod-audio-merge-transcoding': VODAudioMergeTranscodingJobHandler,
|
'vod-audio-merge-transcoding': VODAudioMergeTranscodingJobHandler,
|
||||||
'live-rtmp-hls-transcoding': LiveRTMPHLSTranscodingJobHandler,
|
'live-rtmp-hls-transcoding': LiveRTMPHLSTranscodingJobHandler,
|
||||||
'video-studio-transcoding': VideoStudioTranscodingJobHandler,
|
'video-studio-transcoding': VideoStudioTranscodingJobHandler,
|
||||||
'video-transcription': TranscriptionJobHandler
|
'video-transcription': TranscriptionJobHandler,
|
||||||
|
'generate-video-storyboard': VideoStoryboardJobHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRunnerJobHandlerClass (job: MRunnerJob) {
|
export function getRunnerJobHandlerClass (job: MRunnerJob) {
|
||||||
|
|
|
@ -72,7 +72,7 @@ export class TranscriptionJobHandler extends AbstractJobHandler<CreateOptions, R
|
||||||
jobUUID,
|
jobUUID,
|
||||||
payload,
|
payload,
|
||||||
privatePayload,
|
privatePayload,
|
||||||
priority: JOB_PRIORITY.TRANSCODING
|
priority: JOB_PRIORITY.TRANSCRIPTION
|
||||||
})
|
})
|
||||||
|
|
||||||
return job
|
return job
|
||||||
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
import {
|
||||||
|
GenerateStoryboardSuccess,
|
||||||
|
RunnerJobGenerateStoryboardPayload,
|
||||||
|
RunnerJobGenerateStoryboardPrivatePayload,
|
||||||
|
RunnerJobUpdatePayload,
|
||||||
|
VideoFileStream
|
||||||
|
} from '@peertube/peertube-models'
|
||||||
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
|
import { generateImageFilename } from '@server/helpers/image-utils.js'
|
||||||
|
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||||
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
|
import { JOB_PRIORITY, STORYBOARD } from '@server/initializers/constants.js'
|
||||||
|
import { VideoPathManager } from '@server/lib/video-path-manager.js'
|
||||||
|
import { getImageSizeFromWorker } from '@server/lib/worker/parent-process.js'
|
||||||
|
import { VideoModel } from '@server/models/video/video.js'
|
||||||
|
import { MRunnerJob } from '@server/types/models/runners/index.js'
|
||||||
|
import { move } from 'fs-extra/esm'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { buildSpriteSize, buildTotalSprites, findGridSize, insertStoryboardInDatabase } from '../../storyboard.js'
|
||||||
|
import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls.js'
|
||||||
|
import { AbstractJobHandler } from './abstract-job-handler.js'
|
||||||
|
|
||||||
|
const lTagsBase = loggerTagsFactory('storyboard', 'runners')
|
||||||
|
|
||||||
|
type CreateOptions = {
|
||||||
|
videoUUID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VideoStoryboardJobHandler extends AbstractJobHandler<CreateOptions, RunnerJobUpdatePayload, GenerateStoryboardSuccess> {
|
||||||
|
async create (options: CreateOptions) {
|
||||||
|
const { videoUUID } = options
|
||||||
|
const lTags = lTagsBase(videoUUID)
|
||||||
|
|
||||||
|
const jobUUID = buildUUID()
|
||||||
|
|
||||||
|
const video = await VideoModel.loadFull(videoUUID)
|
||||||
|
const inputFile = video.getMaxQualityFile(VideoFileStream.VIDEO)
|
||||||
|
|
||||||
|
return VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => {
|
||||||
|
const { spriteHeight, spriteWidth } = await buildSpriteSize(videoPath)
|
||||||
|
|
||||||
|
const { spriteDuration, totalSprites } = buildTotalSprites(video)
|
||||||
|
if (totalSprites === 0) {
|
||||||
|
logger.info(`Do not generate remote storyboard job of ${videoUUID} because the video is not long enough`, lTags)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const spritesCount = findGridSize({ toFind: totalSprites, maxEdgeCount: STORYBOARD.SPRITES_MAX_EDGE_COUNT })
|
||||||
|
|
||||||
|
const payload: RunnerJobGenerateStoryboardPayload = {
|
||||||
|
input: {
|
||||||
|
videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, videoUUID)
|
||||||
|
},
|
||||||
|
sprites: {
|
||||||
|
size: { height: spriteHeight, width: spriteWidth },
|
||||||
|
count: spritesCount,
|
||||||
|
duration: spriteDuration
|
||||||
|
},
|
||||||
|
output: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const privatePayload: RunnerJobGenerateStoryboardPrivatePayload = { videoUUID }
|
||||||
|
|
||||||
|
const job = await this.createRunnerJob({
|
||||||
|
type: 'generate-video-storyboard',
|
||||||
|
jobUUID,
|
||||||
|
payload,
|
||||||
|
privatePayload,
|
||||||
|
priority: JOB_PRIORITY.STORYBOARD
|
||||||
|
})
|
||||||
|
|
||||||
|
return job
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isAbortSupported () {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
protected specificUpdate (_options: { runnerJob: MRunnerJob }) {}
|
||||||
|
protected specificAbort (_options: { runnerJob: MRunnerJob }) {}
|
||||||
|
|
||||||
|
// When runner returns the storyboard image, finish the server-side creation like local job would
|
||||||
|
protected async specificComplete (options: { runnerJob: MRunnerJob, resultPayload: GenerateStoryboardSuccess }) {
|
||||||
|
const { runnerJob, resultPayload } = options
|
||||||
|
|
||||||
|
const video = await VideoModel.loadFull(runnerJob.privatePayload.videoUUID)
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
const destinationFilename = generateImageFilename()
|
||||||
|
const destinationPath = join(CONFIG.STORAGE.STORYBOARDS_DIR, destinationFilename)
|
||||||
|
|
||||||
|
await move(resultPayload.storyboardFile as string, destinationPath)
|
||||||
|
|
||||||
|
const { sprites } = runnerJob.payload as RunnerJobGenerateStoryboardPayload
|
||||||
|
|
||||||
|
await insertStoryboardInDatabase({
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
lTags: this.lTags(video.uuid, runnerJob.uuid),
|
||||||
|
|
||||||
|
filename: destinationFilename,
|
||||||
|
destination: destinationPath,
|
||||||
|
|
||||||
|
imageSize: await getImageSizeFromWorker(destinationPath),
|
||||||
|
|
||||||
|
spriteHeight: sprites.size.height,
|
||||||
|
spriteWidth: sprites.size.width,
|
||||||
|
spriteDuration: sprites.duration,
|
||||||
|
|
||||||
|
federate: true
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('Runner storyboard job %s for %s ended.', runnerJob.uuid, video.uuid, this.lTags(video.uuid, runnerJob.uuid))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected specificError (_options: { runnerJob: MRunnerJob }) {}
|
||||||
|
protected specificCancel (_options: { runnerJob: MRunnerJob }) {}
|
||||||
|
}
|
|
@ -384,7 +384,10 @@ class ServerConfigManager {
|
||||||
},
|
},
|
||||||
|
|
||||||
storyboards: {
|
storyboards: {
|
||||||
enabled: CONFIG.STORYBOARDS.ENABLED
|
enabled: CONFIG.STORYBOARDS.ENABLED,
|
||||||
|
remoteRunners: {
|
||||||
|
enabled: CONFIG.STORYBOARDS.REMOTE_RUNNERS.ENABLED
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
webrtc: {
|
webrtc: {
|
||||||
|
|
98
server/core/lib/storyboard.ts
Normal file
98
server/core/lib/storyboard.ts
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import { ffprobePromise, getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg'
|
||||||
|
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||||
|
import { LoggerTags, logger } from '@server/helpers/logger.js'
|
||||||
|
import { deleteFileAndCatch } from '@server/helpers/utils.js'
|
||||||
|
import { STORYBOARD } from '@server/initializers/constants.js'
|
||||||
|
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||||
|
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
||||||
|
import { VideoModel } from '@server/models/video/video.js'
|
||||||
|
import { MVideo } from '@server/types/models/index.js'
|
||||||
|
import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
|
||||||
|
|
||||||
|
export async function buildSpriteSize (videoPath: string) {
|
||||||
|
const probe = await ffprobePromise(videoPath)
|
||||||
|
const videoStreamInfo = await getVideoStreamDimensionsInfo(videoPath, probe)
|
||||||
|
|
||||||
|
if (videoStreamInfo.isPortraitMode) {
|
||||||
|
return {
|
||||||
|
spriteHeight: STORYBOARD.SPRITE_MAX_SIZE,
|
||||||
|
spriteWidth: Math.round(STORYBOARD.SPRITE_MAX_SIZE * videoStreamInfo.ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
spriteWidth: STORYBOARD.SPRITE_MAX_SIZE,
|
||||||
|
spriteHeight: Math.round(STORYBOARD.SPRITE_MAX_SIZE / videoStreamInfo.ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTotalSprites (video: MVideo) {
|
||||||
|
if (video.duration < 3) return { spriteDuration: undefined, totalSprites: 0 }
|
||||||
|
|
||||||
|
const maxSprites = Math.min(Math.ceil(video.duration), STORYBOARD.SPRITES_MAX_EDGE_COUNT * STORYBOARD.SPRITES_MAX_EDGE_COUNT)
|
||||||
|
|
||||||
|
const spriteDuration = Math.ceil(video.duration / maxSprites)
|
||||||
|
const totalSprites = Math.ceil(video.duration / spriteDuration)
|
||||||
|
|
||||||
|
// We can generate a single line so we don't need a prime number
|
||||||
|
if (totalSprites <= STORYBOARD.SPRITES_MAX_EDGE_COUNT) return { spriteDuration, totalSprites }
|
||||||
|
|
||||||
|
return { spriteDuration, totalSprites }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGridSize (options: {
|
||||||
|
toFind: number
|
||||||
|
maxEdgeCount: number
|
||||||
|
}) {
|
||||||
|
const { toFind, maxEdgeCount } = options
|
||||||
|
|
||||||
|
for (let i = 1; i <= maxEdgeCount; i++) {
|
||||||
|
for (let j = i; j <= maxEdgeCount; j++) {
|
||||||
|
if (toFind <= i * j) return { width: j, height: i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not find grid size (to find: ${toFind}, max edge count: ${maxEdgeCount}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertStoryboardInDatabase (options: {
|
||||||
|
videoUUID: string
|
||||||
|
lTags: LoggerTags
|
||||||
|
filename: string
|
||||||
|
destination: string
|
||||||
|
imageSize: { width: number, height: number }
|
||||||
|
spriteHeight: number
|
||||||
|
spriteWidth: number
|
||||||
|
spriteDuration: number
|
||||||
|
federate: boolean
|
||||||
|
}) {
|
||||||
|
const { videoUUID, lTags, imageSize, spriteHeight, spriteWidth, spriteDuration, destination, filename, federate } = options
|
||||||
|
|
||||||
|
await retryTransactionWrapper(() => {
|
||||||
|
return sequelizeTypescript.transaction(async transaction => {
|
||||||
|
const video = await VideoModel.loadFull(videoUUID, transaction)
|
||||||
|
if (!video) {
|
||||||
|
logger.info(`Video ${videoUUID} does not exist anymore, skipping storyboard generation.`, lTags)
|
||||||
|
deleteFileAndCatch(destination)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await StoryboardModel.loadByVideo(video.id, transaction)
|
||||||
|
if (existing) await existing.destroy({ transaction })
|
||||||
|
|
||||||
|
await StoryboardModel.create({
|
||||||
|
filename,
|
||||||
|
totalHeight: imageSize.height,
|
||||||
|
totalWidth: imageSize.width,
|
||||||
|
spriteHeight,
|
||||||
|
spriteWidth,
|
||||||
|
spriteDuration,
|
||||||
|
videoId: video.id
|
||||||
|
}, { transaction })
|
||||||
|
|
||||||
|
if (federate) {
|
||||||
|
await federateVideoIfNeeded(video, false, transaction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -16,10 +16,9 @@ import { copyFile } from 'fs/promises'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { CONFIG } from '../../initializers/config.js'
|
import { CONFIG } from '../../initializers/config.js'
|
||||||
import { VideoFileModel } from '../../models/video/video-file.js'
|
import { VideoFileModel } from '../../models/video/video-file.js'
|
||||||
import { JobQueue } from '../job-queue/index.js'
|
|
||||||
import { generateWebVideoFilename } from '../paths.js'
|
import { generateWebVideoFilename } from '../paths.js'
|
||||||
import { buildNewFile, saveNewOriginalFileIfNeeded } from '../video-file.js'
|
import { buildNewFile, saveNewOriginalFileIfNeeded } from '../video-file.js'
|
||||||
import { buildStoryboardJobIfNeeded } from '../video-jobs.js'
|
import { addLocalOrRemoteStoryboardJobIfNeeded } from '../video-jobs.js'
|
||||||
import { VideoPathManager } from '../video-path-manager.js'
|
import { VideoPathManager } from '../video-path-manager.js'
|
||||||
import { buildFFmpegVOD } from './shared/index.js'
|
import { buildFFmpegVOD } from './shared/index.js'
|
||||||
import { buildOriginalFileResolution } from './transcoding-resolutions.js'
|
import { buildOriginalFileResolution } from './transcoding-resolutions.js'
|
||||||
|
@ -229,7 +228,7 @@ export async function onWebVideoFileTranscoding (options: {
|
||||||
video.VideoFiles = await video.$get('VideoFiles')
|
video.VideoFiles = await video.$get('VideoFiles')
|
||||||
|
|
||||||
if (wasAudioFile) {
|
if (wasAudioFile) {
|
||||||
await JobQueue.Instance.createJob(buildStoryboardJobIfNeeded({ video, federate: false }))
|
await addLocalOrRemoteStoryboardJobIfNeeded({ video, federate: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { video, videoFile }
|
return { video, videoFile }
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||||
import { MVideo, MVideoFile, MVideoFullLight, MVideoUUID } from '@server/types/models/index.js'
|
import { MVideo, MVideoFile, MVideoFullLight, MVideoUUID } from '@server/types/models/index.js'
|
||||||
import { CreateJobArgument, CreateJobOptions, JobQueue } from './job-queue/job-queue.js'
|
import { CreateJobArgument, CreateJobOptions, JobQueue } from './job-queue/job-queue.js'
|
||||||
|
import { VideoStoryboardJobHandler } from './runners/index.js'
|
||||||
import { createTranscriptionTaskIfNeeded } from './video-captions.js'
|
import { createTranscriptionTaskIfNeeded } from './video-captions.js'
|
||||||
import { moveFilesIfPrivacyChanged } from './video-privacy.js'
|
import { moveFilesIfPrivacyChanged } from './video-privacy.js'
|
||||||
|
|
||||||
|
@ -26,13 +27,13 @@ export async function buildMoveVideoJob (options: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStoryboardJobIfNeeded (options: {
|
export function buildLocalStoryboardJobIfNeeded (options: {
|
||||||
video: MVideo
|
video: MVideo
|
||||||
federate: boolean
|
federate: boolean
|
||||||
}) {
|
}) {
|
||||||
const { video, federate } = options
|
const { video, federate } = options
|
||||||
|
|
||||||
if (CONFIG.STORYBOARDS.ENABLED) {
|
if (CONFIG.STORYBOARDS.ENABLED && !CONFIG.STORYBOARDS.REMOTE_RUNNERS.ENABLED) {
|
||||||
return {
|
return {
|
||||||
type: 'generate-video-storyboard' as 'generate-video-storyboard',
|
type: 'generate-video-storyboard' as 'generate-video-storyboard',
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -55,6 +56,28 @@ export function buildStoryboardJobIfNeeded (options: {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addRemoteStoryboardJobIfNeeded (video: MVideo) {
|
||||||
|
if (CONFIG.STORYBOARDS.ENABLED !== true) return
|
||||||
|
if (CONFIG.STORYBOARDS.REMOTE_RUNNERS.ENABLED !== true) return
|
||||||
|
|
||||||
|
return new VideoStoryboardJobHandler().create({ videoUUID: video.uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addLocalOrRemoteStoryboardJobIfNeeded (options: {
|
||||||
|
video: MVideo
|
||||||
|
federate: boolean
|
||||||
|
}) {
|
||||||
|
const { video, federate } = options
|
||||||
|
|
||||||
|
if (CONFIG.STORYBOARDS.ENABLED !== true) return
|
||||||
|
|
||||||
|
if (CONFIG.STORYBOARDS.REMOTE_RUNNERS.ENABLED === true) {
|
||||||
|
await addRemoteStoryboardJobIfNeeded(video)
|
||||||
|
} else {
|
||||||
|
await JobQueue.Instance.createJob(buildLocalStoryboardJobIfNeeded({ video, federate }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function addVideoJobsAfterCreation (options: {
|
export async function addVideoJobsAfterCreation (options: {
|
||||||
video: MVideo
|
video: MVideo
|
||||||
videoFile: MVideoFile
|
videoFile: MVideoFile
|
||||||
|
@ -72,7 +95,7 @@ export async function addVideoJobsAfterCreation (options: {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
buildStoryboardJobIfNeeded({ video, federate: false }),
|
buildLocalStoryboardJobIfNeeded({ video, federate: false }),
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'notify',
|
type: 'notify',
|
||||||
|
@ -109,6 +132,8 @@ export async function addVideoJobsAfterCreation (options: {
|
||||||
|
|
||||||
await JobQueue.Instance.createSequentialJobFlow(...jobs)
|
await JobQueue.Instance.createSequentialJobFlow(...jobs)
|
||||||
|
|
||||||
|
await addRemoteStoryboardJobIfNeeded(video)
|
||||||
|
|
||||||
if (generateTranscription === true) {
|
if (generateTranscription === true) {
|
||||||
await createTranscriptionTaskIfNeeded(video)
|
await createTranscriptionTaskIfNeeded(video)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { VideoStudioTranscodingJobHandler } from './runners/index.js'
|
||||||
import { getTranscodingJobPriority } from './transcoding/transcoding-priority.js'
|
import { getTranscodingJobPriority } from './transcoding/transcoding-priority.js'
|
||||||
import { regenerateTranscriptionTaskIfNeeded } from './video-captions.js'
|
import { regenerateTranscriptionTaskIfNeeded } from './video-captions.js'
|
||||||
import { buildNewFile, removeHLSPlaylist, removeWebVideoFile } from './video-file.js'
|
import { buildNewFile, removeHLSPlaylist, removeWebVideoFile } from './video-file.js'
|
||||||
import { buildStoryboardJobIfNeeded } from './video-jobs.js'
|
import { addRemoteStoryboardJobIfNeeded, buildLocalStoryboardJobIfNeeded } from './video-jobs.js'
|
||||||
import { VideoPathManager } from './video-path-manager.js'
|
import { VideoPathManager } from './video-path-manager.js'
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('video-studio')
|
const lTags = loggerTagsFactory('video-studio')
|
||||||
|
@ -110,7 +110,7 @@ export async function onVideoStudioEnded (options: {
|
||||||
await video.save()
|
await video.save()
|
||||||
|
|
||||||
await JobQueue.Instance.createSequentialJobFlow(
|
await JobQueue.Instance.createSequentialJobFlow(
|
||||||
buildStoryboardJobIfNeeded({ video, federate: false }),
|
buildLocalStoryboardJobIfNeeded({ video, federate: false }),
|
||||||
{
|
{
|
||||||
type: 'federate-video' as 'federate-video',
|
type: 'federate-video' as 'federate-video',
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -129,6 +129,7 @@ export async function onVideoStudioEnded (options: {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await addRemoteStoryboardJobIfNeeded(video)
|
||||||
await regenerateTranscriptionTaskIfNeeded(video)
|
await regenerateTranscriptionTaskIfNeeded(video)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { program } from 'commander'
|
|
||||||
import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
|
import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
|
||||||
import { initDatabaseModels } from '@server/initializers/database.js'
|
import { initDatabaseModels } from '@server/initializers/database.js'
|
||||||
import { JobQueue } from '@server/lib/job-queue/index.js'
|
import { JobQueue } from '@server/lib/job-queue/index.js'
|
||||||
|
import { addLocalOrRemoteStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
|
||||||
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
import { StoryboardModel } from '@server/models/video/storyboard.js'
|
||||||
import { VideoModel } from '@server/models/video/video.js'
|
import { VideoModel } from '@server/models/video/video.js'
|
||||||
import { buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
|
import { program } from 'commander'
|
||||||
|
|
||||||
program
|
program
|
||||||
.description('Generate videos storyboard')
|
.description('Generate videos storyboard')
|
||||||
|
@ -61,7 +61,7 @@ async function run () {
|
||||||
|
|
||||||
if (videoFull.isLive) continue
|
if (videoFull.isLive) continue
|
||||||
|
|
||||||
await JobQueue.Instance.createJob(buildStoryboardJobIfNeeded({ video: videoFull, federate: true }))
|
await addLocalOrRemoteStoryboardJobIfNeeded({ video: videoFull, federate: true })
|
||||||
|
|
||||||
console.log(`Created generate-storyboard job for ${videoFull.name}.`)
|
console.log(`Created generate-storyboard job for ${videoFull.name}.`)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue