VideoTubeEditor 1.0.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.
- VideoTubeEditor/__init__.py +8 -0
- VideoTubeEditor/__main__.py +6 -0
- VideoTubeEditor/about_page.py +280 -0
- VideoTubeEditor/check_system.py +521 -0
- VideoTubeEditor/cli.py +720 -0
- VideoTubeEditor/edit_walk.py +470 -0
- VideoTubeEditor/encoder.py +456 -0
- VideoTubeEditor/file_stage.py +74 -0
- VideoTubeEditor/gui.py +1992 -0
- VideoTubeEditor/host_setup.py +224 -0
- VideoTubeEditor/join.py +355 -0
- VideoTubeEditor/media_info.py +68 -0
- VideoTubeEditor/menu_language.py +1669 -0
- VideoTubeEditor/menu_model.py +273 -0
- VideoTubeEditor/menu_painter.py +462 -0
- VideoTubeEditor/menu_session.py +118 -0
- VideoTubeEditor/run_output.py +114 -0
- VideoTubeEditor/self_management.py +79 -0
- VideoTubeEditor/system_log.py +92 -0
- VideoTubeEditor/tui.py +728 -0
- videotubeeditor-1.0.1.dist-info/METADATA +328 -0
- videotubeeditor-1.0.1.dist-info/RECORD +26 -0
- videotubeeditor-1.0.1.dist-info/WHEEL +5 -0
- videotubeeditor-1.0.1.dist-info/entry_points.txt +4 -0
- videotubeeditor-1.0.1.dist-info/licenses/LICENSE.md +21 -0
- videotubeeditor-1.0.1.dist-info/top_level.txt +1 -0
VideoTubeEditor/cli.py
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# VideoTubeEditor CLI — text menu and typed verbs from the join and speed domains.
|
|
4
|
+
# Specialized from VideoJoin. Speed steps live beside the join steps.
|
|
5
|
+
# Law keys: requirement-python-cli-interface, requirement-python-cli-logging,
|
|
6
|
+
# requirement-python-tui, requirement-python-gui, requirement-python-oop,
|
|
7
|
+
# requirement-python-about, requirement-domain-videoeditor,
|
|
8
|
+
# requirement-python-error-handling, requirement-python-coding-style,
|
|
9
|
+
# requirement-runtime-prerequisites, requirement-video-ffmpeg-pipeline
|
|
10
|
+
# CIAO-Lite: Caution • Intentional • Anti-fragile • Over-protect
|
|
11
|
+
# def main writes ChronicleLogger(...). This module does not construct it
|
|
12
|
+
# at import time and does not re-export it.
|
|
13
|
+
# =============================================================================
|
|
14
|
+
from __future__ import print_function, unicode_literals
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .about_page import AboutPage
|
|
22
|
+
from .check_system import CheckSystem
|
|
23
|
+
from .edit_walk import EditWalk
|
|
24
|
+
from .encoder import Encoder
|
|
25
|
+
from .file_stage import FileStage
|
|
26
|
+
from .gui import Gui
|
|
27
|
+
from .host_setup import HostSetup
|
|
28
|
+
from .join import Join
|
|
29
|
+
from .media_info import MediaInfo
|
|
30
|
+
from .run_output import RunOutput
|
|
31
|
+
from .self_management import SelfManage
|
|
32
|
+
from .tui import Tui
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Cli:
|
|
36
|
+
"""Parser, dispatch, verbs, and the tty gate. One class in this file.
|
|
37
|
+
|
|
38
|
+
Identity, verb lists, and suffix sets are attributes of this class.
|
|
39
|
+
Collaborators arrive through the constructor. def main stays beside this class.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from . import __version__ as VERSION
|
|
43
|
+
|
|
44
|
+
APP_NAME = "VideoTubeEditor"
|
|
45
|
+
CONSOLE_NAME = "video-tube-editor"
|
|
46
|
+
PRODUCT_VERBS = (
|
|
47
|
+
"help",
|
|
48
|
+
"version",
|
|
49
|
+
"about",
|
|
50
|
+
"hello",
|
|
51
|
+
"tui",
|
|
52
|
+
"join",
|
|
53
|
+
"list-videos",
|
|
54
|
+
"edit",
|
|
55
|
+
"list-mp4",
|
|
56
|
+
"self-install",
|
|
57
|
+
"version-check",
|
|
58
|
+
"self-update",
|
|
59
|
+
"self-uninstall",
|
|
60
|
+
"setup",
|
|
61
|
+
)
|
|
62
|
+
LIFECYCLE_VERBS = (
|
|
63
|
+
"version",
|
|
64
|
+
"self-install",
|
|
65
|
+
"version-check",
|
|
66
|
+
"self-update",
|
|
67
|
+
"self-uninstall",
|
|
68
|
+
)
|
|
69
|
+
VIDEO_SUFFIXES = {".mp4", ".mov", ".mkv", ".avi", ".m4v"}
|
|
70
|
+
OUTPUT_SUFFIXES = (".mp4", ".mkv", ".mov")
|
|
71
|
+
AUTHOR_NAME = "Wilgat Wong"
|
|
72
|
+
AUTHOR_EMAIL = "wilgat.wong@gmail.com"
|
|
73
|
+
HOMEPAGE = "https://github.com/cloudgen/VideoTubeEditor"
|
|
74
|
+
LAST_UPDATE = "2026-10-12"
|
|
75
|
+
DOWNLOAD_URL = ""
|
|
76
|
+
BASIC_USAGE = "video-tube-editor join"
|
|
77
|
+
RATIO_MIN = 20.0
|
|
78
|
+
RATIO_MAX = 200.0
|
|
79
|
+
|
|
80
|
+
def __init__(self, logger=None):
|
|
81
|
+
self.logger = logger
|
|
82
|
+
if logger is not None:
|
|
83
|
+
logger.log_message("instantiated", component="Cli")
|
|
84
|
+
self.app_name = Cli.APP_NAME
|
|
85
|
+
self.version = Cli.VERSION
|
|
86
|
+
self.output = RunOutput(Cli.APP_NAME, Cli.VERSION, logger=logger)
|
|
87
|
+
self.join = Join(
|
|
88
|
+
logger=logger,
|
|
89
|
+
video_suffixes=Cli.VIDEO_SUFFIXES,
|
|
90
|
+
output_suffixes=Cli.OUTPUT_SUFFIXES,
|
|
91
|
+
)
|
|
92
|
+
self.stage = FileStage(logger=logger)
|
|
93
|
+
self.media = MediaInfo(self.output, logger=logger)
|
|
94
|
+
self.encoder = Encoder(
|
|
95
|
+
self.output,
|
|
96
|
+
self.media,
|
|
97
|
+
self.stage,
|
|
98
|
+
Cli.RATIO_MIN,
|
|
99
|
+
Cli.RATIO_MAX,
|
|
100
|
+
logger=logger,
|
|
101
|
+
)
|
|
102
|
+
self.edit = EditWalk(
|
|
103
|
+
self.output,
|
|
104
|
+
self.encoder,
|
|
105
|
+
self.media,
|
|
106
|
+
Cli.RATIO_MIN,
|
|
107
|
+
Cli.RATIO_MAX,
|
|
108
|
+
logger=logger,
|
|
109
|
+
)
|
|
110
|
+
self.about = AboutPage(
|
|
111
|
+
CheckSystem(
|
|
112
|
+
logger=logger,
|
|
113
|
+
app_name=Cli.APP_NAME,
|
|
114
|
+
version=Cli.VERSION,
|
|
115
|
+
console_name=Cli.CONSOLE_NAME,
|
|
116
|
+
),
|
|
117
|
+
Cli.APP_NAME,
|
|
118
|
+
Cli.VERSION,
|
|
119
|
+
Cli.AUTHOR_NAME,
|
|
120
|
+
Cli.LAST_UPDATE,
|
|
121
|
+
Cli.HOMEPAGE,
|
|
122
|
+
Cli.DOWNLOAD_URL,
|
|
123
|
+
Cli.BASIC_USAGE,
|
|
124
|
+
Cli.CONSOLE_NAME,
|
|
125
|
+
logger=logger,
|
|
126
|
+
)
|
|
127
|
+
self.self_manage = SelfManage(Cli.APP_NAME, Cli.VERSION, logger=logger)
|
|
128
|
+
self.host_setup = HostSetup(logger=logger)
|
|
129
|
+
self.tui = Tui(self, logger=logger)
|
|
130
|
+
self.gui = Gui(logger=logger)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def stdin_is_tty():
|
|
134
|
+
"""General Purpose: Whether stdin can take interactive prompts."""
|
|
135
|
+
try:
|
|
136
|
+
return sys.stdin.isatty()
|
|
137
|
+
except Exception:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def stdout_is_tty():
|
|
142
|
+
"""General Purpose: Whether the text screen can be drawn on this stdout."""
|
|
143
|
+
try:
|
|
144
|
+
return sys.stdout.isatty()
|
|
145
|
+
except Exception:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def opens_text_screen(argv):
|
|
150
|
+
"""
|
|
151
|
+
General Purpose: Whether this argv draws the text screen.
|
|
152
|
+
|
|
153
|
+
The real parser has not run yet. This walk only decides is_quiet.
|
|
154
|
+
Help, version, hello, the pip verbs, and --json do not draw the screen.
|
|
155
|
+
The tui verb does, on a terminal. Empty argv, join, list-videos, about,
|
|
156
|
+
and list-mp4 do, on a terminal. edit does, unless --file, --start, and
|
|
157
|
+
--end are already present. --verbose alone still opens a screen.
|
|
158
|
+
A bare start with DISPLAY set is the window; this still returns True
|
|
159
|
+
so the logger stays quiet.
|
|
160
|
+
"""
|
|
161
|
+
if argv is None:
|
|
162
|
+
argv = sys.argv[1:]
|
|
163
|
+
argv = list(argv)
|
|
164
|
+
if "--json" in argv or "--help" in argv or "-h" in argv or "--version" in argv:
|
|
165
|
+
return False
|
|
166
|
+
if not Cli.stdout_is_tty():
|
|
167
|
+
return False
|
|
168
|
+
verb = None
|
|
169
|
+
saw_force = False
|
|
170
|
+
has_file = False
|
|
171
|
+
has_start = False
|
|
172
|
+
has_end = False
|
|
173
|
+
has_folder = False
|
|
174
|
+
has_percent = False
|
|
175
|
+
has_boomerang = False
|
|
176
|
+
has_repeat = False
|
|
177
|
+
index = 0
|
|
178
|
+
while index < len(argv):
|
|
179
|
+
token = argv[index]
|
|
180
|
+
if token == "--force":
|
|
181
|
+
saw_force = True
|
|
182
|
+
elif token == "--verbose":
|
|
183
|
+
pass
|
|
184
|
+
elif token == "--file" or token.startswith("--file="):
|
|
185
|
+
has_file = True
|
|
186
|
+
if token == "--file":
|
|
187
|
+
index += 1
|
|
188
|
+
elif token == "--start" or token.startswith("--start="):
|
|
189
|
+
has_start = True
|
|
190
|
+
if token == "--start":
|
|
191
|
+
index += 1
|
|
192
|
+
elif token == "--end" or token.startswith("--end="):
|
|
193
|
+
has_end = True
|
|
194
|
+
if token == "--end":
|
|
195
|
+
index += 1
|
|
196
|
+
elif token == "--folder" or token.startswith("--folder="):
|
|
197
|
+
has_folder = True
|
|
198
|
+
if token == "--folder":
|
|
199
|
+
index += 1
|
|
200
|
+
elif token == "--percent" or token.startswith("--percent="):
|
|
201
|
+
has_percent = True
|
|
202
|
+
if token == "--percent":
|
|
203
|
+
index += 1
|
|
204
|
+
elif token == "--boomerang":
|
|
205
|
+
has_boomerang = True
|
|
206
|
+
elif token == "--repeat":
|
|
207
|
+
has_repeat = True
|
|
208
|
+
elif token.startswith("-"):
|
|
209
|
+
return False
|
|
210
|
+
elif verb is None:
|
|
211
|
+
verb = token
|
|
212
|
+
else:
|
|
213
|
+
return False
|
|
214
|
+
index += 1
|
|
215
|
+
if saw_force and verb != "self-uninstall":
|
|
216
|
+
return False
|
|
217
|
+
if verb in ("help", "hello", "setup") or verb in Cli.LIFECYCLE_VERBS:
|
|
218
|
+
return False
|
|
219
|
+
if verb == "tui":
|
|
220
|
+
return True
|
|
221
|
+
if verb == "edit":
|
|
222
|
+
return not (has_file and has_start and has_end)
|
|
223
|
+
if verb in ("join", "list-videos", "about", "list-mp4"):
|
|
224
|
+
return True
|
|
225
|
+
if verb is not None:
|
|
226
|
+
return False
|
|
227
|
+
if (
|
|
228
|
+
has_file or has_start or has_end or has_folder
|
|
229
|
+
or has_percent or has_boomerang or has_repeat
|
|
230
|
+
):
|
|
231
|
+
return False
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def opens_window(argv):
|
|
236
|
+
"""
|
|
237
|
+
General Purpose: Bare interactive start on X11 opens the window.
|
|
238
|
+
|
|
239
|
+
DISPLAY is set for a local X session, XWayland, or ssh -X.
|
|
240
|
+
A verb, a job flag, --json, or a closed stdin stays off this path.
|
|
241
|
+
requirement-python-gui. On Windows the host contract is
|
|
242
|
+
requirement-python-windows. An empty DISPLAY does not remove this window.
|
|
243
|
+
"""
|
|
244
|
+
if not os.environ.get("DISPLAY", "").strip():
|
|
245
|
+
return False
|
|
246
|
+
if not Cli.stdin_is_tty() or not Cli.stdout_is_tty():
|
|
247
|
+
return False
|
|
248
|
+
if argv is None:
|
|
249
|
+
argv = sys.argv[1:]
|
|
250
|
+
argv = list(argv)
|
|
251
|
+
if "--json" in argv or "--help" in argv or "-h" in argv or "--version" in argv:
|
|
252
|
+
return False
|
|
253
|
+
verb = None
|
|
254
|
+
has_job = False
|
|
255
|
+
index = 0
|
|
256
|
+
while index < len(argv):
|
|
257
|
+
token = argv[index]
|
|
258
|
+
if token in ("--force", "--verbose"):
|
|
259
|
+
pass
|
|
260
|
+
elif token in ("--boomerang", "--repeat"):
|
|
261
|
+
has_job = True
|
|
262
|
+
elif token in ("--file", "--start", "--end", "--folder", "--percent") or any(
|
|
263
|
+
token.startswith(prefix)
|
|
264
|
+
for prefix in ("--file=", "--start=", "--end=", "--folder=", "--percent=")
|
|
265
|
+
):
|
|
266
|
+
has_job = True
|
|
267
|
+
if "=" not in token:
|
|
268
|
+
index += 1
|
|
269
|
+
elif token.startswith("-"):
|
|
270
|
+
return False
|
|
271
|
+
elif verb is None:
|
|
272
|
+
verb = token
|
|
273
|
+
else:
|
|
274
|
+
return False
|
|
275
|
+
index += 1
|
|
276
|
+
return verb is None and not has_job
|
|
277
|
+
|
|
278
|
+
def report_error(self, message, nxt):
|
|
279
|
+
"""User-visible failure plus the same fact on the logger when one exists."""
|
|
280
|
+
if self.logger is not None:
|
|
281
|
+
self.logger.log_message(
|
|
282
|
+
"{0} Next: {1}".format(message, nxt),
|
|
283
|
+
level="ERROR",
|
|
284
|
+
component="main",
|
|
285
|
+
)
|
|
286
|
+
if self.output is not None:
|
|
287
|
+
self.output._remember(message, nxt)
|
|
288
|
+
print("ERROR: {0}".format(message), file=sys.stderr)
|
|
289
|
+
print(" Next: {0}".format(nxt), file=sys.stderr)
|
|
290
|
+
return 1
|
|
291
|
+
|
|
292
|
+
def build_parser(self):
|
|
293
|
+
"""
|
|
294
|
+
General Purpose: One optional product verb, plus the edit job flags.
|
|
295
|
+
help is also --help. --force belongs only to self-uninstall.
|
|
296
|
+
Join stays on the current folder. Edit may name a file and a cut.
|
|
297
|
+
"""
|
|
298
|
+
name = Cli.APP_NAME
|
|
299
|
+
dist = self.self_manage.DISTRIBUTION
|
|
300
|
+
parser = argparse.ArgumentParser(
|
|
301
|
+
prog=Cli.CONSOLE_NAME,
|
|
302
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
303
|
+
description=(
|
|
304
|
+
"{0} — join two videos, or cut, speed, and optionally boomerang or repeat an MP4.\n"
|
|
305
|
+
"With no arguments on a terminal and DISPLAY set, opens the window.\n"
|
|
306
|
+
"With no arguments on a terminal and no DISPLAY, opens the text menu.\n"
|
|
307
|
+
"With no arguments and no terminal, prints this help and stops.\n"
|
|
308
|
+
"tui opens the text menu on a terminal, including when DISPLAY is set.\n"
|
|
309
|
+
"Product verbs: help, version, about, hello, tui, join, list-videos,\n"
|
|
310
|
+
"edit, list-mp4, self-install, version-check, self-update,\n"
|
|
311
|
+
"self-uninstall, setup.\n"
|
|
312
|
+
"help prints this usage. version prints the installed version.\n"
|
|
313
|
+
"about opens the about page on a terminal. hello does not draw the menu.\n"
|
|
314
|
+
"join asks for two videos in this folder and an output name.\n"
|
|
315
|
+
"list-videos lists eligible videos and does not join.\n"
|
|
316
|
+
"edit asks for a folder, then a file, when those are not already named.\n"
|
|
317
|
+
"list-mp4 lists MP4 files and does not encode.\n"
|
|
318
|
+
"video-join and video-speed call this same program.\n"
|
|
319
|
+
"version-check runs: python -m pip index versions {1}\n"
|
|
320
|
+
"self-update runs: python -m pip install --upgrade {1}\n"
|
|
321
|
+
"self-install runs: python -m pip install {1}\n"
|
|
322
|
+
"self-uninstall runs: python -m pip uninstall -y {1}\n"
|
|
323
|
+
"and needs --force. setup installs the declared pip libraries.\n"
|
|
324
|
+
"On Windows, setup also tries curses and ffmpeg.\n"
|
|
325
|
+
"Empty arguments do not install or update.\n"
|
|
326
|
+
"Join formats: .mp4 .mov .mkv .avi .m4v.\n"
|
|
327
|
+
"Edit input: MP4. For a script, pass edit --file, --start, and --end.\n"
|
|
328
|
+
"--json prints one object and does not open the text menu.\n"
|
|
329
|
+
"This product does not cut, speed, or boomerang inside join."
|
|
330
|
+
.format(name, dist)
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
parser.add_argument(
|
|
334
|
+
"verb",
|
|
335
|
+
nargs="?",
|
|
336
|
+
default=None,
|
|
337
|
+
metavar="verb",
|
|
338
|
+
help=(
|
|
339
|
+
"Product verb: help, version, about, hello, tui, join, list-videos, "
|
|
340
|
+
"edit, list-mp4, self-install, version-check, self-update, "
|
|
341
|
+
"self-uninstall, or setup"
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
"--version",
|
|
346
|
+
action="version",
|
|
347
|
+
version="{0} {1}".format(Cli.APP_NAME, Cli.VERSION),
|
|
348
|
+
)
|
|
349
|
+
parser.add_argument("--file", help="MP4 path for a non-interactive edit")
|
|
350
|
+
parser.add_argument(
|
|
351
|
+
"--folder",
|
|
352
|
+
help="Folder of MP4s for list-mp4 or an edit that still needs a file",
|
|
353
|
+
)
|
|
354
|
+
parser.add_argument(
|
|
355
|
+
"--start",
|
|
356
|
+
type=float,
|
|
357
|
+
help="Cut start seconds (with --file and --end)",
|
|
358
|
+
)
|
|
359
|
+
parser.add_argument(
|
|
360
|
+
"--end",
|
|
361
|
+
type=float,
|
|
362
|
+
help="Cut end seconds (with --file and --start)",
|
|
363
|
+
)
|
|
364
|
+
parser.add_argument(
|
|
365
|
+
"--percent",
|
|
366
|
+
type=float,
|
|
367
|
+
default=None,
|
|
368
|
+
help="New length percent of the cut (default 100; allowed 20–200)",
|
|
369
|
+
)
|
|
370
|
+
parser.add_argument(
|
|
371
|
+
"--boomerang",
|
|
372
|
+
action="store_true",
|
|
373
|
+
help="Append the reverse after the forward clip. Not with --repeat",
|
|
374
|
+
)
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"--repeat",
|
|
377
|
+
action="store_true",
|
|
378
|
+
help="Append the forward clip again so it plays twice. Not with --boomerang",
|
|
379
|
+
)
|
|
380
|
+
parser.add_argument(
|
|
381
|
+
"--json",
|
|
382
|
+
action="store_true",
|
|
383
|
+
help="Quiet stdout and print one JSON object. Does not open the text menu.",
|
|
384
|
+
)
|
|
385
|
+
parser.add_argument(
|
|
386
|
+
"--verbose",
|
|
387
|
+
action="store_true",
|
|
388
|
+
help="Accepted. A text screen and --json stay quiet.",
|
|
389
|
+
)
|
|
390
|
+
parser.add_argument(
|
|
391
|
+
"--force",
|
|
392
|
+
action="store_true",
|
|
393
|
+
help="Confirm self-uninstall. Required on the command line.",
|
|
394
|
+
)
|
|
395
|
+
return parser
|
|
396
|
+
|
|
397
|
+
def _page(self, text):
|
|
398
|
+
stream = sys.stderr if self.output.json_mode else sys.stdout
|
|
399
|
+
print(text, file=stream)
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
def _verb_hello(self):
|
|
403
|
+
"""Hello from this package, then the help next step. Does not draw the menu."""
|
|
404
|
+
stream = sys.stderr if self.output.json_mode else sys.stdout
|
|
405
|
+
print(self.tui.hello_text(), file=stream)
|
|
406
|
+
print("Next: {0} help".format(Cli.CONSOLE_NAME), file=stream)
|
|
407
|
+
return 0
|
|
408
|
+
|
|
409
|
+
def _unknown_verb(self, token):
|
|
410
|
+
names = ", ".join(Cli.PRODUCT_VERBS)
|
|
411
|
+
return self.report_error(
|
|
412
|
+
"Unknown verb '{0}'.".format(token),
|
|
413
|
+
"{0} help — verbs: {1}".format(Cli.CONSOLE_NAME, names),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _print_video_list(self):
|
|
417
|
+
"""Eligible names on the console. Does not join and does not draw the screen."""
|
|
418
|
+
stream = sys.stderr if self.output.json_mode else sys.stdout
|
|
419
|
+
print(self.tui._video_listing(), file=stream)
|
|
420
|
+
return 0
|
|
421
|
+
|
|
422
|
+
def _print_mp4_list(self, folder):
|
|
423
|
+
"""Numbered MP4 names. Does not encode."""
|
|
424
|
+
folder_path = Path(folder)
|
|
425
|
+
if not folder_path.is_dir():
|
|
426
|
+
return self.report_error(
|
|
427
|
+
"Not a directory: {0}".format(folder),
|
|
428
|
+
"{0} list-mp4 --folder .".format(Cli.CONSOLE_NAME),
|
|
429
|
+
)
|
|
430
|
+
files = self.media.get_mp4_files(folder_path)
|
|
431
|
+
if not files:
|
|
432
|
+
return self.report_error(
|
|
433
|
+
"No MP4 files found!",
|
|
434
|
+
"{0} list-mp4 --folder .".format(Cli.CONSOLE_NAME),
|
|
435
|
+
)
|
|
436
|
+
lines = ["{0:2d}. {1}".format(i, item.name) for i, item in enumerate(files, 1)]
|
|
437
|
+
stream = sys.stderr if self.output.json_mode else sys.stdout
|
|
438
|
+
for line in lines:
|
|
439
|
+
print(line, file=stream)
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
def _job_ready(self, args):
|
|
443
|
+
return args.file is not None and args.start is not None and args.end is not None
|
|
444
|
+
|
|
445
|
+
def _tail_from_args(self, args):
|
|
446
|
+
"""One tail. The caller rejects --boomerang together with --repeat."""
|
|
447
|
+
if args.repeat:
|
|
448
|
+
return Encoder.TAIL_REPEAT
|
|
449
|
+
if args.boomerang:
|
|
450
|
+
return Encoder.TAIL_BOOMERANG
|
|
451
|
+
return Encoder.TAIL_NONE
|
|
452
|
+
|
|
453
|
+
def _reject_both_tails(self, args):
|
|
454
|
+
"""Fail closed before encode when both tail flags are set."""
|
|
455
|
+
if args.boomerang and args.repeat:
|
|
456
|
+
return self.report_error(
|
|
457
|
+
"Choose one tail: --boomerang or --repeat.",
|
|
458
|
+
"{0} edit --file clip.mp4 --start 0 --end 5 --repeat".format(
|
|
459
|
+
Cli.CONSOLE_NAME
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
def _run_batch(self, args):
|
|
465
|
+
percent = 100.0 if args.percent is None else args.percent
|
|
466
|
+
return self.encoder.batch_session(
|
|
467
|
+
args.file, args.start, args.end, percent, self._tail_from_args(args)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def _verb_edit(self, args):
|
|
471
|
+
"""One edit job, or the edit questions when a target is missing."""
|
|
472
|
+
if self._job_ready(args):
|
|
473
|
+
return self._run_batch(args)
|
|
474
|
+
if not self.stdin_is_tty():
|
|
475
|
+
return self.report_error(
|
|
476
|
+
"Non-interactive job needs --file, --start, and --end.",
|
|
477
|
+
"{0} edit --file clip.mp4 --start 0 --end 5".format(Cli.CONSOLE_NAME),
|
|
478
|
+
)
|
|
479
|
+
if self.output.json_mode:
|
|
480
|
+
return self.edit._edit_json(args)
|
|
481
|
+
if self.logger is not None:
|
|
482
|
+
self.logger.quiet(True)
|
|
483
|
+
return self.tui.open_direct("edit", args)
|
|
484
|
+
|
|
485
|
+
def _verb_list_mp4(self, args):
|
|
486
|
+
"""List MP4 files and stop. Does not encode."""
|
|
487
|
+
if args.folder is None and args.file is not None:
|
|
488
|
+
args.folder = str(Path(args.file).parent)
|
|
489
|
+
if not self.stdin_is_tty():
|
|
490
|
+
return self._print_mp4_list("." if args.folder is None else args.folder)
|
|
491
|
+
if self.output.json_mode:
|
|
492
|
+
if args.folder is None:
|
|
493
|
+
self.output.json_walk = True
|
|
494
|
+
raw = self.edit._prompt_line("Folder (Enter = current):")
|
|
495
|
+
if raw is None:
|
|
496
|
+
return 0
|
|
497
|
+
args.folder = raw.strip() or "."
|
|
498
|
+
return self._print_mp4_list(args.folder)
|
|
499
|
+
if self.logger is not None:
|
|
500
|
+
self.logger.quiet(True)
|
|
501
|
+
return self.tui.open_direct("list-mp4", args)
|
|
502
|
+
|
|
503
|
+
def _has_selectors(self, args):
|
|
504
|
+
return (
|
|
505
|
+
args.file is not None
|
|
506
|
+
or args.start is not None
|
|
507
|
+
or args.end is not None
|
|
508
|
+
or args.folder is not None
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _dispatch(self, args):
|
|
512
|
+
"""One product verb, one edit job, the front board, or a fail-closed stop."""
|
|
513
|
+
verb = args.verb
|
|
514
|
+
json_mode = self.output.json_mode
|
|
515
|
+
if args.force and verb != "self-uninstall":
|
|
516
|
+
return self.report_error(
|
|
517
|
+
"--force is only for self-uninstall.",
|
|
518
|
+
"{0} self-uninstall --force".format(Cli.CONSOLE_NAME),
|
|
519
|
+
)
|
|
520
|
+
rejected = self._reject_both_tails(args)
|
|
521
|
+
if rejected is not None:
|
|
522
|
+
return rejected
|
|
523
|
+
if verb == "about":
|
|
524
|
+
if json_mode or not self.stdout_is_tty():
|
|
525
|
+
return self._page(self.tui.about_text())
|
|
526
|
+
result = self.tui.about_on_screen()
|
|
527
|
+
if isinstance(result, int):
|
|
528
|
+
return result
|
|
529
|
+
return self._page(result)
|
|
530
|
+
if verb == "hello":
|
|
531
|
+
return self._verb_hello()
|
|
532
|
+
if verb == "tui":
|
|
533
|
+
if json_mode or not self.stdin_is_tty() or not self.stdout_is_tty():
|
|
534
|
+
return self.report_error(
|
|
535
|
+
"No terminal for the text menu.",
|
|
536
|
+
"{0} tui".format(Cli.CONSOLE_NAME),
|
|
537
|
+
)
|
|
538
|
+
if self.logger is not None:
|
|
539
|
+
self.logger.quiet(True)
|
|
540
|
+
action = self.tui.open_text_menu()
|
|
541
|
+
if action == "missing":
|
|
542
|
+
return 1
|
|
543
|
+
return 0
|
|
544
|
+
if verb == "version":
|
|
545
|
+
return self._page(self.self_manage.local_version())
|
|
546
|
+
if verb == "self-uninstall":
|
|
547
|
+
if not args.force:
|
|
548
|
+
return self.report_error(
|
|
549
|
+
"self-uninstall removes this package with pip.",
|
|
550
|
+
"{0} self-uninstall --force".format(Cli.CONSOLE_NAME),
|
|
551
|
+
)
|
|
552
|
+
return self.self_manage.emit(verb, json_mode=json_mode)
|
|
553
|
+
if verb in ("version-check", "self-update", "self-install"):
|
|
554
|
+
return self.self_manage.emit(verb, json_mode=json_mode)
|
|
555
|
+
if verb == "setup":
|
|
556
|
+
return self.host_setup.run(
|
|
557
|
+
window_lines=self.about.window_library_lines()
|
|
558
|
+
)
|
|
559
|
+
if verb == "join":
|
|
560
|
+
if json_mode or not self.stdout_is_tty():
|
|
561
|
+
return self.report_error(
|
|
562
|
+
"No terminal for join. Use a terminal.",
|
|
563
|
+
"{0} join".format(Cli.CONSOLE_NAME),
|
|
564
|
+
)
|
|
565
|
+
return self.tui.open_direct("join")
|
|
566
|
+
if verb == "list-videos":
|
|
567
|
+
if json_mode or not self.stdout_is_tty():
|
|
568
|
+
return self._print_video_list()
|
|
569
|
+
result = self.tui.list_on_screen()
|
|
570
|
+
if isinstance(result, int):
|
|
571
|
+
return result
|
|
572
|
+
return self._page(result)
|
|
573
|
+
if verb == "edit":
|
|
574
|
+
return self._verb_edit(args)
|
|
575
|
+
if verb == "list-mp4":
|
|
576
|
+
return self._verb_list_mp4(args)
|
|
577
|
+
if verb is not None:
|
|
578
|
+
return self._unknown_verb(verb)
|
|
579
|
+
|
|
580
|
+
selectors = self._has_selectors(args)
|
|
581
|
+
if (args.percent is not None or args.boomerang or args.repeat) and not selectors:
|
|
582
|
+
return self.report_error(
|
|
583
|
+
"--percent, --boomerang, and --repeat need --file, --start, and --end.",
|
|
584
|
+
"{0} edit --file clip.mp4 --start 0 --end 5".format(Cli.CONSOLE_NAME),
|
|
585
|
+
)
|
|
586
|
+
if selectors:
|
|
587
|
+
if args.folder is not None and args.file is None:
|
|
588
|
+
folder_path = Path(args.folder)
|
|
589
|
+
if not folder_path.is_dir():
|
|
590
|
+
return self.report_error(
|
|
591
|
+
"Not a directory: {0}".format(args.folder),
|
|
592
|
+
"{0} list-mp4 --folder .".format(Cli.CONSOLE_NAME),
|
|
593
|
+
)
|
|
594
|
+
files = self.media.get_mp4_files(folder_path)
|
|
595
|
+
if not files:
|
|
596
|
+
return self.report_error(
|
|
597
|
+
"No MP4 files found!",
|
|
598
|
+
"{0} list-mp4 --folder .".format(Cli.CONSOLE_NAME),
|
|
599
|
+
)
|
|
600
|
+
return self.report_error(
|
|
601
|
+
"--folder listed {0} MP4 file(s); also pass --file, --start, and --end.".format(
|
|
602
|
+
len(files)
|
|
603
|
+
),
|
|
604
|
+
"{0} edit --file clip.mp4 --start 0 --end 5".format(Cli.CONSOLE_NAME),
|
|
605
|
+
)
|
|
606
|
+
if not self._job_ready(args):
|
|
607
|
+
return self.report_error(
|
|
608
|
+
"Non-interactive job needs --file, --start, and --end.",
|
|
609
|
+
"{0} edit --file clip.mp4 --start 0 --end 5".format(Cli.CONSOLE_NAME),
|
|
610
|
+
)
|
|
611
|
+
return self._run_batch(args)
|
|
612
|
+
if not self.stdout_is_tty():
|
|
613
|
+
self.build_parser().print_help()
|
|
614
|
+
return 0
|
|
615
|
+
if self.logger is not None:
|
|
616
|
+
self.logger.quiet(True)
|
|
617
|
+
if Cli.opens_window([]):
|
|
618
|
+
code = self.gui.open(self)
|
|
619
|
+
if code != 0:
|
|
620
|
+
return self.report_error(
|
|
621
|
+
self.gui.failure or "The window did not open.",
|
|
622
|
+
"{0} tui".format(Cli.CONSOLE_NAME),
|
|
623
|
+
)
|
|
624
|
+
return 0
|
|
625
|
+
action = self.tui.open_text_menu()
|
|
626
|
+
if action == "missing":
|
|
627
|
+
return 1
|
|
628
|
+
return 0
|
|
629
|
+
|
|
630
|
+
def run(self, argv=None):
|
|
631
|
+
"""One job. def main already wrote ChronicleLogger(...) and passed it in."""
|
|
632
|
+
self.output._json_reset()
|
|
633
|
+
if argv is None:
|
|
634
|
+
argv = sys.argv[1:]
|
|
635
|
+
argv = list(argv)
|
|
636
|
+
parser = self.build_parser()
|
|
637
|
+
try:
|
|
638
|
+
args = parser.parse_args(argv)
|
|
639
|
+
except SystemExit as exc:
|
|
640
|
+
code = exc.code
|
|
641
|
+
if code is None or code == 0:
|
|
642
|
+
return 0
|
|
643
|
+
return int(code)
|
|
644
|
+
if args.json:
|
|
645
|
+
self.output.json_mode = True
|
|
646
|
+
bare_json_help = (
|
|
647
|
+
args.json
|
|
648
|
+
and args.verb is None
|
|
649
|
+
and not self._has_selectors(args)
|
|
650
|
+
and args.percent is None
|
|
651
|
+
and not args.boomerang
|
|
652
|
+
and not args.repeat
|
|
653
|
+
)
|
|
654
|
+
if args.verb == "help" or bare_json_help:
|
|
655
|
+
if args.json:
|
|
656
|
+
parser.print_help(file=sys.stderr)
|
|
657
|
+
code = self.output._emit_json("noninteractive", 0)
|
|
658
|
+
self.output.json_mode = False
|
|
659
|
+
return code
|
|
660
|
+
parser.print_help()
|
|
661
|
+
return 0
|
|
662
|
+
code = self._dispatch(args)
|
|
663
|
+
if not args.json:
|
|
664
|
+
return code
|
|
665
|
+
mode = "interactive" if self.output.json_walk else "noninteractive"
|
|
666
|
+
code = self.output._emit_json(mode, code)
|
|
667
|
+
self.output.json_mode = False
|
|
668
|
+
return code
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def main(argv=None, basedir="", logdir=""):
|
|
672
|
+
"""This function instantiates ChronicleLogger. The statement below is the construct."""
|
|
673
|
+
if argv is None:
|
|
674
|
+
argv = sys.argv[1:]
|
|
675
|
+
argv = list(argv)
|
|
676
|
+
from VideoTubeEditor import __version__
|
|
677
|
+
|
|
678
|
+
HostSetup.ensure_geteuid()
|
|
679
|
+
try:
|
|
680
|
+
from ChronicleLogger import ChronicleLogger
|
|
681
|
+
except ImportError:
|
|
682
|
+
sys.stderr.write(
|
|
683
|
+
'ChronicleLogger is required. Next step: python -m pip install "ChronicleLogger>=1.3.1"\n'
|
|
684
|
+
)
|
|
685
|
+
return 1
|
|
686
|
+
|
|
687
|
+
screen = Cli.opens_text_screen(argv) or Cli.opens_window(argv)
|
|
688
|
+
quiet = screen or ("--json" in argv)
|
|
689
|
+
logger = ChronicleLogger(
|
|
690
|
+
logname="VideoTubeEditor",
|
|
691
|
+
is_quiet=quiet,
|
|
692
|
+
basedir=basedir,
|
|
693
|
+
logdir=logdir,
|
|
694
|
+
)
|
|
695
|
+
appname = logger.logName()
|
|
696
|
+
resolved_base = logger.baseDir()
|
|
697
|
+
logger.logDir()
|
|
698
|
+
|
|
699
|
+
if logger.isDebug():
|
|
700
|
+
logger.log_message(
|
|
701
|
+
"{0} v{1} ({2})".format(appname, __version__, __file__),
|
|
702
|
+
component="main",
|
|
703
|
+
)
|
|
704
|
+
logger.log_message(
|
|
705
|
+
"Using {0}".format(ChronicleLogger.class_version()),
|
|
706
|
+
component="main",
|
|
707
|
+
)
|
|
708
|
+
logger.log_message(
|
|
709
|
+
"Base {0}".format(resolved_base),
|
|
710
|
+
level="DEBUG",
|
|
711
|
+
component="main",
|
|
712
|
+
)
|
|
713
|
+
logger.log_message("debug mode", component="main")
|
|
714
|
+
|
|
715
|
+
app = Cli(logger)
|
|
716
|
+
return app.run(argv)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
if __name__ == "__main__":
|
|
720
|
+
sys.exit(main())
|