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.
- typed_ffmpeg/__init__.py +2 -1
- typed_ffmpeg/_version.py +21 -0
- typed_ffmpeg/compile/compile_cli.py +413 -13
- typed_ffmpeg/compile/compile_python.py +3 -4
- typed_ffmpeg/ffprobe/__init__.py +0 -0
- typed_ffmpeg/ffprobe/parse.py +133 -0
- typed_ffmpeg/ffprobe/probe.py +272 -0
- typed_ffmpeg/ffprobe/schema.py +455 -0
- typed_ffmpeg/ffprobe/xml2json.py +70 -0
- {typed_ffmpeg_compatible-3.0.1.dist-info → typed_ffmpeg_compatible-3.2.1.dist-info}/METADATA +36 -17
- typed_ffmpeg_compatible-3.2.1.dist-info/RECORD +57 -0
- {typed_ffmpeg_compatible-3.0.1.dist-info → typed_ffmpeg_compatible-3.2.1.dist-info}/WHEEL +2 -1
- typed_ffmpeg_compatible-3.2.1.dist-info/top_level.txt +1 -0
- typed_ffmpeg/common/cache/.gitignore +0 -3
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/acrossover.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/afir.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/aiir.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/ainterleave.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/amerge.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/amix.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/amovie.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/anequalizer.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/aphasemeter.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/asegment.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/aselect.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/asplit.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/astreamselect.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/bm3d.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/channelsplit.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/concat.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/decimate.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/ebur128.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/extractplanes.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/fieldmatch.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/guided.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/headphone.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/hstack.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/interleave.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/join.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/libplacebo.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/limitdiff.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/mergeplanes.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/mix.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/movie.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/premultiply.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/segment.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/select.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/signature.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/split.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/streamselect.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/unpremultiply.json +0 -6
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/vstack.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/xmedian.json +0 -9
- typed_ffmpeg/common/cache/FFMpegFilterManuallyDefined/xstack.json +0 -9
- typed_ffmpeg/common/cache/list/filters.json +0 -90747
- typed_ffmpeg/common/cache/list/options.json +0 -1694
- typed_ffmpeg/probe.py +0 -75
- typed_ffmpeg_compatible-3.0.1.dist-info/RECORD +0 -95
- typed_ffmpeg_compatible-3.0.1.dist-info/entry_points.txt +0 -3
- {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",
|
typed_ffmpeg/_version.py
ADDED
@@ -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
|
-
|
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.
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
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
|
-
|
60
|
-
|
61
|
-
|
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
|
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(
|
220
|
-
outgoing_labels += f"[{get_stream_label(
|
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()
|