From b6b1aaa56f5eb0b5e5b7366e08a698d3a6f917cd Mon Sep 17 00:00:00 2001 From: Chocobozzz <me@florianbigard.com> Date: Tue, 27 Feb 2024 11:18:56 +0100 Subject: [PATCH] Add video aspect ratio in server --- .../shared/shared-main/video/video.model.ts | 4 + packages/core-utils/src/videos/bitrate.ts | 7 +- packages/core-utils/src/videos/common.ts | 15 ++-- packages/ffmpeg/src/ffprobe.ts | 4 +- .../src/activitypub/objects/common-objects.ts | 10 ++- .../src/activitypub/objects/video-object.ts | 2 + .../src/videos/file/video-file.model.ts | 3 + packages/models/src/videos/video.model.ts | 2 + .../fixtures/video_import_preview_yt_dlp.jpg | Bin 15844 -> 49065 bytes packages/tests/src/api/live/live.ts | 3 + .../tests/src/api/redundancy/redundancy.ts | 4 +- packages/tests/src/api/server/follows.ts | 2 + packages/tests/src/api/server/handle-down.ts | 2 + packages/tests/src/api/server/tracker.ts | 6 +- packages/tests/src/api/users/user-import.ts | 4 + .../tests/src/api/videos/multiple-servers.ts | 24 ++++++ .../tests/src/api/videos/single-server.ts | 4 + packages/tests/src/api/videos/video-files.ts | 7 +- .../api/videos/video-static-file-privacy.ts | 7 +- .../tests/src/server-helpers/core-utils.ts | 20 ++++- packages/tests/src/shared/checks.ts | 10 ++- packages/tests/src/shared/live.ts | 2 + .../tests/src/shared/streaming-playlists.ts | 3 + packages/tests/src/shared/videos.ts | 14 +++- packages/tests/src/shared/webtorrent.ts | 9 ++ server/core/controllers/api/videos/source.ts | 2 + server/core/helpers/activity-pub-utils.ts | 4 + server/core/initializers/constants.ts | 2 +- .../migrations/0825-video-ratio.ts | 43 ++++++++++ .../shared/object-to-model-attributes.ts | 5 +- server/core/lib/activitypub/videos/updater.ts | 1 + .../job-queue/handlers/generate-storyboard.ts | 4 +- .../job-queue/handlers/video-file-import.ts | 25 ++---- .../lib/job-queue/handlers/video-import.ts | 54 ++++-------- .../job-queue/handlers/video-live-ending.ts | 1 + server/core/lib/live/live-manager.ts | 13 ++- server/core/lib/local-video-creator.ts | 3 + .../job-handlers/shared/vod-helpers.ts | 38 ++------- ...vod-audio-merge-transcoding-job-handler.ts | 8 +- .../vod-hls-transcoding-job-handler.ts | 21 +---- .../vod-web-video-transcoding-job-handler.ts | 2 +- .../core/lib/transcoding/hls-transcoding.ts | 51 +++++------ .../core/lib/transcoding/web-transcoding.ts | 79 +++++++----------- server/core/lib/video-file.ts | 5 +- server/core/lib/video-studio.ts | 2 + server/core/models/server/plugin.ts | 2 + .../formatter/video-activity-pub-format.ts | 13 ++- .../video/formatter/video-api-format.ts | 5 ++ .../video/shared/video-table-attributes.ts | 3 + server/core/models/video/video-file.ts | 11 ++- server/core/models/video/video.ts | 4 + support/doc/api/openapi.yaml | 13 ++- 52 files changed, 345 insertions(+), 237 deletions(-) create mode 100644 server/core/initializers/migrations/0825-video-ratio.ts diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 81a7cd3ee..99ea394ca 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -50,6 +50,8 @@ export class Video implements VideoServerModel { thumbnailPath: string thumbnailUrl: string + aspectRatio: number + isLive: boolean previewPath: string @@ -197,6 +199,8 @@ export class Video implements VideoServerModel { this.originInstanceUrl = 'https://' + this.originInstanceHost this.pluginData = hash.pluginData + + this.aspectRatio = hash.aspectRatio } isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) { diff --git a/packages/core-utils/src/videos/bitrate.ts b/packages/core-utils/src/videos/bitrate.ts index b28eaf460..40dcd6bdf 100644 --- a/packages/core-utils/src/videos/bitrate.ts +++ b/packages/core-utils/src/videos/bitrate.ts @@ -103,9 +103,14 @@ function calculateBitrate (options: { VideoResolution.H_NOVIDEO ] + const size1 = resolution + const size2 = ratio < 1 && ratio > 0 + ? resolution / ratio // Portrait mode + : resolution * ratio + for (const toTestResolution of resolutionsOrder) { if (toTestResolution <= resolution) { - return Math.floor(resolution * resolution * ratio * fps * bitPerPixel[toTestResolution]) + return Math.floor(size1 * size2 * fps * bitPerPixel[toTestResolution]) } } diff --git a/packages/core-utils/src/videos/common.ts b/packages/core-utils/src/videos/common.ts index 47564fb2a..64e66094c 100644 --- a/packages/core-utils/src/videos/common.ts +++ b/packages/core-utils/src/videos/common.ts @@ -1,10 +1,10 @@ import { VideoDetails, VideoPrivacy, VideoStreamingPlaylistType } from '@peertube/peertube-models' -function getAllPrivacies () { +export function getAllPrivacies () { return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ] } -function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) { +export function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) { const files = video.files const hls = getHLS(video) @@ -13,12 +13,13 @@ function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlay return files } -function getHLS (video: Partial<Pick<VideoDetails, 'streamingPlaylists'>>) { +export function getHLS (video: Partial<Pick<VideoDetails, 'streamingPlaylists'>>) { return video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) } -export { - getAllPrivacies, - getAllFiles, - getHLS +export function buildAspectRatio (options: { width: number, height: number }) { + const { width, height } = options + if (!width || !height) return null + + return Math.round((width / height) * 10000) / 10000 // 4 decimals precision } diff --git a/packages/ffmpeg/src/ffprobe.ts b/packages/ffmpeg/src/ffprobe.ts index 657676972..d86ba3d12 100644 --- a/packages/ffmpeg/src/ffprobe.ts +++ b/packages/ffmpeg/src/ffprobe.ts @@ -1,5 +1,5 @@ import ffmpeg, { FfprobeData } from 'fluent-ffmpeg' -import { forceNumber } from '@peertube/peertube-core-utils' +import { buildAspectRatio, forceNumber } from '@peertube/peertube-core-utils' import { VideoResolution } from '@peertube/peertube-models' /** @@ -123,7 +123,7 @@ async function getVideoStreamDimensionsInfo (path: string, existingProbe?: Ffpro return { width: videoStream.width, height: videoStream.height, - ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width), + ratio: buildAspectRatio({ width: videoStream.width, height: videoStream.height }), resolution: Math.min(videoStream.height, videoStream.width), isPortraitMode: videoStream.height > videoStream.width } diff --git a/packages/models/src/activitypub/objects/common-objects.ts b/packages/models/src/activitypub/objects/common-objects.ts index df5dcb56f..6c8fca2ff 100644 --- a/packages/models/src/activitypub/objects/common-objects.ts +++ b/packages/models/src/activitypub/objects/common-objects.ts @@ -10,8 +10,8 @@ export interface ActivityIconObject { type: 'Image' url: string mediaType: string - width?: number - height?: number + width: number + height: number | null } export type ActivityVideoUrlObject = { @@ -19,6 +19,7 @@ export type ActivityVideoUrlObject = { mediaType: 'video/mp4' | 'video/webm' | 'video/ogg' | 'audio/mp4' href: string height: number + width: number | null size: number fps: number } @@ -35,6 +36,7 @@ export type ActivityVideoFileMetadataUrlObject = { rel: [ 'metadata', any ] mediaType: 'application/json' height: number + width: number | null href: string fps: number } @@ -63,6 +65,8 @@ export type ActivityBitTorrentUrlObject = { mediaType: 'application/x-bittorrent' | 'application/x-bittorrent;x-scheme-handler/magnet' href: string height: number + width: number | null + fps: number | null } export type ActivityMagnetUrlObject = { @@ -70,6 +74,8 @@ export type ActivityMagnetUrlObject = { mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' href: string height: number + width: number | null + fps: number | null } export type ActivityHtmlUrlObject = { diff --git a/packages/models/src/activitypub/objects/video-object.ts b/packages/models/src/activitypub/objects/video-object.ts index 1861454a8..16dbe1aab 100644 --- a/packages/models/src/activitypub/objects/video-object.ts +++ b/packages/models/src/activitypub/objects/video-object.ts @@ -44,6 +44,8 @@ export interface VideoObject { support: string + aspectRatio: number + icon: ActivityIconObject[] url: ActivityUrlObject[] diff --git a/packages/models/src/videos/file/video-file.model.ts b/packages/models/src/videos/file/video-file.model.ts index 2ed1ac4be..9745eb752 100644 --- a/packages/models/src/videos/file/video-file.model.ts +++ b/packages/models/src/videos/file/video-file.model.ts @@ -7,6 +7,9 @@ export interface VideoFile { resolution: VideoConstant<number> size: number // Bytes + width?: number + height?: number + torrentUrl: string torrentDownloadUrl: string diff --git a/packages/models/src/videos/video.model.ts b/packages/models/src/videos/video.model.ts index a750e220d..4e78b267e 100644 --- a/packages/models/src/videos/video.model.ts +++ b/packages/models/src/videos/video.model.ts @@ -29,6 +29,8 @@ export interface Video extends Partial<VideoAdditionalAttributes> { isLocal: boolean name: string + aspectRatio: number | null + isLive: boolean thumbnailPath: string diff --git a/packages/tests/fixtures/video_import_preview_yt_dlp.jpg b/packages/tests/fixtures/video_import_preview_yt_dlp.jpg index 9e8833bf9424964a91d8ce38718f780ca016a057..029fb9ee8ebfc6f7c2baa078aed0a107b3a5d9e2 100644 GIT binary patch literal 49065 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%0D6`T0pLT{gl0^`U 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 literal 15844 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 TZU6WF4fp@2KMxc9`T0KpI%<R9 diff --git a/packages/tests/src/api/live/live.ts b/packages/tests/src/api/live/live.ts index ac839c872..ea05ccc3c 100644 --- a/packages/tests/src/api/live/live.ts +++ b/packages/tests/src/api/live/live.ts @@ -115,6 +115,8 @@ describe('Test live', function () { expect(video.isLive).to.be.true + expect(video.aspectRatio).to.not.exist + expect(video.nsfw).to.be.false expect(video.waitTranscoding).to.be.false expect(video.name).to.equal('my super live') @@ -552,6 +554,7 @@ describe('Test live', function () { expect(video.state.id).to.equal(VideoState.PUBLISHED) expect(video.duration).to.be.greaterThan(1) + expect(video.aspectRatio).to.equal(1.7778) expect(video.files).to.have.lengthOf(0) const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS) diff --git a/packages/tests/src/api/redundancy/redundancy.ts b/packages/tests/src/api/redundancy/redundancy.ts index 69afae037..2540abb40 100644 --- a/packages/tests/src/api/redundancy/redundancy.ts +++ b/packages/tests/src/api/redundancy/redundancy.ts @@ -2,7 +2,6 @@ import { expect } from 'chai' import { readdir } from 'fs/promises' -import { decode as magnetUriDecode } from 'magnet-uri' import { basename, join } from 'path' import { wait } from '@peertube/peertube-core-utils' import { @@ -25,12 +24,13 @@ import { } from '@peertube/peertube-server-commands' import { checkSegmentHash } from '@tests/shared/streaming-playlists.js' import { checkVideoFilesWereRemoved, saveVideoInServers } from '@tests/shared/videos.js' +import { magnetUriDecode } from '@tests/shared/webtorrent.js' let servers: PeerTubeServer[] = [] let video1Server2: VideoDetails async function checkMagnetWebseeds (file: VideoFile, baseWebseeds: string[], server: PeerTubeServer) { - const parsed = magnetUriDecode(file.magnetUri) + const parsed = await magnetUriDecode(file.magnetUri) for (const ws of baseWebseeds) { const found = parsed.urlList.find(url => url === `${ws}${basename(file.fileUrl)}`) diff --git a/packages/tests/src/api/server/follows.ts b/packages/tests/src/api/server/follows.ts index 56eb86e87..448f28d62 100644 --- a/packages/tests/src/api/server/follows.ts +++ b/packages/tests/src/api/server/follows.ts @@ -479,6 +479,8 @@ describe('Test follows', function () { files: [ { resolution: 720, + width: 1280, + height: 720, size: 218910 } ] diff --git a/packages/tests/src/api/server/handle-down.ts b/packages/tests/src/api/server/handle-down.ts index e5f0796a1..474048037 100644 --- a/packages/tests/src/api/server/handle-down.ts +++ b/packages/tests/src/api/server/handle-down.ts @@ -69,6 +69,8 @@ describe('Test handle downs', function () { fixture: 'video_short1.webm', files: [ { + height: 720, + width: 1280, resolution: 720, size: 572456 } diff --git a/packages/tests/src/api/server/tracker.ts b/packages/tests/src/api/server/tracker.ts index 4df4e4613..159b49c49 100644 --- a/packages/tests/src/api/server/tracker.ts +++ b/packages/tests/src/api/server/tracker.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await,@typescript-eslint/no-floating-promises */ -import { decode as magnetUriDecode, encode as magnetUriEncode } from 'magnet-uri' import WebTorrent from 'webtorrent' import { cleanupTests, @@ -9,6 +8,7 @@ import { PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' +import { magnetUriDecode, magnetUriEncode } from '@tests/shared/webtorrent.js' describe('Test tracker', function () { let server: PeerTubeServer @@ -25,10 +25,10 @@ describe('Test tracker', function () { const video = await server.videos.get({ id: uuid }) goodMagnet = video.files[0].magnetUri - const parsed = magnetUriDecode(goodMagnet) + const parsed = await magnetUriDecode(goodMagnet) parsed.infoHash = '010597bb88b1968a5693a4fa8267c592ca65f2e9' - badMagnet = magnetUriEncode(parsed) + badMagnet = await magnetUriEncode(parsed) } }) diff --git a/packages/tests/src/api/users/user-import.ts b/packages/tests/src/api/users/user-import.ts index 798d04220..f67bb0178 100644 --- a/packages/tests/src/api/users/user-import.ts +++ b/packages/tests/src/api/users/user-import.ts @@ -401,10 +401,14 @@ function runTest (withObjectStorage: boolean) { files: [ { resolution: 720, + height: 720, + width: 1280, size: 61000 }, { resolution: 240, + height: 240, + width: 426, size: 23000 } ], diff --git a/packages/tests/src/api/videos/multiple-servers.ts b/packages/tests/src/api/videos/multiple-servers.ts index 6c62a1d95..69d13d48e 100644 --- a/packages/tests/src/api/videos/multiple-servers.ts +++ b/packages/tests/src/api/videos/multiple-servers.ts @@ -118,6 +118,8 @@ describe('Test multiple servers', function () { files: [ { resolution: 720, + height: 720, + width: 1280, size: 572456 } ] @@ -205,18 +207,26 @@ describe('Test multiple servers', function () { files: [ { resolution: 240, + height: 240, + width: 426, size: 270000 }, { resolution: 360, + height: 360, + width: 640, size: 359000 }, { resolution: 480, + height: 480, + width: 854, size: 465000 }, { resolution: 720, + height: 720, + width: 1280, size: 750000 } ], @@ -312,6 +322,8 @@ describe('Test multiple servers', function () { files: [ { resolution: 720, + height: 720, + width: 1280, size: 292677 } ] @@ -344,6 +356,8 @@ describe('Test multiple servers', function () { files: [ { resolution: 720, + height: 720, + width: 1280, size: 218910 } ] @@ -654,6 +668,8 @@ describe('Test multiple servers', function () { files: [ { resolution: 720, + height: 720, + width: 1280, size: 292677 } ], @@ -1061,18 +1077,26 @@ describe('Test multiple servers', function () { files: [ { resolution: 720, + height: 720, + width: 1280, size: 61000 }, { resolution: 480, + height: 480, + width: 854, size: 40000 }, { resolution: 360, + height: 360, + width: 640, size: 32000 }, { resolution: 240, + height: 240, + width: 426, size: 23000 } ] diff --git a/packages/tests/src/api/videos/single-server.ts b/packages/tests/src/api/videos/single-server.ts index a60928ebb..82b5fe6ce 100644 --- a/packages/tests/src/api/videos/single-server.ts +++ b/packages/tests/src/api/videos/single-server.ts @@ -50,6 +50,8 @@ describe('Test a single server', function () { files: [ { resolution: 720, + height: 720, + width: 1280, size: 218910 } ] @@ -81,6 +83,8 @@ describe('Test a single server', function () { files: [ { resolution: 720, + height: 720, + width: 1280, size: 292677 } ] diff --git a/packages/tests/src/api/videos/video-files.ts b/packages/tests/src/api/videos/video-files.ts index 1d7c218a4..8d577e876 100644 --- a/packages/tests/src/api/videos/video-files.ts +++ b/packages/tests/src/api/videos/video-files.ts @@ -105,7 +105,8 @@ describe('Test videos files', function () { const video = await servers[0].videos.get({ id: webVideoId }) const files = video.files - await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: files[0].id }) + const toDelete = files[0] + await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: toDelete.id }) await waitJobs(servers) @@ -113,7 +114,7 @@ describe('Test videos files', function () { const video = await server.videos.get({ id: webVideoId }) expect(video.files).to.have.lengthOf(files.length - 1) - expect(video.files.find(f => f.id === files[0].id)).to.not.exist + expect(video.files.find(f => f.resolution.id === toDelete.resolution.id)).to.not.exist } }) @@ -151,7 +152,7 @@ describe('Test videos files', function () { const video = await server.videos.get({ id: hlsId }) expect(video.streamingPlaylists[0].files).to.have.lengthOf(files.length - 1) - expect(video.streamingPlaylists[0].files.find(f => f.id === toDelete.id)).to.not.exist + expect(video.streamingPlaylists[0].files.find(f => f.resolution.id === toDelete.resolution.id)).to.not.exist const { text } = await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) diff --git a/packages/tests/src/api/videos/video-static-file-privacy.ts b/packages/tests/src/api/videos/video-static-file-privacy.ts index 7c8d14815..8794aef3d 100644 --- a/packages/tests/src/api/videos/video-static-file-privacy.ts +++ b/packages/tests/src/api/videos/video-static-file-privacy.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ import { expect } from 'chai' -import { decode } from 'magnet-uri' import { getAllFiles, wait } from '@peertube/peertube-core-utils' import { HttpStatusCode, HttpStatusCodeType, LiveVideo, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' import { @@ -18,7 +17,7 @@ import { } from '@peertube/peertube-server-commands' import { expectStartWith } from '@tests/shared/checks.js' import { checkVideoFileTokenReinjection } from '@tests/shared/streaming-playlists.js' -import { parseTorrentVideo } from '@tests/shared/webtorrent.js' +import { magnetUriDecode, parseTorrentVideo } from '@tests/shared/webtorrent.js' describe('Test video static file privacy', function () { let server: PeerTubeServer @@ -48,7 +47,7 @@ describe('Test video static file privacy', function () { const torrent = await parseTorrentVideo(server, file) expect(torrent.urlList).to.have.lengthOf(0) - const magnet = decode(file.magnetUri) + const magnet = await magnetUriDecode(file.magnetUri) expect(magnet.urlList).to.have.lengthOf(0) await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) @@ -74,7 +73,7 @@ describe('Test video static file privacy', function () { const torrent = await parseTorrentVideo(server, file) expect(torrent.urlList[0]).to.not.include('private') - const magnet = decode(file.magnetUri) + const magnet = await magnetUriDecode(file.magnetUri) expect(magnet.urlList[0]).to.not.include('private') await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) diff --git a/packages/tests/src/server-helpers/core-utils.ts b/packages/tests/src/server-helpers/core-utils.ts index f1e7c72f7..c3bec176d 100644 --- a/packages/tests/src/server-helpers/core-utils.ts +++ b/packages/tests/src/server-helpers/core-utils.ts @@ -3,7 +3,13 @@ import { expect } from 'chai' import snakeCase from 'lodash-es/snakeCase.js' import validator from 'validator' -import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, parseChapters, timeToInt } from '@peertube/peertube-core-utils' +import { + buildAspectRatio, + getAverageTheoreticalBitrate, + getMaxTheoreticalBitrate, + parseChapters, + timeToInt +} from '@peertube/peertube-core-utils' import { VideoResolution } from '@peertube/peertube-models' import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/core/helpers/core-utils.js' @@ -169,6 +175,18 @@ describe('Bitrate', function () { expect(getAverageTheoreticalBitrate(test)).to.be.above(test.min * 1000).and.below(test.max * 1000) } }) + + describe('Ratio', function () { + + it('Should have the correct aspect ratio in landscape', function () { + expect(buildAspectRatio({ width: 1920, height: 1080 })).to.equal(1.7778) + expect(buildAspectRatio({ width: 1000, height: 1000 })).to.equal(1) + }) + + it('Should have the correct aspect ratio in portrait', function () { + expect(buildAspectRatio({ width: 1080, height: 1920 })).to.equal(0.5625) + }) + }) }) describe('Parse semantic version string', function () { diff --git a/packages/tests/src/shared/checks.ts b/packages/tests/src/shared/checks.ts index 0f1d9d02e..365d02e25 100644 --- a/packages/tests/src/shared/checks.ts +++ b/packages/tests/src/shared/checks.ts @@ -103,9 +103,15 @@ async function testImage (url: string, imageName: string, imageHTTPPath: string, ? PNG.sync.read(data) : JPEG.decode(data) - const result = pixelmatch(img1.data, img2.data, null, img1.width, img1.height, { threshold: 0.1 }) + const errorMsg = `${imageHTTPPath} image is not the same as ${imageName}${extension}` - expect(result).to.equal(0, `${imageHTTPPath} image is not the same as ${imageName}${extension}`) + try { + const result = pixelmatch(img1.data, img2.data, null, img1.width, img1.height, { threshold: 0.1 }) + + expect(result).to.equal(0, errorMsg) + } catch (err) { + throw new Error(`${errorMsg}: ${err.message}`) + } } async function testFileExistsOrNot (server: PeerTubeServer, directory: string, filePath: string, exist: boolean) { diff --git a/packages/tests/src/shared/live.ts b/packages/tests/src/shared/live.ts index 9c7991b0d..2c7f02be0 100644 --- a/packages/tests/src/shared/live.ts +++ b/packages/tests/src/shared/live.ts @@ -66,6 +66,8 @@ async function testLiveVideoResolutions (options: { expect(data.find(v => v.uuid === liveVideoId)).to.exist const video = await server.videos.get({ id: liveVideoId }) + + expect(video.aspectRatio).to.equal(1.7778) expect(video.streamingPlaylists).to.have.lengthOf(1) const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS) diff --git a/packages/tests/src/shared/streaming-playlists.ts b/packages/tests/src/shared/streaming-playlists.ts index ec5a0187a..8a601266f 100644 --- a/packages/tests/src/shared/streaming-playlists.ts +++ b/packages/tests/src/shared/streaming-playlists.ts @@ -145,6 +145,9 @@ async function completeCheckHlsPlaylist (options: { expect(file.resolution.label).to.equal(resolution + 'p') } + expect(Math.min(file.height, file.width)).to.equal(resolution) + expect(Math.max(file.height, file.width)).to.be.greaterThan(resolution) + expect(file.magnetUri).to.have.lengthOf.above(2) await checkWebTorrentWorks(file.magnetUri) diff --git a/packages/tests/src/shared/videos.ts b/packages/tests/src/shared/videos.ts index 0bf1956af..ede4ecc6c 100644 --- a/packages/tests/src/shared/videos.ts +++ b/packages/tests/src/shared/videos.ts @@ -26,6 +26,8 @@ export async function completeWebVideoFilesCheck (options: { fixture: string files: { resolution: number + width?: number + height?: number size?: number }[] objectStorageBaseUrl?: string @@ -84,7 +86,9 @@ export async function completeWebVideoFilesCheck (options: { makeRawRequest({ url: file.fileDownloadUrl, token, - expectedStatus: objectStorageBaseUrl ? HttpStatusCode.FOUND_302 : HttpStatusCode.OK_200 + expectedStatus: objectStorageBaseUrl + ? HttpStatusCode.FOUND_302 + : HttpStatusCode.OK_200 }) ]) } @@ -97,6 +101,12 @@ export async function completeWebVideoFilesCheck (options: { expect(file.resolution.label).to.equal(attributeFile.resolution + 'p') } + if (attributeFile.width !== undefined) expect(file.width).to.equal(attributeFile.width) + if (attributeFile.height !== undefined) expect(file.height).to.equal(attributeFile.height) + + expect(Math.min(file.height, file.width)).to.equal(file.resolution.id) + expect(Math.max(file.height, file.width)).to.be.greaterThan(file.resolution.id) + if (attributeFile.size) { const minSize = attributeFile.size - ((10 * attributeFile.size) / 100) const maxSize = attributeFile.size + ((10 * attributeFile.size) / 100) @@ -156,6 +166,8 @@ export async function completeVideoCheck (options: { files?: { resolution: number size: number + width: number + height: number }[] hls?: { diff --git a/packages/tests/src/shared/webtorrent.ts b/packages/tests/src/shared/webtorrent.ts index 8f83ddf17..a50ab464a 100644 --- a/packages/tests/src/shared/webtorrent.ts +++ b/packages/tests/src/shared/webtorrent.ts @@ -4,6 +4,7 @@ import { basename, join } from 'path' import type { Instance, Torrent } from 'webtorrent' import { VideoFile } from '@peertube/peertube-models' import { PeerTubeServer } from '@peertube/peertube-server-commands' +import type { Instance as MagnetUriInstance } from 'magnet-uri' let webtorrent: Instance @@ -28,6 +29,14 @@ export async function parseTorrentVideo (server: PeerTubeServer, file: VideoFile return (await import('parse-torrent')).default(data) } +export async function magnetUriDecode (data: string) { + return (await import('magnet-uri')).decode(data) +} + +export async function magnetUriEncode (data: MagnetUriInstance) { + return (await import('magnet-uri')).encode(data) +} + // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- diff --git a/server/core/controllers/api/videos/source.ts b/server/core/controllers/api/videos/source.ts index d5882d489..fd2631110 100644 --- a/server/core/controllers/api/videos/source.ts +++ b/server/core/controllers/api/videos/source.ts @@ -23,6 +23,7 @@ import { replaceVideoSourceResumableValidator, videoSourceGetLatestValidator } from '../../../middlewares/index.js' +import { buildAspectRatio } from '@peertube/peertube-core-utils' const lTags = loggerTagsFactory('api', 'video') @@ -96,6 +97,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R video.state = buildNextVideoState() video.duration = videoPhysicalFile.duration video.inputFileUpdatedAt = inputFileUpdatedAt + video.aspectRatio = buildAspectRatio({ width: videoFile.width, height: videoFile.height }) await video.save({ transaction }) await autoBlacklistVideoIfNeeded({ diff --git a/server/core/helpers/activity-pub-utils.ts b/server/core/helpers/activity-pub-utils.ts index aa05e6031..7ea701d34 100644 --- a/server/core/helpers/activity-pub-utils.ts +++ b/server/core/helpers/activity-pub-utils.ts @@ -94,6 +94,10 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string '@type': 'sc:Number', '@id': 'pt:tileDuration' }, + aspectRatio: { + '@type': 'sc:Float', + '@id': 'pt:aspectRatio' + }, originallyPublishedAt: 'sc:datePublished', diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 0fb0710aa..5cc8a53e2 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -45,7 +45,7 @@ import { cpus } from 'os' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 820 +const LAST_MIGRATION_VERSION = 825 // --------------------------------------------------------------------------- diff --git a/server/core/initializers/migrations/0825-video-ratio.ts b/server/core/initializers/migrations/0825-video-ratio.ts new file mode 100644 index 000000000..4bfd4c402 --- /dev/null +++ b/server/core/initializers/migrations/0825-video-ratio.ts @@ -0,0 +1,43 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise<void> { + { + const data = { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: true + } + await utils.queryInterface.addColumn('videoFile', 'width', data) + } + + { + const data = { + type: Sequelize.INTEGER, + defaultValue: null, + allowNull: true + } + await utils.queryInterface.addColumn('videoFile', 'height', data) + } + + { + const data = { + type: Sequelize.FLOAT, + defaultValue: null, + allowNull: true + } + await utils.queryInterface.addColumn('video', 'aspectRatio', data) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts index 9657bd172..71846172e 100644 --- a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts +++ b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts @@ -55,7 +55,6 @@ function getFileAttributesFromUrl ( urls: (ActivityTagObject | ActivityUrlObject)[] ) { const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[] - if (fileUrls.length === 0) return [] const attributes: FilteredModelAttributes<VideoFileModel>[] = [] @@ -96,6 +95,9 @@ function getFileAttributesFromUrl ( fps: fileUrl.fps || -1, metadataUrl: metadata?.href, + width: fileUrl.width, + height: fileUrl.height, + // Use the name of the remote file because we don't proxify video file requests filename: basename(fileUrl.href), fileUrl: fileUrl.href, @@ -223,6 +225,7 @@ function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: Vi waitTranscoding: videoObject.waitTranscoding, isLive: videoObject.isLiveBroadcast, state: videoObject.state, + aspectRatio: videoObject.aspectRatio, channelId: videoChannel.id, duration: getDurationFromActivityStream(videoObject.duration), createdAt: new Date(videoObject.published), diff --git a/server/core/lib/activitypub/videos/updater.ts b/server/core/lib/activitypub/videos/updater.ts index a8d1558fb..e722744bd 100644 --- a/server/core/lib/activitypub/videos/updater.ts +++ b/server/core/lib/activitypub/videos/updater.ts @@ -143,6 +143,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder { this.video.channelId = videoData.channelId this.video.views = videoData.views this.video.isLive = videoData.isLive + this.video.aspectRatio = videoData.aspectRatio // Ensures we update the updatedAt attribute, even if main attributes did not change this.video.changed('updatedAt', true) diff --git a/server/core/lib/job-queue/handlers/generate-storyboard.ts b/server/core/lib/job-queue/handlers/generate-storyboard.ts index 62ae64189..b4539aabb 100644 --- a/server/core/lib/job-queue/handlers/generate-storyboard.ts +++ b/server/core/lib/job-queue/handlers/generate-storyboard.ts @@ -51,10 +51,10 @@ async function processGenerateStoryboard (job: Job): Promise<void> { if (videoStreamInfo.isPortraitMode) { spriteHeight = STORYBOARD.SPRITE_MAX_SIZE - spriteWidth = Math.round(STORYBOARD.SPRITE_MAX_SIZE / videoStreamInfo.ratio) + spriteWidth = Math.round(spriteHeight * videoStreamInfo.ratio) } else { - spriteHeight = Math.round(STORYBOARD.SPRITE_MAX_SIZE / videoStreamInfo.ratio) spriteWidth = STORYBOARD.SPRITE_MAX_SIZE + spriteHeight = Math.round(spriteWidth / videoStreamInfo.ratio) } const ffmpeg = new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail')) diff --git a/server/core/lib/job-queue/handlers/video-file-import.ts b/server/core/lib/job-queue/handlers/video-file-import.ts index a306c6b80..ae876b355 100644 --- a/server/core/lib/job-queue/handlers/video-file-import.ts +++ b/server/core/lib/job-queue/handlers/video-file-import.ts @@ -1,20 +1,17 @@ import { Job } from 'bullmq' import { copy } from 'fs-extra/esm' -import { stat } from 'fs/promises' -import { VideoFileImportPayload, FileStorage } from '@peertube/peertube-models' +import { VideoFileImportPayload } from '@peertube/peertube-models' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js' import { CONFIG } from '@server/initializers/config.js' import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js' -import { generateWebVideoFilename } from '@server/lib/paths.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' -import { VideoFileModel } from '@server/models/video/video-file.js' import { VideoModel } from '@server/models/video/video.js' import { MVideoFullLight } from '@server/types/models/index.js' -import { getLowercaseExtension } from '@peertube/peertube-node-utils' -import { getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg' +import { getVideoStreamDimensionsInfo } from '@peertube/peertube-ffmpeg' import { logger } from '../../../helpers/logger.js' import { JobQueue } from '../job-queue.js' import { buildMoveJob } from '@server/lib/video-jobs.js' +import { buildNewFile } from '@server/lib/video-file.js' async function processVideoFileImport (job: Job) { const payload = job.data as VideoFileImportPayload @@ -48,11 +45,6 @@ export { async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { const { resolution } = await getVideoStreamDimensionsInfo(inputFilePath) - const { size } = await stat(inputFilePath) - const fps = await getVideoStreamFPS(inputFilePath) - - const fileExt = getLowercaseExtension(inputFilePath) - const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === resolution) if (currentVideoFile) { @@ -64,15 +56,8 @@ async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { await currentVideoFile.destroy() } - const newVideoFile = new VideoFileModel({ - resolution, - extname: fileExt, - filename: generateWebVideoFilename(resolution, fileExt), - storage: FileStorage.FILE_SYSTEM, - size, - fps, - videoId: video.id - }) + const newVideoFile = await buildNewFile({ mode: 'web-video', path: inputFilePath }) + newVideoFile.videoId = video.id const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newVideoFile) await copy(inputFilePath, outputPath) diff --git a/server/core/lib/job-queue/handlers/video-import.ts b/server/core/lib/job-queue/handlers/video-import.ts index 6ebe973db..db8f84077 100644 --- a/server/core/lib/job-queue/handlers/video-import.ts +++ b/server/core/lib/job-queue/handlers/video-import.ts @@ -10,15 +10,12 @@ import { VideoImportTorrentPayload, VideoImportTorrentPayloadType, VideoImportYoutubeDLPayload, - VideoImportYoutubeDLPayloadType, - VideoResolution, - VideoState + VideoImportYoutubeDLPayloadType, VideoState } from '@peertube/peertube-models' import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { YoutubeDLWrapper } from '@server/helpers/youtube-dl/index.js' import { CONFIG } from '@server/initializers/config.js' import { isPostImportVideoAccepted } from '@server/lib/moderation.js' -import { generateWebVideoFilename } from '@server/lib/paths.js' import { Hooks } from '@server/lib/plugins/hooks.js' import { ServerConfigManager } from '@server/lib/server-config-manager.js' import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job.js' @@ -28,14 +25,9 @@ import { buildNextVideoState } from '@server/lib/video-state.js' import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js' import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import.js' -import { getLowercaseExtension } from '@peertube/peertube-node-utils' import { ffprobePromise, - getChaptersFromContainer, - getVideoStreamDimensionsInfo, - getVideoStreamDuration, - getVideoStreamFPS, - isAudioFile + getChaptersFromContainer, getVideoStreamDuration } from '@peertube/peertube-ffmpeg' import { logger } from '../../../helpers/logger.js' import { getSecureTorrentName } from '../../../helpers/utils.js' @@ -51,6 +43,8 @@ import { generateLocalVideoMiniature } from '../../thumbnail.js' import { JobQueue } from '../job-queue.js' import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js' import { FfprobeData } from 'fluent-ffmpeg' +import { buildNewFile } from '@server/lib/video-file.js' +import { buildAspectRatio } from '@peertube/peertube-core-utils' async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> { const payload = job.data as VideoImportPayload @@ -129,46 +123,31 @@ type ProcessFileOptions = { videoImportId: number } async function processFile (downloader: () => Promise<string>, videoImport: MVideoImportDefault, options: ProcessFileOptions) { - let tempVideoPath: string + let tmpVideoPath: string let videoFile: VideoFileModel try { // Download video from youtubeDL - tempVideoPath = await downloader() + tmpVideoPath = await downloader() // Get information about this video - const stats = await stat(tempVideoPath) + const stats = await stat(tmpVideoPath) const isAble = await isUserQuotaValid({ userId: videoImport.User.id, uploadSize: stats.size }) if (isAble === false) { throw new Error('The user video quota is exceeded with this video to import.') } - const ffprobe = await ffprobePromise(tempVideoPath) - - const { resolution } = await isAudioFile(tempVideoPath, ffprobe) - ? { resolution: VideoResolution.H_NOVIDEO } - : await getVideoStreamDimensionsInfo(tempVideoPath, ffprobe) - - const fps = await getVideoStreamFPS(tempVideoPath, ffprobe) - const duration = await getVideoStreamDuration(tempVideoPath, ffprobe) + const ffprobe = await ffprobePromise(tmpVideoPath) + const duration = await getVideoStreamDuration(tmpVideoPath, ffprobe) const containerChapters = await getChaptersFromContainer({ - path: tempVideoPath, + path: tmpVideoPath, maxTitleLength: CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE.max, ffprobe }) - // Prepare video file object for creation in database - const fileExt = getLowercaseExtension(tempVideoPath) - const videoFileData = { - extname: fileExt, - resolution, - size: stats.size, - filename: generateWebVideoFilename(resolution, fileExt), - fps, - videoId: videoImport.videoId - } - videoFile = new VideoFileModel(videoFileData) + videoFile = await buildNewFile({ mode: 'web-video', ffprobe, path: tmpVideoPath }) + videoFile.videoId = videoImport.videoId const hookName = options.type === 'youtube-dl' ? 'filter:api.video.post-import-url.accept.result' @@ -178,7 +157,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid const acceptParameters = { videoImport, video: videoImport.Video, - videoFilePath: tempVideoPath, + videoFilePath: tmpVideoPath, videoFile, user: videoImport.User } @@ -201,9 +180,9 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid // Move file const videoDestFile = VideoPathManager.Instance.getFSVideoFileOutputPath(videoImportWithFiles.Video, videoFile) - await move(tempVideoPath, videoDestFile) + await move(tmpVideoPath, videoDestFile) - tempVideoPath = null // This path is not used anymore + tmpVideoPath = null // This path is not used anymore const thumbnails = await generateMiniature({ videoImportWithFiles, videoFile, ffprobe }) @@ -221,6 +200,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid // Update video DB object video.duration = duration video.state = buildNextVideoState(video.state) + video.aspectRatio = buildAspectRatio({ width: videoFile.width, height: videoFile.height }) await video.save({ transaction: t }) for (const thumbnail of thumbnails) { @@ -248,7 +228,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid videoFileLockReleaser() } } catch (err) { - await onImportError(err, tempVideoPath, videoImport) + await onImportError(err, tmpVideoPath, videoImport) throw err } diff --git a/server/core/lib/job-queue/handlers/video-live-ending.ts b/server/core/lib/job-queue/handlers/video-live-ending.ts index 206ce2108..1a326645d 100644 --- a/server/core/lib/job-queue/handlers/video-live-ending.ts +++ b/server/core/lib/job-queue/handlers/video-live-ending.ts @@ -125,6 +125,7 @@ async function saveReplayToExternalVideo (options: { waitTranscoding: true, nsfw: liveVideo.nsfw, description: liveVideo.description, + aspectRatio: liveVideo.aspectRatio, support: liveVideo.support, privacy: replaySettings.privacy, channelId: liveVideo.channelId diff --git a/server/core/lib/live/live-manager.ts b/server/core/lib/live/live-manager.ts index 3ef1661b8..797b3bdfa 100644 --- a/server/core/lib/live/live-manager.ts +++ b/server/core/lib/live/live-manager.ts @@ -328,7 +328,7 @@ class LiveManager { allResolutions: number[] hasAudio: boolean }) { - const { sessionId, videoLive, user } = options + const { sessionId, videoLive, user, ratio } = options const videoUUID = videoLive.Video.uuid const localLTags = lTags(sessionId, videoUUID) @@ -345,7 +345,7 @@ class LiveManager { ...pick(options, [ 'inputLocalUrl', 'inputPublicUrl', 'bitrate', 'ratio', 'fps', 'allResolutions', 'hasAudio' ]) }) - muxingSession.on('live-ready', () => this.publishAndFederateLive(videoLive, localLTags)) + muxingSession.on('live-ready', () => this.publishAndFederateLive({ live: videoLive, ratio, localLTags })) muxingSession.on('bad-socket-health', ({ videoUUID }) => { logger.error( @@ -405,7 +405,13 @@ class LiveManager { }) } - private async publishAndFederateLive (live: MVideoLiveVideo, localLTags: { tags: (string | number)[] }) { + private async publishAndFederateLive (options: { + live: MVideoLiveVideo + ratio: number + localLTags: { tags: (string | number)[] } + }) { + const { live, ratio, localLTags } = options + const videoId = live.videoId try { @@ -415,6 +421,7 @@ class LiveManager { video.state = VideoState.PUBLISHED video.publishedAt = new Date() + video.aspectRatio = ratio await video.save() live.Video = video diff --git a/server/core/lib/local-video-creator.ts b/server/core/lib/local-video-creator.ts index a41937ecc..b2e8acc99 100644 --- a/server/core/lib/local-video-creator.ts +++ b/server/core/lib/local-video-creator.ts @@ -33,6 +33,7 @@ import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video import { LoggerTagsFn, logger } from '@server/helpers/logger.js' import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { federateVideoIfNeeded } from './activitypub/videos/federate.js' +import { buildAspectRatio } from '@peertube/peertube-core-utils' type VideoAttributes = Omit<VideoCreate, 'channelId'> & { duration: number @@ -116,6 +117,8 @@ export class LocalVideoCreator { const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(this.video, this.videoFile) await move(this.videoFilePath, destination) + + this.video.aspectRatio = buildAspectRatio({ width: this.videoFile.width, height: this.videoFile.height }) } const thumbnails = await this.createThumbnails() diff --git a/server/core/lib/runners/job-handlers/shared/vod-helpers.ts b/server/core/lib/runners/job-handlers/shared/vod-helpers.ts index d0eb6264f..3c63206bc 100644 --- a/server/core/lib/runners/job-handlers/shared/vod-helpers.ts +++ b/server/core/lib/runners/job-handlers/shared/vod-helpers.ts @@ -1,50 +1,24 @@ -import { move } from 'fs-extra/esm' -import { dirname, join } from 'path' import { logger, LoggerTagsFn } from '@server/helpers/logger.js' import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding.js' import { onWebVideoFileTranscoding } from '@server/lib/transcoding/web-transcoding.js' -import { buildNewFile } from '@server/lib/video-file.js' import { VideoModel } from '@server/models/video/video.js' import { MVideoFullLight } from '@server/types/models/index.js' import { MRunnerJob } from '@server/types/models/runners/index.js' import { RunnerJobVODAudioMergeTranscodingPrivatePayload, RunnerJobVODWebVideoTranscodingPrivatePayload } from '@peertube/peertube-models' -import { lTags } from '@server/lib/object-storage/shared/logger.js' export async function onVODWebVideoOrAudioMergeTranscodingJob (options: { video: MVideoFullLight videoFilePath: string privatePayload: RunnerJobVODWebVideoTranscodingPrivatePayload | RunnerJobVODAudioMergeTranscodingPrivatePayload + wasAudioFile: boolean }) { - const { video, videoFilePath, privatePayload } = options + const { video, videoFilePath, privatePayload, wasAudioFile } = options - const videoFile = await buildNewFile({ path: videoFilePath, mode: 'web-video' }) - videoFile.videoId = video.id + const deleteWebInputVideoFile = privatePayload.deleteInputFileId + ? video.VideoFiles.find(f => f.id === privatePayload.deleteInputFileId) + : undefined - const newVideoFilePath = join(dirname(videoFilePath), videoFile.filename) - await move(videoFilePath, newVideoFilePath) - - await onWebVideoFileTranscoding({ - video, - videoFile, - videoOutputPath: newVideoFilePath - }) - - if (privatePayload.deleteInputFileId) { - const inputFile = video.VideoFiles.find(f => f.id === privatePayload.deleteInputFileId) - - if (inputFile) { - await video.removeWebVideoFile(inputFile) - await inputFile.destroy() - - video.VideoFiles = video.VideoFiles.filter(f => f.id !== inputFile.id) - } else { - logger.error( - 'Cannot delete input file %d of video %s: does not exist anymore', - privatePayload.deleteInputFileId, video.uuid, - { ...lTags(video.uuid), privatePayload } - ) - } - } + await onWebVideoFileTranscoding({ video, videoOutputPath: videoFilePath, deleteWebInputVideoFile, wasAudioFile }) await onTranscodingEnded({ isNewVideo: privatePayload.isNewVideo, moveVideoToNextState: true, video }) } diff --git a/server/core/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts b/server/core/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts index bcae1b27a..b93ab37a4 100644 --- a/server/core/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts +++ b/server/core/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts @@ -4,7 +4,6 @@ import { MVideo } from '@server/types/models/index.js' import { MRunnerJob } from '@server/types/models/runners/index.js' import { pick } from '@peertube/peertube-core-utils' import { buildUUID } from '@peertube/peertube-node-utils' -import { getVideoStreamDuration } from '@peertube/peertube-ffmpeg' import { RunnerJobUpdatePayload, RunnerJobVODAudioMergeTranscodingPayload, @@ -77,12 +76,7 @@ export class VODAudioMergeTranscodingJobHandler extends AbstractVODTranscodingJo const videoFilePath = resultPayload.videoFile as string - // ffmpeg generated a new video file, so update the video duration - // See https://trac.ffmpeg.org/ticket/5456 - video.duration = await getVideoStreamDuration(videoFilePath) - await video.save() - - await onVODWebVideoOrAudioMergeTranscodingJob({ video, videoFilePath, privatePayload }) + await onVODWebVideoOrAudioMergeTranscodingJob({ video, videoFilePath, privatePayload, wasAudioFile: true }) logger.info( 'Runner VOD audio merge transcoding job %s for %s ended.', diff --git a/server/core/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts b/server/core/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts index e0b90313f..0cb011d95 100644 --- a/server/core/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts +++ b/server/core/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts @@ -1,11 +1,7 @@ -import { move } from 'fs-extra/esm' -import { dirname, join } from 'path' import { logger } from '@server/helpers/logger.js' -import { renameVideoFileInPlaylist } from '@server/lib/hls.js' -import { getHlsResolutionPlaylistFilename } from '@server/lib/paths.js' import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding.js' import { onHLSVideoFileTranscoding } from '@server/lib/transcoding/hls-transcoding.js' -import { buildNewFile, removeAllWebVideoFiles } from '@server/lib/video-file.js' +import { removeAllWebVideoFiles } from '@server/lib/video-file.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' import { MVideo } from '@server/types/models/index.js' import { MRunnerJob } from '@server/types/models/runners/index.js' @@ -84,21 +80,10 @@ export class VODHLSTranscodingJobHandler extends AbstractVODTranscodingJobHandle const videoFilePath = resultPayload.videoFile as string const resolutionPlaylistFilePath = resultPayload.resolutionPlaylistFile as string - const videoFile = await buildNewFile({ path: videoFilePath, mode: 'hls' }) - const newVideoFilePath = join(dirname(videoFilePath), videoFile.filename) - await move(videoFilePath, newVideoFilePath) - - const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFile.filename) - const newResolutionPlaylistFilePath = join(dirname(resolutionPlaylistFilePath), resolutionPlaylistFilename) - await move(resolutionPlaylistFilePath, newResolutionPlaylistFilePath) - - await renameVideoFileInPlaylist(newResolutionPlaylistFilePath, videoFile.filename) - await onHLSVideoFileTranscoding({ video, - videoFile, - m3u8OutputPath: newResolutionPlaylistFilePath, - videoOutputPath: newVideoFilePath + m3u8OutputPath: resolutionPlaylistFilePath, + videoOutputPath: videoFilePath }) await onTranscodingEnded({ isNewVideo: privatePayload.isNewVideo, moveVideoToNextState: true, video }) diff --git a/server/core/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts b/server/core/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts index a23adbc3f..12a846985 100644 --- a/server/core/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts +++ b/server/core/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts @@ -75,7 +75,7 @@ export class VODWebVideoTranscodingJobHandler extends AbstractVODTranscodingJobH const videoFilePath = resultPayload.videoFile as string - await onVODWebVideoOrAudioMergeTranscodingJob({ video, videoFilePath, privatePayload }) + await onVODWebVideoOrAudioMergeTranscodingJob({ video, videoFilePath, privatePayload, wasAudioFile: false }) logger.info( 'Runner VOD web video transcoding job %s for %s ended.', diff --git a/server/core/lib/transcoding/hls-transcoding.ts b/server/core/lib/transcoding/hls-transcoding.ts index 15182f5e6..fcb358330 100644 --- a/server/core/lib/transcoding/hls-transcoding.ts +++ b/server/core/lib/transcoding/hls-transcoding.ts @@ -1,20 +1,19 @@ import { MutexInterface } from 'async-mutex' import { Job } from 'bullmq' import { ensureDir, move } from 'fs-extra/esm' -import { stat } from 'fs/promises' -import { basename, extname as extnameUtil, join } from 'path' +import { join } from 'path' import { pick } from '@peertube/peertube-core-utils' import { retryTransactionWrapper } from '@server/helpers/database-utils.js' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js' import { sequelizeTypescript } from '@server/initializers/database.js' -import { MVideo, MVideoFile } from '@server/types/models/index.js' -import { getVideoStreamDuration, getVideoStreamFPS } from '@peertube/peertube-ffmpeg' +import { MVideo } from '@server/types/models/index.js' +import { getVideoStreamDuration } from '@peertube/peertube-ffmpeg' import { CONFIG } from '../../initializers/config.js' import { VideoFileModel } from '../../models/video/video-file.js' import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js' -import { updatePlaylistAfterFileChange } from '../hls.js' +import { renameVideoFileInPlaylist, updatePlaylistAfterFileChange } from '../hls.js' import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths.js' -import { buildFileMetadata } from '../video-file.js' +import { buildNewFile } from '../video-file.js' import { VideoPathManager } from '../video-path-manager.js' import { buildFFmpegVOD } from './shared/index.js' @@ -55,12 +54,11 @@ export function generateHlsPlaylistResolution (options: { export async function onHLSVideoFileTranscoding (options: { video: MVideo - videoFile: MVideoFile videoOutputPath: string m3u8OutputPath: string filesLockedInParent?: boolean // default false }) { - const { video, videoFile, videoOutputPath, m3u8OutputPath, filesLockedInParent = false } = options + const { video, videoOutputPath, m3u8OutputPath, filesLockedInParent = false } = options // Create or update the playlist const playlist = await retryTransactionWrapper(() => { @@ -68,7 +66,9 @@ export async function onHLSVideoFileTranscoding (options: { return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction) }) }) - videoFile.videoStreamingPlaylistId = playlist.id + + const newVideoFile = await buildNewFile({ mode: 'hls', path: videoOutputPath }) + newVideoFile.videoStreamingPlaylistId = playlist.id const mutexReleaser = !filesLockedInParent ? await VideoPathManager.Instance.lockFiles(video.uuid) @@ -77,33 +77,33 @@ export async function onHLSVideoFileTranscoding (options: { try { await video.reload() - const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, videoFile) + const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, newVideoFile) await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video)) // Move playlist file - const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, basename(m3u8OutputPath)) + const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath( + video, + getHlsResolutionPlaylistFilename(newVideoFile.filename) + ) await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true }) + // Move video file await move(videoOutputPath, videoFilePath, { overwrite: true }) + await renameVideoFileInPlaylist(resolutionPlaylistPath, newVideoFile.filename) + // Update video duration if it was not set (in case of a live for example) if (!video.duration) { video.duration = await getVideoStreamDuration(videoFilePath) await video.save() } - const stats = await stat(videoFilePath) - - videoFile.size = stats.size - videoFile.fps = await getVideoStreamFPS(videoFilePath) - videoFile.metadata = await buildFileMetadata(videoFilePath) - - await createTorrentAndSetInfoHash(playlist, videoFile) + await createTorrentAndSetInfoHash(playlist, newVideoFile) const oldFile = await VideoFileModel.loadHLSFile({ playlistId: playlist.id, - fps: videoFile.fps, - resolution: videoFile.resolution + fps: newVideoFile.fps, + resolution: newVideoFile.resolution }) if (oldFile) { @@ -111,7 +111,7 @@ export async function onHLSVideoFileTranscoding (options: { await oldFile.destroy() } - const savedVideoFile = await VideoFileModel.customUpsert(videoFile, 'streaming-playlist', undefined) + const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) await updatePlaylistAfterFileChange(video, playlist) @@ -171,17 +171,8 @@ async function generateHlsPlaylistCommon (options: { await buildFFmpegVOD(job).transcode(transcodeOptions) - const newVideoFile = new VideoFileModel({ - resolution, - extname: extnameUtil(videoFilename), - size: 0, - filename: videoFilename, - fps: -1 - }) - await onHLSVideoFileTranscoding({ video, - videoFile: newVideoFile, videoOutputPath, m3u8OutputPath, filesLockedInParent: !inputFileMutexReleaser diff --git a/server/core/lib/transcoding/web-transcoding.ts b/server/core/lib/transcoding/web-transcoding.ts index 8e07a5f37..22c6ef030 100644 --- a/server/core/lib/transcoding/web-transcoding.ts +++ b/server/core/lib/transcoding/web-transcoding.ts @@ -1,22 +1,22 @@ import { Job } from 'bullmq' import { move, remove } from 'fs-extra/esm' -import { copyFile, stat } from 'fs/promises' +import { copyFile } from 'fs/promises' import { basename, join } from 'path' -import { FileStorage } from '@peertube/peertube-models' import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js' import { VideoModel } from '@server/models/video/video.js' import { MVideoFile, MVideoFullLight } from '@server/types/models/index.js' -import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVODOptionsType } from '@peertube/peertube-ffmpeg' +import { getVideoStreamDuration, TranscodeVODOptionsType } from '@peertube/peertube-ffmpeg' import { CONFIG } from '../../initializers/config.js' import { VideoFileModel } from '../../models/video/video-file.js' import { JobQueue } from '../job-queue/index.js' import { generateWebVideoFilename } from '../paths.js' -import { buildFileMetadata } from '../video-file.js' +import { buildNewFile } from '../video-file.js' import { VideoPathManager } from '../video-path-manager.js' import { buildFFmpegVOD } from './shared/index.js' import { buildOriginalFileResolution } from './transcoding-resolutions.js' import { buildStoryboardJobIfNeeded } from '../video-jobs.js' +import { buildAspectRatio } from '@peertube/peertube-core-utils' // Optimize the original video file and replace it. The resolution is not changed. export async function optimizeOriginalVideofile (options: { @@ -62,19 +62,7 @@ export async function optimizeOriginalVideofile (options: { fps }) - // Important to do this before getVideoFilename() to take in account the new filename - inputVideoFile.resolution = resolution - inputVideoFile.extname = newExtname - inputVideoFile.filename = generateWebVideoFilename(resolution, newExtname) - inputVideoFile.storage = FileStorage.FILE_SYSTEM - - const { videoFile } = await onWebVideoFileTranscoding({ - video, - videoFile: inputVideoFile, - videoOutputPath - }) - - await remove(videoInputPath) + const { videoFile } = await onWebVideoFileTranscoding({ video, videoOutputPath, deleteWebInputVideoFile: inputVideoFile }) return { transcodeType, videoFile } }) @@ -104,15 +92,8 @@ export async function transcodeNewWebVideoResolution (options: { const file = video.getMaxQualityFile().withVideoOrPlaylist(video) const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => { - const newVideoFile = new VideoFileModel({ - resolution, - extname: newExtname, - filename: generateWebVideoFilename(resolution, newExtname), - size: 0, - videoId: video.id - }) - - const videoOutputPath = join(transcodeDirectory, newVideoFile.filename) + const filename = generateWebVideoFilename(resolution, newExtname) + const videoOutputPath = join(transcodeDirectory, filename) const transcodeOptions = { type: 'video' as 'video', @@ -128,7 +109,7 @@ export async function transcodeNewWebVideoResolution (options: { await buildFFmpegVOD(job).transcode(transcodeOptions) - return onWebVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath }) + return onWebVideoFileTranscoding({ video, videoOutputPath }) }) return result @@ -188,20 +169,10 @@ export async function mergeAudioVideofile (options: { throw err } - // Important to do this before getVideoFilename() to take in account the new file extension - inputVideoFile.extname = newExtname - inputVideoFile.resolution = resolution - inputVideoFile.filename = generateWebVideoFilename(inputVideoFile.resolution, newExtname) - - // ffmpeg generated a new video file, so update the video duration - // See https://trac.ffmpeg.org/ticket/5456 - video.duration = await getVideoStreamDuration(videoOutputPath) - await video.save() - - return onWebVideoFileTranscoding({ + await onWebVideoFileTranscoding({ video, - videoFile: inputVideoFile, videoOutputPath, + deleteWebInputVideoFile: inputVideoFile, wasAudioFile: true }) }) @@ -214,36 +185,42 @@ export async function mergeAudioVideofile (options: { export async function onWebVideoFileTranscoding (options: { video: MVideoFullLight - videoFile: MVideoFile videoOutputPath: string wasAudioFile?: boolean // default false + deleteWebInputVideoFile?: MVideoFile }) { - const { video, videoFile, videoOutputPath, wasAudioFile } = options + const { video, videoOutputPath, wasAudioFile, deleteWebInputVideoFile } = options const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) + const videoFile = await buildNewFile({ mode: 'web-video', path: videoOutputPath }) + videoFile.videoId = video.id + try { await video.reload() + // ffmpeg generated a new video file, so update the video duration + // See https://trac.ffmpeg.org/ticket/5456 + if (wasAudioFile) { + video.duration = await getVideoStreamDuration(videoOutputPath) + video.aspectRatio = buildAspectRatio({ width: videoFile.width, height: videoFile.height }) + await video.save() + } + const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile) - const stats = await stat(videoOutputPath) - - const probe = await ffprobePromise(videoOutputPath) - const fps = await getVideoStreamFPS(videoOutputPath, probe) - const metadata = await buildFileMetadata(videoOutputPath, probe) - await move(videoOutputPath, outputPath, { overwrite: true }) - videoFile.size = stats.size - videoFile.fps = fps - videoFile.metadata = metadata - await createTorrentAndSetInfoHash(video, videoFile) const oldFile = await VideoFileModel.loadWebVideoFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution }) if (oldFile) await video.removeWebVideoFile(oldFile) + if (deleteWebInputVideoFile) { + await video.removeWebVideoFile(deleteWebInputVideoFile) + await deleteWebInputVideoFile.destroy() + } + await VideoFileModel.customUpsert(videoFile, 'video', undefined) video.VideoFiles = await video.$get('VideoFiles') diff --git a/server/core/lib/video-file.ts b/server/core/lib/video-file.ts index 326dff75e..f5463363e 100644 --- a/server/core/lib/video-file.ts +++ b/server/core/lib/video-file.ts @@ -29,8 +29,11 @@ async function buildNewFile (options: { if (await isAudioFile(path, probe)) { videoFile.resolution = VideoResolution.H_NOVIDEO } else { + const dimensions = await getVideoStreamDimensionsInfo(path, probe) videoFile.fps = await getVideoStreamFPS(path, probe) - videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution + videoFile.resolution = dimensions.resolution + videoFile.width = dimensions.width + videoFile.height = dimensions.height } videoFile.filename = mode === 'web-video' diff --git a/server/core/lib/video-studio.ts b/server/core/lib/video-studio.ts index 6e118bd00..09ccaf14b 100644 --- a/server/core/lib/video-studio.ts +++ b/server/core/lib/video-studio.ts @@ -12,6 +12,7 @@ import { getTranscodingJobPriority } from './transcoding/transcoding-priority.js import { buildNewFile, removeHLSPlaylist, removeWebVideoFile } from './video-file.js' import { VideoPathManager } from './video-path-manager.js' import { buildStoryboardJobIfNeeded } from './video-jobs.js' +import { buildAspectRatio } from '@peertube/peertube-core-utils' const lTags = loggerTagsFactory('video-studio') @@ -104,6 +105,7 @@ export async function onVideoStudioEnded (options: { await newFile.save() video.duration = await getVideoStreamDuration(outputPath) + video.aspectRatio = buildAspectRatio({ width: newFile.width, height: newFile.height }) await video.save() return JobQueue.Instance.createSequentialJobFlow( diff --git a/server/core/models/server/plugin.ts b/server/core/models/server/plugin.ts index 500e59e33..13ca809ef 100644 --- a/server/core/models/server/plugin.ts +++ b/server/core/models/server/plugin.ts @@ -18,6 +18,7 @@ import { isPluginTypeValid } from '../../helpers/custom-validators/plugins.js' import { SequelizeModel, getSort, throwIfNotValid } from '../shared/index.js' +import { logger } from '@server/helpers/logger.js' @DefaultScope(() => ({ attributes: { @@ -173,6 +174,7 @@ export class PluginModel extends SequelizeModel<PluginModel> { result[name] = p.settings[name] } } + logger.error('internal', { result }) return result }) diff --git a/server/core/models/video/formatter/video-activity-pub-format.ts b/server/core/models/video/formatter/video-activity-pub-format.ts index bfa28cbca..a95fbb3e3 100644 --- a/server/core/models/video/formatter/video-activity-pub-format.ts +++ b/server/core/models/video/formatter/video-activity-pub-format.ts @@ -88,6 +88,8 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject { preview: buildPreviewAPAttribute(video), + aspectRatio: video.aspectRatio, + url, likes: getLocalVideoLikesActivityPubUrl(video), @@ -185,7 +187,8 @@ function buildVideoFileUrls (options: { rel: [ 'metadata', fileAP.mediaType ], mediaType: 'application/json' as 'application/json', href: getLocalVideoFileMetadataUrl(video, file), - height: file.resolution, + height: file.height || file.resolution, + width: file.width, fps: file.fps }) @@ -194,14 +197,18 @@ function buildVideoFileUrls (options: { type: 'Link', mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', href: file.getTorrentUrl(), - height: file.resolution + height: file.height || file.resolution, + width: file.width, + fps: file.fps }) urls.push({ type: 'Link', mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet', href: generateMagnetUri(video, file, trackerUrls), - height: file.resolution + height: file.height || file.resolution, + width: file.width, + fps: file.fps }) } } diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts index a4bb0b733..7e6bcb431 100644 --- a/server/core/models/video/formatter/video-api-format.ts +++ b/server/core/models/video/formatter/video-api-format.ts @@ -89,6 +89,8 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi isLocal: video.isOwned(), duration: video.duration, + aspectRatio: video.aspectRatio, + views: video.views, viewers: VideoViewsManager.Instance.getTotalViewersOf(video), @@ -214,6 +216,9 @@ export function videoFilesModelToFormattedJSON ( : `${videoFile.resolution}p` }, + width: videoFile.width, + height: videoFile.height, + magnetUri: includeMagnet && videoFile.hasTorrent() ? generateMagnetUri(video, videoFile, trackerUrls) : undefined, diff --git a/server/core/models/video/sql/video/shared/video-table-attributes.ts b/server/core/models/video/sql/video/shared/video-table-attributes.ts index f13fcf7ce..b6222cebb 100644 --- a/server/core/models/video/sql/video/shared/video-table-attributes.ts +++ b/server/core/models/video/sql/video/shared/video-table-attributes.ts @@ -88,6 +88,8 @@ export class VideoTableAttributes { 'metadataUrl', 'videoStreamingPlaylistId', 'videoId', + 'width', + 'height', 'storage' ] } @@ -255,6 +257,7 @@ export class VideoTableAttributes { 'dislikes', 'remote', 'isLive', + 'aspectRatio', 'url', 'commentsEnabled', 'downloadEnabled', diff --git a/server/core/models/video/video-file.ts b/server/core/models/video/video-file.ts index dbe9ab5d9..31b2323cb 100644 --- a/server/core/models/video/video-file.ts +++ b/server/core/models/video/video-file.ts @@ -167,6 +167,14 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> { @Column resolution: number + @AllowNull(true) + @Column + width: number + + @AllowNull(true) + @Column + height: number + @AllowNull(false) @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size')) @Column(DataType.BIGINT) @@ -640,7 +648,8 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> { type: 'Link', mediaType: mimeType as ActivityVideoUrlObject['mediaType'], href: this.getFileUrl(video), - height: this.resolution, + height: this.height || this.resolution, + width: this.width, size: this.size, fps: this.fps } diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index 88af4f429..530db47b0 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -565,6 +565,10 @@ export class VideoModel extends SequelizeModel<VideoModel> { @Column state: VideoStateType + @AllowNull(true) + @Column(DataType.FLOAT) + aspectRatio: number + // We already have the information in videoSource table for local videos, but we prefer to normalize it for performance // And also to store the info from remote instances @AllowNull(true) diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 84ac053cb..983b65c13 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -2086,7 +2086,7 @@ paths: /api/v1/users/me/videos: get: - summary: Get videos of my user + summary: List videos of my user security: - OAuth2: - user @@ -7560,6 +7560,12 @@ components: fps: type: number description: Frames per second of the video file + width: + type: number + description: "**PeerTube >= 6.1** Video stream width" + height: + type: number + description: "**PeerTube >= 6.1** Video stream height" metadataUrl: type: string format: url @@ -7676,6 +7682,11 @@ components: example: 1419 format: seconds description: duration of the video in seconds + aspectRatio: + type: number + format: float + example: 1.778 + description: "**PeerTube >= 6.1** Aspect ratio of the video stream" isLocal: type: boolean name: