typed-ffmpeg-compatible 3.0.1__py3-none-any.whl → 3.2.1__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.
Files changed (60) hide show
  1. typed_ffmpeg/__init__.py +2 -1
  2. typed_ffmpeg/_version.py +21 -0
  3. typed_ffmpeg/compile/compile_cli.py +413 -13
  4. typed_ffmpeg/compile/compile_python.py +3 -4
  5. typed_ffmpeg/ffprobe/__init__.py +0 -0
  6. typed_ffmpeg/ffprobe/parse.py +133 -0
  7. typed_ffmpeg/ffprobe/probe.py +272 -0
  8. typed_ffmpeg/ffprobe/schema.py +455 -0
  9. typed_ffmpeg/ffprobe/xml2json.py +70 -0
  10. {typed_ffmpeg_compatible-3.0.1.dist-info → typed_ffmpeg_compatible-3.2.1.dist-info}/METADATA +36 -17
  11. typed_ffmpeg_compatible-3.2.1.dist-info/RECORD +57 -0
  12. {typed_ffmpeg_compatible-3.0.1.dist-info → typed_ffmpeg_compatible-3.2.1.dist-info}/WHEEL +2 -1
  13. typed_ffmpeg_compatible-3.2.1.dist-info/top_level.txt +1 -0
  14. typed_ffmpeg/common/cache/.gitignore +0 -3
  15. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/acrossover.json +0 -6
  16. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/afir.json +0 -9
  17. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/aiir.json +0 -6
  18. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/ainterleave.json +0 -9
  19. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/amerge.json +0 -9
  20. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/amix.json +0 -9
  21. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/amovie.json +0 -6
  22. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/anequalizer.json +0 -6
  23. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/aphasemeter.json +0 -6
  24. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/asegment.json +0 -6
  25. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/aselect.json +0 -6
  26. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/asplit.json +0 -6
  27. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/astreamselect.json +0 -9
  28. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/bm3d.json +0 -6
  29. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/channelsplit.json +0 -6
  30. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/concat.json +0 -9
  31. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/decimate.json +0 -6
  32. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/ebur128.json +0 -6
  33. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/extractplanes.json +0 -6
  34. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/fieldmatch.json +0 -6
  35. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/guided.json +0 -6
  36. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/headphone.json +0 -6
  37. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/hstack.json +0 -9
  38. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/interleave.json +0 -9
  39. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/join.json +0 -9
  40. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/libplacebo.json +0 -9
  41. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/limitdiff.json +0 -6
  42. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/mergeplanes.json +0 -6
  43. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/mix.json +0 -9
  44. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/movie.json +0 -6
  45. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/premultiply.json +0 -6
  46. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/segment.json +0 -6
  47. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/select.json +0 -6
  48. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/signature.json +0 -9
  49. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/split.json +0 -6
  50. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/streamselect.json +0 -9
  51. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/unpremultiply.json +0 -6
  52. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/vstack.json +0 -9
  53. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/xmedian.json +0 -9
  54. typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/xstack.json +0 -9
  55. typed_ffmpeg/common/cache/list/filters.json +0 -90747
  56. typed_ffmpeg/common/cache/list/options.json +0 -1694
  57. typed_ffmpeg/probe.py +0 -75
  58. typed_ffmpeg_compatible-3.0.1.dist-info/RECORD +0 -95
  59. typed_ffmpeg_compatible-3.0.1.dist-info/entry_points.txt +0 -3
  60. {typed_ffmpeg_compatible-3.0.1.dist-info → typed_ffmpeg_compatible-3.2.1.dist-info/licenses}/LICENSE +0 -0
typed_ffmpeg/__init__.py CHANGED
@@ -26,8 +26,8 @@ from . import compile, dag, filters, sources
26
26
  from .base import afilter, filter_multi_output, input, merge_outputs, output, vfilter
27
27
  from .dag import Stream
28
28
  from .exceptions import FFMpegExecuteError, FFMpegTypeError, FFMpegValueError
29
+ from .ffprobe.probe import probe, probe_obj
29
30
  from .info import get_codecs, get_decoders, get_encoders
30
- from .probe import probe
31
31
  from .streams import AudioStream, AVStream, VideoStream
32
32
 
33
33
  __all__ = [
@@ -41,6 +41,7 @@ __all__ = [
41
41
  "FFMpegValueError",
42
42
  "Stream",
43
43
  "probe",
44
+ "probe_obj",
44
45
  "compile",
45
46
  "AudioStream",
46
47
  "VideoStream",
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '3.2.1'
21
+ __version_tuple__ = version_tuple = (3, 2, 1)
@@ -16,10 +16,29 @@ filter graph syntax, and escaping of special characters in FFmpeg commands.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- from ..dag.nodes import FilterableStream, FilterNode, GlobalNode, InputNode, OutputNode
19
+ import re
20
+ import shlex
21
+ from collections import defaultdict
22
+ from collections.abc import Mapping
23
+
24
+ from ..base import input, merge_outputs, output
25
+ from ..common.cache import load
26
+ from ..common.schema import FFMpegFilter, FFMpegFilterDef, FFMpegOption, StreamType
27
+ from ..dag.factory import filter_node_factory
28
+ from ..dag.nodes import (
29
+ FilterableStream,
30
+ FilterNode,
31
+ GlobalNode,
32
+ InputNode,
33
+ OutputNode,
34
+ OutputStream,
35
+ )
20
36
  from ..dag.schema import Node, Stream
21
37
  from ..exceptions import FFMpegValueError
22
38
  from ..schema import Default
39
+ from ..streams.audio import AudioStream
40
+ from ..streams.av import AVStream
41
+ from ..streams.video import VideoStream
23
42
  from ..utils.escaping import escape
24
43
  from ..utils.lazy_eval.schema import LazyValue
25
44
  from ..utils.run import command_line
@@ -27,6 +46,350 @@ from .context import DAGContext
27
46
  from .validate import validate
28
47
 
29
48
 
49
+ def get_options_dict() -> dict[str, FFMpegOption]:
50
+ options = load(list[FFMpegOption], "options")
51
+ return {option.name: option for option in options}
52
+
53
+
54
+ def get_filter_dict() -> dict[str, FFMpegFilter]:
55
+ filters = load(list[FFMpegFilter], "filters")
56
+ return {filter.name: filter for filter in filters}
57
+
58
+
59
+ def parse_options(tokens: list[str]) -> dict[str, list[str | None | bool]]:
60
+ """
61
+ Parse FFmpeg command-line options into a structured dictionary.
62
+
63
+ This function processes a list of command-line tokens and converts them into
64
+ a dictionary where keys are option names (without the leading '-') and values
65
+ are lists of their corresponding values. Boolean options are handled specially:
66
+ - '-option' becomes {'option': [None]}
67
+ - '-nooption' becomes {'option': [False]}
68
+
69
+ Args:
70
+ tokens: List of command-line tokens to parse
71
+
72
+ Returns:
73
+ Dictionary mapping option names to lists of their values
74
+ """
75
+ parsed_options: dict[str, list[str | None | bool]] = defaultdict(list)
76
+
77
+ while tokens:
78
+ assert tokens[0][0] == "-", f"Expected option, got {tokens[0]}"
79
+ if len(tokens) == 1 or tokens[1][0] == "-":
80
+ if tokens[0].startswith("-no"):
81
+ # Handle boolean options with -no prefix
82
+ option_name = tokens[0][3:]
83
+ parsed_options[option_name] = [False]
84
+ else:
85
+ # Handle boolean options without value
86
+ option_name = tokens[0][1:]
87
+ parsed_options[option_name] = [None]
88
+ tokens = tokens[1:]
89
+ else:
90
+ # Handle options with values
91
+ option_name = tokens[0][1:]
92
+ option_value = tokens[1]
93
+ parsed_options[option_name].append(option_value)
94
+ tokens = tokens[2:]
95
+
96
+ return parsed_options
97
+
98
+
99
+ def parse_stream_selector(
100
+ selector: str, mapping: Mapping[str, FilterableStream]
101
+ ) -> FilterableStream:
102
+ selector = selector.strip("[]")
103
+
104
+ if ":" in selector:
105
+ stream_label, _ = selector.split(":", 1)
106
+ else:
107
+ stream_label = selector
108
+
109
+ assert stream_label in mapping, f"Unknown stream label: {stream_label}"
110
+ stream = mapping[stream_label]
111
+
112
+ if isinstance(stream, AVStream):
113
+ if selector.count(":") == 1:
114
+ stream_label, stream_type = selector.split(":", 1)
115
+ return stream.video if stream_type == "v" else stream.audio
116
+ elif selector.count(":") == 2:
117
+ stream_label, stream_type, stream_index = selector.split(":", 2)
118
+ return (
119
+ stream.video_stream(int(stream_index))
120
+ if stream_type == "v"
121
+ else stream.audio_stream(int(stream_index))
122
+ )
123
+ else:
124
+ return stream
125
+ else:
126
+ return stream
127
+
128
+
129
+ def parse_output(
130
+ source: list[str],
131
+ in_streams: Mapping[str, FilterableStream],
132
+ ffmpeg_options: dict[str, FFMpegOption],
133
+ ) -> list[OutputStream]:
134
+ tokens = source.copy()
135
+
136
+ export: list[OutputStream] = []
137
+
138
+ buffer: list[str] = []
139
+ while tokens:
140
+ token = tokens.pop(0)
141
+ if token.startswith("-") or len(buffer) % 2 == 1:
142
+ buffer.append(token)
143
+ continue
144
+
145
+ filename = token
146
+ options = parse_options(buffer)
147
+
148
+ map_options = options.pop("map", [])
149
+ inputs: list[FilterableStream] = []
150
+ for map_option in map_options:
151
+ assert isinstance(map_option, str), f"Expected map option, got {map_option}"
152
+ stream = parse_stream_selector(map_option, in_streams)
153
+ inputs.append(stream)
154
+
155
+ if not inputs:
156
+ # NOTE: if there is no inputs, and there is only one input node
157
+ if len([k for k in in_streams if isinstance(in_streams[k], AVStream)]) == 1:
158
+ inputs = [
159
+ in_streams[k]
160
+ for k in in_streams
161
+ if isinstance(in_streams[k], AVStream)
162
+ ]
163
+
164
+ assert inputs, f"No inputs found for output {filename}"
165
+ export.append(output(*inputs, filename=filename, extra_options=options))
166
+ buffer = []
167
+
168
+ return export
169
+
170
+
171
+ def parse_input(
172
+ tokens: list[str], ffmpeg_options: dict[str, FFMpegOption]
173
+ ) -> dict[str, FilterableStream]:
174
+ output: list[AVStream] = []
175
+
176
+ while "-i" in tokens:
177
+ index = tokens.index("-i")
178
+ filename = tokens[index + 1]
179
+ assert filename[0] != "-", f"Expected filename, got {filename}"
180
+
181
+ input_options_args = tokens[:index]
182
+
183
+ options = parse_options(input_options_args)
184
+ parameters: dict[str, str | bool] = {}
185
+
186
+ for key, value in options.items():
187
+ assert key in ffmpeg_options, f"Unknown option: {key}"
188
+ option = ffmpeg_options[key]
189
+
190
+ if option.is_input_option:
191
+ # just ignore not input options
192
+ if value[-1] is None:
193
+ parameters[key] = True
194
+ else:
195
+ parameters[key] = value[-1]
196
+
197
+ output.append(input(filename=filename, extra_options=parameters))
198
+
199
+ tokens = tokens[index + 2 :]
200
+
201
+ return {str(idx): stream for idx, stream in enumerate(output)}
202
+
203
+
204
+ def parse_filter_complex(
205
+ filter_complex: str,
206
+ stream_mapping: dict[str, FilterableStream],
207
+ ffmpeg_filters: dict[str, FFMpegFilter],
208
+ ) -> dict[str, FilterableStream]:
209
+ """
210
+ Parse an FFmpeg filter_complex string into a stream mapping.
211
+
212
+ This function processes a filter_complex string (e.g. "[0:v]scale=1280:720[v0]")
213
+ and converts it into a mapping of stream labels to their corresponding
214
+ FilterableStream objects. It handles:
215
+ - Input stream references (e.g. [0:v])
216
+ - Filter definitions with parameters
217
+ - Output stream labels (e.g. [v0])
218
+
219
+ Args:
220
+ filter_complex: The filter_complex string to parse
221
+ stream_mapping: Existing mapping of stream labels to streams
222
+ ffmpeg_filters: Dictionary of available FFmpeg filters
223
+
224
+ Returns:
225
+ Updated stream mapping with new filter outputs added
226
+ """
227
+ filter_units = filter_complex.split(";")
228
+
229
+ for filter_unit in filter_units:
230
+ pattern = re.compile(
231
+ r"""
232
+ (?P<inputs>(\[[^\[\]]+\])*) # inputs: zero or more [label]
233
+ (?P<filter>[a-zA-Z0-9_]+) # filter name
234
+ (=?(?P<params>[^[]+?))? # optional =params (until next [ or end)
235
+ (?P<outputs>(\[[^\[\]]+\])*)$ # outputs: zero or more [label] at end
236
+ """,
237
+ re.VERBOSE,
238
+ )
239
+
240
+ match = pattern.match(filter_unit)
241
+ assert match, f"Invalid filter unit: {filter_unit}"
242
+
243
+ def extract_labels(label_str: str) -> list[str]:
244
+ return re.findall(r"\[([^\[\]]+)\]", label_str)
245
+
246
+ input_labels = extract_labels(match.group("inputs") or "")
247
+ output_labels = extract_labels(match.group("outputs") or "")
248
+ filter_name = match.group("filter")
249
+ param_str = match.group("params")
250
+
251
+ # Parse filter parameters into key-value pairs
252
+ filter_params = {}
253
+ if param_str:
254
+ param_parts = param_str.strip().split(":")
255
+ for part in param_parts:
256
+ if "=" in part:
257
+ key, value = part.split("=", 1)
258
+ filter_params[key.strip()] = value.strip()
259
+
260
+ assert isinstance(filter_name, str), f"Expected filter name, got {filter_name}"
261
+ ffmpeg_filter = ffmpeg_filters[filter_name]
262
+ filter_def = FFMpegFilterDef(
263
+ name=ffmpeg_filter.name,
264
+ typings_input=ffmpeg_filter.formula_typings_input
265
+ or tuple(k.type.value for k in ffmpeg_filter.stream_typings_input),
266
+ typings_output=ffmpeg_filter.formula_typings_output
267
+ or tuple(k.type.value for k in ffmpeg_filter.stream_typings_output),
268
+ )
269
+ input_streams = [
270
+ parse_stream_selector(label, stream_mapping) for label in input_labels
271
+ ]
272
+
273
+ # Create the filter node with default options and parsed parameters
274
+ filter_node = filter_node_factory(
275
+ filter_def,
276
+ *input_streams,
277
+ **(
278
+ {k.name: Default(k.default) for k in ffmpeg_filter.options}
279
+ | filter_params
280
+ ),
281
+ )
282
+
283
+ # Map output streams to their labels
284
+ for idx, (output_label, output_typing) in enumerate(
285
+ zip(output_labels, filter_node.output_typings)
286
+ ):
287
+ if output_typing == StreamType.video:
288
+ stream_mapping[output_label] = VideoStream(node=filter_node, index=idx)
289
+ elif output_typing == StreamType.audio:
290
+ stream_mapping[output_label] = AudioStream(node=filter_node, index=idx)
291
+ else:
292
+ raise FFMpegValueError(f"Unknown stream type: {output_typing}")
293
+
294
+ return stream_mapping
295
+
296
+
297
+ def parse_global(
298
+ tokens: list[str], ffmpeg_options: dict[str, FFMpegOption]
299
+ ) -> tuple[dict[str, str | bool], list[str]]:
300
+ """
301
+ Parse global FFmpeg options from command-line tokens.
302
+
303
+ This function processes the global options that appear before any input files
304
+ in the FFmpeg command line. These options affect the entire FFmpeg process,
305
+ such as log level, overwrite flags, etc.
306
+
307
+ Args:
308
+ tokens: List of command-line tokens to parse
309
+ ffmpeg_options: Dictionary of valid FFmpeg options
310
+
311
+ Returns:
312
+ A tuple containing:
313
+ - Dictionary of parsed global options and their values
314
+ - Remaining tokens after global options are consumed
315
+
316
+ Example:
317
+ For tokens like ['-y', '-loglevel', 'quiet', '-i', 'input.mp4']:
318
+ Returns ({'y': True, 'loglevel': 'quiet'}, ['-i', 'input.mp4'])
319
+ """
320
+ global_params: dict[str, str | bool] = {}
321
+ remaining_tokens = tokens.copy()
322
+
323
+ # Process tokens until we hit an input file marker (-i)
324
+ while remaining_tokens and remaining_tokens[0] != "-i":
325
+ if remaining_tokens[0].startswith("-"):
326
+ option_name = remaining_tokens[0][1:]
327
+ assert option_name in ffmpeg_options, (
328
+ f"Unknown global option: {option_name}"
329
+ )
330
+ option = ffmpeg_options[option_name]
331
+
332
+ if not option.is_global_option:
333
+ continue
334
+
335
+ if len(remaining_tokens) > 1 and not remaining_tokens[1].startswith("-"):
336
+ # Option with value
337
+ global_params[option_name] = remaining_tokens[1]
338
+ remaining_tokens = remaining_tokens[2:]
339
+ else:
340
+ # Boolean option
341
+ if option_name.startswith("no"):
342
+ global_params[option_name[2:]] = False
343
+ else:
344
+ global_params[option_name] = True
345
+ remaining_tokens = remaining_tokens[1:]
346
+ else:
347
+ # Skip non-option tokens
348
+ remaining_tokens = remaining_tokens[1:]
349
+
350
+ return global_params, remaining_tokens
351
+
352
+
353
+ def parse(cli: str) -> Stream:
354
+ # ffmpeg [global_options] {[input_file_options] -i input_url} ... {[output_file_options] output_url} ...
355
+ ffmpeg_options = get_options_dict()
356
+ ffmpeg_filters = get_filter_dict()
357
+
358
+ tokens = shlex.split(cli)
359
+ assert tokens[0] == "ffmpeg"
360
+ tokens = tokens[1:]
361
+
362
+ # Parse global options first
363
+ global_params, remaining_tokens = parse_global(tokens, ffmpeg_options)
364
+
365
+ # find the index of the last -i option
366
+ index = len(remaining_tokens) - 1 - list(reversed(remaining_tokens)).index("-i")
367
+ input_streams = parse_input(remaining_tokens[: index + 2], ffmpeg_options)
368
+ remaining_tokens = remaining_tokens[index + 2 :]
369
+
370
+ if "-filter_complex" in remaining_tokens:
371
+ index = remaining_tokens.index("-filter_complex")
372
+ filter_complex = remaining_tokens[index + 1]
373
+ filterable_streams = parse_filter_complex(
374
+ filter_complex, input_streams, ffmpeg_filters
375
+ )
376
+ remaining_tokens = remaining_tokens[index + 2 :]
377
+ else:
378
+ filterable_streams = {}
379
+
380
+ output_streams = parse_output(
381
+ remaining_tokens,
382
+ input_streams | filterable_streams,
383
+ ffmpeg_options,
384
+ )
385
+
386
+ # Create a stream with global options
387
+ result = merge_outputs(*output_streams)
388
+ if global_params:
389
+ result = result.global_args(extra_options=global_params)
390
+ return result
391
+
392
+
30
393
  def compile(stream: Stream, auto_fix: bool = True) -> str:
31
394
  """
32
395
  Compile a stream into a command-line string.
@@ -41,7 +404,7 @@ def compile(stream: Stream, auto_fix: bool = True) -> str:
41
404
  Returns:
42
405
  A command-line string that can be passed to FFmpeg
43
406
  """
44
- return command_line(compile_as_list(stream, auto_fix))
407
+ return "ffmpeg " + command_line(compile_as_list(stream, auto_fix))
45
408
 
46
409
 
47
410
  def compile_as_list(stream: Stream, auto_fix: bool = True) -> list[str]:
@@ -50,15 +413,36 @@ def compile_as_list(stream: Stream, auto_fix: bool = True) -> list[str]:
50
413
 
51
414
  This function takes a Stream object representing an FFmpeg filter graph
52
415
  and converts it into a list of command-line arguments that can be passed
53
- to FFmpeg. It processes the graph in the correct order:
54
- 1. Global nodes (general FFmpeg options)
55
- 2. Input nodes (input files and their options)
56
- 3. Filter nodes (combined into a -filter_complex argument)
57
- 4. Output nodes (output files and their options)
416
+ to FFmpeg. The compilation process follows these steps:
417
+
418
+ 1. Validation: The graph is validated to ensure it's properly formed
419
+ - Checks for cycles in the graph
420
+ - Verifies stream types match filter requirements
421
+ - Ensures all streams are properly connected
422
+
423
+ 2. Global Options: Processes global FFmpeg settings
424
+ - Log level, overwrite flags, etc.
425
+ - These affect the entire FFmpeg process
426
+
427
+ 3. Input Files: Handles source media files
428
+ - File paths and input-specific options
429
+ - Stream selection and format options
430
+ - Timestamp and duration settings
431
+
432
+ 4. Filter Graph: Combines all filters into a -filter_complex argument
433
+ - Properly labels all streams
434
+ - Maintains correct filter chain order
435
+ - Handles stream splitting and merging
436
+
437
+ 5. Output Files: Processes destination files
438
+ - File paths and output options
439
+ - Codec and format settings
440
+ - Stream mapping and selection
58
441
 
59
- The function validates the graph before compilation to ensure it's properly
60
- formed. If auto_fix is enabled, it will attempt to fix common issues like
61
- disconnected nodes or invalid stream mappings.
442
+ If auto_fix is enabled, the function will attempt to fix common issues:
443
+ - Reconnecting disconnected nodes
444
+ - Adding missing split filters
445
+ - Fixing stream type mismatches
62
446
 
63
447
  Args:
64
448
  stream: The Stream object to compile into arguments
@@ -214,10 +598,10 @@ def get_args_filter_node(node: FilterNode, context: DAGContext) -> list[str]:
214
598
  outputs = context.get_outgoing_streams(node)
215
599
 
216
600
  outgoing_labels = ""
217
- for output in sorted(outputs, key=lambda stream: stream.index or 0):
601
+ for _output in sorted(outputs, key=lambda stream: stream.index or 0):
218
602
  # NOTE: all outgoing streams must be filterable
219
- assert isinstance(output, FilterableStream)
220
- outgoing_labels += f"[{get_stream_label(output, context)}]"
603
+ assert isinstance(_output, FilterableStream)
604
+ outgoing_labels += f"[{get_stream_label(_output, context)}]"
221
605
 
222
606
  commands = []
223
607
  for key, value in node.kwargs.items():
@@ -301,6 +685,22 @@ def get_args_output_node(node: OutputNode, context: DAGContext) -> list[str]:
301
685
  if context:
302
686
  for input in node.inputs:
303
687
  if isinstance(input.node, InputNode):
688
+ # NOTE: specially rules,
689
+ # if there is only one input node,
690
+ # only one output node,
691
+ # the output node has only one input,
692
+ # and the stream selector is not specified,
693
+ # then the map can be ignore.
694
+ if (
695
+ input.index is None
696
+ and isinstance(input, AVStream)
697
+ and len([k for k in context.all_nodes if isinstance(k, InputNode)])
698
+ == 1
699
+ and len([k for k in context.all_nodes if isinstance(k, OutputNode)])
700
+ == 1
701
+ and len(node.inputs) == 1
702
+ ):
703
+ continue
304
704
  commands += ["-map", get_stream_label(input, context)]
305
705
  else:
306
706
  commands += ["-map", f"[{get_stream_label(input, context)}]"]
@@ -3,10 +3,6 @@ from __future__ import annotations
3
3
  from collections.abc import Mapping
4
4
  from typing import Any
5
5
 
6
- from ffmpeg.streams.audio import AudioStream
7
- from ffmpeg.streams.av import AVStream
8
- from ffmpeg.streams.video import VideoStream
9
-
10
6
  from ..common.cache import load
11
7
  from ..common.schema import FFMpegFilter
12
8
  from ..dag.nodes import (
@@ -19,6 +15,9 @@ from ..dag.nodes import (
19
15
  OutputStream,
20
16
  )
21
17
  from ..dag.schema import Node, Stream
18
+ from ..streams.audio import AudioStream
19
+ from ..streams.av import AVStream
20
+ from ..streams.video import VideoStream
22
21
  from .context import DAGContext
23
22
  from .validate import validate
24
23
 
File without changes
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import types
5
+ from dataclasses import is_dataclass
6
+ from typing import (
7
+ Any,
8
+ TypeGuard,
9
+ TypeVar,
10
+ Union,
11
+ cast,
12
+ get_args,
13
+ get_origin,
14
+ get_type_hints,
15
+ )
16
+
17
+ from .schema import ffprobeType, registered_types
18
+ from .xml2json import xml_string_to_json
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ def _get_actual_type(type_hint: Any) -> type[Any]:
24
+ """
25
+ Get the actual type from a type hint, handling Optional and Union (including | syntax).
26
+
27
+ Args:
28
+ type_hint: The type hint to get the actual type from
29
+
30
+ Returns:
31
+ The actual type
32
+ """
33
+ # If type_hint is a string, evaluate it in the schema's module context
34
+ if isinstance(type_hint, str):
35
+ type_hint = registered_types[type_hint]
36
+
37
+ origin = get_origin(type_hint)
38
+ # Handle typing.Union and types.UnionType (for int | None in Python 3.10+)
39
+ if origin is Union or (hasattr(types, "UnionType") and origin is types.UnionType):
40
+ non_none_types = [t for t in get_args(type_hint) if t is not type(None)]
41
+ if non_none_types:
42
+ return _get_actual_type(non_none_types[0])
43
+ # Handle direct types.UnionType (for int | None in Python 3.10+)
44
+ if hasattr(types, "UnionType") and isinstance(type_hint, types.UnionType):
45
+ non_none_types = [t for t in type_hint.__args__ if t is not type(None)]
46
+ if non_none_types:
47
+ return _get_actual_type(non_none_types[0])
48
+ return type_hint
49
+
50
+
51
+ def is_dataclass_type(obj: type[Any]) -> TypeGuard[type[T]]:
52
+ """
53
+ Check if an object is a dataclass type.
54
+
55
+ Args:
56
+ obj: The object to check
57
+
58
+ Returns:
59
+ True if the object is a dataclass type, False otherwise
60
+ """
61
+ return is_dataclass(obj)
62
+
63
+
64
+ def _parse_obj_from_dict(data: Any, cls: type[T]) -> T | None:
65
+ """
66
+ Parse a dictionary into a dataclass instance.
67
+
68
+ Args:
69
+ data: The dictionary to parse
70
+ cls: The dataclass to parse into
71
+
72
+ Returns:
73
+ The parsed dataclass instance
74
+ """
75
+
76
+ if data is None:
77
+ return None
78
+
79
+ if isinstance(cls, type):
80
+ if cls is str:
81
+ return cast(T, str(data))
82
+ elif cls is int:
83
+ return cast(T, int(data))
84
+ elif cls is float:
85
+ return cast(T, float(data))
86
+ elif cls is bool:
87
+ return cast(T, bool(data))
88
+
89
+ if not isinstance(data, dict):
90
+ return cls()
91
+
92
+ if isinstance(cls, str): # NOTE: python 3.10
93
+ cls = registered_types[cls]
94
+
95
+ type_hints = get_type_hints(cls)
96
+ kwargs: dict[str, Any] = {}
97
+
98
+ for field_name, field_type in type_hints.items():
99
+ actual_type = _get_actual_type(field_type)
100
+
101
+ if get_origin(actual_type) is tuple:
102
+ tuple_args = get_args(actual_type)
103
+ if not tuple_args:
104
+ continue
105
+ item_type = tuple_args[0]
106
+ value = data.get(field_name, [])
107
+ if not isinstance(value, list):
108
+ value = [value]
109
+ kwargs[field_name] = tuple(
110
+ _parse_obj_from_dict(item, item_type) for item in value
111
+ )
112
+ continue
113
+
114
+ kwargs[field_name] = _parse_obj_from_dict(data.get(field_name), actual_type)
115
+
116
+ return cls(**kwargs)
117
+
118
+
119
+ def parse_ffprobe(xml_string: str) -> ffprobeType:
120
+ """
121
+ Parse ffprobe XML output into ffprobeType dataclass using JSON dict.
122
+
123
+ Args:
124
+ xml_string: The XML string to parse
125
+
126
+ Returns:
127
+ The parsed ffprobeType instance
128
+ """
129
+ json_str = xml_string_to_json(xml_string)
130
+ json_dict = json.loads(json_str)
131
+ # The root key is 'ffprobe'
132
+ root_data = json_dict.get("ffprobe", json_dict)
133
+ return _parse_obj_from_dict(root_data, ffprobeType) or ffprobeType()