sprocket-systems.coda.sdk 1.3.3__py3-none-any.whl → 2.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
coda/sdk/essence.py ADDED
@@ -0,0 +1,496 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+
7
+ from pathlib import Path
8
+ from typing import List, Dict
9
+ from .enums import Format, SourceType, InputStemType
10
+ from .constants import (
11
+ ENV_CODA_CLI_EXE,
12
+ ENV_NO_CODA_EXE,
13
+ ENV_CODA_API_GROUP_ID,
14
+ DEFAULT_PROGRAM_ID,
15
+ DEFAULT_BIT_DEPTH,
16
+ DEFAULT_SAMPLE_RATE,
17
+ URL_PREFIX_S3,
18
+ URL_PREFIX_IO,
19
+ )
20
+
21
+
22
+ class Essence:
23
+ """Single source essence for a Coda job.
24
+
25
+ This class constructs the essence/s payload, which can
26
+ consist of one or more media files (e.g., an interleaved WAV or a
27
+ group of multi-mono WAVs).
28
+
29
+ Attributes:
30
+ payload (dict): The dictionary that holds the essence definition.
31
+
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ format: Format,
37
+ stem_type: InputStemType = InputStemType.PRINTMASTER,
38
+ program: str = DEFAULT_PROGRAM_ID,
39
+ description: str = "",
40
+ timing_info: dict[str, str] | None = None
41
+ ) -> None:
42
+ """Initialize the CodaEssence object.
43
+
44
+ Args:
45
+ format (Format): The input audio format of the essence (e.g., "5.1", "7.1.2", "atmos").
46
+ stem_type (InputStemType, optional): The type of input source stem (e.g., "audio/pm", "audio/dx").
47
+ Defaults to InputStemType.PRINTMASTER.
48
+ program (str, optional): The program identifier for the essence definition.
49
+ Defaults to "program-1".
50
+ description (str, optional): A human-readable description of the essence.
51
+ Defaults to "".
52
+ timing_info (dict, optional): Framerate, FFOA and LFOA info.
53
+
54
+ Raises:
55
+ ValueError: If format is an empty string.
56
+
57
+ """
58
+ if not format or not isinstance(format, str):
59
+ raise ValueError("format must not be an empty string and must be a string type.")
60
+
61
+ self.payload = {
62
+ "type": "",
63
+ "definition": {
64
+ "format": format,
65
+ "program": program,
66
+ "description": description,
67
+ "type": stem_type,
68
+ },
69
+ "timing_info": timing_info
70
+ }
71
+
72
+ def add_interleaved_resource(
73
+ self,
74
+ file: str | dict,
75
+ channel_selection: dict[str, int],
76
+ channel_count: int,
77
+ frames: int,
78
+ bit_depth: int = DEFAULT_BIT_DEPTH,
79
+ sample_rate: int = DEFAULT_SAMPLE_RATE,
80
+ io_location_id: str | None = None
81
+ ) -> None:
82
+ """Configure the essence as a single interleaved resource.
83
+
84
+ This sets the essence type to 'interleaved' and populates the definition
85
+ with the properties of a single, multichannel media file.
86
+
87
+ Args:
88
+ file (str | dict): The filepath of the interleaved media file, or a dict containing a 'url' key.
89
+ If a dict is passed in it will require a 'url' key to sepcify the location path.
90
+ The dict can contain the optional keys 'auth' and 'opts' for resource authorization.
91
+ channel_selection (dict[str, int]): A map of channel labels to their stream IDs.
92
+ channel_count (int): The total number of channels in the file.
93
+ frames (int): The duration of the file in frames.
94
+ bit_depth (int, optional): The bit depth. Defaults to 24.
95
+ sample_rate (int, optional): The sample rate. Defaults to 48000.
96
+ io_location_id (str, optional): The IO Location ID associated with the source files. Required for agent transfers.
97
+
98
+ Raises:
99
+ TypeError: If channel_selection is not a dictionary.
100
+ ValueError: If IO Location ID has not been provided for non-S3 sources.
101
+
102
+ """
103
+ if not isinstance(channel_selection, dict):
104
+ raise TypeError("channel_selection must be a dictionary.")
105
+
106
+ self.payload["type"] = SourceType.INTERLEAVED
107
+
108
+ if isinstance(file, str):
109
+ url = file
110
+ auth = None
111
+ opts = None
112
+ else:
113
+ url = file["url"]
114
+ auth = file.get("auth")
115
+ opts = file.get("opts")
116
+
117
+ if URL_PREFIX_S3 not in url:
118
+ if io_location_id is None:
119
+ raise ValueError("IO Location ID is required for non-S3 file sources.")
120
+ url = f"{URL_PREFIX_IO}{io_location_id}{url}"
121
+
122
+ resource_dict = {"url": url}
123
+ if auth is not None:
124
+ resource_dict["auth"] = auth
125
+ if opts is not None:
126
+ resource_dict["opts"] = opts
127
+
128
+ self.payload["definition"].update({
129
+ "resource": resource_dict,
130
+ "bit_depth": bit_depth,
131
+ "sample_rate": sample_rate,
132
+ "channel_count": channel_count,
133
+ "frames": frames,
134
+ "channel_selection": channel_selection.copy(),
135
+ })
136
+
137
+ def add_multi_mono_resources(
138
+ self, files: List, frames: int, bit_depth: int = DEFAULT_BIT_DEPTH, sample_rate: int = DEFAULT_SAMPLE_RATE, io_location_id: str | None = None
139
+ ) -> None:
140
+ """Configure the essence as a group of multi-mono resources.
141
+
142
+ This sets the essence type to 'multi_mono' and populates the definition
143
+ with a list of single-channel media files that form a multichannel group.
144
+
145
+ Args:
146
+ files (list): A list of file URLs (or dicts with 'url' keys).
147
+ frames (int): The duration of the files in frames.
148
+ bit_depth (int, optional): The bit depth. Defaults to 24.
149
+ sample_rate (int, optional): The sample rate. Defaults to 48000.
150
+ io_location_id (str, optional): The IO Location ID associated with the source files. Required for agent transfers.
151
+
152
+ Raises:
153
+ TypeError: If files is not a list.
154
+ ValueError: If the number of provided files does not match the
155
+ channel count implied by the essence's format.
156
+ ValueError: If IO Location ID has not been provided for non-S3 sources.
157
+
158
+ """
159
+ if not isinstance(files, list):
160
+ raise TypeError("files must be provided as a list.")
161
+
162
+ stem_format = self.payload["definition"]["format"]
163
+ if stem_format != Format.ATMOS:
164
+ try:
165
+ expected_channels = sum(int(p) for p in stem_format.split('.'))
166
+ actual_channels = len(files)
167
+ if actual_channels != expected_channels:
168
+ raise ValueError(
169
+ f"Channel count mismatch for format '{stem_format}': "
170
+ f"Expected {expected_channels} files for multi-mono resources, but received {actual_channels}."
171
+ )
172
+ except (ValueError, TypeError) as e:
173
+ if isinstance(e, ValueError) and "Channel count mismatch" in str(e):
174
+ raise e
175
+ print(
176
+ f"Warning: Could not validate channel count for format '{stem_format}'.",
177
+ file=sys.stderr,
178
+ )
179
+
180
+ self.payload["type"] = SourceType.MULTI_MONO.value
181
+ self.payload["definition"]["resources"] = []
182
+ for file_entry in files:
183
+ if isinstance(file_entry, str):
184
+ url = file_entry
185
+ auth = None
186
+ opts = None
187
+ label = ""
188
+ else:
189
+ url = file_entry["url"]
190
+ auth = file_entry.get("auth")
191
+ opts = file_entry.get("opts")
192
+ label = file_entry.get("channel_label", "")
193
+
194
+ if not label:
195
+ ch_labels = [
196
+ "Lsr", "Rsr", "Lts", "Rts", "Lss", "Rss",
197
+ "Lfe", "Ls", "Rs", "L", "C", "R",
198
+ ]
199
+ for ch in ch_labels:
200
+ if "." + ch.upper() + "." in url.upper():
201
+ label = "LFE" if ch == "Lfe" else ch.capitalize()
202
+ break
203
+
204
+ if URL_PREFIX_S3 not in url:
205
+ if io_location_id is None:
206
+ raise ValueError("IO Location ID is required for non-S3 file sources.")
207
+ url = f"{URL_PREFIX_IO}{io_location_id}{url}"
208
+
209
+ res = {
210
+ "resource": {"url": url},
211
+ "bit_depth": bit_depth,
212
+ "sample_rate": sample_rate,
213
+ "channel_count": 1,
214
+ "frames": frames,
215
+ "channel_label": label,
216
+ "bext_time_reference": 0,
217
+ }
218
+ if auth is not None:
219
+ res["resource"]["auth"] = auth
220
+ if opts is not None:
221
+ res["resource"]["opts"] = opts
222
+ self.payload["definition"]["resources"].append(res)
223
+
224
+ def override_stem_type(self, stem_type: InputStemType) -> None:
225
+ """Override the stem type of the essence input source.
226
+
227
+ Args:
228
+ stem_type: The type of input source stem (e.g., "audio/pm", "audio/dx").
229
+
230
+ """
231
+ self.payload["definition"]["type"] = stem_type
232
+
233
+ def override_program(self, program: str) -> None:
234
+ """Override the program identifier of the essence.
235
+
236
+ Args:
237
+ program (str): The program identifier for the essence definition.
238
+
239
+ """
240
+ self.payload["definition"]["program"] = program
241
+
242
+ def override_format(self, format: Format) -> None:
243
+ """Override the audio format of the essence input source.
244
+
245
+ Args:
246
+ format (Format): The input audio format of the essence (e.g., "5.1", "7.1.2", "atmos").
247
+
248
+ """
249
+ self.payload["definition"]["format"] = format
250
+
251
+ def override_language(self, language) -> None:
252
+ """Override the language of the essence.
253
+
254
+ Args:
255
+ language: The language for the essence (e.g., Language.ENGLISH).
256
+
257
+ """
258
+ lang_value = language.value if hasattr(language, 'value') else language
259
+ self.payload["definition"]["language"] = lang_value
260
+
261
+ def override_timing_info(
262
+ self,
263
+ source_frame_rate=None,
264
+ ffoa_timecode: str | None = None,
265
+ lfoa_timecode: str | None = None
266
+ ) -> None:
267
+ """Override timing information for the essence.
268
+
269
+ All parameters are optional. Only provided values will be set.
270
+
271
+ Args:
272
+ source_frame_rate: The source frame rate (e.g., FrameRate.TWENTY_FOUR).
273
+ ffoa_timecode (str, optional): First frame of action timecode.
274
+ lfoa_timecode (str, optional): Last frame of action timecode.
275
+
276
+ """
277
+ # Initialize timing_info if it's None
278
+ if self.payload["timing_info"] is None:
279
+ self.payload["timing_info"] = {}
280
+
281
+ if source_frame_rate is not None:
282
+ fr_value = source_frame_rate.value if hasattr(source_frame_rate, 'value') else source_frame_rate
283
+ self.payload["timing_info"]["source_frame_rate"] = fr_value
284
+ if ffoa_timecode is not None:
285
+ self.payload["timing_info"]["ffoa_timecode"] = ffoa_timecode
286
+ if lfoa_timecode is not None:
287
+ self.payload["timing_info"]["lfoa_timecode"] = lfoa_timecode
288
+
289
+ def override_bext_time_reference(self, bext_time_reference: int) -> None:
290
+ """Set BEXT time reference on all resources.
291
+
292
+ Args:
293
+ bext_time_reference (int): The BEXT time reference value.
294
+
295
+ """
296
+ for resource in self.payload["definition"]["resources"]:
297
+ resource["bext_time_reference"] = bext_time_reference
298
+
299
+ @staticmethod
300
+ def essences_from_files(
301
+ files: List,
302
+ io_location_id: str | None = None,
303
+ file_info: Dict | None = None,
304
+ program: str = "",
305
+ forced_frame_rate: str | None = None
306
+ ) -> List["Essence"]:
307
+ """Create a list of CodaEssence objects from files.
308
+
309
+ This method inspects local files using the 'coda inspect' command-line tool
310
+ to automatically determine their properties. For S3 files or when the CLI
311
+ is unavailable, it relies on the `file_info` dictionary for manual creation.
312
+
313
+ Args:
314
+ files (List): A list of file paths.
315
+ io_location_id (str, optional): The IO Location ID associated with the source files. Required for agent transfers.
316
+ file_info (dict, optional): Manual override info for files. Required for S3.
317
+ Should contain keys like 'format', 'type', 'frames', 's3_auth'. Defaults to None.
318
+ Example: file_info = {
319
+ "frames": 720000,
320
+ "format": Format.SEVEN_ONE,
321
+ "type": InputStemType.PRINTMASTER,
322
+ "s3_auth": {
323
+ "role": os.getenv(ENV_S3_SRC_ROLE, "XXXX"),
324
+ "external_id": os.getenv(ENV_S3_SRC_EXTERNAL_ID, "XXXX"),
325
+ },
326
+ "s3_options": {"region": "us-west-2"},
327
+ }
328
+ program (str, optional): The program identifier to assign to the essences.
329
+ Defaults to "".
330
+ forced_frame_rate (str, optional): Specify the frame rate to calculate the FFOA and LFOA
331
+
332
+ Raises:
333
+ ValueError: If `files` is not populated with a list of file paths.
334
+ ValueError: If the 'coda' CLI tool is required but not found, or if
335
+ the inspection process fails.
336
+ ValueError: If the 'coda inspect' CLI tool does not return sources info.
337
+ ValueError: If S3 files are provided without a `file_info` dictionary.
338
+ ValueError: If IO Location ID has not been provided for non-S3 sources.
339
+ ValueError: If CODA_API_GROUP_ID has not been set for using `coda inspect`.
340
+
341
+ Returns:
342
+ List[CodaEssence]: A list of CodaEssence objects.
343
+
344
+ """
345
+ if not files:
346
+ raise ValueError("Files must contain a list of file paths.")
347
+
348
+ local_files = [f for f in files if URL_PREFIX_S3 not in str(f)]
349
+ s3_files = [f for f in files if URL_PREFIX_S3 in str(f)]
350
+ essences = []
351
+
352
+ if local_files:
353
+ if io_location_id is None:
354
+ raise ValueError("IO Location ID must be filled to use supplied files.")
355
+
356
+ absfiles = [str(Path(f).resolve()) for f in local_files]
357
+ codaexe = shutil.which("coda") or os.getenv(ENV_CODA_CLI_EXE)
358
+ if not codaexe or os.getenv(ENV_NO_CODA_EXE) is not None:
359
+ if not file_info:
360
+ raise ValueError("Coda CLI not found and file_info was not provided for manual creation.")
361
+
362
+ print("Creating essence manually from file_info.", file=sys.stderr)
363
+
364
+ essence = Essence(file_info["format"], stem_type=file_info["type"], program=program)
365
+ res = [{"url": url} for url in absfiles]
366
+ essence.add_multi_mono_resources(res, frames=file_info["frames"], io_location_id=io_location_id)
367
+ essences.append(essence)
368
+ else:
369
+ try:
370
+ group_id = os.getenv(ENV_CODA_API_GROUP_ID)
371
+ if not group_id:
372
+ raise ValueError("CODA_API_GROUP_ID must be set. Use workflow.with_group() for convenience.")
373
+
374
+ print(f"coda inspect scanning {len(absfiles)} files", file=sys.stderr)
375
+ print(f"using coda cli: {codaexe}", file=sys.stderr)
376
+
377
+ args = [codaexe, "inspect", "--group-id", group_id, "--io-location-id", io_location_id]
378
+ if forced_frame_rate:
379
+ args = [
380
+ *args,
381
+ "--frame-rate",
382
+ forced_frame_rate,
383
+ ]
384
+ args = [*args, *absfiles]
385
+
386
+ ret = subprocess.run(
387
+ args,
388
+ shell=False,
389
+ check=True,
390
+ capture_output=True,
391
+ text=True,
392
+ )
393
+
394
+ j = json.loads(ret.stdout)
395
+ print(json.dumps(j, indent=2))
396
+ if not j.get("sources"):
397
+ raise ValueError("`coda inspect` was unable to retrieve the sources information.")
398
+
399
+ timing_info = {
400
+ "source_frame_rate": j.get("source_frame_rate"),
401
+ "ffoa_timecode": j.get("ffoa_timecode"),
402
+ "lfoa_timecode": j.get("lfoa_timecode")
403
+ }
404
+
405
+ for source in j.get("sources", []):
406
+ source_type = source.get("type")
407
+ if source_type in [SourceType.ADM, SourceType.IAB_MXF]:
408
+ format = Format.ATMOS
409
+ source_def = source.get("definition")
410
+ essence = Essence(
411
+ format=source_def.get("format") or format,
412
+ stem_type=source_def.get("type"),
413
+ program=source_def.get("program", program),
414
+ description=source_def.get("description"),
415
+ timing_info=timing_info,
416
+ )
417
+
418
+ essence.payload["type"] = source_type
419
+
420
+ for key, value in source_def.items():
421
+ essence.payload["definition"][f"{key}"] = value
422
+
423
+ essences.append(essence)
424
+
425
+ except subprocess.CalledProcessError as e:
426
+ error_message = (
427
+ f"The 'coda inspect' command failed with exit code {e.returncode}.\n"
428
+ f"--- STDOUT ---\n{e.stdout}\n"
429
+ f"--- STDERR ---\n{e.stderr}\n"
430
+ )
431
+ raise ValueError(error_message) from e
432
+ except Exception as e:
433
+ raise ValueError(f"An unexpected error occurred during 'coda inspect': {e}") from e
434
+
435
+ if s3_files:
436
+ print(f"Adding {len(s3_files)} S3 files", file=sys.stderr)
437
+ if not file_info:
438
+ raise ValueError("file_info must be provided for S3 files.")
439
+
440
+ essence = Essence(
441
+ file_info["format"], stem_type=file_info["type"], program=program
442
+ )
443
+ res = [
444
+ {
445
+ "url": r,
446
+ "auth": file_info.get("s3_auth"),
447
+ "opts": file_info.get("s3_options"),
448
+ }
449
+ for r in s3_files
450
+ ]
451
+ essence.add_multi_mono_resources(res, frames=file_info["frames"])
452
+ essences.append(essence)
453
+
454
+ return essences
455
+
456
+ def dict(self) -> dict:
457
+ """Return the final payload dictionary for the essence.
458
+
459
+ Performs a validation check to ensure the number of resource files or
460
+ channel selections matches the channel count specified by the audio format.
461
+
462
+ Raises:
463
+ ValueError: If the number of files/channels doesn't match the format's channel count.
464
+
465
+ Returns:
466
+ dict: The essence payload dictionary.
467
+
468
+ """
469
+ definition = self.payload["definition"]
470
+ if definition.get("format") in [Format.ATMOS, Format.IMAX5, Format.IMAX6, Format.IMAX12]:
471
+ return self.payload
472
+
473
+ try:
474
+ expected_channels = sum(int(e) for e in definition["format"].split("."))
475
+ actual_channels = 0
476
+
477
+ if "resources" in definition:
478
+ actual_channels = len(definition["resources"])
479
+ elif "channel_selection" in definition:
480
+ actual_channels = len(definition["channel_selection"])
481
+
482
+ if actual_channels > 0 and actual_channels != expected_channels:
483
+ raise ValueError(
484
+ f"Channel count mismatch for format '{definition['format']}': "
485
+ f"Expected {expected_channels} channels, but found {actual_channels}."
486
+ )
487
+
488
+ except (ValueError, TypeError) as e:
489
+ if isinstance(e, ValueError) and "Channel count mismatch" in str(e):
490
+ raise e
491
+ print(
492
+ f"Warning: Could not validate channel count for format '{definition['format']}'.",
493
+ file=sys.stderr,
494
+ )
495
+
496
+ return self.payload