From b6b1aaa56f5eb0b5e5b7366e08a698d3a6f917cd Mon Sep 17 00:00:00 2001 From: Chocobozzz 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>) { +export function getAllFiles (video: Partial>) { const files = video.files const hls = getHLS(video) @@ -13,12 +13,13 @@ function getAllFiles (video: Partial>) { +export function getHLS (video: Partial>) { 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 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 { 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 zcmbTe2|SeT`!{^ejD3sD*vVRh2q8pGWM9JA8?x_YNJ{CRvegjDl7z;-Z-oZwwkwf+ zS9f;VN-6C<=QZm7{oeoc`MmG@JT+Z2b6)3l&AA-Q_xK*i)vt+PuOUt&JwrVRfq)h znvahc&BG%gEVWNSP(p}@NA#em#Qp=)GSd9}LcB&`I|m?X4oS}~S`?<5bq247=l;o;@u7m(V2 z04pt{tfG2EOTgFuFXe<&_GdND`cC+DSPzt;`aKE#F~Wg9ll7B$a2Sw%{qzX7vAWLf8JUX7ulb{+-XSDTob) z02_wlf{sI5!g&MZcYDJLIkn*e>X9j`-vlnV_Q;Sl$Ywf=!tcLYE^(NDiNO9jT3p!9 zOTV1cT_DujbLjaSeTCrI|F>g<&LZfqu9Ij4ZmqVRJKG}^j;DSB-{pnCO?WxX=ZEen zH2gXLUtRw{KNj^0`a=!#Mq*U_>$1PEoh5btqbN@x*=K%X16b+}R@`vEtnC@-o#q-KOpz z7QU7KDerFdtt;pdg$H}u5Z+#;Dylj<1Fg!N$i2OA&SK}GA{RC&dE?u z55AEh-`R6OB;`wVPh`SK2%`*(dSog+m}evdnC6K{FiT1&{8eO>0FLj#e9iAigN7)7 zFzy5BBN9WPpSDOdJKXjiKE`aV_*R-mChCB}MuWX@VG@3Tr7H6sD~yXv(YYTkFK@p5 zDlQhA`E4+0ksEv$%Dp^7xSXp>)NM}*JXHdAZ$ItG53Y#CHh^Pt5C4ub;UMiGc0kya zmz8~aWH6{YWYwE$r~VO~68f4U&!b^tD?I|4Bj-p+EJD%1g$ya;Sm6T$7V3ofC@3`K zO5#N`nV2I7AZZenLhHeR<#fIEyT|W_e|x7#!k4g_BX;kCs3l<`3WUeWL1@SvMZ`tI zH$xGKCJb}wOuEp1EW#V2&~-v|=E&cdnS-BJWF|vQb5Y8wrR^xg4!V*>M7ff;Q5!W~ zxowv0LR7>NOw2{$0U}8}Bo+t@6(R*7BS;%e0(T8Gcuv(I93azLD9SYhaTv$o4fR3$ zy}>8u3`lRm9HnSLCk_UQoV#WR|0<-9?odr<0wY0^G|sj)n347wL1)@XsBH@8{b;e+ zSsddmj)S!mlAnT1{!EQFvVfTp~EzdDg@=3cHoSnJbcuArgV0^Vx&PN5bt!cOjM!95kc(6kjvb)ZGNw$k!MeQLK$ zlg}+Mz-b)23s?>;0_+TZrvg|8cn#RFoKYb-uRek1&jA!Xj8+gHi8pC>TV% zf;8!$HhAe=Ny21iu=_@k{R~e*%)IeAS5!VjO59bz+9u^n`+4WyhqOCW>Z^6T&2Y$;bf=W3- zrUSF#!y~ALNG>3{ut*__5wRJ6Hj5QUugKmBi3wRcAHk?{;ti0cEn;gnIXRgHo>Cl8 zA%=r((1iVzd_f2yzz&ulJ$_gw!A%u$9GwcB@ho7PIyibH=K#_iX)X#+HHNe|ok`CW zbOHmTBjVU7s0wAUzf5{yVG-U^aX5AgU0_Pb5p)YpEE%w<($3&4h#ry$SD@G|Y4f3{ z3C8cm0W)?b387uDGPL5r3vmL+T#e`RM?>J1qbE%tJ%wdu7{u8j;|F+ztIZWAu+Rh+ z5g(}x-J^#D!AlpCmnOH8k>J(Fg|kz{*%={Qady9T1t>F>LI>8Hp1*Y9AU)CY`}M#M z`B_sxvwLYvdrJqJQY!rh@~*}2=qYuoZ7Nr9SZlJ`JT>OMLg6qaT2bZ#1a^elkq!A7 zs-`u0uWm1SK8mj6CBL*xUNO!*dcFPlIoE=r`>q$qPvjV^X%~)nsE84b48?2NtR`7+P3B3Je5Xe7#WhFe;-kI^zNw*TdA~w62})m2BTT2`FS1e2#%0D6`T0pLT{gl0^`U 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 literal 15844 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 TZU6WF4fp@2KMxc9`T0KpI% 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 { + { + 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[] = [] @@ -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 { 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 { const payload = job.data as VideoImportPayload @@ -129,46 +123,31 @@ type ProcessFileOptions = { videoImportId: number } async function processFile (downloader: () => Promise, 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, 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, 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, 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, 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 & { 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 { 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 { @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 { 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 { @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: