diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/__tests__/nrk.spec.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/__tests__/nrk.spec.ts index c44196c4..2dec8afa 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/__tests__/nrk.spec.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/__tests__/nrk.spec.ts @@ -60,12 +60,13 @@ describe('Generate expectations - NRK', () => { o.settings ) - expect(Object.keys(expectations)).toHaveLength(5) // copy, scan, deep-scan, thumbnail, preview + expect(Object.keys(expectations)).toHaveLength(6) // copy, scan, deep-scan, thumbnail, preview, loudness // expect(expectations).toMatchSnapshot() const eCopy = Object.values(expectations).find((e) => e.type === Expectation.Type.FILE_COPY) const eScan = Object.values(expectations).find((e) => e.type === Expectation.Type.PACKAGE_SCAN) const eDeepScan = Object.values(expectations).find((e) => e.type === Expectation.Type.PACKAGE_DEEP_SCAN) + const eLoudness = Object.values(expectations).find((e) => e.type === Expectation.Type.PACKAGE_LOUDNESS_SCAN) const eThumbnail = Object.values(expectations).find((e) => e.type === Expectation.Type.MEDIA_FILE_THUMBNAIL) const ePreview = Object.values(expectations).find((e) => e.type === Expectation.Type.MEDIA_FILE_PREVIEW) @@ -74,6 +75,7 @@ describe('Generate expectations - NRK', () => { expect(eDeepScan).toBeTruthy() expect(eThumbnail).toBeTruthy() expect(ePreview).toBeTruthy() + expect(eLoudness).toBeTruthy() }) test('Duplicated packages', () => { const o = setup() @@ -108,7 +110,7 @@ describe('Generate expectations - NRK', () => { o.settings ) - expect(Object.keys(expectations)).toHaveLength(5) // copy, scan, deep-scan, thumbnail, preview + expect(Object.keys(expectations)).toHaveLength(6) // copy, scan, deep-scan, thumbnail, preview, loudness const eCopy = Object.values(expectations).find((e) => e.type === Expectation.Type.FILE_COPY) expect(eCopy).toBeTruthy() @@ -151,7 +153,7 @@ describe('Generate expectations - NRK', () => { o.settings ) - expect(Object.keys(expectations)).toHaveLength(10) // 2x (copy, scan, deep-scan, thumbnail, preview) + expect(Object.keys(expectations)).toHaveLength(12) // 2x (copy, scan, deep-scan, thumbnail, preview, loudness) const sorted = Object.values(expectations).sort((a, b) => { // Lowest first: (lower is better) @@ -165,13 +167,15 @@ describe('Generate expectations - NRK', () => { Expectation.Type.FILE_COPY, Expectation.Type.PACKAGE_SCAN, Expectation.Type.PACKAGE_SCAN, - // The rest aren't as important + // The order of the rest aren't as important: Expectation.Type.MEDIA_FILE_THUMBNAIL, Expectation.Type.MEDIA_FILE_PREVIEW, Expectation.Type.MEDIA_FILE_THUMBNAIL, Expectation.Type.PACKAGE_DEEP_SCAN, Expectation.Type.MEDIA_FILE_PREVIEW, Expectation.Type.PACKAGE_DEEP_SCAN, + Expectation.Type.PACKAGE_LOUDNESS_SCAN, + Expectation.Type.PACKAGE_LOUDNESS_SCAN, ]) }) }) @@ -289,6 +293,9 @@ function setup() { thumbnailPackageSettings: { path: 'simpleMedia-thumbnail.webm', }, + loudnessPackageSettings: { + channelSpec: ['0+1'], + }, }, }), simpleMedia2: literal({ @@ -315,6 +322,9 @@ function setup() { thumbnailPackageSettings: { path: 'simpleMedia2-thumbnail.webm', }, + loudnessPackageSettings: { + channelSpec: ['0+1'], + }, }, }), } diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts index 8b842bda..66f5fa02 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts @@ -15,6 +15,14 @@ import { PriorityAdditions, } from './types' +type SomeClipCopyExpectation = + | Expectation.FileCopy + | Expectation.FileCopyProxy + | Expectation.FileVerify + | Expectation.QuantelClipCopy + +type SomeClipFileOnDiskCopyExpectation = Expectation.FileCopy | Expectation.FileCopyProxy | Expectation.FileVerify + export function generateMediaFileCopy( managerId: string, expWrap: ExpectedPackageWrap, @@ -171,11 +179,7 @@ export function generateQuantelCopy(managerId: string, expWrap: ExpectedPackageW return exp } export function generatePackageScan( - expectation: - | Expectation.FileCopy - | Expectation.FileCopyProxy - | Expectation.FileVerify - | Expectation.QuantelClipCopy, + expectation: SomeClipCopyExpectation, settings: PackageManagerSettings ): Expectation.PackageScan { let priority = expectation.priority + PriorityAdditions.SCAN @@ -230,11 +234,7 @@ export function generatePackageScan( }) } export function generatePackageDeepScan( - expectation: - | Expectation.FileCopy - | Expectation.FileCopyProxy - | Expectation.FileVerify - | Expectation.QuantelClipCopy, + expectation: SomeClipCopyExpectation, settings: PackageManagerSettings ): Expectation.PackageDeepScan { return literal({ @@ -288,8 +288,61 @@ export function generatePackageDeepScan( }) } +export function generatePackageLoudness( + expectation: SomeClipCopyExpectation, + packageSettings: ExpectedPackage.SideEffectLoudnessSettings, + settings: PackageManagerSettings +): Expectation.PackageLoudnessScan { + return literal({ + id: expectation.id + '_loudness', + priority: expectation.priority + PriorityAdditions.LOUDNESS_SCAN, + managerId: expectation.managerId, + type: Expectation.Type.PACKAGE_LOUDNESS_SCAN, + fromPackages: expectation.fromPackages, + + statusReport: { + label: `Loudness Scan`, + description: `Measure clip loudness, using channels ${packageSettings.channelSpec.join(', ')}`, + requiredForPlayout: false, + displayRank: 14, + sendReport: expectation.statusReport.sendReport, + }, + + startRequirement: { + sources: expectation.endRequirement.targets, + content: expectation.endRequirement.content, + version: expectation.endRequirement.version, + }, + endRequirement: { + targets: [ + { + containerId: '__corePackageInfo', + label: 'Core package info', + accessors: { + coreCollection: { + type: Accessor.AccessType.CORE_PACKAGE_INFO, + }, + }, + }, + ], + content: null, + version: { + channels: packageSettings.channelSpec, + }, + }, + workOptions: { + ...expectation.workOptions, + allowWaitForCPU: true, + usesCPUCount: 1, + removeDelay: settings.delayRemovalPackageInfo, + }, + dependsOnFullfilled: [expectation.id], + triggerByFullfilledIds: [expectation.id], + }) +} + export function generateMediaFileThumbnail( - expectation: Expectation.FileCopy | Expectation.FileCopyProxy | Expectation.FileVerify, + expectation: SomeClipFileOnDiskCopyExpectation, packageContainerId: string, settings: ExpectedPackage.SideEffectThumbnailSettings, packageContainer: PackageContainer @@ -342,7 +395,7 @@ export function generateMediaFileThumbnail( }) } export function generateMediaFilePreview( - expectation: Expectation.FileCopy | Expectation.FileCopyProxy | Expectation.FileVerify, + expectation: SomeClipFileOnDiskCopyExpectation, packageContainerId: string, settings: ExpectedPackage.SideEffectPreviewSettings, packageContainer: PackageContainer diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations.ts index 2ca3f459..4b6853ae 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations.ts @@ -20,6 +20,7 @@ import { generateQuantelClipPreview, generateJsonDataCopy, generatePackageCopyFileProxy, + generatePackageLoudness, } from './expectations-lib' import { getSmartbullExpectedPackages, shouldBeIgnored } from './smartbull' import { TEMPORARY_STORAGE_ID } from './lib' @@ -259,6 +260,15 @@ function getSideEffectOfExpectation( expectations[preview.id] = preview } } + + if (expectation0.sideEffect?.loudnessPackageSettings) { + const loudness = generatePackageLoudness( + expectation, + expectation0.sideEffect?.loudnessPackageSettings, + settings + ) + expectations[loudness.id] = loudness + } } else if (expectation0.type === Expectation.Type.QUANTEL_CLIP_COPY) { const expectation = expectation0 as Expectation.QuantelClipCopy @@ -303,6 +313,15 @@ function getSideEffectOfExpectation( expectations[preview.id] = preview } } + + if (expectation0.sideEffect?.loudnessPackageSettings) { + const loudness = generatePackageLoudness( + expectation, + expectation0.sideEffect?.loudnessPackageSettings, + settings + ) + expectations[loudness.id] = loudness + } } return expectations } diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/types.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/types.ts index 2479d4cb..58bf9b35 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/types.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/types.ts @@ -61,4 +61,5 @@ export enum PriorityAdditions { THUMBNAIL = 1002, PREVIEW = 1003, DEEP_SCAN = 1004, + LOUDNESS_SCAN = 1010, } diff --git a/apps/single-app/app/expectedPackages.json b/apps/single-app/app/expectedPackages.json index 2e2186b4..2f470c74 100644 --- a/apps/single-app/app/expectedPackages.json +++ b/apps/single-app/app/expectedPackages.json @@ -76,11 +76,16 @@ "sideEffect": { "previewContainerId": null, "previewPackageSettings": null, - "thumbnailContainerId": "thumbnails0", + "thumbnailContainerId": "thumbnails0_local", "thumbnailPackageSettings": { "path": "thumbnail.png" + }, + "loudnessPackageSettings": { + "channelSpec": [ + "0" + ] } } } ] -} \ No newline at end of file +} diff --git a/shared/packages/api/src/expectationApi.ts b/shared/packages/api/src/expectationApi.ts index 90e60d76..b1936733 100644 --- a/shared/packages/api/src/expectationApi.ts +++ b/shared/packages/api/src/expectationApi.ts @@ -15,6 +15,7 @@ export namespace Expectation { | FileCopyProxy | PackageScan | PackageDeepScan + | PackageLoudnessScan | MediaFileThumbnail | MediaFilePreview | QuantelClipCopy @@ -35,6 +36,7 @@ export namespace Expectation { PACKAGE_SCAN = 'package_scan', PACKAGE_DEEP_SCAN = 'package_deep_scan', + PACKAGE_LOUDNESS_SCAN = 'package_loudness_scan', QUANTEL_CLIP_COPY = 'quantel_clip_copy', // QUANTEL_CLIP_SCAN = 'quantel_clip_scan', @@ -186,6 +188,25 @@ export namespace Expectation { } workOptions: WorkOptions.Base & WorkOptions.RemoveDelay } + /** Defines a Loudness Scan of a Media file. A Loudness Scan is to be performed on (one of) the sources and the scan result is to be stored on the target. */ + export interface PackageLoudnessScan extends Base { + type: Type.PACKAGE_LOUDNESS_SCAN + + startRequirement: { + sources: SpecificPackageContainerOnPackage.FileSource[] | SpecificPackageContainerOnPackage.QuantelClip[] + content: FileCopy['endRequirement']['content'] | QuantelClipCopy['endRequirement']['content'] + version: FileCopy['endRequirement']['version'] | QuantelClipCopy['endRequirement']['version'] + } + endRequirement: { + targets: SpecificPackageContainerOnPackage.CorePackage[] + content: null // not using content, entries are stored using this.fromPackages + version: { + /** List of channels or stereo channel pairs to be inspected for loudness, 0-indexed. Use channel number as string (e.g. "0") or two numbers with a plus sign for stereo pairs (e.g. "0+1") */ + channels: (`${number}` | `${number}+${number}`)[] + } + } + workOptions: WorkOptions.Base & WorkOptions.RemoveDelay + } /** Defines a Thumbnail of a Media file. A Thumbnail is to be created from one of the the sources and the resulting file is to be stored on the target. */ export interface MediaFileThumbnail extends Base { type: Type.MEDIA_FILE_THUMBNAIL diff --git a/shared/packages/api/src/inputApi.ts b/shared/packages/api/src/inputApi.ts index b63993a7..4ba67a78 100644 --- a/shared/packages/api/src/inputApi.ts +++ b/shared/packages/api/src/inputApi.ts @@ -86,6 +86,9 @@ export namespace ExpectedPackage { /** Which container thumbnails are to be put into */ thumbnailContainerId?: string | null thumbnailPackageSettings?: SideEffectThumbnailSettings | null + + /** Should the package be scanned for loudness */ + loudnessPackageSettings?: SideEffectLoudnessSettings } } export interface SideEffectPreviewSettings { @@ -99,6 +102,20 @@ export namespace ExpectedPackage { seekTime?: number } + export interface SideEffectLoudnessSettings { + /** Which channels should be scanned. Use a single 0-indexed number, or two numbers with a plus sign ("0+1") for stereo pairs. + * You can specify multiple channels and channel pairs to be scanned, as separate entries in the array. This can be useful + * when the streams contain different language versions or audio that will be played jointly, but processed separately + * in the production chain (f.g. a stereo mix of a speaker and a stereo ambient sound mix) + * + * When expecting varied channel arrangements within the clip, it can be useful to specify multiple combinations, + * f.g. ["0", "0+1"] (for single stream stereo and discreet channel stereo) and then select the correct measurement in the + * blueprints based on the context */ + channelSpec: SideEffectLoudnessSettingsChannelSpec[] + } + + export type SideEffectLoudnessSettingsChannelSpec = `${number}` | `${number}+${number}` + export interface ExpectedPackageMediaFile extends Base { type: PackageType.MEDIA_FILE content: { diff --git a/shared/packages/worker/src/worker/accessorHandlers/atem.ts b/shared/packages/worker/src/worker/accessorHandlers/atem.ts index fab6136c..6ac35a8a 100644 --- a/shared/packages/worker/src/worker/accessorHandlers/atem.ts +++ b/shared/packages/worker/src/worker/accessorHandlers/atem.ts @@ -24,6 +24,7 @@ import { promisify } from 'util' import { UniversalVersion } from '../workers/windowsWorker/lib/lib' import { MAX_EXEC_BUFFER } from '../lib/lib' import { defaultCheckHandleRead, defaultCheckHandleWrite } from './lib/lib' +import { getFFMpegExecutable, getFFProbeExecutable } from '../workers/windowsWorker/expectationHandlers/lib/ffmpeg' const fsReadFile = promisify(fs.readFile) @@ -544,7 +545,7 @@ async function getStreamIndicies(inputFile: string, type: 'video' | 'audio'): Pr async function ffprobe(args: string[]): Promise { return new Promise((resolve, reject) => { - const file = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe' + const file = getFFProbeExecutable() execFile( file, args, @@ -565,7 +566,7 @@ async function ffprobe(args: string[]): Promise { async function ffmpeg(args: string[]): Promise { return new Promise((resolve, reject) => { - const file = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + const file = getFFMpegExecutable() execFile( file, ['-v error', ...args], diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib.ts index 47c1dc0c..cf8e9ae0 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib.ts @@ -340,7 +340,6 @@ interface ThumbnailMetadata { /** Returns arguments for FFMpeg to generate a thumbnail image file */ export function thumbnailFFMpegArguments(input: string, metadata: ThumbnailMetadata, seekTimeCode?: string): string[] { return [ - // process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', '-hide_banner', seekTimeCode ? `-ss ${seekTimeCode}` : undefined, `-i "${input}"`, diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/coreApi.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/coreApi.ts index 48d31663..30fc6779 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/coreApi.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/coreApi.ts @@ -1,3 +1,9 @@ +export enum PackageInfoType { + Scan = 'scan', + DeepScan = 'deepScan', + Loudness = 'loudness', +} + export interface DeepScanResult { field_order: FieldOrder blacks: ScanAnomaly[] @@ -16,3 +22,33 @@ export interface ScanAnomaly { duration: number end: number } + +export interface LoudnessScanResult { + channels: { + [channelSpec: string]: LoudnessScanResultForStream + } +} + +export type LoudnessScanResultForStream = + | { + success: false + reason: string + } + | { + success: true + // Detected channel layout for the stream + layout: string + /** Unit: LUFS */ + integrated: number + /** Unit: LUFS */ + integratedThreshold: number + + /** Unit: LU */ + range: number + /** Unit: LUFS */ + rangeThreshold: number + /** Unit: LUFS */ + rangeLow: number + /** Unit: LUFS */ + rangeHigh: number + } diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/ffmpeg.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/ffmpeg.ts index fb6c0b72..e09839ba 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/ffmpeg.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/ffmpeg.ts @@ -15,13 +15,19 @@ export interface FFMpegProcess { pid: number cancel: () => void } +export function getFFMpegExecutable(): string { + return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' +} +export function getFFProbeExecutable(): string { + return process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe' +} /** Check if FFMpeg is available, returns null if no error found */ export async function testFFMpeg(): Promise { - return testFFExecutable(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg') + return testFFExecutable(getFFMpegExecutable()) } /** Check if FFProbe is available */ export async function testFFProbe(): Promise { - return testFFExecutable(process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe') + return testFFExecutable(getFFProbeExecutable()) } export async function testFFExecutable(ffExecutable: string): Promise { return new Promise((resolve) => { @@ -96,13 +102,9 @@ export async function spawnFFMpeg( } log?.('ffmpeg: spawn..') - let ffMpegProcess: ChildProcessWithoutNullStreams | undefined = spawn( - process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', - args, - { - windowsVerbatimArguments: true, // To fix an issue with ffmpeg.exe on Windows - } - ) + let ffMpegProcess: ChildProcessWithoutNullStreams | undefined = spawn(getFFMpegExecutable(), args, { + windowsVerbatimArguments: true, // To fix an issue with ffmpeg.exe on Windows + }) log?.('ffmpeg: spawned') function killFFMpeg() { diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/scan.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/scan.ts index 7843501f..c118b72e 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/scan.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/scan.ts @@ -1,5 +1,5 @@ import { execFile, ChildProcess, spawn } from 'child_process' -import { Expectation, assertNever } from '@sofie-package-manager/api' +import { Expectation, assertNever, Accessor, AccessorOnPackage } from '@sofie-package-manager/api' import { isQuantelClipAccessorHandle, isLocalFolderAccessorHandle, @@ -10,12 +10,14 @@ import { import { LocalFolderAccessorHandle } from '../../../../accessorHandlers/localFolder' import { QuantelAccessorHandle } from '../../../../accessorHandlers/quantel' import { CancelablePromise } from '../../../../lib/cancelablePromise' -import { FieldOrder, ScanAnomaly } from './coreApi' +import { FieldOrder, LoudnessScanResult, LoudnessScanResultForStream, ScanAnomaly } from './coreApi' import { generateFFProbeFromClipData } from './quantelFormats' import { FileShareAccessorHandle } from '../../../../accessorHandlers/fileShare' import { HTTPProxyAccessorHandle } from '../../../../accessorHandlers/httpProxy' import { HTTPAccessorHandle } from '../../../../accessorHandlers/http' import { MAX_EXEC_BUFFER } from '../../../../lib/lib' +import { getFFMpegExecutable } from './ffmpeg' +import { GenericAccessorHandle } from '../../../../accessorHandlers/genericHandle' export interface FFProbeScanResultStream { index: number @@ -159,7 +161,7 @@ export function scanFieldOrder( return } - const file = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + const file = getFFMpegExecutable() const args = [ '-hide_banner', '-filter:v idet', @@ -171,25 +173,7 @@ export function scanFieldOrder( process.platform === 'win32' ? 'NUL' : '/dev/null', ] - if (isLocalFolderAccessorHandle(sourceHandle)) { - args.push(`-i "${sourceHandle.fullPath}"`) - } else if (isFileShareAccessorHandle(sourceHandle)) { - await sourceHandle.prepareFileAccess() - args.push(`-i "${sourceHandle.fullPath}"`) - } else if (isHTTPAccessorHandle(sourceHandle)) { - args.push(`-i "${sourceHandle.fullUrl}"`) - } else if (isHTTPProxyAccessorHandle(sourceHandle)) { - args.push(`-i "${sourceHandle.fullUrl}"`) - } else if (isQuantelClipAccessorHandle(sourceHandle)) { - const httpStreamURL = await sourceHandle.getTransformerStreamURL() - - if (!httpStreamURL.success) throw new Error(`Source Clip not found (${httpStreamURL.reason.tech})`) - - args.push('-seekable 0') - args.push(`-i "${httpStreamURL.fullURL}"`) - } else { - assertNever(sourceHandle) - } + args.push(...(await getFFMpegInputArgsFromAccessorHandle(sourceHandle))) let ffmpegProcess: ChildProcess | undefined = undefined onCancel(() => { @@ -282,25 +266,8 @@ export function scanMoreInfo( const args = ['-hide_banner'] - if (isLocalFolderAccessorHandle(sourceHandle)) { - args.push(`-i "${sourceHandle.fullPath}"`) - } else if (isFileShareAccessorHandle(sourceHandle)) { - await sourceHandle.prepareFileAccess() - args.push(`-i "${sourceHandle.fullPath}"`) - } else if (isHTTPAccessorHandle(sourceHandle)) { - args.push(`-i "${sourceHandle.fullUrl}"`) - } else if (isHTTPProxyAccessorHandle(sourceHandle)) { - args.push(`-i "${sourceHandle.fullUrl}"`) - } else if (isQuantelClipAccessorHandle(sourceHandle)) { - const httpStreamURL = await sourceHandle.getTransformerStreamURL() - - if (!httpStreamURL.success) throw new Error(`Source Clip not found (${httpStreamURL.reason.tech})`) + args.push(...(await getFFMpegInputArgsFromAccessorHandle(sourceHandle))) - args.push('-seekable 0') - args.push(`-i "${httpStreamURL.fullURL}"`) - } else { - assertNever(sourceHandle) - } args.push('-filter:v', filterString) args.push('-an') args.push('-f null') @@ -323,7 +290,7 @@ export function scanMoreInfo( reject('Cancelled') }) - ffMpegProcess = spawn(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg', args, { + ffMpegProcess = spawn(getFFMpegExecutable(), args, { windowsVerbatimArguments: true, // To fix an issue with ffmpeg.exe on Windows }) @@ -472,3 +439,216 @@ export function scanMoreInfo( }) }) } + +function scanLoudnessStream( + sourceHandle: + | LocalFolderAccessorHandle + | FileShareAccessorHandle + | HTTPAccessorHandle + | HTTPProxyAccessorHandle + | QuantelAccessorHandle, + _previouslyScanned: FFProbeScanResult, + channelSpec: string +): CancelablePromise { + return new CancelablePromise(async (resolve, reject, onCancel) => { + const stereoPairMatch = channelSpec.match(/^(\d+)\+(\d+)$/) + const singleChannel = Number.parseInt(channelSpec) + if (!stereoPairMatch && !Number.isInteger(singleChannel)) { + reject(`Invalid channel specification: ${channelSpec}`) + return + } + + let filterString: string + + if (stereoPairMatch) { + filterString = `[0:a:${stereoPairMatch[1]}][0:a:${stereoPairMatch[2]}]join=inputs=2:channel_layout=stereo,ebur128[out]` + } else { + filterString = `[0:a:${singleChannel}]ebur128[out]` + } + + const file = getFFMpegExecutable() + const args = [ + '-nostats', + '-filter_complex', + JSON.stringify(filterString), + '-map', + JSON.stringify('[out]'), + '-f', + 'null', + '-', + ] + + args.push(...(await getFFMpegInputArgsFromAccessorHandle(sourceHandle))) + + let ffmpegProcess: ChildProcess | undefined = undefined + onCancel(() => { + ffmpegProcess?.stdin?.write('q') // send "q" to quit, because .kill() doesn't quite do it. + ffmpegProcess?.kill() + reject('Cancelled') + }) + + ffmpegProcess = execFile( + file, + args, + { + maxBuffer: MAX_EXEC_BUFFER, + windowsVerbatimArguments: true, // To fix an issue with ffmpeg.exe on Windows + }, + (err, _stdout, stderr) => { + // this.logger.debug(`Worker: metadata generate: output (stdout, stderr)`, stdout, stderr) + ffmpegProcess = undefined + if (err) { + reject(err) + return + } + + const StreamNotFoundRegex = /Stream specifier [\S\s]+ matches no streams./ + + const LayoutRegex = /Output #0, null[\S\s]+Stream #0:0: Audio: [\w]+, [\d]+ Hz, (?\w+),/ + + const LoudnessRegex = + /Integrated loudness:\s+I:\s+(?[\d-.,]+)\s+LUFS\s+Threshold:\s+(?[\d-.,]+)\s+LUFS\s+Loudness range:\s+LRA:\s+(?[\d-.,]+)\s+LU\s+Threshold:\s+(?[\d-.,]+)\s+LUFS\s+LRA low:\s+(?[\d-.,]+)\s+LUFS\s+LRA high:\s+(?[\d-.,]+)\s+LUFS\s*$/i + + const loudnessRes = LoudnessRegex.exec(stderr) + const layoutRes = LayoutRegex.exec(stderr) + const streamNotFound = StreamNotFoundRegex.exec(stderr) + + if (streamNotFound) { + return resolve({ + success: false, + reason: 'Specified Audio stream not found', + }) + } + + if (loudnessRes === null) { + reject(`ffmpeg output unreadable`) + } else { + resolve({ + success: true, + layout: layoutRes?.groups?.['layout'] ?? 'unknown', + integrated: Number.parseFloat(loudnessRes.groups?.['integrated'] ?? ''), + integratedThreshold: Number.parseFloat(loudnessRes.groups?.['threshold'] ?? ''), + range: Number.parseFloat(loudnessRes.groups?.['lra'] ?? ''), + rangeThreshold: Number.parseFloat(loudnessRes.groups?.['rangeThreshold'] ?? ''), + rangeHigh: Number.parseFloat(loudnessRes.groups?.['lraHigh'] ?? ''), + rangeLow: Number.parseFloat(loudnessRes.groups?.['lraLow'] ?? ''), + }) + } + } + ) + }) +} + +export function scanLoudness( + sourceHandle: + | LocalFolderAccessorHandle + | FileShareAccessorHandle + | HTTPAccessorHandle + | HTTPProxyAccessorHandle + | QuantelAccessorHandle, + previouslyScanned: FFProbeScanResult, + targetVersion: Expectation.PackageLoudnessScan['endRequirement']['version'], + /** Callback which is called when there is some new progress */ + onProgress: ( + /** Progress, goes from 0 to 1 */ + progress: number + ) => void +): CancelablePromise { + return new CancelablePromise(async (resolve, _reject, onCancel) => { + if (!targetVersion.channels.length) { + resolve({ + channels: {}, + }) + return + } + + const step = 1 / targetVersion.channels.length + + let progress = 0 + + const packageScanResult: Record = {} + + for (const channelSpec of targetVersion.channels) { + try { + const resultPromise = scanLoudnessStream(sourceHandle, previouslyScanned, channelSpec) + onCancel(() => { + resultPromise.cancel() + }) + const result = await resultPromise + packageScanResult[channelSpec] = result + } catch (e) { + packageScanResult[channelSpec] = { + success: false, + reason: String(e), + } + } + progress += step + onProgress(progress) + } + + resolve({ + channels: packageScanResult, + }) + }) +} + +async function getFFMpegInputArgsFromAccessorHandle( + sourceHandle: + | LocalFolderAccessorHandle + | FileShareAccessorHandle + | HTTPAccessorHandle + | HTTPProxyAccessorHandle + | QuantelAccessorHandle +): Promise { + const args: string[] = [] + if (isLocalFolderAccessorHandle(sourceHandle)) { + args.push(`-i "${sourceHandle.fullPath}"`) + } else if (isFileShareAccessorHandle(sourceHandle)) { + await sourceHandle.prepareFileAccess() + args.push(`-i "${sourceHandle.fullPath}"`) + } else if (isHTTPAccessorHandle(sourceHandle)) { + args.push(`-i "${sourceHandle.fullUrl}"`) + } else if (isHTTPProxyAccessorHandle(sourceHandle)) { + args.push(`-i "${sourceHandle.fullUrl}"`) + } else if (isQuantelClipAccessorHandle(sourceHandle)) { + const httpStreamURL = await sourceHandle.getTransformerStreamURL() + + if (!httpStreamURL.success) throw new Error(`Source Clip not found (${httpStreamURL.reason.tech})`) + + args.push('-seekable 0') + args.push(`-i "${httpStreamURL.fullURL}"`) + } else { + assertNever(sourceHandle) + } + + return args +} + +const FFMPEG_SUPPORTED_SOURCE_ACCESSORS: Set = new Set([ + Accessor.AccessType.LOCAL_FOLDER, + Accessor.AccessType.FILE_SHARE, + Accessor.AccessType.HTTP, + Accessor.AccessType.HTTP_PROXY, + Accessor.AccessType.QUANTEL, +]) + +export function isAnFFMpegSupportedSourceAccessor(sourceAccessorOnPackage: AccessorOnPackage.Any): boolean { + return FFMPEG_SUPPORTED_SOURCE_ACCESSORS.has(sourceAccessorOnPackage.type) +} + +export function isAnFFMpegSupportedSourceAccessorHandle( + sourceHandle: GenericAccessorHandle +): sourceHandle is + | LocalFolderAccessorHandle + | FileShareAccessorHandle + | HTTPAccessorHandle + | HTTPProxyAccessorHandle + | QuantelAccessorHandle { + return ( + isLocalFolderAccessorHandle(sourceHandle) || + isFileShareAccessorHandle(sourceHandle) || + isHTTPAccessorHandle(sourceHandle) || + isHTTPProxyAccessorHandle(sourceHandle) || + isQuantelClipAccessorHandle(sourceHandle) + ) +} diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageDeepScan.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageDeepScan.ts index 2fb01d0b..1114e3b1 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageDeepScan.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageDeepScan.ts @@ -12,19 +12,19 @@ import { ReturnTypeRemoveExpectation, stringifyError, } from '@sofie-package-manager/api' -import { - isCorePackageInfoAccessorHandle, - isFileShareAccessorHandle, - isHTTPAccessorHandle, - isHTTPProxyAccessorHandle, - isLocalFolderAccessorHandle, - isQuantelClipAccessorHandle, -} from '../../../accessorHandlers/accessor' +import { isCorePackageInfoAccessorHandle } from '../../../accessorHandlers/accessor' import { IWorkInProgress, WorkInProgress } from '../../../lib/workInProgress' import { checkWorkerHasAccessToPackageContainersOnPackage, lookupAccessorHandles, LookupPackageContainer } from './lib' -import { DeepScanResult, FieldOrder, ScanAnomaly } from './lib/coreApi' +import { DeepScanResult, FieldOrder, PackageInfoType, ScanAnomaly } from './lib/coreApi' import { CancelablePromise } from '../../../lib/cancelablePromise' -import { FFProbeScanResult, scanFieldOrder, scanMoreInfo, scanWithFFProbe } from './lib/scan' +import { + FFProbeScanResult, + isAnFFMpegSupportedSourceAccessor, + isAnFFMpegSupportedSourceAccessorHandle, + scanFieldOrder, + scanMoreInfo, + scanWithFFProbe, +} from './lib/scan' import { WindowsWorker } from '../windowsWorker' /** @@ -107,7 +107,7 @@ export const PackageDeepScan: ExpectationWindowsHandler = { if (!isCorePackageInfoAccessorHandle(lookupTarget.handle)) throw new Error(`Target AccessHandler type is wrong`) const packageInfoSynced = await lookupTarget.handle.findUnUpdatedPackageInfo( - 'deepScan', + PackageInfoType.DeepScan, exp, exp.startRequirement.content, actualSourceVersion, @@ -116,7 +116,7 @@ export const PackageDeepScan: ExpectationWindowsHandler = { if (packageInfoSynced.needsUpdate) { if (wasFullfilled) { // Remove the outdated scan result: - await lookupTarget.handle.removePackageInfo('deepScan', exp) + await lookupTarget.handle.removePackageInfo(PackageInfoType.DeepScan, exp) } return { fulfilled: false, reason: packageInfoSynced.reason } } else { @@ -142,98 +142,86 @@ export const PackageDeepScan: ExpectationWindowsHandler = { const sourceHandle = lookupSource.handle const targetHandle = lookupTarget.handle if ( - (lookupSource.accessor.type === Accessor.AccessType.LOCAL_FOLDER || - lookupSource.accessor.type === Accessor.AccessType.FILE_SHARE || - lookupSource.accessor.type === Accessor.AccessType.HTTP || - lookupSource.accessor.type === Accessor.AccessType.HTTP_PROXY || - lookupSource.accessor.type === Accessor.AccessType.QUANTEL) && - lookupTarget.accessor.type === Accessor.AccessType.CORE_PACKAGE_INFO - ) { - if ( - !isLocalFolderAccessorHandle(sourceHandle) && - !isFileShareAccessorHandle(sourceHandle) && - !isHTTPAccessorHandle(sourceHandle) && - !isHTTPProxyAccessorHandle(sourceHandle) && - !isQuantelClipAccessorHandle(sourceHandle) + !isAnFFMpegSupportedSourceAccessor(lookupSource.accessor) || + lookupTarget.accessor.type !== Accessor.AccessType.CORE_PACKAGE_INFO + ) + throw new Error( + `PackageDeepScan.workOnExpectation: Unsupported accessor source-target pair "${lookupSource.accessor.type}"-"${lookupTarget.accessor.type}"` ) - throw new Error(`Source AccessHandler type is wrong`) - - if (!isCorePackageInfoAccessorHandle(targetHandle)) - throw new Error(`Target AccessHandler type is wrong`) - const tryReadPackage = await sourceHandle.checkPackageReadAccess() - if (!tryReadPackage.success) throw new Error(tryReadPackage.reason.tech) + if (!isAnFFMpegSupportedSourceAccessorHandle(sourceHandle)) + throw new Error(`Source AccessHandler type is wrong`) - const actualSourceVersion = await sourceHandle.getPackageActualVersion() - const sourceVersionHash = hashObj(actualSourceVersion) + if (!isCorePackageInfoAccessorHandle(targetHandle)) throw new Error(`Target AccessHandler type is wrong`) - workInProgress._reportProgress(sourceVersionHash, 0.01) + const tryReadPackage = await sourceHandle.checkPackageReadAccess() + if (!tryReadPackage.success) throw new Error(tryReadPackage.reason.tech) - // Scan with FFProbe: - currentProcess = scanWithFFProbe(sourceHandle) - const ffProbeScan: FFProbeScanResult = await currentProcess - const hasVideoStream = - ffProbeScan.streams && ffProbeScan.streams.some((stream) => stream.codec_type === 'video') - workInProgress._reportProgress(sourceVersionHash, 0.1) - currentProcess = undefined + const actualSourceVersion = await sourceHandle.getPackageActualVersion() + const sourceVersionHash = hashObj(actualSourceVersion) - // Scan field order: - let resultFieldOrder = FieldOrder.Unknown - if (hasVideoStream) { - currentProcess = scanFieldOrder(sourceHandle, exp.endRequirement.version) - resultFieldOrder = await currentProcess - currentProcess = undefined - } - workInProgress._reportProgress(sourceVersionHash, 0.2) + workInProgress._reportProgress(sourceVersionHash, 0.01) - // Scan more info: - let resultBlacks: ScanAnomaly[] = [] - let resultFreezes: ScanAnomaly[] = [] - let resultScenes: number[] = [] - if (hasVideoStream) { - currentProcess = scanMoreInfo(sourceHandle, ffProbeScan, exp.endRequirement.version, (progress) => { - workInProgress._reportProgress(sourceVersionHash, 0.21 + 0.77 * progress) - }) - const result = await currentProcess - resultBlacks = result.blacks - resultFreezes = result.freezes - resultScenes = result.scenes - currentProcess = undefined - } - workInProgress._reportProgress(sourceVersionHash, 0.99) + // Scan with FFProbe: + currentProcess = scanWithFFProbe(sourceHandle) + const ffProbeScan: FFProbeScanResult = await currentProcess + const hasVideoStream = + ffProbeScan.streams && ffProbeScan.streams.some((stream) => stream.codec_type === 'video') + workInProgress._reportProgress(sourceVersionHash, 0.1) + currentProcess = undefined - const deepScan: DeepScanResult = { - field_order: resultFieldOrder, - blacks: resultBlacks, - freezes: resultFreezes, - scenes: resultScenes, - } + // Scan field order: + let resultFieldOrder = FieldOrder.Unknown + if (hasVideoStream) { + currentProcess = scanFieldOrder(sourceHandle, exp.endRequirement.version) + resultFieldOrder = await currentProcess + currentProcess = undefined + } + workInProgress._reportProgress(sourceVersionHash, 0.2) - // all done: - await targetHandle.packageIsInPlace() - await targetHandle.updatePackageInfo( - 'deepScan', - exp, - exp.startRequirement.content, - actualSourceVersion, - exp.endRequirement.version, - deepScan - ) + // Scan more info: + let resultBlacks: ScanAnomaly[] = [] + let resultFreezes: ScanAnomaly[] = [] + let resultScenes: number[] = [] + if (hasVideoStream) { + currentProcess = scanMoreInfo(sourceHandle, ffProbeScan, exp.endRequirement.version, (progress) => { + workInProgress._reportProgress(sourceVersionHash, 0.21 + 0.77 * progress) + }) + const result = await currentProcess + resultBlacks = result.blacks + resultFreezes = result.freezes + resultScenes = result.scenes + currentProcess = undefined + } + workInProgress._reportProgress(sourceVersionHash, 0.99) - const duration = Date.now() - startTime - workInProgress._reportComplete( - sourceVersionHash, - { - user: `Scan completed in ${Math.round(duration / 100) / 10}s`, - tech: `Completed at ${Date.now()}`, - }, - undefined - ) - } else { - throw new Error( - `MediaFileScan.workOnExpectation: Unsupported accessor source-target pair "${lookupSource.accessor.type}"-"${lookupTarget.accessor.type}"` - ) + const deepScan: DeepScanResult = { + field_order: resultFieldOrder, + blacks: resultBlacks, + freezes: resultFreezes, + scenes: resultScenes, } + + // all done: + await targetHandle.packageIsInPlace() + await targetHandle.updatePackageInfo( + PackageInfoType.DeepScan, + exp, + exp.startRequirement.content, + actualSourceVersion, + exp.endRequirement.version, + deepScan + ) + + const duration = Date.now() - startTime + workInProgress._reportComplete( + sourceVersionHash, + { + user: `Scan completed in ${Math.round(duration / 100) / 10}s`, + tech: `Completed at ${Date.now()}`, + }, + undefined + ) }) return workInProgress @@ -252,7 +240,7 @@ export const PackageDeepScan: ExpectationWindowsHandler = { if (!isCorePackageInfoAccessorHandle(lookupTarget.handle)) throw new Error(`Target AccessHandler type is wrong`) try { - await lookupTarget.handle.removePackageInfo('deepScan', exp) + await lookupTarget.handle.removePackageInfo(PackageInfoType.DeepScan, exp) } catch (err) { return { removed: false, diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageLoudnessScan.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageLoudnessScan.ts new file mode 100644 index 00000000..0dc084a3 --- /dev/null +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageLoudnessScan.ts @@ -0,0 +1,268 @@ +import { getStandardCost } from '../lib/lib' +import { GenericWorker } from '../../../worker' +import { ExpectationWindowsHandler } from './expectationWindowsHandler' +import { + Accessor, + hashObj, + Expectation, + ReturnTypeDoYouSupportExpectation, + ReturnTypeGetCostFortExpectation, + ReturnTypeIsExpectationFullfilled, + ReturnTypeIsExpectationReadyToStartWorkingOn, + ReturnTypeRemoveExpectation, + stringifyError, +} from '@sofie-package-manager/api' +import { isCorePackageInfoAccessorHandle } from '../../../accessorHandlers/accessor' +import { IWorkInProgress, WorkInProgress } from '../../../lib/workInProgress' +import { checkWorkerHasAccessToPackageContainersOnPackage, lookupAccessorHandles, LookupPackageContainer } from './lib' +import { CancelablePromise } from '../../../lib/cancelablePromise' +import { + FFProbeScanResult, + isAnFFMpegSupportedSourceAccessor, + isAnFFMpegSupportedSourceAccessorHandle, + scanLoudness, + scanWithFFProbe, +} from './lib/scan' +import { WindowsWorker } from '../windowsWorker' +import { LoudnessScanResult, PackageInfoType } from './lib/coreApi' + +/** + * Performs a "deep scan" of the source package and saves the result file into the target PackageContainer (a Sofie Core collection) + * The "deep scan" differs from the usual scan in that it does things that takes a bit longer, like scene-detection, field order detection etc.. + */ +export const PackageLoudnessScan: ExpectationWindowsHandler = { + doYouSupportExpectation( + exp: Expectation.Any, + genericWorker: GenericWorker, + windowsWorker: WindowsWorker + ): ReturnTypeDoYouSupportExpectation { + if (windowsWorker.testFFMpeg) + return { + support: false, + reason: { + user: 'There is an issue with the Worker (FFMpeg)', + tech: `Cannot access FFMpeg executable: ${windowsWorker.testFFMpeg}`, + }, + } + return checkWorkerHasAccessToPackageContainersOnPackage(genericWorker, { + sources: exp.startRequirement.sources, + }) + }, + getCostForExpectation: async ( + exp: Expectation.Any, + worker: GenericWorker + ): Promise => { + if (!isPackageLoudnessScan(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + return getStandardCost(exp, worker) + }, + + isExpectationReadyToStartWorkingOn: async ( + exp: Expectation.Any, + worker: GenericWorker + ): Promise => { + if (!isPackageLoudnessScan(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + + const lookupSource = await lookupLoudnessSources(worker, exp) + if (!lookupSource.ready) return { ready: lookupSource.ready, sourceExists: false, reason: lookupSource.reason } + const lookupTarget = await lookupLoudnessSources(worker, exp) + if (!lookupTarget.ready) return { ready: lookupTarget.ready, reason: lookupTarget.reason } + + const tryReading = await lookupSource.handle.tryPackageRead() + if (!tryReading.success) + return { ready: false, sourceExists: tryReading.packageExists, reason: tryReading.reason } + + return { + ready: true, + } + }, + isExpectationFullfilled: async ( + exp: Expectation.Any, + wasFullfilled: boolean, + worker: GenericWorker + ): Promise => { + if (!isPackageLoudnessScan(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + + const lookupSource = await lookupLoudnessSources(worker, exp) + if (!lookupSource.ready) + return { + fulfilled: false, + reason: { + user: `Not able to access source, due to ${lookupSource.reason.user}`, + tech: `Not able to access source: ${lookupSource.reason.tech}`, + }, + } + const lookupTarget = await lookupLoudnessTargets(worker, exp) + if (!lookupTarget.ready) + return { + fulfilled: false, + reason: { + user: `Not able to access target, due to ${lookupTarget.reason.user}`, + tech: `Not able to access target: ${lookupTarget.reason.tech}`, + }, + } + + const actualSourceVersion = await lookupSource.handle.getPackageActualVersion() + + if (!isCorePackageInfoAccessorHandle(lookupTarget.handle)) throw new Error(`Target AccessHandler type is wrong`) + + const packageInfoSynced = await lookupTarget.handle.findUnUpdatedPackageInfo( + PackageInfoType.Loudness, + exp, + exp.startRequirement.content, + actualSourceVersion, + exp.endRequirement.version + ) + if (packageInfoSynced.needsUpdate) { + if (wasFullfilled) { + // Remove the outdated scan result: + await lookupTarget.handle.removePackageInfo(PackageInfoType.Loudness, exp) + } + return { fulfilled: false, reason: packageInfoSynced.reason } + } else { + return { fulfilled: true } + } + }, + workOnExpectation: async (exp: Expectation.Any, worker: GenericWorker): Promise => { + if (!isPackageLoudnessScan(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + // Scan the source media file and upload the results to Core + const startTime = Date.now() + + const lookupSource = await lookupLoudnessSources(worker, exp) + if (!lookupSource.ready) throw new Error(`Can't start working due to source: ${lookupSource.reason.tech}`) + + const lookupTarget = await lookupLoudnessTargets(worker, exp) + if (!lookupTarget.ready) throw new Error(`Can't start working due to target: ${lookupTarget.reason.tech}`) + + let currentProcess: CancelablePromise | undefined + const workInProgress = new WorkInProgress({ workLabel: 'Scanning file' }, async () => { + // On cancel + currentProcess?.cancel() + }).do(async () => { + const sourceHandle = lookupSource.handle + const targetHandle = lookupTarget.handle + if ( + !isAnFFMpegSupportedSourceAccessor(lookupSource.accessor) || + lookupTarget.accessor.type !== Accessor.AccessType.CORE_PACKAGE_INFO + ) + throw new Error( + `PackageLoudnessScan.workOnExpectation: Unsupported accessor source-target pair "${lookupSource.accessor.type}"-"${lookupTarget.accessor.type}"` + ) + + if (!isAnFFMpegSupportedSourceAccessorHandle(sourceHandle)) + throw new Error(`Source AccessHandler type is wrong`) + + if (!isCorePackageInfoAccessorHandle(targetHandle)) throw new Error(`Target AccessHandler type is wrong`) + + const tryReadPackage = await sourceHandle.checkPackageReadAccess() + if (!tryReadPackage.success) throw new Error(tryReadPackage.reason.tech) + + const actualSourceVersion = await sourceHandle.getPackageActualVersion() + const sourceVersionHash = hashObj(actualSourceVersion) + + workInProgress._reportProgress(sourceVersionHash, 0.01) + + // Scan with FFProbe: + currentProcess = scanWithFFProbe(sourceHandle) + const ffProbeScan: FFProbeScanResult = await currentProcess + const hasAudioStream = + ffProbeScan.streams && ffProbeScan.streams.some((stream) => stream.codec_type === 'audio') + workInProgress._reportProgress(sourceVersionHash, 0.1) + currentProcess = undefined + let result: LoudnessScanResult | undefined = undefined + + if (hasAudioStream) { + currentProcess = scanLoudness(sourceHandle, ffProbeScan, exp.endRequirement.version, (progress) => { + workInProgress._reportProgress(sourceVersionHash, 0.21 + 0.77 * progress) + }) + result = await currentProcess + } + workInProgress._reportProgress(sourceVersionHash, 0.2) + + // all done: + await targetHandle.packageIsInPlace() + await targetHandle.updatePackageInfo( + PackageInfoType.Loudness, + exp, + exp.startRequirement.content, + actualSourceVersion, + exp.endRequirement.version, + result + ) + + const duration = Date.now() - startTime + workInProgress._reportComplete( + sourceVersionHash, + { + user: `Scan completed in ${Math.round(duration / 100) / 10}s`, + tech: `Completed at ${Date.now()}`, + }, + undefined + ) + }) + + return workInProgress + }, + removeExpectation: async (exp: Expectation.Any, worker: GenericWorker): Promise => { + if (!isPackageLoudnessScan(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + const lookupTarget = await lookupLoudnessTargets(worker, exp) + if (!lookupTarget.ready) + return { + removed: false, + reason: { + user: `Can't access target, due to: ${lookupTarget.reason.user}`, + tech: `No access to target: ${lookupTarget.reason.tech}`, + }, + } + if (!isCorePackageInfoAccessorHandle(lookupTarget.handle)) throw new Error(`Target AccessHandler type is wrong`) + + try { + await lookupTarget.handle.removePackageInfo(PackageInfoType.Loudness, exp) + } catch (err) { + return { + removed: false, + reason: { + user: `Cannot remove the scan result due to an internal error`, + tech: `Cannot remove CorePackageInfo: ${stringifyError(err)}`, + }, + } + } + + return { removed: true } + }, +} +function isPackageLoudnessScan(exp: Expectation.Any): exp is Expectation.PackageLoudnessScan { + return exp.type === Expectation.Type.PACKAGE_LOUDNESS_SCAN +} +type Metadata = any // not used + +async function lookupLoudnessSources( + worker: GenericWorker, + exp: Expectation.PackageLoudnessScan +): Promise> { + return lookupAccessorHandles( + worker, + exp.startRequirement.sources, + exp.startRequirement.content, + exp.workOptions, + { + read: true, + readPackage: true, + packageVersion: exp.startRequirement.version, + } + ) +} +async function lookupLoudnessTargets( + worker: GenericWorker, + exp: Expectation.PackageLoudnessScan +): Promise> { + return lookupAccessorHandles( + worker, + exp.endRequirement.targets, + exp.endRequirement.content, + exp.workOptions, + { + write: true, + writePackageContainer: true, + } + ) +} diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageScan.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageScan.ts index 4aee3e35..04666419 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageScan.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageScan.ts @@ -12,19 +12,13 @@ import { ReturnTypeRemoveExpectation, stringifyError, } from '@sofie-package-manager/api' -import { - isCorePackageInfoAccessorHandle, - isFileShareAccessorHandle, - isHTTPAccessorHandle, - isHTTPProxyAccessorHandle, - isLocalFolderAccessorHandle, - isQuantelClipAccessorHandle, -} from '../../../accessorHandlers/accessor' +import { isCorePackageInfoAccessorHandle } from '../../../accessorHandlers/accessor' import { IWorkInProgress, WorkInProgress } from '../../../lib/workInProgress' import { checkWorkerHasAccessToPackageContainersOnPackage, lookupAccessorHandles, LookupPackageContainer } from './lib' import { CancelablePromise } from '../../../lib/cancelablePromise' -import { scanWithFFProbe } from './lib/scan' +import { isAnFFMpegSupportedSourceAccessor, isAnFFMpegSupportedSourceAccessorHandle, scanWithFFProbe } from './lib/scan' import { WindowsWorker } from '../windowsWorker' +import { PackageInfoType } from './lib/coreApi' /** * Scans the source package and saves the result file into the target PackageContainer (a Sofie Core collection) @@ -105,7 +99,7 @@ export const PackageScan: ExpectationWindowsHandler = { if (!isCorePackageInfoAccessorHandle(lookupTarget.handle)) throw new Error(`Target AccessHandler type is wrong`) const packageInfoSynced = await lookupTarget.handle.findUnUpdatedPackageInfo( - 'scan', + PackageInfoType.Scan, exp, exp.startRequirement.content, actualSourceVersion, @@ -114,7 +108,7 @@ export const PackageScan: ExpectationWindowsHandler = { if (packageInfoSynced.needsUpdate) { if (wasFullfilled) { // Remove the outdated scan result: - await lookupTarget.handle.removePackageInfo('scan', exp) + await lookupTarget.handle.removePackageInfo(PackageInfoType.Scan, exp) } return { fulfilled: false, reason: packageInfoSynced.reason } } else { @@ -141,63 +135,52 @@ export const PackageScan: ExpectationWindowsHandler = { const sourceHandle = lookupSource.handle const targetHandle = lookupTarget.handle if ( - (lookupSource.accessor.type === Accessor.AccessType.LOCAL_FOLDER || - lookupSource.accessor.type === Accessor.AccessType.FILE_SHARE || - lookupSource.accessor.type === Accessor.AccessType.HTTP || - lookupSource.accessor.type === Accessor.AccessType.HTTP_PROXY || - lookupSource.accessor.type === Accessor.AccessType.QUANTEL) && - lookupTarget.accessor.type === Accessor.AccessType.CORE_PACKAGE_INFO - ) { - if ( - !isLocalFolderAccessorHandle(sourceHandle) && - !isFileShareAccessorHandle(sourceHandle) && - !isHTTPAccessorHandle(sourceHandle) && - !isHTTPProxyAccessorHandle(sourceHandle) && - !isQuantelClipAccessorHandle(sourceHandle) - ) - throw new Error(`Source AccessHandler type is wrong`) - if (!isCorePackageInfoAccessorHandle(targetHandle)) - throw new Error(`Target AccessHandler type is wrong`) - - const tryReadPackage = await sourceHandle.checkPackageReadAccess() - if (!tryReadPackage.success) throw new Error(tryReadPackage.reason.tech) - - const actualSourceVersion = await sourceHandle.getPackageActualVersion() - const sourceVersionHash = hashObj(actualSourceVersion) - - workInProgress._reportProgress(sourceVersionHash, 0.1) - - // Scan with FFProbe: - currentProcess = scanWithFFProbe(sourceHandle) - const scanResult = await currentProcess - workInProgress._reportProgress(sourceVersionHash, 0.5) - currentProcess = undefined - - // all done: - await targetHandle.packageIsInPlace() - await targetHandle.updatePackageInfo( - 'scan', - exp, - exp.startRequirement.content, - actualSourceVersion, - exp.endRequirement.version, - scanResult - ) - - const duration = Date.now() - startTime - workInProgress._reportComplete( - sourceVersionHash, - { - user: `Scan completed in ${Math.round(duration / 100) / 10}s`, - tech: `Completed at ${Date.now()}`, - }, - undefined - ) - } else { + !isAnFFMpegSupportedSourceAccessor(lookupSource.accessor) || + lookupTarget.accessor.type !== Accessor.AccessType.CORE_PACKAGE_INFO + ) throw new Error( `PackageScan.workOnExpectation: Unsupported accessor source-target pair "${lookupSource.accessor.type}"-"${lookupTarget.accessor.type}"` ) - } + + if (!isAnFFMpegSupportedSourceAccessorHandle(sourceHandle)) + throw new Error(`Source AccessHandler type is wrong`) + + if (!isCorePackageInfoAccessorHandle(targetHandle)) throw new Error(`Target AccessHandler type is wrong`) + + const tryReadPackage = await sourceHandle.checkPackageReadAccess() + if (!tryReadPackage.success) throw new Error(tryReadPackage.reason.tech) + + const actualSourceVersion = await sourceHandle.getPackageActualVersion() + const sourceVersionHash = hashObj(actualSourceVersion) + + workInProgress._reportProgress(sourceVersionHash, 0.1) + + // Scan with FFProbe: + currentProcess = scanWithFFProbe(sourceHandle) + const scanResult = await currentProcess + workInProgress._reportProgress(sourceVersionHash, 0.5) + currentProcess = undefined + + // all done: + await targetHandle.packageIsInPlace() + await targetHandle.updatePackageInfo( + PackageInfoType.Scan, + exp, + exp.startRequirement.content, + actualSourceVersion, + exp.endRequirement.version, + scanResult + ) + + const duration = Date.now() - startTime + workInProgress._reportComplete( + sourceVersionHash, + { + user: `Scan completed in ${Math.round(duration / 100) / 10}s`, + tech: `Completed at ${Date.now()}`, + }, + undefined + ) }) return workInProgress @@ -216,7 +199,7 @@ export const PackageScan: ExpectationWindowsHandler = { if (!isCorePackageInfoAccessorHandle(lookupTarget.handle)) throw new Error(`Target AccessHandler type is wrong`) try { - await lookupTarget.handle.removePackageInfo('scan', exp) + await lookupTarget.handle.removePackageInfo(PackageInfoType.Scan, exp) } catch (err) { return { removed: false, diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/windowsWorker.ts b/shared/packages/worker/src/worker/workers/windowsWorker/windowsWorker.ts index afd51fad..61e510f1 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/windowsWorker.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/windowsWorker.ts @@ -18,6 +18,7 @@ import { FileCopy } from './expectationHandlers/fileCopy' import { FileCopyProxy } from './expectationHandlers/fileCopyProxy' import { PackageScan } from './expectationHandlers/packageScan' import { PackageDeepScan } from './expectationHandlers/packageDeepScan' +import { PackageLoudnessScan } from './expectationHandlers/packageLoudnessScan' import { MediaFileThumbnail } from './expectationHandlers/mediaFileThumbnail' import { ExpectationHandler } from '../../lib/expectationHandler' import { IWorkInProgress } from '../../lib/workInProgress' @@ -108,6 +109,8 @@ export class WindowsWorker extends GenericWorker { return PackageScan case Expectation.Type.PACKAGE_DEEP_SCAN: return PackageDeepScan + case Expectation.Type.PACKAGE_LOUDNESS_SCAN: + return PackageLoudnessScan case Expectation.Type.MEDIA_FILE_THUMBNAIL: return MediaFileThumbnail case Expectation.Type.MEDIA_FILE_PREVIEW: diff --git a/tests/internal-tests/src/__tests__/issues.spec.ts b/tests/internal-tests/src/__tests__/issues.spec.ts index cd3cbf1b..bfd964a2 100644 --- a/tests/internal-tests/src/__tests__/issues.spec.ts +++ b/tests/internal-tests/src/__tests__/issues.spec.ts @@ -20,6 +20,9 @@ const fsStat = promisify(fs.stat) // const fsStat = promisify(fs.stat) +// this test can be a bit slower in CI sometimes +jest.setTimeout(10000) + describe('Handle unhappy paths', () => { let env: TestEnviromnent @@ -40,7 +43,7 @@ describe('Handle unhappy paths', () => { env.reset() }) - test.only('Wait for non-existing local file', async () => { + test('Wait for non-existing local file', async () => { fs.__mockSetDirectory('/sources/source0/') fs.__mockSetDirectory('/targets/target0') addCopyFileExpectation(