deffcode 0.2.7__py3-none-any.whl → 0.2.9__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.
- deffcode/ffdecoder.py +133 -30
- deffcode/ffhelper.py +68 -16
- deffcode/sourcer.py +56 -31
- deffcode/version.py +1 -1
- {deffcode-0.2.7.dist-info → deffcode-0.2.9.dist-info}/METADATA +26 -14
- deffcode-0.2.9.dist-info/RECORD +11 -0
- {deffcode-0.2.7.dist-info → deffcode-0.2.9.dist-info}/WHEEL +1 -1
- deffcode-0.2.7.dist-info/RECORD +0 -11
- {deffcode-0.2.7.dist-info → deffcode-0.2.9.dist-info}/licenses/LICENSE +0 -0
- {deffcode-0.2.7.dist-info → deffcode-0.2.9.dist-info}/top_level.txt +0 -0
deffcode/ffdecoder.py
CHANGED
|
@@ -26,16 +26,19 @@ import platform
|
|
|
26
26
|
import queue
|
|
27
27
|
import re
|
|
28
28
|
import subprocess as sp
|
|
29
|
+
import sys
|
|
29
30
|
import threading
|
|
30
|
-
from collections import OrderedDict
|
|
31
|
+
from collections import OrderedDict, deque
|
|
31
32
|
from collections.abc import Generator
|
|
33
|
+
from itertools import pairwise
|
|
32
34
|
from types import TracebackType
|
|
33
|
-
from typing import Any
|
|
35
|
+
from typing import Any, Self
|
|
34
36
|
|
|
35
37
|
import numpy as np
|
|
36
38
|
|
|
37
39
|
# import utils packages
|
|
38
40
|
from .ffhelper import (
|
|
41
|
+
TERMINATE_TIMEOUT_SUBPROCESS,
|
|
39
42
|
get_supported_pixfmts,
|
|
40
43
|
get_supported_vdecoders,
|
|
41
44
|
)
|
|
@@ -207,6 +210,15 @@ class FFdecoder:
|
|
|
207
210
|
self.__ffmpeg_prefixes = []
|
|
208
211
|
sourcer_params["-ffprefixes"] = self.__ffmpeg_prefixes
|
|
209
212
|
|
|
213
|
+
# handles user-defined additional FFmpeg outputs, appended after the rawvideo pipe output (must be a flat list)
|
|
214
|
+
self.__extra_outputs = self.__extra_params.pop("-extra_outputs", [])
|
|
215
|
+
if not isinstance(self.__extra_outputs, list) or any(
|
|
216
|
+
isinstance(x, (list, tuple, dict)) for x in self.__extra_outputs
|
|
217
|
+
):
|
|
218
|
+
logger.warning("Discarding invalid `-extra_outputs` value! It must be a flat list.")
|
|
219
|
+
self.__extra_outputs = []
|
|
220
|
+
self.__extra_outputs = [str(x) for x in self.__extra_outputs]
|
|
221
|
+
|
|
210
222
|
# pass parameter(if specified) to Sourcer API, specifying where to save the downloaded FFmpeg Static
|
|
211
223
|
# assets on Windows(if specified)
|
|
212
224
|
sourcer_params["-ffmpeg_download_path"] = self.__extra_params.pop(
|
|
@@ -226,6 +238,20 @@ class FFdecoder:
|
|
|
226
238
|
if {"-vf", "-filter_complex"}.intersection(self.__extra_params.keys()):
|
|
227
239
|
key = "-vf" if "-vf" in self.__extra_params else "-filter_complex"
|
|
228
240
|
sourcer_params[key] = self.__extra_params[key]
|
|
241
|
+
# multi-input output streams are selected via `-map`, so probe with it too
|
|
242
|
+
if self.__is_multi and "-map" in self.__extra_params:
|
|
243
|
+
sourcer_params["-map"] = self.__extra_params["-map"]
|
|
244
|
+
# filtergraph labels consumed only by `-extra_outputs` must also be consumed while
|
|
245
|
+
# probing, otherwise FFmpeg aborts with "unconnected output"; map all into the probe sink.
|
|
246
|
+
if self.__extra_outputs and "-filter_complex" in sourcer_params:
|
|
247
|
+
if "-map" in self.__extra_params:
|
|
248
|
+
sourcer_params["-map"] = self.__extra_params["-map"]
|
|
249
|
+
sourcer_params["-clones"] = [
|
|
250
|
+
arg
|
|
251
|
+
for key, val in pairwise(self.__extra_outputs)
|
|
252
|
+
if key == "-map" and val.startswith("[")
|
|
253
|
+
for arg in (key, val)
|
|
254
|
+
]
|
|
229
255
|
|
|
230
256
|
# define dict to store user-defined parameters
|
|
231
257
|
self.__user_metadata = {}
|
|
@@ -277,6 +303,13 @@ class FFdecoder:
|
|
|
277
303
|
"Enabling asynchronous `showinfo` per-frame metadata extraction."
|
|
278
304
|
)
|
|
279
305
|
|
|
306
|
+
# handle capturing FFmpeg stderr tail for diagnosing pipeline failures
|
|
307
|
+
self.__capture_stderr = self.__extra_params.pop("-capture_stderr", False)
|
|
308
|
+
if not isinstance(self.__capture_stderr, bool):
|
|
309
|
+
self.__capture_stderr = False
|
|
310
|
+
self.__stderr_tail: deque[str] = deque(maxlen=50)
|
|
311
|
+
self.__returncode: int | None = None
|
|
312
|
+
|
|
280
313
|
# handle disabling window for ffmpeg subprocess on Windows OS
|
|
281
314
|
# this patch prevents ffmpeg creation window from opening when
|
|
282
315
|
# building exe files
|
|
@@ -364,7 +397,7 @@ class FFdecoder:
|
|
|
364
397
|
# reset improper values
|
|
365
398
|
self.__custom_resolution = None
|
|
366
399
|
|
|
367
|
-
def formulate(self) ->
|
|
400
|
+
def formulate(self) -> Self:
|
|
368
401
|
"""
|
|
369
402
|
This method formulates all necessary FFmpeg pipeline arguments and executes it inside the FFmpeg `subprocess` pipe.
|
|
370
403
|
|
|
@@ -662,7 +695,9 @@ class FFdecoder:
|
|
|
662
695
|
if "-frames:v" in input_params:
|
|
663
696
|
self.__raw_frame_num = input_params["-frames:v"]
|
|
664
697
|
elif (
|
|
665
|
-
|
|
698
|
+
# composite output length is unrelated to the primary source's length
|
|
699
|
+
not self.__is_multi
|
|
700
|
+
and self.__sourcer_metadata["approx_video_nframes"] is not None
|
|
666
701
|
and self.__sourcer_metadata["approx_video_nframes"] > 0
|
|
667
702
|
):
|
|
668
703
|
self.__raw_frame_num = self.__sourcer_metadata["approx_video_nframes"]
|
|
@@ -778,16 +813,39 @@ class FFdecoder:
|
|
|
778
813
|
while not self.__terminate_stream: # infinite raw frames
|
|
779
814
|
frame = self.__fetchNextFrame()
|
|
780
815
|
if frame is None:
|
|
781
|
-
self.
|
|
816
|
+
self.__handle_stream_end()
|
|
782
817
|
break
|
|
783
818
|
yield self.__attach_metadata(frame)
|
|
784
819
|
else:
|
|
785
820
|
for _ in range(self.__raw_frame_num): # finite raw frames
|
|
786
821
|
frame = self.__fetchNextFrame()
|
|
787
822
|
if frame is None:
|
|
788
|
-
self.
|
|
823
|
+
self.__handle_stream_end()
|
|
789
824
|
break
|
|
790
825
|
yield self.__attach_metadata(frame)
|
|
826
|
+
# generator exhausted, FFmpeg may still be flushing/exiting cleanly
|
|
827
|
+
self.__terminate_stream = True
|
|
828
|
+
|
|
829
|
+
def __handle_stream_end(self) -> None:
|
|
830
|
+
"""
|
|
831
|
+
Internal: marks stream as ended on a short read and, when `-capture_stderr`
|
|
832
|
+
is enabled and FFmpeg exited with a non-zero code, logs the `stderr_tail`.
|
|
833
|
+
"""
|
|
834
|
+
self.__terminate_stream = True
|
|
835
|
+
if not self.__capture_stderr or self.__process is None:
|
|
836
|
+
return
|
|
837
|
+
try:
|
|
838
|
+
retcode = self.__process.wait(timeout=TERMINATE_TIMEOUT_SUBPROCESS)
|
|
839
|
+
except sp.TimeoutExpired:
|
|
840
|
+
return
|
|
841
|
+
if retcode:
|
|
842
|
+
# let the reader thread drain remaining stderr lines first
|
|
843
|
+
self.__stderr_thread and self.__stderr_thread.join(timeout=TERMINATE_TIMEOUT_SUBPROCESS)
|
|
844
|
+
logger.error(
|
|
845
|
+
"FFmpeg exited with code {}. Last stderr lines:\n{}".format(
|
|
846
|
+
retcode, "\n".join(self.__stderr_tail)
|
|
847
|
+
)
|
|
848
|
+
)
|
|
791
849
|
|
|
792
850
|
def __attach_metadata(self, frame: np.ndarray):
|
|
793
851
|
"""
|
|
@@ -825,6 +883,25 @@ class FFdecoder:
|
|
|
825
883
|
"""
|
|
826
884
|
self.terminate()
|
|
827
885
|
|
|
886
|
+
@property
|
|
887
|
+
def returncode(self) -> int | None:
|
|
888
|
+
"""
|
|
889
|
+
A property object that holds FFmpeg process exit code, set by `terminate()`.
|
|
890
|
+
|
|
891
|
+
**Returns:** FFmpeg exit code, or `None` if pipeline has not been terminated yet.
|
|
892
|
+
"""
|
|
893
|
+
return self.__returncode
|
|
894
|
+
|
|
895
|
+
@property
|
|
896
|
+
def stderr_tail(self) -> list[str]:
|
|
897
|
+
"""
|
|
898
|
+
A property object that holds the last 50 lines of FFmpeg's stderr output.
|
|
899
|
+
Only populated when `-capture_stderr` (or `-extract_metadata`) is enabled.
|
|
900
|
+
|
|
901
|
+
**Returns:** List of stderr lines (oldest first).
|
|
902
|
+
"""
|
|
903
|
+
return list(self.__stderr_tail)
|
|
904
|
+
|
|
828
905
|
@property
|
|
829
906
|
def metadata(self) -> str:
|
|
830
907
|
"""
|
|
@@ -903,8 +980,10 @@ class FFdecoder:
|
|
|
903
980
|
else:
|
|
904
981
|
# otherwise discard and log it
|
|
905
982
|
logger.warning(
|
|
906
|
-
"Manually assigned `{}` metadata property value is of invalid type. Discarding!"
|
|
907
|
-
|
|
983
|
+
"Manually assigned `{}` metadata property value is of invalid type. Discarding!".format(
|
|
984
|
+
key
|
|
985
|
+
)
|
|
986
|
+
)
|
|
908
987
|
# delete invalid key
|
|
909
988
|
del value[key]
|
|
910
989
|
# There is no concept of a tuple in the JSON format.
|
|
@@ -956,7 +1035,7 @@ class FFdecoder:
|
|
|
956
1035
|
)
|
|
957
1036
|
if _src_meta.get("source_demuxer"):
|
|
958
1037
|
cmd += ["-f", _src_meta["source_demuxer"]]
|
|
959
|
-
cmd += ["-i", _src]
|
|
1038
|
+
cmd += ["-i", _src_meta.get("source", _src)]
|
|
960
1039
|
else:
|
|
961
1040
|
cmd += self.__ffmpeg_prefixes
|
|
962
1041
|
cmd += input_parameters
|
|
@@ -969,11 +1048,12 @@ class FFdecoder:
|
|
|
969
1048
|
|
|
970
1049
|
cmd += output_parameters
|
|
971
1050
|
cmd += ["-f", "rawvideo", "-"]
|
|
972
|
-
|
|
973
|
-
#
|
|
974
|
-
#
|
|
1051
|
+
cmd += self.__extra_outputs
|
|
1052
|
+
# When metadata extraction or stderr capture is enabled we must pipe stderr
|
|
1053
|
+
# regardless of verbose mode so the background reader thread can consume
|
|
1054
|
+
# it. Without PIPE the reader would have nothing to read (verbose
|
|
975
1055
|
# inherits parent stderr; silent discards it).
|
|
976
|
-
if self.__extract_metadata:
|
|
1056
|
+
if self.__extract_metadata or self.__capture_stderr:
|
|
977
1057
|
stderr_target = sp.PIPE
|
|
978
1058
|
elif self.__verbose_logs:
|
|
979
1059
|
stderr_target = None
|
|
@@ -996,18 +1076,20 @@ class FFdecoder:
|
|
|
996
1076
|
),
|
|
997
1077
|
)
|
|
998
1078
|
|
|
999
|
-
# spin up the stderr reader thread that
|
|
1000
|
-
#
|
|
1001
|
-
if self.__extract_metadata:
|
|
1002
|
-
self.
|
|
1079
|
+
# spin up the stderr reader thread that records stderr tail, and parses
|
|
1080
|
+
# `showinfo` lines into the metadata queue consumed by generateFrame()
|
|
1081
|
+
if self.__extract_metadata or self.__capture_stderr:
|
|
1082
|
+
if self.__extract_metadata:
|
|
1083
|
+
self.__metadata_queue = queue.Queue()
|
|
1003
1084
|
self.__stderr_stop.clear()
|
|
1004
1085
|
self.__stderr_thread = threading.Thread(target=self.__read_stderr, daemon=True)
|
|
1005
1086
|
self.__stderr_thread.start()
|
|
1006
1087
|
|
|
1007
1088
|
def __read_stderr(self) -> None:
|
|
1008
1089
|
"""
|
|
1009
|
-
Internal: background daemon that
|
|
1010
|
-
|
|
1090
|
+
Internal: background daemon that appends FFmpeg stderr lines to `__stderr_tail`
|
|
1091
|
+
(echoing them when verbose), and parses `showinfo` lines into per-frame metadata
|
|
1092
|
+
dicts on `__metadata_queue` when `-extract_metadata` is enabled.
|
|
1011
1093
|
Exits when FFmpeg closes stderr or when `__stderr_stop` is signalled.
|
|
1012
1094
|
"""
|
|
1013
1095
|
assert self.__process is not None and self.__process.stderr is not None
|
|
@@ -1017,6 +1099,11 @@ class FFdecoder:
|
|
|
1017
1099
|
if self.__stderr_stop.is_set():
|
|
1018
1100
|
break
|
|
1019
1101
|
decoded = line.decode("utf-8", errors="ignore")
|
|
1102
|
+
self.__stderr_tail.append(decoded.rstrip())
|
|
1103
|
+
# echo so verbose console output stays unchanged
|
|
1104
|
+
self.__verbose_logs and sys.stderr.write(decoded)
|
|
1105
|
+
if not self.__extract_metadata:
|
|
1106
|
+
continue
|
|
1020
1107
|
match = _SHOWINFO_REGEX.search(decoded)
|
|
1021
1108
|
if not match:
|
|
1022
1109
|
continue
|
|
@@ -1032,35 +1119,51 @@ class FFdecoder:
|
|
|
1032
1119
|
pass
|
|
1033
1120
|
finally:
|
|
1034
1121
|
# sentinel so consumers unblock on EOF
|
|
1035
|
-
self.__metadata_queue.put(None)
|
|
1122
|
+
self.__metadata_queue is not None and self.__metadata_queue.put(None)
|
|
1036
1123
|
|
|
1037
|
-
def terminate(self) -> None:
|
|
1124
|
+
def terminate(self) -> int | None:
|
|
1038
1125
|
"""
|
|
1039
1126
|
Safely terminates all processes.
|
|
1127
|
+
|
|
1128
|
+
**Returns:** FFmpeg process exit code (also available via `returncode`), or `None` if no process was launched.
|
|
1040
1129
|
"""
|
|
1041
1130
|
|
|
1042
1131
|
# signal we are closing
|
|
1043
1132
|
self.__verbose_logs and logger.debug("Terminating FFdecoder Pipeline...")
|
|
1133
|
+
stream_exhausted = self.__terminate_stream
|
|
1044
1134
|
self.__terminate_stream = True
|
|
1045
1135
|
self.__stderr_stop.set()
|
|
1046
1136
|
# check if no process was initiated at first place
|
|
1047
|
-
if self.__process is None
|
|
1137
|
+
if self.__process is None:
|
|
1048
1138
|
logger.info("Pipeline already terminated.")
|
|
1049
|
-
return
|
|
1139
|
+
return self.__returncode
|
|
1050
1140
|
# Attempt to close pipeline.
|
|
1051
1141
|
# close `stdin` output
|
|
1052
1142
|
self.__process.stdin and self.__process.stdin.close()
|
|
1053
1143
|
# close `stdout` output
|
|
1054
1144
|
self.__process.stdout and self.__process.stdout.close()
|
|
1055
|
-
#
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1145
|
+
# let FFmpeg finalize `-extra_outputs` (trailers/moov) on EPIPE, or exit cleanly
|
|
1146
|
+
# after stream end, before forcing termination
|
|
1147
|
+
if self.__extra_outputs or stream_exhausted:
|
|
1148
|
+
try:
|
|
1149
|
+
self.__process.wait(TERMINATE_TIMEOUT_SUBPROCESS)
|
|
1150
|
+
except sp.TimeoutExpired:
|
|
1151
|
+
pass
|
|
1152
|
+
# terminate process if still processing, escalate to kill if unresponsive
|
|
1153
|
+
if self.__process.poll() is None:
|
|
1154
|
+
self.__process.terminate()
|
|
1155
|
+
try:
|
|
1156
|
+
self.__process.wait(TERMINATE_TIMEOUT_SUBPROCESS)
|
|
1157
|
+
except sp.TimeoutExpired:
|
|
1158
|
+
self.__process.kill()
|
|
1159
|
+
self.__process.wait()
|
|
1160
|
+
# join the stderr reader thread (it hits EOF once FFmpeg exits) so it does not outlive
|
|
1161
|
+
# the pipeline, then close `stderr`; closing earlier blocks on the reader's buffer lock
|
|
1062
1162
|
if self.__stderr_thread is not None and self.__stderr_thread.is_alive():
|
|
1063
1163
|
self.__stderr_thread.join(timeout=2.0)
|
|
1064
1164
|
self.__stderr_thread = None
|
|
1165
|
+
self.__process.stderr and self.__process.stderr.close()
|
|
1166
|
+
self.__returncode = self.__process.returncode
|
|
1065
1167
|
self.__process = None
|
|
1066
1168
|
logger.info("Pipeline terminated successfully.")
|
|
1169
|
+
return self.__returncode
|
deffcode/ffhelper.py
CHANGED
|
@@ -42,8 +42,14 @@ logger.propagate = False
|
|
|
42
42
|
logger.addHandler(logger_handler())
|
|
43
43
|
logger.setLevel(logging.DEBUG)
|
|
44
44
|
|
|
45
|
+
# set default timeout for subprocesses
|
|
46
|
+
MAX_TIMEOUT_SUBPROCESS: float = float(os.getenv("MAX_TIMEOUT_SUBPROCESS", 10.0))
|
|
47
|
+
# set grace period (seconds) between SIGTERM and SIGKILL when terminating
|
|
48
|
+
# a timed-out subprocess. Allows FFmpeg to flush buffers and release hardware
|
|
49
|
+
# resources before being force-killed.
|
|
50
|
+
TERMINATE_TIMEOUT_SUBPROCESS: float = float(os.getenv("TERMINATE_TIMEOUT_SUBPROCESS", 2.0))
|
|
45
51
|
# set default timer for download requests
|
|
46
|
-
|
|
52
|
+
DEFAULT_TIMEOUT_REQUESTS: float = float(os.getenv("DEFAULT_TIMEOUT_REQUESTS", 3.0))
|
|
47
53
|
|
|
48
54
|
|
|
49
55
|
class TimeoutHTTPAdapter(HTTPAdapter):
|
|
@@ -52,7 +58,7 @@ class TimeoutHTTPAdapter(HTTPAdapter):
|
|
|
52
58
|
"""
|
|
53
59
|
|
|
54
60
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
55
|
-
self.timeout: float =
|
|
61
|
+
self.timeout: float = DEFAULT_TIMEOUT_REQUESTS
|
|
56
62
|
if "timeout" in kwargs:
|
|
57
63
|
self.timeout = kwargs["timeout"]
|
|
58
64
|
del kwargs["timeout"]
|
|
@@ -210,7 +216,9 @@ def download_ffmpeg_binaries(path: str, os_windows: bool = False, os_bit: str =
|
|
|
210
216
|
status_forcelist=[429, 500, 502, 503, 504],
|
|
211
217
|
)
|
|
212
218
|
# Mount it for https usage
|
|
213
|
-
adapter = TimeoutHTTPAdapter(
|
|
219
|
+
adapter = TimeoutHTTPAdapter(
|
|
220
|
+
timeout=MAX_TIMEOUT_SUBPROCESS, max_retries=retries
|
|
221
|
+
)
|
|
214
222
|
http.mount("https://", adapter)
|
|
215
223
|
response = http.get(file_url, stream=True)
|
|
216
224
|
response.raise_for_status()
|
|
@@ -410,6 +418,7 @@ def extract_device_n_demuxer(
|
|
|
410
418
|
metadata = check_sp_output(
|
|
411
419
|
[path, *default_ffcommand.split(" ")],
|
|
412
420
|
force_retrieve_stderr=True,
|
|
421
|
+
timeout=MAX_TIMEOUT_SUBPROCESS,
|
|
413
422
|
)
|
|
414
423
|
# clean and split metadata
|
|
415
424
|
splitted = [x.decode("utf-8").strip() for x in metadata.split(b"\n")]
|
|
@@ -469,6 +478,7 @@ def extract_device_n_demuxer(
|
|
|
469
478
|
# search in path properties
|
|
470
479
|
metadata_path = check_sp_output(
|
|
471
480
|
["v4l2-ctl", "--device={}".format(path), "--all"],
|
|
481
|
+
timeout=MAX_TIMEOUT_SUBPROCESS,
|
|
472
482
|
)
|
|
473
483
|
# decode path metadata
|
|
474
484
|
decoded_path = metadata_path.decode("utf-8").strip()
|
|
@@ -488,6 +498,7 @@ def extract_device_n_demuxer(
|
|
|
488
498
|
metadata = check_sp_output(
|
|
489
499
|
[path, *default_ffcommand.split(" ")],
|
|
490
500
|
force_retrieve_stderr=True,
|
|
501
|
+
timeout=MAX_TIMEOUT_SUBPROCESS,
|
|
491
502
|
)
|
|
492
503
|
# clean and split metadata
|
|
493
504
|
splitted = [x.decode("utf-8").strip() for x in metadata.split(b"\n")]
|
|
@@ -641,44 +652,85 @@ def check_sp_output(*args: Any, **kwargs: Any) -> bytes:
|
|
|
641
652
|
"""
|
|
642
653
|
## check_sp_output
|
|
643
654
|
|
|
644
|
-
|
|
655
|
+
Executes a subprocess command and returns its `stdout` (or `stderr` when
|
|
656
|
+
requested). On timeout, performs a two-step graceful shutdown — sends
|
|
657
|
+
`SIGTERM` first to allow FFmpeg to flush buffers and release hardware
|
|
658
|
+
resources (decoders, capture devices), then escalates to `SIGKILL` if the
|
|
659
|
+
process fails to exit within a short grace period.
|
|
645
660
|
|
|
646
661
|
Parameters:
|
|
647
662
|
args (based on input): Non Keyword Arguments
|
|
648
663
|
kwargs (based on input): Keyword Arguments
|
|
664
|
+
force_retrieve_stderr (bool): If True, returns stderr. Also
|
|
665
|
+
suppresses `CalledProcessError` on non-zero exit, since some
|
|
666
|
+
FFmpeg diagnostic commands (e.g. `-list_devices`) emit useful
|
|
667
|
+
output on stderr while exiting non-zero by design.
|
|
668
|
+
timeout (float): Seconds to wait before terminating the process.
|
|
649
669
|
|
|
650
|
-
**Returns:** A
|
|
670
|
+
**Returns:** A bytes value.
|
|
651
671
|
"""
|
|
652
672
|
# workaround for python bug: https://bugs.python.org/issue37380
|
|
653
673
|
if platform.system() == "Windows":
|
|
654
674
|
# see comment https://bugs.python.org/msg370334
|
|
655
675
|
sp._cleanup = lambda: None
|
|
676
|
+
|
|
656
677
|
# handle additional params
|
|
657
678
|
retrieve_stderr = kwargs.pop("force_retrieve_stderr", False)
|
|
679
|
+
timeout = kwargs.pop("timeout", None)
|
|
680
|
+
# never inherit parent's stdin, avoids blocking on unexpected input reads
|
|
681
|
+
kwargs.setdefault("stdin", sp.DEVNULL)
|
|
682
|
+
|
|
658
683
|
# execute command in subprocess
|
|
659
684
|
process = sp.Popen(
|
|
660
685
|
*args,
|
|
661
686
|
stdout=sp.PIPE,
|
|
662
|
-
stderr=sp.
|
|
687
|
+
stderr=sp.PIPE if retrieve_stderr else sp.DEVNULL,
|
|
663
688
|
**kwargs,
|
|
664
689
|
)
|
|
665
|
-
|
|
666
|
-
|
|
690
|
+
|
|
691
|
+
# communicate and poll process with two-step timeout handling
|
|
692
|
+
timeout_occurred = False
|
|
693
|
+
try:
|
|
694
|
+
output, stderr = process.communicate(timeout=timeout)
|
|
695
|
+
except sp.TimeoutExpired:
|
|
696
|
+
timeout_occurred = True
|
|
697
|
+
logger.warning(
|
|
698
|
+
f"[Pipeline-Warning] :: Process exceeded timeout of {timeout}s. "
|
|
699
|
+
"Attempting graceful termination..."
|
|
700
|
+
)
|
|
701
|
+
# Step 1: polite SIGTERM, give process a chance to clean up
|
|
702
|
+
process.terminate()
|
|
703
|
+
try:
|
|
704
|
+
output, stderr = process.communicate(timeout=TERMINATE_TIMEOUT_SUBPROCESS)
|
|
705
|
+
except sp.TimeoutExpired:
|
|
706
|
+
# Step 2: process ignored SIGTERM, force kill
|
|
707
|
+
logger.error("[Pipeline-Error] :: Process unresponsive to SIGTERM. Hard killing...")
|
|
708
|
+
process.kill()
|
|
709
|
+
output, stderr = process.communicate()
|
|
710
|
+
|
|
667
711
|
retcode = process.poll()
|
|
712
|
+
|
|
668
713
|
# handle return code
|
|
669
|
-
if
|
|
670
|
-
|
|
714
|
+
# Bypass CalledProcessError if caller wants stderr (some FFmpeg commands
|
|
715
|
+
# exit non-zero by design) or if we killed the process via our timeout.
|
|
716
|
+
if retcode and not retrieve_stderr and not timeout_occurred:
|
|
717
|
+
logger.error(
|
|
718
|
+
"[Pipeline-Error] :: {}".format(output.decode("utf-8") if output else "No output")
|
|
719
|
+
)
|
|
671
720
|
cmd = kwargs.get("args")
|
|
672
721
|
if cmd is None:
|
|
673
722
|
cmd = args[0]
|
|
674
723
|
error = sp.CalledProcessError(retcode, cmd)
|
|
675
724
|
error.output = output
|
|
676
725
|
raise error
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
726
|
+
|
|
727
|
+
# warn if process emitted nothing on either stream
|
|
728
|
+
if not (bool(output) or bool(stderr)):
|
|
729
|
+
logger.error(
|
|
730
|
+
"[Pipeline-Error] :: Pipeline failed to extract any data from command: {}!".format(
|
|
731
|
+
args[0] if args else []
|
|
732
|
+
)
|
|
681
733
|
)
|
|
682
|
-
|
|
683
|
-
# return
|
|
734
|
+
|
|
735
|
+
# return stderr when explicitly requested (and present), else stdout
|
|
684
736
|
return stderr if retrieve_stderr and stderr else output
|
deffcode/sourcer.py
CHANGED
|
@@ -28,11 +28,12 @@ import os
|
|
|
28
28
|
import platform
|
|
29
29
|
import re
|
|
30
30
|
import shutil
|
|
31
|
-
from typing import Any
|
|
31
|
+
from typing import Any, Self
|
|
32
32
|
|
|
33
33
|
import numpy as np
|
|
34
34
|
|
|
35
35
|
from .ffhelper import (
|
|
36
|
+
MAX_TIMEOUT_SUBPROCESS,
|
|
36
37
|
check_sp_output,
|
|
37
38
|
extract_device_n_demuxer,
|
|
38
39
|
get_supported_demuxers,
|
|
@@ -294,7 +295,7 @@ class Sourcer:
|
|
|
294
295
|
# check whether metadata probed or not?
|
|
295
296
|
self.__metadata_probed = False
|
|
296
297
|
|
|
297
|
-
def probe_stream(self, default_stream_indexes: list[int] | tuple[int, int] = (0, 0)) ->
|
|
298
|
+
def probe_stream(self, default_stream_indexes: list[int] | tuple[int, int] = (0, 0)) -> Self:
|
|
298
299
|
"""
|
|
299
300
|
This method Parses/Probes FFmpeg `subprocess` pipe's Standard Output for given input source and Populates the information in private class variables.
|
|
300
301
|
|
|
@@ -308,6 +309,40 @@ class Sourcer:
|
|
|
308
309
|
and len(default_stream_indexes) == 2
|
|
309
310
|
and all(isinstance(x, int) for x in default_stream_indexes)
|
|
310
311
|
), "Invalid default_stream_indexes value!"
|
|
312
|
+
# probe extra inputs first via independent single-source Sourcers, so
|
|
313
|
+
# their resolved source/demuxer (e.g. camera index -> device) can be
|
|
314
|
+
# reused by the combined filtergraph probe below.
|
|
315
|
+
extra_metadata = []
|
|
316
|
+
if self.__is_multi:
|
|
317
|
+
for idx in range(1, len(self.__source_list)):
|
|
318
|
+
_src = self.__source_list[idx]
|
|
319
|
+
_demux = self.__source_demuxer_list[idx]
|
|
320
|
+
_prefixes = self.__ffmpeg_prefixes_list[idx]
|
|
321
|
+
_params = self.__sourcer_params.copy()
|
|
322
|
+
_params["-ffprefixes"] = _prefixes
|
|
323
|
+
# output routing references all inputs; meaningless per-source
|
|
324
|
+
for _key in ("-vf", "-filter_complex", "-map", "-clones"):
|
|
325
|
+
_params.pop(_key, None)
|
|
326
|
+
# Resolve to an absolute path: on Unix `self.__ffmpeg` may be
|
|
327
|
+
# the bare command "ffmpeg" found via PATH, which the nested
|
|
328
|
+
# `get_valid_ffmpeg_path()` would reject as "not a file".
|
|
329
|
+
_custom_ffmpeg = (
|
|
330
|
+
self.__ffmpeg
|
|
331
|
+
if self.__ffmpeg and os.path.isfile(self.__ffmpeg)
|
|
332
|
+
else (shutil.which(self.__ffmpeg) or "")
|
|
333
|
+
)
|
|
334
|
+
_s = Sourcer(
|
|
335
|
+
_src,
|
|
336
|
+
source_demuxer=_demux,
|
|
337
|
+
custom_ffmpeg=_custom_ffmpeg,
|
|
338
|
+
verbose=self.__verbose_logs,
|
|
339
|
+
**_params,
|
|
340
|
+
)
|
|
341
|
+
_s.probe_stream(default_stream_indexes)
|
|
342
|
+
extra_metadata.append(_s.retrieve_metadata(force_retrieve_missing=True))
|
|
343
|
+
self.__resolved_extra_inputs = [
|
|
344
|
+
(m[0]["source"], m[0].get("source_demuxer")) for m in extra_metadata
|
|
345
|
+
]
|
|
311
346
|
# validate source and extract metadata
|
|
312
347
|
self.__ffsp_output = self.__validate_source(
|
|
313
348
|
self.__source,
|
|
@@ -394,34 +429,7 @@ class Sourcer:
|
|
|
394
429
|
# the guard inside retrieve_metadata (checks for non-empty
|
|
395
430
|
# __multi_source_metadata) prevents `sources: []` self-pollution.
|
|
396
431
|
self.__multi_source_metadata.append(self.retrieve_metadata(force_retrieve_missing=True))
|
|
397
|
-
|
|
398
|
-
_src = self.__source_list[idx]
|
|
399
|
-
_demux = self.__source_demuxer_list[idx]
|
|
400
|
-
_prefixes = self.__ffmpeg_prefixes_list[idx]
|
|
401
|
-
_params = self.__sourcer_params.copy()
|
|
402
|
-
_params["-ffprefixes"] = _prefixes
|
|
403
|
-
# spawn an independent single-source Sourcer per extra input;
|
|
404
|
-
# this reuses the resolved ffmpeg path and isolates per-source
|
|
405
|
-
# parsing state (which probe_stream otherwise clobbers).
|
|
406
|
-
# Resolve to an absolute path: on Unix `self.__ffmpeg` may be
|
|
407
|
-
# the bare command "ffmpeg" found via PATH, which the nested
|
|
408
|
-
# `get_valid_ffmpeg_path()` would reject as "not a file".
|
|
409
|
-
_custom_ffmpeg = (
|
|
410
|
-
self.__ffmpeg
|
|
411
|
-
if self.__ffmpeg and os.path.isfile(self.__ffmpeg)
|
|
412
|
-
else (shutil.which(self.__ffmpeg) or "")
|
|
413
|
-
)
|
|
414
|
-
_s = Sourcer(
|
|
415
|
-
_src,
|
|
416
|
-
source_demuxer=_demux,
|
|
417
|
-
custom_ffmpeg=_custom_ffmpeg,
|
|
418
|
-
verbose=self.__verbose_logs,
|
|
419
|
-
**_params,
|
|
420
|
-
)
|
|
421
|
-
_s.probe_stream(default_stream_indexes)
|
|
422
|
-
self.__multi_source_metadata.append(
|
|
423
|
-
_s.retrieve_metadata(force_retrieve_missing=True)
|
|
424
|
-
)
|
|
432
|
+
self.__multi_source_metadata.extend(extra_metadata)
|
|
425
433
|
|
|
426
434
|
# return reference to the instance object.
|
|
427
435
|
return self
|
|
@@ -671,6 +679,20 @@ class Sourcer:
|
|
|
671
679
|
+ self.__ffmpeg_prefixes
|
|
672
680
|
+ (["-f", source_demuxer] if source_demuxer else [])
|
|
673
681
|
+ ["-i", source]
|
|
682
|
+
# multi-input filtergraphs reference every input (e.g. `[1:v]`),
|
|
683
|
+
# so the probe must ingest all of them to resolve output metadata.
|
|
684
|
+
+ [
|
|
685
|
+
arg
|
|
686
|
+
for (src, demux), prefixes in zip(
|
|
687
|
+
self.__resolved_extra_inputs,
|
|
688
|
+
self.__ffmpeg_prefixes_list[1:],
|
|
689
|
+
strict=True,
|
|
690
|
+
)
|
|
691
|
+
for arg in ["-t", "0.0001"]
|
|
692
|
+
+ prefixes
|
|
693
|
+
+ (["-f", demux] if demux else [])
|
|
694
|
+
+ ["-i", src]
|
|
695
|
+
]
|
|
674
696
|
+ dict2Args(self.__sourcer_params)
|
|
675
697
|
+ ["-f", "null", "-"]
|
|
676
698
|
)
|
|
@@ -687,13 +709,16 @@ class Sourcer:
|
|
|
687
709
|
check_sp_output(
|
|
688
710
|
meta_cmd,
|
|
689
711
|
force_retrieve_stderr=True,
|
|
712
|
+
timeout=MAX_TIMEOUT_SUBPROCESS,
|
|
690
713
|
)
|
|
691
714
|
.decode("utf-8")
|
|
692
715
|
.strip()
|
|
693
716
|
)
|
|
694
717
|
# separate input and output metadata (if available)
|
|
695
718
|
if "Output #" in metadata:
|
|
696
|
-
|
|
719
|
+
metadata, self.__metadata_output = metadata.split("Output #")
|
|
720
|
+
# primary `source_*` metadata must only describe input 0
|
|
721
|
+
metadata = re.split(r"^Input #1\b", metadata, maxsplit=1, flags=re.M)[0]
|
|
697
722
|
# return metadata based on params
|
|
698
723
|
return metadata
|
|
699
724
|
|
deffcode/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__: str = "0.2.
|
|
1
|
+
__version__: str = "0.2.9"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deffcode
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: A cross-platform High-performance & Flexible Real-time Video Frames Decoder in Python.
|
|
5
5
|
Author-email: Abhishek Thakur <abhi.una12@gmail.com>
|
|
6
6
|
License: Apache License 2.0
|
|
@@ -22,11 +22,11 @@ Classifier: Intended Audience :: Developers
|
|
|
22
22
|
Classifier: Intended Audience :: Science/Research
|
|
23
23
|
Classifier: Intended Audience :: Education
|
|
24
24
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
25
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
26
25
|
Classifier: Programming Language :: Python :: 3.11
|
|
27
26
|
Classifier: Programming Language :: Python :: 3.12
|
|
28
27
|
Classifier: Programming Language :: Python :: 3.13
|
|
29
|
-
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
30
|
Description-Content-Type: text/markdown
|
|
31
31
|
License-File: LICENSE
|
|
32
32
|
Requires-Dist: cython
|
|
@@ -108,12 +108,13 @@ Here are some key features that stand out:
|
|
|
108
108
|
- Hands down the easiest [**Index based Camera Device Capturing**][decoding-camera-devices-using-indexes], similar to OpenCV.
|
|
109
109
|
- Easy to code **Real-time [Simple][transcoding-live-simple-filtergraphs] & [Complex][transcoding-live-complex-filtergraphs] Filtergraphs**. _(Yes, You read it correctly "Real-time"!)_
|
|
110
110
|
- Native **[Multi-Input Source Configurations][multi-input-source-configurations]** support for decoding complex topologies.
|
|
111
|
+
- Native **[Multi-Outputs Configurations][multi-outputs-configurations]** support for recording/streaming while decoding, in a single FFmpeg pipeline.
|
|
111
112
|
- Lightning fast dedicated **GPU-Accelerated Video [Decoding][hardware-accelerated-video-decoding] & [Transcoding][hardware-accelerated-video-transcoding]**.
|
|
112
113
|
- Enables precise FFmpeg [**Key-frame Seeking**][extracting-key-frames-as-png-image] with pinpoint accuracy.
|
|
113
114
|
- Effortless [**Metadata Extraction**][extracting-video-metadata] from all streams available in the source.
|
|
114
115
|
- Maintains the standard easy to learn [**OpenCV-Python**](https://docs.opencv.org/4.x/d6/d00/tutorial_py_root.html) coding syntax.
|
|
115
116
|
- Out-of-the-box support for all prominent Computer Vision libraries.
|
|
116
|
-
- Cross-platform, runs on Python 3.
|
|
117
|
+
- Cross-platform, runs on Python 3.11+, and easy to install.
|
|
117
118
|
|
|
118
119
|
<!--
|
|
119
120
|
- [x] Lossless Transcoding support with [WriteGear](https://abhitronix.github.io/deffcode/latest/gears/writegear/introduction/). #TODO
|
|
@@ -235,6 +236,11 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
|
|
|
235
236
|
- [CUDA-NVENC-accelerated Video Transcoding with WriteGear API][cuda-nvenc-accelerated-video-transcoding-with-writegear-api]
|
|
236
237
|
- [CUDA-NVENC-accelerated End-to-end Lossless Video Transcoding with WriteGear API][cuda-nvenc-accelerated-end-to-end-lossless-video-transcoding-with-writegear-api]
|
|
237
238
|
- [Multi-Input Source Configurations][multi-input-source-configurations]
|
|
239
|
+
- [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
|
|
240
|
+
- [Decoding crossfade transition between multiple inputs][decoding-crossfade-transition-between-multiple-inputs]
|
|
241
|
+
- [Multi-Outputs Configurations][multi-outputs-configurations]
|
|
242
|
+
- [Recording while decoding][recording-while-decoding]
|
|
243
|
+
- [Encode once, duplicate to file and stream][encode-once-duplicate-to-file-and-stream]
|
|
238
244
|
|
|
239
245
|
</details>
|
|
240
246
|
|
|
@@ -271,9 +277,8 @@ decoder = FFdecoder("https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1
|
|
|
271
277
|
|
|
272
278
|
# grab RGB24(default) 3D frames from decoder
|
|
273
279
|
for frame in decoder.generateFrame():
|
|
274
|
-
|
|
275
280
|
# lets print its shape
|
|
276
|
-
print(frame.shape)
|
|
281
|
+
print(frame.shape) # (1080, 1920, 3)
|
|
277
282
|
|
|
278
283
|
# terminate the decoder
|
|
279
284
|
decoder.terminate()
|
|
@@ -288,7 +293,9 @@ The primary function of [**Sourcer API**][sourcer-api] is to **gather metadata i
|
|
|
288
293
|
from deffcode import Sourcer
|
|
289
294
|
|
|
290
295
|
# initialize and formulate the decoder using suitable source
|
|
291
|
-
sourcer = Sourcer(
|
|
296
|
+
sourcer = Sourcer(
|
|
297
|
+
"https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4"
|
|
298
|
+
).probe_stream()
|
|
292
299
|
|
|
293
300
|
# print metadata as `json.dump`
|
|
294
301
|
print(sourcer.retrieve_metadata(pretty_json=True))
|
|
@@ -354,18 +361,18 @@ It is something I am doing with my own free time. But so much more needs to be d
|
|
|
354
361
|
|
|
355
362
|
Here is a Bibtex entry you can use to cite this project in a publication:
|
|
356
363
|
|
|
357
|
-
[](https://doi.org/10.5281/zenodo.23446604)
|
|
358
365
|
|
|
359
366
|
```BibTeX
|
|
360
367
|
@software{deffcode,
|
|
361
368
|
author = {Abhishek Thakur},
|
|
362
|
-
title = {abhiTronix/deffcode: v0.2.
|
|
363
|
-
month =
|
|
364
|
-
year =
|
|
369
|
+
title = {abhiTronix/deffcode: v0.2.8},
|
|
370
|
+
month = oct,
|
|
371
|
+
year = 2026,
|
|
365
372
|
publisher = {Zenodo},
|
|
366
|
-
version = {v0.2.
|
|
367
|
-
doi = {10.5281/zenodo.
|
|
368
|
-
url = {https://doi.org/10.5281/zenodo.
|
|
373
|
+
version = {v0.2.8},
|
|
374
|
+
doi = {10.5281/zenodo.23446604},
|
|
375
|
+
url = {https://doi.org/10.5281/zenodo.23446604},
|
|
369
376
|
}
|
|
370
377
|
```
|
|
371
378
|
|
|
@@ -478,6 +485,11 @@ Advanced Recipes
|
|
|
478
485
|
[smart-keyframe-only-decoding-for-heavy-ai-inference]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/extract-frame-metadata/#smart-keyframe-only-decoding-for-heavy-ai-inference
|
|
479
486
|
[variable-frame-rate-vfr-synchronization-via-pts_time]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/extract-frame-metadata/#variable-frame-rate-vfr-synchronization-via-pts_time
|
|
480
487
|
[multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
|
|
488
|
+
[decoding-multiple-inputs-as-2x2-video-grid-mosaic]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-multiple-inputs-as-2x2-video-grid-mosaic
|
|
489
|
+
[decoding-crossfade-transition-between-multiple-inputs]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-crossfade-transition-between-multiple-inputs
|
|
490
|
+
[multi-outputs-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#multi-outputs-configurations
|
|
491
|
+
[recording-while-decoding]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#recording-while-decoding
|
|
492
|
+
[encode-once-duplicate-to-file-and-stream]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#encode-once-duplicate-to-file-and-stream
|
|
481
493
|
|
|
482
494
|
<!--
|
|
483
495
|
CI Apps URLs
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
deffcode/__init__.py,sha256=BQO_1Yv0E9PZCPtJ8-1z3X0VLP6VgBbdV5RahJU_RIk,286
|
|
2
|
+
deffcode/ffdecoder.py,sha256=4ywrmP076ZGdrZdcIrFP336HXEAyWT2NKmRiJOo7-5Q,55259
|
|
3
|
+
deffcode/ffhelper.py,sha256=Kms1CiVsyB3CgQvLdScGSuL5ml0eRqxm6xrTd-0g6fE,29032
|
|
4
|
+
deffcode/sourcer.py,sha256=xys3OH4AO0RgdaIj-bYc76rP0F3ZZJSocxGuM0OG2N0,43628
|
|
5
|
+
deffcode/utils.py,sha256=bIR1Hu_icLs3Xe7njHR9YE09MYUbvUfhOUMSqSHh730,4729
|
|
6
|
+
deffcode/version.py,sha256=cHThOwcEXKpIPW-ZC1HETHv-MyCCWj4sJISXFDdM8u4,27
|
|
7
|
+
deffcode-0.2.9.dist-info/licenses/LICENSE,sha256=pF-0xwtgiRjzW5P97fyEOCIqe7LV5Sd5cjIC4fBDwVQ,12259
|
|
8
|
+
deffcode-0.2.9.dist-info/METADATA,sha256=WMCK5mRr3eEfDt5wuvh7iG4gOfJlfKNeVttRqf73dIM,35196
|
|
9
|
+
deffcode-0.2.9.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
deffcode-0.2.9.dist-info/top_level.txt,sha256=1C-kyZgIKFviemaQMmhP0VKR3wkzEZcrf1o5oUckvHI,9
|
|
11
|
+
deffcode-0.2.9.dist-info/RECORD,,
|
deffcode-0.2.7.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
deffcode/__init__.py,sha256=BQO_1Yv0E9PZCPtJ8-1z3X0VLP6VgBbdV5RahJU_RIk,286
|
|
2
|
-
deffcode/ffdecoder.py,sha256=BXCGHDg56lZg08UEX16jGZNaREIweIjnOt3wAn50aGk,50278
|
|
3
|
-
deffcode/ffhelper.py,sha256=AP9OJoKrHFonAFfiv19QCl5G_LIuTUzRjQ7yT0sTLUo,26409
|
|
4
|
-
deffcode/sourcer.py,sha256=V5TxxSvT1kEWm6e2x2w7Nrgi40Bg9IoKyTuyNsY0Y6Y,42405
|
|
5
|
-
deffcode/utils.py,sha256=bIR1Hu_icLs3Xe7njHR9YE09MYUbvUfhOUMSqSHh730,4729
|
|
6
|
-
deffcode/version.py,sha256=dxcyVFoGmkMidzPt7T448p1Z-y0g7gXUtzvD5b-1rgg,27
|
|
7
|
-
deffcode-0.2.7.dist-info/licenses/LICENSE,sha256=pF-0xwtgiRjzW5P97fyEOCIqe7LV5Sd5cjIC4fBDwVQ,12259
|
|
8
|
-
deffcode-0.2.7.dist-info/METADATA,sha256=syRnP0R-4DZnflnfVaZg64wbb-CA9WdEif2sw6v5XJI,33812
|
|
9
|
-
deffcode-0.2.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
-
deffcode-0.2.7.dist-info/top_level.txt,sha256=1C-kyZgIKFviemaQMmhP0VKR3wkzEZcrf1o5oUckvHI,9
|
|
11
|
-
deffcode-0.2.7.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|