From c729caf6cc34630877a0e5a1bda1719384cd0c8a Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 11 Feb 2022 10:51:33 +0100 Subject: Add basic video editor support --- shared/extra-utils/ffprobe.ts | 96 +++++++++------------- shared/models/server/custom-config.model.ts | 4 + shared/models/server/job.model.ts | 39 +++++++++ shared/models/server/server-config.model.ts | 4 + shared/models/videos/editor/index.ts | 1 + .../editor/video-editor-create-edit.model.ts | 42 ++++++++++ shared/models/videos/index.ts | 1 + .../transcoding/video-transcoding-fps.model.ts | 1 + .../videos/transcoding/video-transcoding.model.ts | 7 +- shared/models/videos/video-state.enum.ts | 3 +- shared/server-commands/server/config-command.ts | 34 +++++++- shared/server-commands/server/server.ts | 3 + shared/server-commands/videos/index.ts | 1 + .../server-commands/videos/video-editor-command.ts | 67 +++++++++++++++ 14 files changed, 244 insertions(+), 59 deletions(-) create mode 100644 shared/models/videos/editor/index.ts create mode 100644 shared/models/videos/editor/video-editor-create-edit.model.ts create mode 100644 shared/server-commands/videos/video-editor-command.ts (limited to 'shared') diff --git a/shared/extra-utils/ffprobe.ts b/shared/extra-utils/ffprobe.ts index 53a3aa001..dfacd251c 100644 --- a/shared/extra-utils/ffprobe.ts +++ b/shared/extra-utils/ffprobe.ts @@ -17,12 +17,22 @@ function ffprobePromise (path: string) { }) } +// --------------------------------------------------------------------------- +// Audio +// --------------------------------------------------------------------------- + async function isAudioFile (path: string, existingProbe?: FfprobeData) { - const videoStream = await getVideoStreamFromFile(path, existingProbe) + const videoStream = await getVideoStream(path, existingProbe) return !videoStream } +async function hasAudioStream (path: string, existingProbe?: FfprobeData) { + const { audioStream } = await getAudioStream(path, existingProbe) + + return !!audioStream +} + async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) { // without position, ffprobe considers the last input only // we make it consider the first input only @@ -78,29 +88,26 @@ function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) { } } -async function getVideoStreamSize (path: string, existingProbe?: FfprobeData): Promise<{ width: number, height: number }> { - const videoStream = await getVideoStreamFromFile(path, existingProbe) - - return videoStream === null - ? { width: 0, height: 0 } - : { width: videoStream.width, height: videoStream.height } -} +// --------------------------------------------------------------------------- +// Video +// --------------------------------------------------------------------------- -async function getVideoFileResolution (path: string, existingProbe?: FfprobeData) { - const size = await getVideoStreamSize(path, existingProbe) +async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) return undefined return { - width: size.width, - height: size.height, - ratio: Math.max(size.height, size.width) / Math.min(size.height, size.width), - resolution: Math.min(size.height, size.width), - isPortraitMode: size.height > size.width + width: videoStream.width, + height: videoStream.height, + ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width), + resolution: Math.min(videoStream.height, videoStream.width), + isPortraitMode: videoStream.height > videoStream.width } } -async function getVideoFileFPS (path: string, existingProbe?: FfprobeData) { - const videoStream = await getVideoStreamFromFile(path, existingProbe) - if (videoStream === null) return 0 +async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) return 0 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { const valuesText: string = videoStream[key] @@ -116,19 +123,19 @@ async function getVideoFileFPS (path: string, existingProbe?: FfprobeData) { return 0 } -async function getMetadataFromFile (path: string, existingProbe?: FfprobeData) { +async function buildFileMetadata (path: string, existingProbe?: FfprobeData) { const metadata = existingProbe || await ffprobePromise(path) return new VideoFileMetadata(metadata) } -async function getVideoFileBitrate (path: string, existingProbe?: FfprobeData): Promise { - const metadata = await getMetadataFromFile(path, existingProbe) +async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise { + const metadata = await buildFileMetadata(path, existingProbe) let bitrate = metadata.format.bit_rate as number if (bitrate && !isNaN(bitrate)) return bitrate - const videoStream = await getVideoStreamFromFile(path, existingProbe) + const videoStream = await getVideoStream(path, existingProbe) if (!videoStream) return undefined bitrate = videoStream?.bit_rate @@ -137,51 +144,30 @@ async function getVideoFileBitrate (path: string, existingProbe?: FfprobeData): return undefined } -async function getDurationFromVideoFile (path: string, existingProbe?: FfprobeData) { - const metadata = await getMetadataFromFile(path, existingProbe) +async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) { + const metadata = await buildFileMetadata(path, existingProbe) return Math.round(metadata.format.duration) } -async function getVideoStreamFromFile (path: string, existingProbe?: FfprobeData) { - const metadata = await getMetadataFromFile(path, existingProbe) - - return metadata.streams.find(s => s.codec_type === 'video') || null -} - -async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise { - const parsedAudio = await getAudioStream(path, probe) - - if (!parsedAudio.audioStream) return true - - if (parsedAudio.audioStream['codec_name'] !== 'aac') return false - - const audioBitrate = parsedAudio.bitrate - if (!audioBitrate) return false - - const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate) - if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false - - const channelLayout = parsedAudio.audioStream['channel_layout'] - // Causes playback issues with Chrome - if (!channelLayout || channelLayout === 'unknown') return false +async function getVideoStream (path: string, existingProbe?: FfprobeData) { + const metadata = await buildFileMetadata(path, existingProbe) - return true + return metadata.streams.find(s => s.codec_type === 'video') } // --------------------------------------------------------------------------- export { - getVideoStreamSize, - getVideoFileResolution, - getMetadataFromFile, + getVideoStreamDimensionsInfo, + buildFileMetadata, getMaxAudioBitrate, - getVideoStreamFromFile, - getDurationFromVideoFile, + getVideoStream, + getVideoStreamDuration, getAudioStream, - getVideoFileFPS, + getVideoStreamFPS, isAudioFile, ffprobePromise, - getVideoFileBitrate, - canDoQuickAudioTranscode + getVideoStreamBitrate, + hasAudioStream } diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index 52d3d9588..c9e7654de 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -143,6 +143,10 @@ export interface CustomConfig { } } + videoEditor: { + enabled: boolean + } + import: { videos: { concurrency: number diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts index 1519d1c3e..d0293f542 100644 --- a/shared/models/server/job.model.ts +++ b/shared/models/server/job.model.ts @@ -1,4 +1,5 @@ import { ContextType } from '../activitypub/context' +import { VideoEditorTaskCut } from '../videos/editor' import { VideoResolution } from '../videos/file/video-resolution.enum' import { SendEmailOptions } from './emailer.model' @@ -20,6 +21,7 @@ export type JobType = | 'video-live-ending' | 'actor-keys' | 'move-to-object-storage' + | 'video-edition' export interface Job { id: number @@ -155,3 +157,40 @@ export interface MoveObjectStoragePayload { videoUUID: string isNewVideo: boolean } + +export type VideoEditorTaskCutPayload = VideoEditorTaskCut + +export type VideoEditorTaskIntroPayload = { + name: 'add-intro' + + options: { + file: string + } +} + +export type VideoEditorTaskOutroPayload = { + name: 'add-outro' + + options: { + file: string + } +} + +export type VideoEditorTaskWatermarkPayload = { + name: 'add-watermark' + + options: { + file: string + } +} + +export type VideoEditionTaskPayload = + VideoEditorTaskCutPayload | + VideoEditorTaskIntroPayload | + VideoEditorTaskOutroPayload | + VideoEditorTaskWatermarkPayload + +export interface VideoEditionPayload { + videoUUID: string + tasks: VideoEditionTaskPayload[] +} diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index 32be96b9d..0fe8b0de8 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -167,6 +167,10 @@ export interface ServerConfig { } } + videoEditor: { + enabled: boolean + } + import: { videos: { http: { diff --git a/shared/models/videos/editor/index.ts b/shared/models/videos/editor/index.ts new file mode 100644 index 000000000..3436f2c3f --- /dev/null +++ b/shared/models/videos/editor/index.ts @@ -0,0 +1 @@ +export * from './video-editor-create-edit.model' diff --git a/shared/models/videos/editor/video-editor-create-edit.model.ts b/shared/models/videos/editor/video-editor-create-edit.model.ts new file mode 100644 index 000000000..36b7c8d55 --- /dev/null +++ b/shared/models/videos/editor/video-editor-create-edit.model.ts @@ -0,0 +1,42 @@ +export interface VideoEditorCreateEdition { + tasks: VideoEditorTask[] +} + +export type VideoEditorTask = + VideoEditorTaskCut | + VideoEditorTaskIntro | + VideoEditorTaskOutro | + VideoEditorTaskWatermark + +export interface VideoEditorTaskCut { + name: 'cut' + + options: { + start?: number + end?: number + } +} + +export interface VideoEditorTaskIntro { + name: 'add-intro' + + options: { + file: Blob | string + } +} + +export interface VideoEditorTaskOutro { + name: 'add-outro' + + options: { + file: Blob | string + } +} + +export interface VideoEditorTaskWatermark { + name: 'add-watermark' + + options: { + file: Blob | string + } +} diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 67614efc9..e8eb227ab 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -3,6 +3,7 @@ export * from './caption' export * from './change-ownership' export * from './channel' export * from './comment' +export * from './editor' export * from './live' export * from './file' export * from './import' diff --git a/shared/models/videos/transcoding/video-transcoding-fps.model.ts b/shared/models/videos/transcoding/video-transcoding-fps.model.ts index 25fc1c2da..9a330ac94 100644 --- a/shared/models/videos/transcoding/video-transcoding-fps.model.ts +++ b/shared/models/videos/transcoding/video-transcoding-fps.model.ts @@ -2,6 +2,7 @@ export type VideoTranscodingFPS = { MIN: number STANDARD: number[] HD_STANDARD: number[] + AUDIO_MERGE: number AVERAGE: number MAX: number KEEP_ORIGIN_FPS_RESOLUTION_MIN: number diff --git a/shared/models/videos/transcoding/video-transcoding.model.ts b/shared/models/videos/transcoding/video-transcoding.model.ts index 3a7fb6472..91eacf8dc 100644 --- a/shared/models/videos/transcoding/video-transcoding.model.ts +++ b/shared/models/videos/transcoding/video-transcoding.model.ts @@ -7,8 +7,11 @@ export type EncoderOptionsBuilderParams = { resolution: VideoResolution - // Could be null for "merge audio" transcoding - fps?: number + // If PeerTube applies a filter, transcoding profile must not copy input stream + canCopyAudio: boolean + canCopyVideo: boolean + + fps: number // Could be undefined if we could not get input bitrate (some RTMP streams for example) inputBitrate: number diff --git a/shared/models/videos/video-state.enum.ts b/shared/models/videos/video-state.enum.ts index 09268d2ff..e45e4adc2 100644 --- a/shared/models/videos/video-state.enum.ts +++ b/shared/models/videos/video-state.enum.ts @@ -6,5 +6,6 @@ export const enum VideoState { LIVE_ENDED = 5, TO_MOVE_TO_EXTERNAL_STORAGE = 6, TRANSCODING_FAILED = 7, - TO_MOVE_TO_EXTERNAL_STORAGE_FAILED = 8 + TO_MOVE_TO_EXTERNAL_STORAGE_FAILED = 8, + TO_EDIT = 9 } diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts index 797231b1d..c0042060b 100644 --- a/shared/server-commands/server/config-command.ts +++ b/shared/server-commands/server/config-command.ts @@ -59,6 +59,9 @@ export class ConfigCommand extends AbstractCommand { newConfig: { transcoding: { enabled: false + }, + videoEditor: { + enabled: false } } }) @@ -69,6 +72,10 @@ export class ConfigCommand extends AbstractCommand { newConfig: { transcoding: { enabled: true, + + allowAudioFiles: true, + allowAdditionalExtensions: true, + resolutions: ConfigCommand.getCustomConfigResolutions(true), webtorrent: { @@ -82,6 +89,28 @@ export class ConfigCommand extends AbstractCommand { }) } + enableMinimumTranscoding (webtorrent = true, hls = true) { + return this.updateExistingSubConfig({ + newConfig: { + transcoding: { + enabled: true, + resolutions: { + ...ConfigCommand.getCustomConfigResolutions(false), + + '240p': true + }, + + webtorrent: { + enabled: webtorrent + }, + hls: { + enabled: hls + } + } + } + }) + } + getConfig (options: OverrideCommandOptions = {}) { const path = '/api/v1/config' @@ -148,7 +177,7 @@ export class ConfigCommand extends AbstractCommand { async updateExistingSubConfig (options: OverrideCommandOptions & { newConfig: DeepPartial }) { - const existing = await this.getCustomConfig(options) + const existing = await this.getCustomConfig({ ...options, expectedStatus: HttpStatusCode.OK_200 }) return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) }) } @@ -282,6 +311,9 @@ export class ConfigCommand extends AbstractCommand { } } }, + videoEditor: { + enabled: false + }, import: { videos: { concurrency: 3, diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts index da89fd876..af4423e8d 100644 --- a/shared/server-commands/server/server.ts +++ b/shared/server-commands/server/server.ts @@ -25,6 +25,7 @@ import { PlaylistsCommand, ServicesCommand, StreamingPlaylistsCommand, + VideoEditorCommand, VideosCommand } from '../videos' import { CommentsCommand } from '../videos/comments-command' @@ -124,6 +125,7 @@ export class PeerTubeServer { login?: LoginCommand users?: UsersCommand objectStorage?: ObjectStorageCommand + videoEditor?: VideoEditorCommand videos?: VideosCommand constructor (options: { serverNumber: number } | { url: string }) { @@ -394,5 +396,6 @@ export class PeerTubeServer { this.users = new UsersCommand(this) this.videos = new VideosCommand(this) this.objectStorage = new ObjectStorageCommand(this) + this.videoEditor = new VideoEditorCommand(this) } } diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts index 68a188b21..154aed9a6 100644 --- a/shared/server-commands/videos/index.ts +++ b/shared/server-commands/videos/index.ts @@ -12,4 +12,5 @@ export * from './playlists-command' export * from './services-command' export * from './streaming-playlists-command' export * from './comments-command' +export * from './video-editor-command' export * from './videos-command' diff --git a/shared/server-commands/videos/video-editor-command.ts b/shared/server-commands/videos/video-editor-command.ts new file mode 100644 index 000000000..485edce8e --- /dev/null +++ b/shared/server-commands/videos/video-editor-command.ts @@ -0,0 +1,67 @@ +import { HttpStatusCode, VideoEditorTask } from '@shared/models' +import { AbstractCommand, OverrideCommandOptions } from '../shared' + +export class VideoEditorCommand extends AbstractCommand { + + static getComplexTask (): VideoEditorTask[] { + return [ + // Total duration: 2 + { + name: 'cut', + options: { + start: 1, + end: 3 + } + }, + + // Total duration: 7 + { + name: 'add-outro', + options: { + file: 'video_short.webm' + } + }, + + { + name: 'add-watermark', + options: { + file: 'thumbnail.png' + } + }, + + // Total duration: 9 + { + name: 'add-intro', + options: { + file: 'video_very_short_240p.mp4' + } + } + ] + } + + createEditionTasks (options: OverrideCommandOptions & { + videoId: number | string + tasks: VideoEditorTask[] + }) { + const path = '/api/v1/videos/' + options.videoId + '/editor/edit' + const attaches: { [id: string]: any } = {} + + for (let i = 0; i < options.tasks.length; i++) { + const task = options.tasks[i] + + if (task.name === 'add-intro' || task.name === 'add-outro' || task.name === 'add-watermark') { + attaches[`tasks[${i}][options][file]`] = task.options.file + } + } + + return this.postUploadRequest({ + ...options, + + path, + attaches, + fields: { tasks: options.tasks }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} -- cgit v1.2.3