diff --git a/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html index 43cd7d45a..8eb68bb5c 100644 --- a/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html +++ b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html @@ -27,7 +27,7 @@ [buttonTooltip]="getReactiveFileButtonTooltip()"> - @if (video) { + @if (canSelectFromVideo()) { } } diff --git a/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts index 05bfb441e..808ff4294 100644 --- a/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts +++ b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts @@ -13,7 +13,7 @@ import { import { ReactiveFileComponent } from '@app/shared/shared-forms/reactive-file.component' import { BytesPipe } from '@app/shared/shared-main/common/bytes.pipe' import { EmbedComponent, EmbedVideoInput } from '@app/shared/shared-main/video/embed.component' -import { HTMLServerConfig } from '@peertube/peertube-models' +import { HTMLServerConfig, Video, VideoState } from '@peertube/peertube-models' import { imageToDataURL } from '@root-helpers/images' import { PeerTubePlayer } from '../../../../../standalone/embed-player-api/player' @@ -34,7 +34,7 @@ import { PeerTubePlayer } from '../../../../../standalone/embed-player-api/playe export class ThumbnailManagerComponent implements OnInit, ControlValueAccessor { @ViewChild('embed') embed: EmbedComponent - @Input() video: EmbedVideoInput + @Input() video: EmbedVideoInput & Pick imageSrc: string allowedExtensionsMessage = '' @@ -71,6 +71,10 @@ export class ThumbnailManagerComponent implements OnInit, ControlValueAccessor { return this.bytesPipe.transform(this.maxVideoImageSize) } + canSelectFromVideo () { + return this.video && !this.video.isLive && this.video.state.id === VideoState.PUBLISHED + } + getReactiveFileButtonTooltip () { return $localize`(extensions: ${this.videoImageExtensions}, ${this.maxSizeText}\: ${this.maxVideoImageSizeInBytes})` } diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.ts b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.ts index 3ec01dc64..60fafff5a 100644 --- a/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.ts +++ b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.ts @@ -1,10 +1,10 @@ -import { Component, Input, OnInit } from '@angular/core' -import { HooksService } from '@app/core' -import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' -import { TimeDurationFormatterPipe } from '../../../../shared/shared-main/date/time-duration-formatter.pipe' -import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component' +import { DatePipe, NgFor, NgIf } from '@angular/common' +import { Component, Input, OnChanges } from '@angular/core' import { RouterLink } from '@angular/router' -import { NgIf, NgFor, DatePipe } from '@angular/common' +import { HooksService } from '@app/core' +import { TimeDurationFormatterPipe } from '@app/shared/shared-main/date/time-duration-formatter.pipe' +import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' +import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component' type PluginMetadata = { label: string @@ -20,14 +20,14 @@ type PluginMetadata = { standalone: true, imports: [ NgIf, RouterLink, GlobalIconComponent, NgFor, DatePipe, TimeDurationFormatterPipe ] }) -export class VideoAttributesComponent implements OnInit { +export class VideoAttributesComponent implements OnChanges { @Input() video: VideoDetails pluginMetadata: PluginMetadata[] = [] constructor (private hooks: HooksService) { } - async ngOnInit () { + async ngOnChanges () { this.pluginMetadata = await this.hooks.wrapObject( this.pluginMetadata, 'video-watch', diff --git a/packages/ffmpeg/src/ffprobe.ts b/packages/ffmpeg/src/ffprobe.ts index dfff2066c..1fbcf3a57 100644 --- a/packages/ffmpeg/src/ffprobe.ts +++ b/packages/ffmpeg/src/ffprobe.ts @@ -1,6 +1,6 @@ -import ffmpeg, { FfprobeData } from 'fluent-ffmpeg' import { buildAspectRatio, forceNumber } from '@peertube/peertube-core-utils' import { VideoResolution } from '@peertube/peertube-models' +import ffmpeg, { FfprobeData } from 'fluent-ffmpeg' /** * @@ -111,7 +111,11 @@ async function getVideoStreamDimensionsInfo (path: string, existingProbe?: Ffpro } } - if (videoStream.rotation === '90' || videoStream.rotation === '-90') { + const rotation = videoStream.rotation + ? videoStream.rotation + '' + : undefined + + if (rotation === '90' || rotation === '-90') { const width = videoStream.width videoStream.width = videoStream.height videoStream.height = width @@ -202,16 +206,19 @@ async function getChaptersFromContainer (options: { // --------------------------------------------------------------------------- export { - getVideoStreamDimensionsInfo, - getChaptersFromContainer, - getMaxAudioBitrate, - getVideoStream, - getVideoStreamDuration, - getAudioStream, - getVideoStreamFPS, - isAudioFile, ffprobePromise, + getAudioStream, + getChaptersFromContainer, + + getMaxAudioBitrate, + + getVideoStream, getVideoStreamBitrate, + getVideoStreamDimensionsInfo, + getVideoStreamDuration, + getVideoStreamFPS, hasAudioStream, - hasVideoStream + + hasVideoStream, + isAudioFile } diff --git a/packages/tests/fixtures/custom-thumbnail-2.jpg b/packages/tests/fixtures/custom-thumbnail-2.jpg new file mode 100644 index 000000000..01abe457c Binary files /dev/null and b/packages/tests/fixtures/custom-thumbnail-2.jpg differ diff --git a/packages/tests/fixtures/default-live-preview.jpg b/packages/tests/fixtures/default-live-preview.jpg new file mode 100644 index 000000000..c93dfaa26 Binary files /dev/null and b/packages/tests/fixtures/default-live-preview.jpg differ diff --git a/packages/tests/fixtures/default-live-thumbnail.jpg b/packages/tests/fixtures/default-live-thumbnail.jpg new file mode 100644 index 000000000..5da342dac Binary files /dev/null and b/packages/tests/fixtures/default-live-thumbnail.jpg differ diff --git a/packages/tests/src/api/live/live-save-replay.ts b/packages/tests/src/api/live/live-save-replay.ts index 9e379341d..70abcf20b 100644 --- a/packages/tests/src/api/live/live-save-replay.ts +++ b/packages/tests/src/api/live/live-save-replay.ts @@ -27,6 +27,7 @@ import { waitUntilLiveReplacedByReplayOnAllServers, waitUntilLiveWaitingOnAllServers } from '@peertube/peertube-server-commands' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' import { checkLiveCleanup } from '@tests/shared/live.js' import { expect } from 'chai' import { FfmpegCommand } from 'fluent-ffmpeg' @@ -36,7 +37,13 @@ describe('Save replay setting', function () { let liveVideoUUID: string let ffmpegCommand: FfmpegCommand - async function createLiveWrapper (options: { permanent: boolean, replay: boolean, replaySettings?: { privacy: VideoPrivacyType } }) { + async function createLiveWrapper (options: { + permanent: boolean + replay: boolean + replaySettings?: { privacy: VideoPrivacyType } + thumbnailfile?: string + previewfile?: string + }) { if (liveVideoUUID) { try { await servers[0].videos.remove({ id: liveVideoUUID }) @@ -51,7 +58,9 @@ describe('Save replay setting', function () { tags: [ 'tag1', 'tag2' ], saveReplay: options.replay, replaySettings: options.replaySettings, - permanentLive: options.permanent + permanentLive: options.permanent, + thumbnailfile: options.thumbnailfile, + previewfile: options.previewfile } const { uuid } = await servers[0].live.create({ fields: attributes }) @@ -142,6 +151,15 @@ describe('Save replay setting', function () { } } + async function checkVideoThumbnail (videoId: string, thumbnailfile: string, previewfile?: string) { + for (const server of servers) { + const video = await server.videos.get({ id: videoId }) + await testImageGeneratedByFFmpeg(server.url, thumbnailfile, video.thumbnailPath, '') + + if (previewfile) await testImageGeneratedByFFmpeg(server.url, previewfile, video.previewPath, '') + } + } + before(async function () { this.timeout(120000) @@ -285,6 +303,7 @@ describe('Save replay setting', function () { await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) + await checkVideoThumbnail(liveVideoUUID, 'default-live-thumbnail.jpg', 'default-live-preview.jpg') }) it('Should correctly have updated the live and federated it when streaming in the live', async function () { @@ -298,6 +317,7 @@ describe('Save replay setting', function () { await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) + await checkVideoThumbnail(liveVideoUUID, 'default-live-thumbnail.jpg', 'default-live-preview.jpg') }) it('Should correctly have saved the live and federated it after the streaming', async function () { @@ -345,7 +365,15 @@ describe('Save replay setting', function () { it('Should update the saved live and correctly federate the updated attributes', async function () { this.timeout(120000) - await servers[0].videos.update({ id: liveVideoUUID, attributes: { name: 'video updated', privacy: VideoPrivacy.PUBLIC } }) + await servers[0].videos.update({ + id: liveVideoUUID, + attributes: { + name: 'video updated', + privacy: VideoPrivacy.PUBLIC, + thumbnailfile: 'custom-thumbnail.jpg', + previewfile: 'custom-preview.jpg' + } + }) await waitJobs(servers) for (const server of servers) { @@ -353,6 +381,8 @@ describe('Save replay setting', function () { expect(video.name).to.equal('video updated') expect(video.isLive).to.be.false expect(video.privacy.id).to.equal(VideoPrivacy.PUBLIC) + + await checkVideoThumbnail(liveVideoUUID, 'custom-thumbnail.jpg', 'custom-preview.jpg') } }) @@ -406,13 +436,20 @@ describe('Save replay setting', function () { it('Should correctly create and federate the "waiting for stream" live', async function () { this.timeout(120000) - liveVideoUUID = await createLiveWrapper({ permanent: true, replay: true, replaySettings: { privacy: VideoPrivacy.UNLISTED } }) + liveVideoUUID = await createLiveWrapper({ + permanent: true, + replay: true, + replaySettings: { privacy: VideoPrivacy.UNLISTED }, + thumbnailfile: 'custom-thumbnail.jpg', + previewfile: 'custom-preview.jpg' + }) await waitJobs(servers) await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) + await checkVideoThumbnail(liveVideoUUID, 'custom-thumbnail.jpg', 'custom-preview.jpg') }) it('Should correctly have updated the live and federated it when streaming in the live', async function () { @@ -484,10 +521,19 @@ describe('Save replay setting', function () { await checkVideosExist(lastReplayUUID, 1, HttpStatusCode.OK_200) await checkVideoState(lastReplayUUID, VideoState.PUBLISHED) await checkVideoPrivacy(lastReplayUUID, VideoPrivacy.PUBLIC) + await checkVideoThumbnail(lastReplayUUID, 'custom-thumbnail-from-preview.jpg', 'custom-preview.jpg') + }) + + it('Should update the live replay thumbnail', async function () { + await servers[0].videos.update({ id: lastReplayUUID, attributes: { thumbnailfile: 'custom-thumbnail-2.jpg' } }) + await waitJobs(servers) + + await checkVideoThumbnail(liveVideoUUID, 'custom-thumbnail.jpg', 'custom-preview.jpg') + await checkVideoThumbnail(lastReplayUUID, 'custom-thumbnail-2.jpg') }) }) - describe('With a second live and its replay', function () { + describe('With a second live session', function () { it('Should update the replay settings', async function () { await servers[0].live.update({ videoId: liveVideoUUID, fields: { replaySettings: { privacy: VideoPrivacy.PUBLIC } } }) @@ -498,7 +544,6 @@ describe('Save replay setting', function () { expect(live.saveReplay).to.be.true expect(live.replaySettings).to.exist expect(live.replaySettings.privacy).to.equal(VideoPrivacy.PUBLIC) - }) it('Should correctly have updated the live and federated it when streaming in the live', async function () { @@ -572,6 +617,9 @@ describe('Save replay setting', function () { await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: true, deleted: true }) }) + }) + + describe('With terminated sessions', function () { it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () { this.timeout(120000) @@ -612,6 +660,42 @@ describe('Save replay setting', function () { await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: true, deleted: true }) }) }) + + describe('With a live without custom thumbnail', function () { + + it('Should correctly set the default thumbnail to the live replay', async function () { + this.timeout(120000) + + liveVideoUUID = await createLiveWrapper({ + permanent: true, + replay: true, + replaySettings: { privacy: VideoPrivacy.PUBLIC } + }) + + ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) + await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) + await stopFfmpeg(ffmpegCommand) + + await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID) + await waitJobs(servers) + + const video = await findExternalSavedVideo(servers[0], liveVideoUUID) + lastReplayUUID = video.uuid + + await checkVideoThumbnail(liveVideoUUID, 'default-live-thumbnail.jpg', 'default-live-preview.jpg') + }) + + it('Should update the live replay thumbnail', async function () { + await servers[0].videos.update({ + id: lastReplayUUID, + attributes: { thumbnailfile: 'custom-thumbnail.jpg', previewfile: 'custom-preview.jpg' } + }) + await waitJobs(servers) + + await checkVideoThumbnail(liveVideoUUID, 'default-live-thumbnail.jpg', 'default-live-preview.jpg') + await checkVideoThumbnail(lastReplayUUID, 'custom-thumbnail.jpg', 'custom-preview.jpg') + }) + }) }) after(async function () { diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 6b5699130..75f538597 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -1336,6 +1336,7 @@ function buildVideoMimetypeExt () { // Developed by Apple 'video/quicktime': [ '.mov', '.qt', '.mqv' ], // often used as output format by editing software + 'video/mov': '.mov', // Windows: https://github.com/Chocobozzz/PeerTube/issues/6669 'video/x-m4v': '.m4v', 'video/m4v': '.m4v', 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 ad862799f..e341ff518 100644 --- a/server/core/lib/job-queue/handlers/video-live-ending.ts +++ b/server/core/lib/job-queue/handlers/video-live-ending.ts @@ -209,7 +209,8 @@ async function copyOrRegenerateThumbnails (options: { inputPath: preview.getPath(), video: replayVideo, type, - automaticallyGenerated: false + automaticallyGenerated: false, + keepOriginal: true }) }) )