Skip to content

Commit

Permalink
Merge pull request #185 from tv2norge-collab/EAV-220
Browse files Browse the repository at this point in the history
feat: add I-frames scan side-effect
  • Loading branch information
nytamin authored Jun 10, 2024
2 parents 8daf95e + ea662ee commit b3ff22a
Show file tree
Hide file tree
Showing 12 changed files with 588 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,56 @@ export function generatePackageLoudness(
})
}

export function generatePackageIframes(
expectation: SomeClipCopyExpectation,
settings: PackageManagerSettings
): Expectation.PackageIframesScan {
return {
id: protectString<ExpectationId>(expectation.id + '_iframes'),
priority: expectation.priority + PriorityAdditions.IFRAMES_SCAN,
managerId: expectation.managerId,
type: Expectation.Type.PACKAGE_IFRAMES_SCAN,
fromPackages: expectation.fromPackages,

statusReport: {
label: `I-frames Scan`,
description: `Enumerate I-frames`,
displayRank: 15,
sendReport: expectation.statusReport.sendReport,
},

startRequirement: {
sources: expectation.endRequirement.targets,
content: expectation.endRequirement.content,
version: expectation.endRequirement.version,
},
endRequirement: {
targets: [
{
containerId: protectString<PackageContainerId>('__corePackageInfo'),
label: 'Core package info',
accessors: {
[CORE_COLLECTION_ACCESSOR_ID]: {
type: Accessor.AccessType.CORE_PACKAGE_INFO,
},
},
},
],
content: null,
version: null,
},
workOptions: {
...expectation.workOptions,
allowWaitForCPU: true,
requiredForPlayout: false,
usesCPUCount: 1,
removeDelay: settings.delayRemovalPackageInfo,
},
dependsOnFulfilled: [expectation.id],
triggerByFulfilledIds: [expectation.id],
}
}

export function generateMediaFileThumbnail(
expectation: SomeClipFileOnDiskCopyExpectation,
packageContainerId: PackageContainerId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
generateJsonDataCopy,
generatePackageCopyFileProxy,
generatePackageLoudness,
generatePackageIframes,
} from './expectations-lib'
import { getSmartbullExpectedPackages, shouldBeIgnored } from './smartbull'
import { TEMPORARY_STORAGE_ID } from './lib'
Expand Down Expand Up @@ -223,7 +224,7 @@ function getSideEffectOfExpectation(
expectation0.type === Expectation.Type.FILE_VERIFY ||
expectation0.type === Expectation.Type.FILE_COPY_PROXY
) {
const expectation = expectation0 as Expectation.FileCopy | Expectation.FileVerify | Expectation.FileCopyProxy
const expectation = expectation0

if (!expectation0.external) {
// All files that have been copied should also be scanned:
Expand Down Expand Up @@ -283,6 +284,11 @@ function getSideEffectOfExpectation(
)
expectations[loudness.id] = loudness
}

if (expectation0.sideEffect?.iframes) {
const iframes = generatePackageIframes(expectation, settings)
expectations[iframes.id] = iframes
}
} else if (expectation0.type === Expectation.Type.QUANTEL_CLIP_COPY) {
const expectation = expectation0 as Expectation.QuantelClipCopy

Expand Down Expand Up @@ -351,7 +357,7 @@ function getCopyToTemporaryStorage(
expectation0.type === Expectation.Type.FILE_VERIFY ||
expectation0.type === Expectation.Type.QUANTEL_CLIP_COPY
) {
const expectation = expectation0 as Expectation.FileCopy | Expectation.FileVerify | Expectation.QuantelClipCopy
const expectation = expectation0
const proxy: GenerateExpectation | undefined = generatePackageCopyFileProxy(
expectation,
settings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export enum PriorityAdditions {
COPY_PROXY = 90,
SCAN = 100,
LOUDNESS_SCAN = 500,
IFRAMES_SCAN = 501,
THUMBNAIL = 1002,
PREVIEW = 1003,
DEEP_SCAN = 1004,
Expand Down
3 changes: 2 additions & 1 deletion apps/single-app/app/expectedPackages.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@
],
"inPhaseDifference": true,
"balanceDifference": true
}
},
"iframes": true
}
},
{
Expand Down
17 changes: 17 additions & 0 deletions shared/packages/api/src/expectationApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export namespace Expectation {
| PackageScan
| PackageDeepScan
| PackageLoudnessScan
| PackageIframesScan
| MediaFileThumbnail
| MediaFilePreview
| QuantelClipCopy
Expand All @@ -38,6 +39,7 @@ export namespace Expectation {
PACKAGE_SCAN = 'package_scan',
PACKAGE_DEEP_SCAN = 'package_deep_scan',
PACKAGE_LOUDNESS_SCAN = 'package_loudness_scan',
PACKAGE_IFRAMES_SCAN = 'package_iframes_scan',

QUANTEL_CLIP_COPY = 'quantel_clip_copy',
// QUANTEL_CLIP_SCAN = 'quantel_clip_scan',
Expand Down Expand Up @@ -215,6 +217,21 @@ export namespace Expectation {
}
workOptions: WorkOptions.Base & WorkOptions.RemoveDelay
}
export interface PackageIframesScan extends Base {
type: Type.PACKAGE_IFRAMES_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: null
}
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
Expand Down
5 changes: 5 additions & 0 deletions shared/packages/api/src/inputApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export namespace ExpectedPackage {
/** Should the package be scanned for loudness */
loudnessPackageSettings?: SideEffectLoudnessSettings

/** Should the package be scanned for I-frames */
iframes?: SideEffectIframesScanSettings

/** Other custom configuration */
[key: string]: any
}
Expand Down Expand Up @@ -126,6 +129,8 @@ export namespace ExpectedPackage {

export type SideEffectLoudnessSettingsChannelSpec = `${number}` | `${number}+${number}`

export type SideEffectIframesScanSettings = Record<string, never>

export interface ExpectedPackageMediaFile extends Base {
type: PackageType.MEDIA_FILE
content: {
Expand Down
1 change: 1 addition & 0 deletions shared/packages/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@sofie-package-manager/api": "1.50.5",
"abort-controller": "^3.0.0",
"atem-connection": "^3.2.0",
"csv-parser": "^3.0.0",
"deep-diff": "^1.0.2",
"form-data": "^4.0.0",
"node-fetch": "^2.6.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export enum PackageInfoType {
Scan = 'scan',
DeepScan = 'deepScan',
Loudness = 'loudness',
Iframes = 'iframes',

/** Unknown JSON data */
JSON = 'json',
Expand Down Expand Up @@ -77,3 +78,32 @@ export type LoudnessScanResultForStream =
* */
balanceDifference?: number
}

export enum CompressionType {
/** Undetected */
Unknown = 'unknown',
/** Every frame is a I-frame */
AllIntra = 'all_intra',
/** All I-frame are spaced evenly */
FixedDistance = 'fixed_distance',
/** I-frame distances vary */
VariableDistance = 'variable_distance',
}

export type IframesScanResult =
| {
type: CompressionType.Unknown
}
| {
type: CompressionType.AllIntra
}
| {
type: CompressionType.FixedDistance
/** Distance between I-frames, expressed in seconds */
distance: number
}
| {
type: CompressionType.VariableDistance
/** Frame times of I-frames in seconds */
iframeTimes: number[]
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile, ChildProcess, spawn } from 'child_process'
import csvParser from 'csv-parser'
import {
Expectation,
assertNever,
Expand All @@ -17,7 +18,14 @@ import {
import { LocalFolderAccessorHandle } from '../../../../accessorHandlers/localFolder'
import { QuantelAccessorHandle } from '../../../../accessorHandlers/quantel'
import { CancelablePromise } from '../../../../lib/cancelablePromise'
import { FieldOrder, LoudnessScanResult, LoudnessScanResultForStream, ScanAnomaly } from './coreApi'
import {
CompressionType,
FieldOrder,
IframesScanResult,
LoudnessScanResult,
LoudnessScanResultForStream,
ScanAnomaly,
} from './coreApi'
import { generateFFProbeFromClipData } from './quantelFormats'
import { FileShareAccessorHandle } from '../../../../accessorHandlers/fileShare'
import { HTTPProxyAccessorHandle } from '../../../../accessorHandlers/httpProxy'
Expand Down Expand Up @@ -654,6 +662,154 @@ export function scanLoudness(
})
})
}
const EPSILON = 0.00001
export function scanIframes(
sourceHandle:
| LocalFolderAccessorHandle<any>
| FileShareAccessorHandle<any>
| HTTPAccessorHandle<any>
| HTTPProxyAccessorHandle<any>
| QuantelAccessorHandle<any>,
_targetVersion: Expectation.PackageIframesScan['endRequirement']['version'],
/** Callback which is called when there is some new progress */
onProgress: (
/** Progress, goes from 0 to 1 */
progress: number
) => void,
_logger: LoggerInstance,
duration: number
): CancelablePromise<IframesScanResult> {
return new CancelablePromise<IframesScanResult>(async (resolve, reject, onCancel) => {
let cancelled = false

const args = [
'-hide_banner',
'-select_streams',
'v',
'-show_frames',
'-print_format',
'csv',
'-show_entries',
'frame=pts_time,duration_time,pict_type',
]

args.push(...(await getFFMpegInputArgsFromAccessorHandle(sourceHandle)))

let ffMpegProcess: ChildProcess | undefined = undefined

const killFFMpeg = () => {
// ensure this function doesn't throw, since it is called from various error event handlers
try {
ffMpegProcess?.stdin?.write('q') // send "q" to quit, because .kill() doesn't quite do it.
ffMpegProcess?.kill()
} catch (e) {
// This is probably OK, errors likely means that the process is already dead
}
}
onCancel(() => {
cancelled = true
killFFMpeg()
reject('Cancelled')
})

ffMpegProcess = spawn(getFFProbeExecutable(), args, {
windowsVerbatimArguments: true, // To fix an issue with ffmpeg.exe on Windows
})

const iframes: number[] = []
let prevDistance: number | undefined
let firstIframePtsTime: number | undefined
let prevIframePtsTime: number | undefined
let prevFrameDuration: number | undefined
let distanceVaries = false
let maxDistance = 0

if (!ffMpegProcess.stdout) {
throw new Error('spawned ffmpeg-process stdout is null!')
}

const onError = (err: unknown, context: string | undefined) => {
if (ffMpegProcess) {
killFFMpeg()

reject(
`Error parsing FFMpeg data. Error: "${err} ${
err && typeof err === 'object' ? (err as Error).stack : ''
}", context: "${context}" `
)
}
}

ffMpegProcess.stdout
.pipe(csvParser({ headers: false }))
.on('data', (data) => {
if (cancelled) return
const dataType = data[0]
const pictType = data[3]
if (dataType === 'frame' && pictType === 'I') {
const ptsTime = parseFloat(data[1])
const durationTime = parseFloat(data[2])

if (firstIframePtsTime === undefined) {
firstIframePtsTime = ptsTime
}
iframes.push(ptsTime)
onProgress(ptsTime / duration)
if (prevIframePtsTime !== undefined) {
const distance = ptsTime - prevIframePtsTime
if (prevDistance && Math.abs(distance - prevDistance) > EPSILON) {
distanceVaries = true
}
maxDistance = Math.max(maxDistance, distance)
prevDistance = distance
}
prevIframePtsTime = ptsTime
prevFrameDuration = durationTime
} else if (dataType === 'frame' && pictType === 'P') {
const ptsTime = parseFloat(data[1])
onProgress(ptsTime / duration)
}
})
.on('error', onError)

const onClose = (code: number | null) => {
if (cancelled) return
if (ffMpegProcess) {
ffMpegProcess = undefined
if (code === 0) {
// success
if (prevFrameDuration && Math.abs(maxDistance / prevFrameDuration - 1) < EPSILON) {
resolve({
type: CompressionType.AllIntra,
})
} else if (!distanceVaries && prevIframePtsTime !== undefined && firstIframePtsTime !== undefined) {
resolve({
type: CompressionType.FixedDistance,
distance: (prevIframePtsTime - firstIframePtsTime) / iframes.length,
})
} else if (distanceVaries && iframes.length) {
resolve({
type: CompressionType.VariableDistance,
iframeTimes: iframes,
})
} else {
resolve({
type: CompressionType.Unknown,
})
}
} else {
reject(`FFMpeg exited with code ${code}`)
}
}
}
ffMpegProcess.on('close', (code) => {
onClose(code)
})
ffMpegProcess.on('exit', (code) => {
onClose(code)
})
})
}

async function getFFMpegInputArgsFromAccessorHandle(
sourceHandle:
Expand Down
Loading

0 comments on commit b3ff22a

Please sign in to comment.