ffmpeg-skill 0.12.0 → 0.16.12
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.
- package/README.md +56 -7
- package/SKILL.md +26 -2
- package/bin/install.js +12 -3
- package/mcp/server.py +8 -0
- package/package.json +2 -2
- package/references/scripts.md +177 -2
- package/scripts/_common.py +112 -5
- package/scripts/_contract.py +128 -6
- package/scripts/background.py +11 -2
- package/scripts/batch.py +58 -5
- package/scripts/caption.py +48 -5
- package/scripts/check.py +8 -0
- package/scripts/color.py +102 -22
- package/scripts/crop.py +2 -0
- package/scripts/cropdetect.py +106 -0
- package/scripts/cut.py +4 -0
- package/scripts/deinterlace.py +85 -0
- package/scripts/denoise.py +94 -0
- package/scripts/export.py +2 -1
- package/scripts/fit.py +26 -6
- package/scripts/freeze.py +108 -0
- package/scripts/graphics.py +7 -5
- package/scripts/grid.py +142 -0
- package/scripts/join.py +4 -1
- package/scripts/look.py +13 -8
- package/scripts/loop.py +80 -0
- package/scripts/multicam.py +10 -2
- package/scripts/overlay.py +27 -10
- package/scripts/pad.py +68 -0
- package/scripts/redact.py +100 -0
- package/scripts/render.py +17 -3
- package/scripts/scenes.py +8 -2
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +123 -0
- package/scripts/sphere.py +126 -0
- package/scripts/stabilize.py +22 -4
- package/scripts/straighten.py +97 -0
- package/scripts/verify.py +25 -2
- package/scripts/waveform.py +92 -0
package/scripts/stabilize.py
CHANGED
|
@@ -9,12 +9,18 @@ this run only -- it is not a caller-facing artifact.
|
|
|
9
9
|
--shakiness (1 = barely shaky, fast; 10 = very shaky, slow analysis) and
|
|
10
10
|
--smoothing (how many neighbouring frames to average the camera path over)
|
|
11
11
|
are the two knobs that matter most; --zoom crops in slightly to hide the
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
edges stabilizing can introduce (0 = keep the original framing and let edges
|
|
13
|
+
show). --crop chooses what happens to any edge vidstab reveals that --zoom
|
|
14
|
+
doesn't crop away: "keep" (default) stretches the border pixels, "black"
|
|
15
|
+
fills it in solid black instead. --tripod locks the frame fully still
|
|
16
|
+
against a single reference frame (e.g. a camera meant to be static but
|
|
17
|
+
nudged, or a shot you want dead-locked rather than merely smoothed) instead
|
|
18
|
+
of following the camera's intended motion.
|
|
14
19
|
|
|
15
20
|
Examples:
|
|
16
21
|
python3 stabilize.py shaky.mp4
|
|
17
22
|
python3 stabilize.py shaky.mp4 --shakiness 8 --smoothing 20 --zoom 5
|
|
23
|
+
python3 stabilize.py locked-off.mp4 --tripod --crop black
|
|
18
24
|
"""
|
|
19
25
|
import argparse
|
|
20
26
|
import sys
|
|
@@ -31,6 +37,8 @@ def main() -> int:
|
|
|
31
37
|
ap.add_argument("--shakiness", type=int, default=5, help="1 (barely shaky) .. 10 (very shaky), default 5")
|
|
32
38
|
ap.add_argument("--smoothing", type=int, default=15, help="frames of camera-path smoothing on each side, default 15")
|
|
33
39
|
ap.add_argument("--zoom", type=float, default=0.0, help="percent to zoom in to hide stabilization edges, 0..100 (default 0)")
|
|
40
|
+
ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
|
|
41
|
+
ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
|
|
34
42
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
35
43
|
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
36
44
|
add_common(ap)
|
|
@@ -56,13 +64,23 @@ def main() -> int:
|
|
|
56
64
|
|
|
57
65
|
if not STATE["dry_run"]:
|
|
58
66
|
ffmpeg = require_tool("ffmpeg")
|
|
67
|
+
detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
|
|
68
|
+
if args.tripod:
|
|
69
|
+
# A frame number, not a boolean: frame 1 is the standard reference for "lock to
|
|
70
|
+
# this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
|
|
71
|
+
detect_vf += ":tripod=1"
|
|
59
72
|
detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
|
|
60
|
-
"-vf",
|
|
73
|
+
"-vf", detect_vf, "-f", "null", "-"]
|
|
61
74
|
proc = run(detect_cmd, check=False)
|
|
62
75
|
if proc.returncode != 0:
|
|
63
76
|
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
|
|
64
77
|
|
|
65
|
-
|
|
78
|
+
crop_mode = {"keep": 0, "black": 1}[args.crop]
|
|
79
|
+
transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
|
|
80
|
+
if args.tripod:
|
|
81
|
+
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
82
|
+
# camera path makes no sense once every frame is locked to one fixed reference.
|
|
83
|
+
transform_vf += ":tripod=1"
|
|
66
84
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
|
|
67
85
|
cmd += video_args(meta, args.crf, args.preset)
|
|
68
86
|
cmd += cfr_args(meta)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Rotate a video by an arbitrary angle (horizon correction, not a 90/180/270 turn).
|
|
3
|
+
|
|
4
|
+
Distinct from fit.py --rotate, which only turns the picture in exact 90-degree
|
|
5
|
+
steps (swapping width/height, lossless in intent). This tool wraps FFmpeg's
|
|
6
|
+
rotate filter for a small corrective tilt -- "the horizon is 2 degrees off" --
|
|
7
|
+
which necessarily crops or pads the corners: rotating a rectangle by a
|
|
8
|
+
non-90-degree angle leaves triangular gaps at the corners. --fit crop scales
|
|
9
|
+
up just enough to fill the frame with no visible gap (losing a thin border
|
|
10
|
+
of the original picture); --fit pad keeps the full original picture inside
|
|
11
|
+
the rotated frame and fills the gaps with --fill-color.
|
|
12
|
+
|
|
13
|
+
This tool does not measure the tilt itself -- it has no way to find a
|
|
14
|
+
horizon line in a frame; that is a look.py/vision judgement call. Give the
|
|
15
|
+
degrees once you can see how far off it is.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
python3 straighten.py tilted.mp4 --degrees -2.5
|
|
19
|
+
python3 straighten.py handheld.mp4 --degrees 1.2 --fit pad --fill-color 0x101010
|
|
20
|
+
"""
|
|
21
|
+
import argparse
|
|
22
|
+
import math
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> int:
|
|
29
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
30
|
+
ap.add_argument("input")
|
|
31
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_straighten.<ext>)")
|
|
32
|
+
ap.add_argument("--degrees", type=float, required=True, help="rotation angle in degrees, -45..45, positive = clockwise")
|
|
33
|
+
ap.add_argument("--fit", choices=["crop", "pad"], default="crop",
|
|
34
|
+
help="crop (default): scale up to fill the frame, no visible corner gap; pad: keep the full picture, fill the corner gaps with --fill-color")
|
|
35
|
+
ap.add_argument("--fill-color", default="black", help="corner fill colour with --fit pad (default black)")
|
|
36
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
37
|
+
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
38
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
39
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
40
|
+
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
41
|
+
add_common(ap)
|
|
42
|
+
args = ap.parse_args()
|
|
43
|
+
apply_common(args)
|
|
44
|
+
if args.fps is not None and args.fps <= 0:
|
|
45
|
+
die(f"--fps must be positive, got {args.fps:g}")
|
|
46
|
+
|
|
47
|
+
if not -45 <= args.degrees <= 45:
|
|
48
|
+
die(f"--degrees must be -45..45, got {args.degrees:g}")
|
|
49
|
+
if args.degrees == 0:
|
|
50
|
+
die("--degrees must be nonzero (nothing to straighten)")
|
|
51
|
+
validate_color(args.fill_color, "--fill-color")
|
|
52
|
+
|
|
53
|
+
meta = probe(args.input)
|
|
54
|
+
if not meta.get("video"):
|
|
55
|
+
die("input has no video stream")
|
|
56
|
+
has_audio = bool(meta.get("audio"))
|
|
57
|
+
audio_streams = meta.get("audio_streams") or []
|
|
58
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
59
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
60
|
+
if args.audio_stream and not audio_streams:
|
|
61
|
+
die("--audio-stream needs an input with audio streams")
|
|
62
|
+
output = args.output or default_output(args.input, "straighten")
|
|
63
|
+
|
|
64
|
+
radians = math.radians(args.degrees)
|
|
65
|
+
if args.fit == "pad":
|
|
66
|
+
rotate = (f"rotate={radians:.8f}:fillcolor={args.fill_color}:"
|
|
67
|
+
f"ow=trunc(rotw({radians:.8f})/2)*2:oh=trunc(roth({radians:.8f})/2)*2")
|
|
68
|
+
else:
|
|
69
|
+
# Pre-scale the frame up by a safe, conservative factor (|cos|+|sin|, the exact growth
|
|
70
|
+
# factor for a square, over-generous for a rectangle) so the rotated content fully
|
|
71
|
+
# covers the original W:H window with no black corner, then rotate in place (canvas
|
|
72
|
+
# stays at the scaled size) and crop back down to the original W:H, centred.
|
|
73
|
+
s = abs(math.cos(radians)) + abs(math.sin(radians))
|
|
74
|
+
# crop dimensions must round down to even (4:2:0 chroma); trunc(.../2)*2 floors to the
|
|
75
|
+
# nearest even value instead of leaving an odd width/height that the encoder would refuse.
|
|
76
|
+
rotate = f"scale=iw*{s:.8f}:ih*{s:.8f},rotate={radians:.8f},crop=trunc(iw/{s:.8f}/2)*2:trunc(ih/{s:.8f}/2)*2"
|
|
77
|
+
|
|
78
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", rotate, "-map", "0:v:0"]
|
|
79
|
+
if has_audio:
|
|
80
|
+
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
81
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
82
|
+
cmd += cfr_args(meta, args.fps)
|
|
83
|
+
if has_audio:
|
|
84
|
+
cmd += aac_args()
|
|
85
|
+
else:
|
|
86
|
+
cmd += ["-an"]
|
|
87
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
88
|
+
|
|
89
|
+
result = probe(output, role="output")
|
|
90
|
+
v = result["video"]
|
|
91
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, degrees={args.degrees:g}, fit={args.fit})")
|
|
92
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
sys.exit(main())
|
package/scripts/verify.py
CHANGED
|
@@ -30,6 +30,28 @@ HERE = Path(__file__).resolve().parent
|
|
|
30
30
|
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def unique_stems(files: List[Path]) -> Dict[Path, str]:
|
|
34
|
+
"""Every file's outputs share one flat --out directory (stem = outdir / f.stem), so two files
|
|
35
|
+
with the same basename from different folders -- entirely normal for real footage collected
|
|
36
|
+
from multiple cameras/SD cards, e.g. two "clip.mp4"s in separate campaign folders -- used to
|
|
37
|
+
resolve to the identical output prefix. Each file's own steps still ran correctly in isolation,
|
|
38
|
+
but with --keep the second file's outputs silently overwrote the first file's on disk, and the
|
|
39
|
+
report showed PASS for both without ever flagging the collision (same bug class already fixed
|
|
40
|
+
in batch.py). Disambiguate every colliding stem with a stable per-collision index instead."""
|
|
41
|
+
counts: Dict[str, int] = {}
|
|
42
|
+
for f in files:
|
|
43
|
+
counts[f.stem] = counts.get(f.stem, 0) + 1
|
|
44
|
+
seen: Dict[str, int] = {}
|
|
45
|
+
stems: Dict[Path, str] = {}
|
|
46
|
+
for f in files:
|
|
47
|
+
if counts[f.stem] > 1:
|
|
48
|
+
seen[f.stem] = seen.get(f.stem, 0) + 1
|
|
49
|
+
stems[f] = f"{f.stem}_{seen[f.stem]}"
|
|
50
|
+
else:
|
|
51
|
+
stems[f] = f.stem
|
|
52
|
+
return stems
|
|
53
|
+
|
|
54
|
+
|
|
33
55
|
def collect(paths: List[str]) -> List[Path]:
|
|
34
56
|
files: List[Path] = []
|
|
35
57
|
for p in paths:
|
|
@@ -86,6 +108,7 @@ def main() -> int:
|
|
|
86
108
|
tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
|
|
87
109
|
outdir = Path(tmp.name)
|
|
88
110
|
|
|
111
|
+
stem_for = unique_stems(files)
|
|
89
112
|
results = []
|
|
90
113
|
for f in files:
|
|
91
114
|
info(f"=== {f}")
|
|
@@ -102,7 +125,7 @@ def main() -> int:
|
|
|
102
125
|
entry["steps"].append({"step": "probe", "ok": True, "seconds": 0, "error": ""})
|
|
103
126
|
dur = meta.get("duration") or 0.0
|
|
104
127
|
has_v, has_a = bool(meta.get("video")), bool(meta.get("audio"))
|
|
105
|
-
stem = outdir / f
|
|
128
|
+
stem = outdir / stem_for[f]
|
|
106
129
|
cut = f"{stem}_cut.mp4"
|
|
107
130
|
seg_end = min(dur, args.seconds) if dur else args.seconds
|
|
108
131
|
fast = ["--fast"]
|
|
@@ -111,7 +134,7 @@ def main() -> int:
|
|
|
111
134
|
if has_v:
|
|
112
135
|
plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
|
|
113
136
|
plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
|
|
114
|
-
cues = outdir / f"{f
|
|
137
|
+
cues = outdir / f"{stem_for[f]}_cues.txt"
|
|
115
138
|
cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
|
|
116
139
|
plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
|
|
117
140
|
if not args.quick:
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render an audio track as a waveform or spectrum visualization video.
|
|
3
|
+
|
|
4
|
+
Wraps FFmpeg's showwaves (--style waveform, the default) or showspectrum
|
|
5
|
+
(--style spectrum) source filter over the input's audio -- for a podcast
|
|
6
|
+
episode, a music release, or any clip that has no picture worth showing.
|
|
7
|
+
The rendered clip always carries the same audio it visualizes; the video is
|
|
8
|
+
generated fresh, there is no source picture involved.
|
|
9
|
+
|
|
10
|
+
--style waveform draws the amplitude over time; --style spectrum draws a
|
|
11
|
+
frequency-over-time heatmap instead, which reads more information out of
|
|
12
|
+
dense mixes at the cost of being less immediately readable to a general
|
|
13
|
+
audience. Both accept --width/--height and --color; waveform additionally
|
|
14
|
+
takes --waveform-mode (how each sample is drawn) and --split-channels
|
|
15
|
+
(stereo drawn as two separate lanes instead of summed to one).
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
python3 waveform.py podcast.wav -o waveform.mp4
|
|
19
|
+
python3 waveform.py track.wav --style spectrum --width 1920 --height 1080 -o spectrum.mp4
|
|
20
|
+
python3 waveform.py interview.mp4 --split-channels --color cyan|magenta
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color
|
|
26
|
+
|
|
27
|
+
WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> int:
|
|
31
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
32
|
+
ap.add_argument("input")
|
|
33
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_waveform.<ext>)")
|
|
34
|
+
ap.add_argument("--style", choices=["waveform", "spectrum"], default="waveform", help="waveform (default) or spectrum visualization")
|
|
35
|
+
ap.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
|
|
36
|
+
ap.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
|
|
37
|
+
ap.add_argument("--fps", type=float, default=25.0, help="output frame rate (default 25)")
|
|
38
|
+
ap.add_argument("--color", default="lime", help="channel colour(s), pipe-separated per channel, e.g. 'lime' or 'cyan|magenta' (default lime)")
|
|
39
|
+
ap.add_argument("--background", default="black", help="background colour (default black)")
|
|
40
|
+
ap.add_argument("--waveform-mode", choices=WAVEFORM_MODES, default="line", help="--style waveform only: how each sample is drawn (default line)")
|
|
41
|
+
ap.add_argument("--split-channels", action="store_true", help="draw each channel in its own lane instead of summing to one")
|
|
42
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
43
|
+
help="which audio stream of the input to render, 0-based in file order (default 0)")
|
|
44
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
45
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
46
|
+
add_common(ap)
|
|
47
|
+
args = ap.parse_args()
|
|
48
|
+
apply_common(args)
|
|
49
|
+
|
|
50
|
+
if args.width <= 0 or args.height <= 0:
|
|
51
|
+
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
52
|
+
if args.width % 2 or args.height % 2:
|
|
53
|
+
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
54
|
+
for token in args.color.split("|"):
|
|
55
|
+
validate_color(token, "--color")
|
|
56
|
+
validate_color(args.background, "--background")
|
|
57
|
+
if args.fps <= 0:
|
|
58
|
+
die(f"--fps must be > 0, got {args.fps:g}")
|
|
59
|
+
|
|
60
|
+
meta = probe(args.input)
|
|
61
|
+
if not meta.get("audio"):
|
|
62
|
+
die("input has no audio stream")
|
|
63
|
+
audio_streams = meta.get("audio_streams") or []
|
|
64
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
65
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
66
|
+
output = args.output or default_output(args.input, "waveform")
|
|
67
|
+
|
|
68
|
+
if args.style == "waveform":
|
|
69
|
+
vf = (f"showwaves=s={args.width}x{args.height}:mode={args.waveform_mode}:rate={args.fps:g}:"
|
|
70
|
+
f"split_channels={1 if args.split_channels else 0}:colors={args.color}")
|
|
71
|
+
else:
|
|
72
|
+
vf = f"showspectrum=s={args.width}x{args.height}:mode={'separate' if args.split_channels else 'combined'}:fps={args.fps:g}"
|
|
73
|
+
# showwaves/showspectrum paint the visualization on a transparent-black canvas; composite
|
|
74
|
+
# it over an explicit solid background instead of assuming that canvas already matches
|
|
75
|
+
# --background.
|
|
76
|
+
vf = f"color=c={args.background}:s={args.width}x{args.height}:r={args.fps:g}[bg];[0:a:{args.audio_stream}]{vf}[vis];[bg][vis]overlay=format=auto"
|
|
77
|
+
|
|
78
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", f"0:a:{args.audio_stream}"]
|
|
79
|
+
cmd += ["-c:v", "libx264", "-preset", args.preset, "-crf", str(args.crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
|
|
80
|
+
cmd += aac_args()
|
|
81
|
+
cmd += ["-shortest", output]
|
|
82
|
+
run(cmd)
|
|
83
|
+
|
|
84
|
+
result = probe(output, role="output")
|
|
85
|
+
v = result["video"]
|
|
86
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.style})")
|
|
87
|
+
emit(output)
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
sys.exit(main())
|