Skip to content

Commit b6c1e42

Browse files
committed
feat: add support for multi-step convsion of media files, allowing a user to specify an external application to use for converting a media file, using arbitrary arguments.
1 parent c01ec30 commit b6c1e42

File tree

10 files changed

+1097
-44
lines changed

10 files changed

+1097
-44
lines changed

apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function generateMediaFileCopy(
3434
managerId: ExpectationManagerId,
3535
expWrap: ExpectedPackageWrap,
3636
settings: PackageManagerSettings
37-
): Expectation.FileCopy {
37+
): Expectation.FileCopy | Expectation.MediaFileConvert {
3838
const expWrapMediaFile = expWrap as ExpectedPackageWrapMediaFile
3939

4040
const endRequirement: Expectation.FileCopy['endRequirement'] = {
@@ -78,7 +78,38 @@ export function generateMediaFileCopy(
7878
},
7979
}
8080

81-
return exp
81+
if (
82+
expWrapMediaFile.expectedPackage.version.conversions &&
83+
expWrapMediaFile.expectedPackage.version.conversions.length > 0
84+
) {
85+
// Return a MediaFileConvert expectation:
86+
87+
const convertExp: Expectation.MediaFileConvert = {
88+
...exp,
89+
type: Expectation.Type.MEDIA_FILE_CONVERT,
90+
statusReport: {
91+
...exp.statusReport,
92+
label: exp.statusReport.label.replace('Copying media', 'Copy and converting media'),
93+
description: exp.statusReport.description.replace('Copy media', 'Copy and convert media'),
94+
},
95+
startRequirement: {
96+
sources: expWrapMediaFile.sources,
97+
},
98+
endRequirement: {
99+
targets: endRequirement.targets,
100+
content: endRequirement.content,
101+
version: {
102+
type: Expectation.Version.Type.MEDIA_FILE_CONVERT,
103+
conversions: expWrapMediaFile.expectedPackage.version.conversions,
104+
},
105+
},
106+
}
107+
108+
return convertExp
109+
} else {
110+
// Just return the copy expectation:
111+
return exp
112+
}
82113
}
83114
export function generateMediaFileVerify(
84115
managerId: ExpectationManagerId,

shared/packages/api/src/expectationApi.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export namespace Expectation {
2020
| PackageIframesScan
2121
| MediaFileThumbnail
2222
| MediaFilePreview
23+
| MediaFileConvert
2324
| QuantelClipCopy
2425
// | QuantelClipScan
2526
// | QuantelClipDeepScan
@@ -35,6 +36,7 @@ export namespace Expectation {
3536
FILE_COPY_PROXY = 'file_copy_proxy',
3637
MEDIA_FILE_THUMBNAIL = 'media_file_thumbnail',
3738
MEDIA_FILE_PREVIEW = 'media_file_preview',
39+
MEDIA_FILE_CONVERT = 'media_file_convert',
3840
FILE_VERIFY = 'file_verify',
3941
RENDER_HTML = 'render_html',
4042

@@ -270,6 +272,22 @@ export namespace Expectation {
270272
}
271273
workOptions: WorkOptions.Base & WorkOptions.RemoveDelay & WorkOptions.UseTemporaryFilePath
272274
}
275+
/** Defines a Conversion of a Media file. One of the the sources are piped through an external executable and the resulting file is to be stored on the target. */
276+
export interface MediaFileConvert extends Base {
277+
type: Type.MEDIA_FILE_CONVERT
278+
279+
startRequirement: {
280+
sources: SpecificPackageContainerOnPackage.FileSource[]
281+
}
282+
endRequirement: {
283+
targets: SpecificPackageContainerOnPackage.FileTarget[]
284+
content: {
285+
filePath: string
286+
}
287+
version: Version.ExpectedMediaFileConvert
288+
}
289+
workOptions: WorkOptions.Base & WorkOptions.RemoveDelay & WorkOptions.UseTemporaryFilePath
290+
}
273291

274292
/** Defines a Quantel clip. A Quantel clip is to be copied from one of the Sources, to the Target. */
275293
export interface QuantelClipCopy extends Base {
@@ -508,6 +526,7 @@ export namespace Expectation {
508526

509527
// eslint-disable-next-line @typescript-eslint/no-namespace
510528
export namespace WorkOptions {
529+
export type All = Base & RemoveDelay & UseTemporaryFilePath
511530
export interface Base {
512531
/** If set, a worker might decide to wait with this expectation until the CPU load is lower. */
513532
allowWaitForCPU?: boolean
@@ -534,6 +553,7 @@ export namespace Expectation {
534553
export type ExpectAny =
535554
| ExpectedFileOnDisk
536555
| MediaFileThumbnail
556+
| MediaFileConvert
537557
| ExpectedCorePackageInfo
538558
| ExpectedHTTPFile
539559
| ExpectedQuantelClip
@@ -543,6 +563,7 @@ export namespace Expectation {
543563
export type Any =
544564
| FileOnDisk
545565
| MediaFileThumbnail
566+
| MediaFileConvert
546567
| CorePackageInfo
547568
| HTTPFile
548569
| QuantelClip
@@ -554,6 +575,7 @@ export namespace Expectation {
554575
}
555576
export enum Type {
556577
FILE_ON_DISK = 'file_on_disk',
578+
MEDIA_FILE_CONVERT = 'media_file_convert',
557579
MEDIA_FILE_THUMBNAIL = 'media_file_thumbnail',
558580
MEDIA_FILE_PREVIEW = 'media_file_preview',
559581
CORE_PACKAGE_INFO = 'core_package_info',
@@ -590,6 +612,48 @@ export namespace Expectation {
590612
}
591613
export type ExpectedMediaFileThumbnail = ExpectedType<MediaFileThumbnail>
592614

615+
export interface MediaFileConvert extends Base {
616+
type: Type.MEDIA_FILE_CONVERT
617+
618+
/**
619+
* List of conversion steps to perform, in order.
620+
*/
621+
conversions: ConversionStep[]
622+
}
623+
export interface ConversionStep {
624+
/**
625+
* Path to the executable.
626+
* Note: If this ends with '.exe', but runs on a non-Windows system, the '.exe' will be removed.
627+
*/
628+
executable: string
629+
/**
630+
* Arguments to the executable.
631+
* Supported placeholders:
632+
* - {SOURCE} - replaced with the full path of the source file
633+
* - {TARGET} - replaced with the full path of the target file
634+
*/
635+
args: string[]
636+
637+
/**
638+
* Set to true if the executable needs the source to be locally available
639+
* (So PM will copy the source to a local temp folder before running the executable)
640+
*/
641+
needsLocalSource?: boolean
642+
/**
643+
* Set to true if the executable needs the target to be locally available
644+
* (So PM will create the target in a local temp folder, and then copy it to the actual target when done)
645+
*/
646+
needsLocalTarget?: boolean
647+
648+
/**
649+
* Force the output filename from this step.
650+
* This can be useful in multi-step scenarios where you want to specify the inter-step filename.
651+
* This property is ignored in the final step.
652+
*/
653+
outputFileName?: string
654+
}
655+
export type ExpectedMediaFileConvert = MediaFileConvert // ExpectedType<MediaFileConvert>
656+
593657
export interface MediaFilePreview extends Base {
594658
type: Type.MEDIA_FILE_PREVIEW
595659
bitrate: string // default: '40k'

shared/packages/api/src/inputApi.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,38 @@ export namespace ExpectedPackage {
148148
modifiedDate?: number // timestamp (ms)
149149
checksum?: string
150150
checkSumType?: 'sha' | 'md5' | 'whatever'
151+
conversions?: {
152+
/**
153+
* Path to the executable.
154+
* Note: If this ends with '.exe', but runs on a non-Windows system, the '.exe' will be removed.
155+
*/
156+
executable: string
157+
/**
158+
* Arguments to the executable.
159+
* Supported placeholders:
160+
* - {SOURCE} - replaced with the full path of the source file
161+
* - {TARGET} - replaced with the full path of the target file
162+
*/
163+
args: string[]
164+
165+
/**
166+
* Set to true if the executable needs the source to be locally available
167+
* (So PM will copy the source to a local temp folder before running the executable)
168+
*/
169+
needsLocalSource?: boolean
170+
/**
171+
* Set to true if the executable needs the target to be locally available
172+
* (So PM will create the target in a local temp folder, and then copy it to the actual target when done)
173+
*/
174+
needsLocalTarget?: boolean
175+
176+
/**
177+
* Force the output filename from this step.
178+
* This can be useful in multi-step scenarios where you want to specify the inter-step filename.
179+
* This property is ignored in the final step.
180+
*/
181+
outputFileName?: string
182+
}[]
151183
}
152184
sources: {
153185
containerId: PackageContainerId

shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/lib.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export async function lookupAccessorHandles<Metadata>(
153153
combineWithPackageContainers: PackageContainerOnPackage[],
154154
accessorContext: AccessorContext,
155155
expectationContent: unknown,
156-
expectationWorkOptions: unknown,
156+
expectationWorkOptions: Expectation.WorkOptions.All,
157157
checks: LookupChecks
158158
): Promise<LookupPackageContainer<Metadata>> {
159159
const prioritizedAccessors = prioritizeAccessors(packageContainers)

shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/lib/ffmpeg.ts

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -117,55 +117,20 @@ export async function spawnFFMpeg<Metadata>(
117117
}
118118
const lastFewLines: string[] = []
119119

120-
let fileDuration: number | undefined = undefined
121120
ffMpegProcess.stderr.on('data', (data) => {
122121
const str = data.toString()
123122

124123
log?.('ffmpeg:' + str)
125124

126-
// Duration is reported by FFMpeg at the beginning, this means that it successfully opened the source
127-
// stream and has begun processing
128-
const m = str.match(/Duration:\s?(\d+):(\d+):([\d.]+)/)
129-
if (m) {
130-
const hh = m[1]
131-
const mm = m[2]
132-
const ss = m[3]
133-
134-
fileDuration = parseInt(hh, 10) * 3600 + parseInt(mm, 10) * 60 + parseFloat(ss)
135-
136-
// Report back an initial status, because it looks nice:
137-
// workInProgress._reportProgress(actualSourceVersionHash, 0)
138-
onProgress?.(0).catch((err) => log?.(`spawnFFMpeg onProgress update failed: ${stringifyError(err)}`))
139-
140-
return
141-
}
142-
if (fileDuration) {
143-
// Time position in source is reported periodically, but we need fileDuration to convert that into
144-
// percentages
145-
const m2 = str.match(/time=\s?(\d+):(\d+):([\d.]+)/)
146-
if (m2) {
147-
const hh = m2[1]
148-
const mm = m2[2]
149-
const ss = m2[3]
150-
151-
const progress = parseInt(hh, 10) * 3600 + parseInt(mm, 10) * 60 + parseFloat(ss)
152-
// workInProgress._reportProgress(
153-
// actualSourceVersionHash,
154-
// ((uploadIsDone ? 1 : 0.9) * progress) / fileDuration
155-
// )
156-
onProgress?.(((uploadIsDone ? 1 : 0.9) * progress) / fileDuration).catch((err) =>
157-
log?.(`spawnFFMpeg onProgress update failed: ${stringifyError(err)}`)
158-
)
159-
return
160-
}
161-
}
162-
163125
lastFewLines.push(str)
164126

165127
if (lastFewLines.length > 10) {
166128
lastFewLines.shift()
167129
}
168130
})
131+
ffmpegInterpretProgress(ffMpegProcess, (progress) => {
132+
onProgress?.(progress).catch((err) => log?.(`spawnFFMpeg onProgress update failed: ${stringifyError(err)}`))
133+
})
169134
const onClose = (code: number | null) => {
170135
if (ffMpegProcess) {
171136
log?.('ffmpeg: close ' + code)
@@ -199,3 +164,41 @@ export async function spawnFFMpeg<Metadata>(
199164
},
200165
}
201166
}
167+
168+
export function ffmpegInterpretProgress(
169+
ffMpegProcess: ChildProcessWithoutNullStreams,
170+
cbProgress: (progress: number) => void
171+
): void {
172+
let fileDuration: number | undefined = undefined
173+
ffMpegProcess.stderr.on('data', (data) => {
174+
const str = data.toString()
175+
176+
// Duration is reported by FFMpeg at the beginning, this means that it successfully opened the source
177+
// stream and has begun processing
178+
const m = str.match(/Duration:\s?(\d+):(\d+):([\d.]+)/)
179+
if (m) {
180+
const hh = m[1]
181+
const mm = m[2]
182+
const ss = m[3]
183+
184+
fileDuration = parseInt(hh, 10) * 3600 + parseInt(mm, 10) * 60 + parseFloat(ss)
185+
186+
return
187+
}
188+
if (fileDuration) {
189+
// Time position in source is reported periodically, but we need fileDuration to convert that into
190+
// percentages
191+
const m2 = str.match(/time=\s?(\d+):(\d+):([\d.]+)/)
192+
if (m2) {
193+
const hh = m2[1]
194+
const mm = m2[2]
195+
const ss = m2[3]
196+
197+
const progress = parseInt(hh, 10) * 3600 + parseInt(mm, 10) * 60 + parseFloat(ss)
198+
199+
cbProgress(progress / fileDuration)
200+
return
201+
}
202+
}
203+
})
204+
}

shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/lib/file.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ export async function isFileReadyToStartWorkingOn(
103103
export async function isFileFulfilled(
104104
_worker: BaseWorker,
105105
lookupSource: LookupPackageContainer<UniversalVersion>,
106-
lookupTarget: LookupPackageContainer<UniversalVersion>
106+
lookupTarget: LookupPackageContainer<UniversalVersion>,
107+
modifySourceUVersion?: (version: UniversalVersion) => UniversalVersion
107108
): Promise<ReturnTypeIsExpectationFulfilled> {
108109
if (!lookupTarget.ready)
109110
return {
@@ -140,8 +141,11 @@ export async function isFileFulfilled(
140141
return { fulfilled: false, knownReason: lookupSource.knownReason, reason: lookupSource.reason }
141142

142143
const actualSourceVersion = await lookupSource.handle.getPackageActualVersion()
144+
let actualSourceUVersion = makeUniversalVersion(actualSourceVersion)
145+
146+
if (modifySourceUVersion) actualSourceUVersion = modifySourceUVersion(actualSourceUVersion)
143147

144-
const issueVersions = compareUniversalVersions(makeUniversalVersion(actualSourceVersion), actualTargetVersion)
148+
const issueVersions = compareUniversalVersions(actualSourceUVersion, actualTargetVersion)
145149
if (!issueVersions.success) {
146150
return { fulfilled: false, knownReason: issueVersions.knownReason, reason: issueVersions.reason }
147151
}
@@ -152,7 +156,7 @@ export async function isFileFulfilled(
152156
}
153157
}
154158
export async function doFileCopyExpectation(
155-
exp: Expectation.FileCopy | Expectation.FileCopyProxy,
159+
exp: Expectation.FileCopy | Expectation.FileCopyProxy | Expectation.MediaFileConvert,
156160
lookupSource: LookupPackageContainer<UniversalVersion>,
157161
lookupTarget: LookupPackageContainer<UniversalVersion>
158162
): Promise<WorkInProgress | null> {

0 commit comments

Comments
 (0)