typed-ffmpeg-compatible 3.0.1__py3-none-any.whl → 3.0.2a0__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.
@@ -16,10 +16,30 @@ 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 ffmpeg.dag.factory import filter_node_factory
25
+ from ffmpeg.streams.audio import AudioStream
26
+ from ffmpeg.streams.video import VideoStream
27
+
28
+ from ..base import input, merge_outputs, output
29
+ from ..common.cache import load
30
+ from ..common.schema import FFMpegFilter, FFMpegFilterDef, FFMpegOption, StreamType
31
+ from ..dag.nodes import (
32
+ FilterableStream,
33
+ FilterNode,
34
+ GlobalNode,
35
+ InputNode,
36
+ OutputNode,
37
+ OutputStream,
38
+ )
20
39
  from ..dag.schema import Node, Stream
21
40
  from ..exceptions import FFMpegValueError
22
41
  from ..schema import Default
42
+ from ..streams.av import AVStream
23
43
  from ..utils.escaping import escape
24
44
  from ..utils.lazy_eval.schema import LazyValue
25
45
  from ..utils.run import command_line
@@ -27,6 +47,341 @@ from .context import DAGContext
27
47
  from .validate import validate
28
48
 
29
49
 
50
+ def get_options_dict() -> dict[str, FFMpegOption]:
51
+ options = load(list[FFMpegOption], "options")
52
+ return {option.name: option for option in options}
53
+
54
+
55
+ def get_filter_dict() -> dict[str, FFMpegFilter]:
56
+ filters = load(list[FFMpegFilter], "filters")
57
+ return {filter.name: filter for filter in filters}
58
+
59
+
60
+ def parse_options(tokens: list[str]) -> dict[str, list[str | None | bool]]:
61
+ """
62
+ Parse FFmpeg command-line options into a structured dictionary.
63
+
64
+ This function processes a list of command-line tokens and converts them into
65
+ a dictionary where keys are option names (without the leading '-') and values
66
+ are lists of their corresponding values. Boolean options are handled specially:
67
+ - '-option' becomes {'option': [None]}
68
+ - '-nooption' becomes {'option': [False]}
69
+
70
+ Args:
71
+ tokens: List of command-line tokens to parse
72
+
73
+ Returns:
74
+ Dictionary mapping option names to lists of their values
75
+ """
76
+ parsed_options: dict[str, list[str | None | bool]] = defaultdict(list)
77
+
78
+ while tokens:
79
+ assert tokens[0][0] == "-", f"Expected option, got {tokens[0]}"
80
+ if len(tokens) == 1 or tokens[1][0] == "-":
81
+ if tokens[0].startswith("-no"):
82
+ # Handle boolean options with -no prefix
83
+ option_name = tokens[0][3:]
84
+ parsed_options[option_name] = [False]
85
+ else:
86
+ # Handle boolean options without value
87
+ option_name = tokens[0][1:]
88
+ parsed_options[option_name] = [None]
89
+ tokens = tokens[1:]
90
+ else:
91
+ # Handle options with values
92
+ option_name = tokens[0][1:]
93
+ option_value = tokens[1]
94
+ parsed_options[option_name].append(option_value)
95
+ tokens = tokens[2:]
96
+
97
+ return parsed_options
98
+
99
+
100
+ def parse_stream_selector(
101
+ selector: str, mapping: Mapping[str, FilterableStream]
102
+ ) -> FilterableStream:
103
+ selector = selector.strip("[]")
104
+
105
+ if ":" in selector:
106
+ stream_label, _ = selector.split(":", 1)
107
+ else:
108
+ stream_label = selector
109
+
110
+ assert stream_label in mapping, f"Unknown stream label: {stream_label}"
111
+ stream = mapping[stream_label]
112
+
113
+ if isinstance(stream, AVStream):
114
+ if selector.count(":") == 1:
115
+ stream_label, stream_type = selector.split(":", 1)
116
+ return stream.video if stream_type == "v" else stream.audio
117
+ elif selector.count(":") == 2:
118
+ stream_label, stream_type, stream_index = selector.split(":", 2)
119
+ return (
120
+ stream.video_stream(int(stream_index))
121
+ if stream_type == "v"
122
+ else stream.audio_stream(int(stream_index))
123
+ )
124
+ else:
125
+ return stream
126
+ else:
127
+ return stream
128
+
129
+
130
+ def parse_output(
131
+ source: list[str],
132
+ in_streams: Mapping[str, FilterableStream],
133
+ ffmpeg_options: dict[str, FFMpegOption],
134
+ ) -> list[OutputStream]:
135
+ tokens = source.copy()
136
+
137
+ export: list[OutputStream] = []
138
+
139
+ buffer: list[str] = []
140
+ while tokens:
141
+ token = tokens.pop(0)
142
+ if token.startswith("-") or len(buffer) % 2 == 1:
143
+ buffer.append(token)
144
+ continue
145
+
146
+ filename = token
147
+ options = parse_options(buffer)
148
+
149
+ map_options = options.pop("map", [])
150
+ inputs: list[FilterableStream] = []
151
+ for map_option in map_options:
152
+ assert isinstance(map_option, str), f"Expected map option, got {map_option}"
153
+ stream = parse_stream_selector(map_option, in_streams)
154
+ inputs.append(stream)
155
+
156
+ assert inputs, f"No inputs found for output {filename}"
157
+ export.append(output(*inputs, filename=filename, extra_options=options))
158
+ buffer = []
159
+
160
+ return export
161
+
162
+
163
+ def parse_input(
164
+ tokens: list[str], ffmpeg_options: dict[str, FFMpegOption]
165
+ ) -> dict[str, FilterableStream]:
166
+ output: list[AVStream] = []
167
+
168
+ while "-i" in tokens:
169
+ index = tokens.index("-i")
170
+ filename = tokens[index + 1]
171
+ assert filename[0] != "-", f"Expected filename, got {filename}"
172
+
173
+ input_options_args = tokens[:index]
174
+
175
+ options = parse_options(input_options_args)
176
+ parameters: dict[str, str | bool] = {}
177
+
178
+ for key, value in options.items():
179
+ assert key in ffmpeg_options, f"Unknown option: {key}"
180
+ option = ffmpeg_options[key]
181
+
182
+ if option.is_input_option:
183
+ # just ignore not input options
184
+ if value[-1] is None:
185
+ parameters[key] = True
186
+ else:
187
+ parameters[key] = value[-1]
188
+
189
+ output.append(input(filename=filename, extra_options=parameters))
190
+
191
+ tokens = tokens[index + 2 :]
192
+
193
+ return {str(idx): stream for idx, stream in enumerate(output)}
194
+
195
+
196
+ def parse_filter_complex(
197
+ filter_complex: str,
198
+ stream_mapping: dict[str, FilterableStream],
199
+ ffmpeg_filters: dict[str, FFMpegFilter],
200
+ ) -> dict[str, FilterableStream]:
201
+ """
202
+ Parse an FFmpeg filter_complex string into a stream mapping.
203
+
204
+ This function processes a filter_complex string (e.g. "[0:v]scale=1280:720[v0]")
205
+ and converts it into a mapping of stream labels to their corresponding
206
+ FilterableStream objects. It handles:
207
+ - Input stream references (e.g. [0:v])
208
+ - Filter definitions with parameters
209
+ - Output stream labels (e.g. [v0])
210
+
211
+ Args:
212
+ filter_complex: The filter_complex string to parse
213
+ stream_mapping: Existing mapping of stream labels to streams
214
+ ffmpeg_filters: Dictionary of available FFmpeg filters
215
+
216
+ Returns:
217
+ Updated stream mapping with new filter outputs added
218
+ """
219
+ filter_units = filter_complex.split(";")
220
+
221
+ for filter_unit in filter_units:
222
+ pattern = re.compile(
223
+ r"""
224
+ (?P<inputs>(\[[^\[\]]+\])*) # inputs: zero or more [label]
225
+ (?P<filter>[a-zA-Z0-9_]+) # filter name
226
+ (=?(?P<params>[^[]+?))? # optional =params (until next [ or end)
227
+ (?P<outputs>(\[[^\[\]]+\])*)$ # outputs: zero or more [label] at end
228
+ """,
229
+ re.VERBOSE,
230
+ )
231
+
232
+ match = pattern.match(filter_unit)
233
+ assert match, f"Invalid filter unit: {filter_unit}"
234
+
235
+ def extract_labels(label_str: str) -> list[str]:
236
+ return re.findall(r"\[([^\[\]]+)\]", label_str)
237
+
238
+ input_labels = extract_labels(match.group("inputs") or "")
239
+ output_labels = extract_labels(match.group("outputs") or "")
240
+ filter_name = match.group("filter")
241
+ param_str = match.group("params")
242
+
243
+ # Parse filter parameters into key-value pairs
244
+ filter_params = {}
245
+ if param_str:
246
+ param_parts = param_str.strip().split(":")
247
+ for part in param_parts:
248
+ if "=" in part:
249
+ key, value = part.split("=", 1)
250
+ filter_params[key.strip()] = value.strip()
251
+
252
+ assert isinstance(filter_name, str), f"Expected filter name, got {filter_name}"
253
+ ffmpeg_filter = ffmpeg_filters[filter_name]
254
+ filter_def = FFMpegFilterDef(
255
+ name=ffmpeg_filter.name,
256
+ typings_input=ffmpeg_filter.formula_typings_input
257
+ or tuple(k.type.value for k in ffmpeg_filter.stream_typings_input),
258
+ typings_output=ffmpeg_filter.formula_typings_output
259
+ or tuple(k.type.value for k in ffmpeg_filter.stream_typings_output),
260
+ )
261
+ input_streams = [
262
+ parse_stream_selector(label, stream_mapping) for label in input_labels
263
+ ]
264
+
265
+ # Create the filter node with default options and parsed parameters
266
+ filter_node = filter_node_factory(
267
+ filter_def,
268
+ *input_streams,
269
+ **(
270
+ {k.name: Default(k.default) for k in ffmpeg_filter.options}
271
+ | filter_params
272
+ ),
273
+ )
274
+
275
+ # Map output streams to their labels
276
+ for idx, (output_label, output_typing) in enumerate(
277
+ zip(output_labels, filter_node.output_typings)
278
+ ):
279
+ if output_typing == StreamType.video:
280
+ stream_mapping[output_label] = VideoStream(node=filter_node, index=idx)
281
+ elif output_typing == StreamType.audio:
282
+ stream_mapping[output_label] = AudioStream(node=filter_node, index=idx)
283
+ else:
284
+ raise FFMpegValueError(f"Unknown stream type: {output_typing}")
285
+
286
+ return stream_mapping
287
+
288
+
289
+ def parse_global(
290
+ tokens: list[str], ffmpeg_options: dict[str, FFMpegOption]
291
+ ) -> tuple[dict[str, str | bool], list[str]]:
292
+ """
293
+ Parse global FFmpeg options from command-line tokens.
294
+
295
+ This function processes the global options that appear before any input files
296
+ in the FFmpeg command line. These options affect the entire FFmpeg process,
297
+ such as log level, overwrite flags, etc.
298
+
299
+ Args:
300
+ tokens: List of command-line tokens to parse
301
+ ffmpeg_options: Dictionary of valid FFmpeg options
302
+
303
+ Returns:
304
+ A tuple containing:
305
+ - Dictionary of parsed global options and their values
306
+ - Remaining tokens after global options are consumed
307
+
308
+ Example:
309
+ For tokens like ['-y', '-loglevel', 'quiet', '-i', 'input.mp4']:
310
+ Returns ({'y': True, 'loglevel': 'quiet'}, ['-i', 'input.mp4'])
311
+ """
312
+ global_params: dict[str, str | bool] = {}
313
+ remaining_tokens = tokens.copy()
314
+
315
+ # Process tokens until we hit an input file marker (-i)
316
+ while remaining_tokens and remaining_tokens[0] != "-i":
317
+ if remaining_tokens[0].startswith("-"):
318
+ option_name = remaining_tokens[0][1:]
319
+ assert option_name in ffmpeg_options, (
320
+ f"Unknown global option: {option_name}"
321
+ )
322
+ option = ffmpeg_options[option_name]
323
+
324
+ if not option.is_global_option:
325
+ continue
326
+
327
+ if len(remaining_tokens) > 1 and not remaining_tokens[1].startswith("-"):
328
+ # Option with value
329
+ global_params[option_name] = remaining_tokens[1]
330
+ remaining_tokens = remaining_tokens[2:]
331
+ else:
332
+ # Boolean option
333
+ if option_name.startswith("no"):
334
+ global_params[option_name[2:]] = False
335
+ else:
336
+ global_params[option_name] = True
337
+ remaining_tokens = remaining_tokens[1:]
338
+ else:
339
+ # Skip non-option tokens
340
+ remaining_tokens = remaining_tokens[1:]
341
+
342
+ return global_params, remaining_tokens
343
+
344
+
345
+ def parse(cli: str) -> Stream:
346
+ # ffmpeg [global_options] {[input_file_options] -i input_url} ... {[output_file_options] output_url} ...
347
+ ffmpeg_options = get_options_dict()
348
+ ffmpeg_filters = get_filter_dict()
349
+
350
+ tokens = shlex.split(cli)
351
+ assert tokens[0] == "ffmpeg"
352
+ tokens = tokens[1:]
353
+
354
+ # Parse global options first
355
+ global_params, remaining_tokens = parse_global(tokens, ffmpeg_options)
356
+
357
+ # find the index of the last -i option
358
+ index = len(remaining_tokens) - 1 - list(reversed(remaining_tokens)).index("-i")
359
+ input_streams = parse_input(remaining_tokens[: index + 2], ffmpeg_options)
360
+ remaining_tokens = remaining_tokens[index + 2 :]
361
+
362
+ if "-filter_complex" in remaining_tokens:
363
+ index = remaining_tokens.index("-filter_complex")
364
+ filter_complex = remaining_tokens[index + 1]
365
+ filterable_streams = parse_filter_complex(
366
+ filter_complex, input_streams, ffmpeg_filters
367
+ )
368
+ remaining_tokens = remaining_tokens[index + 2 :]
369
+ else:
370
+ filterable_streams = {}
371
+
372
+ output_streams = parse_output(
373
+ remaining_tokens,
374
+ input_streams | filterable_streams,
375
+ ffmpeg_options,
376
+ )
377
+
378
+ # Create a stream with global options
379
+ result = merge_outputs(*output_streams)
380
+ if global_params:
381
+ result = result.global_args(extra_options=global_params)
382
+ return result
383
+
384
+
30
385
  def compile(stream: Stream, auto_fix: bool = True) -> str:
31
386
  """
32
387
  Compile a stream into a command-line string.
@@ -41,7 +396,7 @@ def compile(stream: Stream, auto_fix: bool = True) -> str:
41
396
  Returns:
42
397
  A command-line string that can be passed to FFmpeg
43
398
  """
44
- return command_line(compile_as_list(stream, auto_fix))
399
+ return "ffmpeg " + command_line(compile_as_list(stream, auto_fix))
45
400
 
46
401
 
47
402
  def compile_as_list(stream: Stream, auto_fix: bool = True) -> list[str]:
@@ -50,15 +405,36 @@ def compile_as_list(stream: Stream, auto_fix: bool = True) -> list[str]:
50
405
 
51
406
  This function takes a Stream object representing an FFmpeg filter graph
52
407
  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)
408
+ to FFmpeg. The compilation process follows these steps:
409
+
410
+ 1. Validation: The graph is validated to ensure it's properly formed
411
+ - Checks for cycles in the graph
412
+ - Verifies stream types match filter requirements
413
+ - Ensures all streams are properly connected
414
+
415
+ 2. Global Options: Processes global FFmpeg settings
416
+ - Log level, overwrite flags, etc.
417
+ - These affect the entire FFmpeg process
418
+
419
+ 3. Input Files: Handles source media files
420
+ - File paths and input-specific options
421
+ - Stream selection and format options
422
+ - Timestamp and duration settings
423
+
424
+ 4. Filter Graph: Combines all filters into a -filter_complex argument
425
+ - Properly labels all streams
426
+ - Maintains correct filter chain order
427
+ - Handles stream splitting and merging
428
+
429
+ 5. Output Files: Processes destination files
430
+ - File paths and output options
431
+ - Codec and format settings
432
+ - Stream mapping and selection
58
433
 
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.
434
+ If auto_fix is enabled, the function will attempt to fix common issues:
435
+ - Reconnecting disconnected nodes
436
+ - Adding missing split filters
437
+ - Fixing stream type mismatches
62
438
 
63
439
  Args:
64
440
  stream: The Stream object to compile into arguments
@@ -214,10 +590,10 @@ def get_args_filter_node(node: FilterNode, context: DAGContext) -> list[str]:
214
590
  outputs = context.get_outgoing_streams(node)
215
591
 
216
592
  outgoing_labels = ""
217
- for output in sorted(outputs, key=lambda stream: stream.index or 0):
593
+ for _output in sorted(outputs, key=lambda stream: stream.index or 0):
218
594
  # NOTE: all outgoing streams must be filterable
219
- assert isinstance(output, FilterableStream)
220
- outgoing_labels += f"[{get_stream_label(output, context)}]"
595
+ assert isinstance(_output, FilterableStream)
596
+ outgoing_labels += f"[{get_stream_label(_output, context)}]"
221
597
 
222
598
  commands = []
223
599
  for key, value in node.kwargs.items():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: typed-ffmpeg-compatible
3
- Version: 3.0.1
3
+ Version: 3.0.2a0
4
4
  Summary: Modern Python FFmpeg wrappers offer comprehensive support for complex filters, complete with detailed typing and documentation.
5
5
  Home-page: https://livingbio.github.io/typed-ffmpeg/
6
6
  License: MIT
@@ -48,7 +48,7 @@ typed_ffmpeg/common/cache.py,sha256=j0JvfX7jewLpdJWxgo7Pwze0BkUJdYGHX2uGR8BZ-9M,
48
48
  typed_ffmpeg/common/schema.py,sha256=qM8yfMX9UU3EAQSNsTrr-SAmyqKx8eQCXTtu3RJWkEk,19673
49
49
  typed_ffmpeg/common/serialize.py,sha256=dLim0DBP5CdJ1JiMV9xEmmh1XMSIhBOWs61EopAL15s,7719
50
50
  typed_ffmpeg/compile/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
- typed_ffmpeg/compile/compile_cli.py,sha256=qwcBtl4-ha7rYAY6dWoM12ngbP8i6QzqwtrQrL3mqC0,14826
51
+ typed_ffmpeg/compile/compile_cli.py,sha256=ZRH3Kd4dJ2hcoXrTzsJqg1UJlCnUVXkQWjRARZhUJWE,27965
52
52
  typed_ffmpeg/compile/compile_json.py,sha256=YCiTyfAnUVSbFr7BiQpmJYs13K5sa-xo77Iih33mb6I,992
53
53
  typed_ffmpeg/compile/compile_python.py,sha256=rsoF4spI3cq6uMHU3iN-j8QByj2yE5pEqjatWCaf0D4,11522
54
54
  typed_ffmpeg/compile/context.py,sha256=macQ3HhEJ73j_WbWYtU9GCQCzcB_KQGAPimcuU-WOac,10946
@@ -88,8 +88,8 @@ typed_ffmpeg/utils/run.py,sha256=mSoAdcvD-InldqkRgWNc8iXKgJJoEMAOE4PL2gVmtqw,217
88
88
  typed_ffmpeg/utils/snapshot.py,sha256=mKILRm6qiQV2egaD-70MSUEl-DFoLD5w_v9GZIequI4,2181
89
89
  typed_ffmpeg/utils/typing.py,sha256=DBQn_gCF8C_DTwsfMHeCgfnNUROwAjlIcHrQ7lNDOoE,1187
90
90
  typed_ffmpeg/utils/view.py,sha256=QCSlQoQkRBI-T0sWjiywGgM9DlKd8Te3CB2ZYX-pEVU,3413
91
- typed_ffmpeg_compatible-3.0.1.dist-info/LICENSE,sha256=8Aaya5i_09Cou2i3QMxTwz6uHGzi_fGA4uhkco07-A4,1066
92
- typed_ffmpeg_compatible-3.0.1.dist-info/METADATA,sha256=LfhzfDCrQibFfmOQwWYOh1oPPbkA8LBPkJjvQW_Vmag,7249
93
- typed_ffmpeg_compatible-3.0.1.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
94
- typed_ffmpeg_compatible-3.0.1.dist-info/entry_points.txt,sha256=KfZmNsM16GT_lF1otASIN6E3i6xXHXoB1gMeEdlptjA,44
95
- typed_ffmpeg_compatible-3.0.1.dist-info/RECORD,,
91
+ typed_ffmpeg_compatible-3.0.2a0.dist-info/LICENSE,sha256=8Aaya5i_09Cou2i3QMxTwz6uHGzi_fGA4uhkco07-A4,1066
92
+ typed_ffmpeg_compatible-3.0.2a0.dist-info/METADATA,sha256=eyXAjyXqnkCUsGoDc4EvOYPpx-3R8GsXKIbMuZpxnbU,7251
93
+ typed_ffmpeg_compatible-3.0.2a0.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
94
+ typed_ffmpeg_compatible-3.0.2a0.dist-info/entry_points.txt,sha256=KfZmNsM16GT_lF1otASIN6E3i6xXHXoB1gMeEdlptjA,44
95
+ typed_ffmpeg_compatible-3.0.2a0.dist-info/RECORD,,