morphconv 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.
- morph/__init__.py +0 -0
- morph/batch.py +492 -0
- morph/cli.py +703 -0
- morph/converters/__init__.py +19 -0
- morph/converters/archives.py +85 -0
- morph/converters/audio.py +58 -0
- morph/converters/csv_json.py +28 -0
- morph/converters/csv_to_xlsx.py +290 -0
- morph/converters/documents.py +252 -0
- morph/converters/ebooks.py +36 -0
- morph/converters/fonts.py +72 -0
- morph/converters/images.py +119 -0
- morph/converters/json_yaml.py +26 -0
- morph/converters/models_3d.py +149 -0
- morph/converters/ocr.py +49 -0
- morph/converters/pandas_extra.py +127 -0
- morph/converters/svg.py +63 -0
- morph/converters/video.py +153 -0
- morph/converters/vtracer_converter.py +84 -0
- morph/converters/web.py +92 -0
- morph/converters/xlsx_to_csv.py +272 -0
- morph/deps.py +228 -0
- morph/ffmpeg_utils.py +79 -0
- morph/history.py +136 -0
- morph/progress.py +78 -0
- morph/registry.py +236 -0
- morph/tui.py +792 -0
- morphconv-1.0.1.dist-info/METADATA +396 -0
- morphconv-1.0.1.dist-info/RECORD +33 -0
- morphconv-1.0.1.dist-info/WHEEL +5 -0
- morphconv-1.0.1.dist-info/entry_points.txt +2 -0
- morphconv-1.0.1.dist-info/licenses/LICENSE +21 -0
- morphconv-1.0.1.dist-info/top_level.txt +1 -0
morph/__init__.py
ADDED
|
File without changes
|
morph/batch.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""
|
|
2
|
+
batch.py — parallel batch conversion with live Rich progress display.
|
|
3
|
+
|
|
4
|
+
Usage from the CLI:
|
|
5
|
+
|
|
6
|
+
morph batch '*.mp4' mp3
|
|
7
|
+
morph batch ./videos/ mp3 --recursive --workers 4
|
|
8
|
+
morph batch '*.flac' '*.wav' mp3 --out-dir ./converted/ --skip-existing
|
|
9
|
+
morph batch '*.mp4' mp3 --rename '{stem}_audio' --bitrate 192k
|
|
10
|
+
|
|
11
|
+
All the heavy lifting lives here; cli.py just parses args and calls
|
|
12
|
+
`run_batch()`. The live display uses a Rich Live table that updates
|
|
13
|
+
every 200 ms showing per-file status, an overall progress bar, and a
|
|
14
|
+
summary report on completion.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import glob
|
|
20
|
+
import tempfile
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from rich import box
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.live import Live
|
|
32
|
+
from rich.panel import Panel
|
|
33
|
+
from rich.progress import BarColumn, Progress, TextColumn
|
|
34
|
+
from rich.table import Table as RichTable
|
|
35
|
+
from rich.theme import Theme
|
|
36
|
+
|
|
37
|
+
from . import deps
|
|
38
|
+
from .history import HistoryEntry, append_entry, generate_batch_id, make_entry
|
|
39
|
+
from .registry import ConversionResult, ConverterSpec, detect_format, registry
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── data types ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
class JobState(Enum):
|
|
45
|
+
WAITING = "waiting"
|
|
46
|
+
RUNNING = "running"
|
|
47
|
+
DONE = "done"
|
|
48
|
+
FAILED = "failed"
|
|
49
|
+
SKIPPED = "skipped"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Job:
|
|
54
|
+
input_path: Path
|
|
55
|
+
output_path: Path
|
|
56
|
+
src_fmt: str
|
|
57
|
+
dst_fmt: str
|
|
58
|
+
conv_path: list[ConverterSpec]
|
|
59
|
+
state: JobState = JobState.WAITING
|
|
60
|
+
elapsed: float = 0.0
|
|
61
|
+
error: Optional[str] = None
|
|
62
|
+
input_size: int = 0
|
|
63
|
+
output_size: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class BatchResult:
|
|
68
|
+
total: int = 0
|
|
69
|
+
converted: int = 0
|
|
70
|
+
failed: int = 0
|
|
71
|
+
skipped: int = 0
|
|
72
|
+
elapsed: float = 0.0
|
|
73
|
+
errors: list[tuple[str, str]] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── input collection ──────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def collect_inputs(
|
|
79
|
+
patterns: list[str],
|
|
80
|
+
target_fmt: str,
|
|
81
|
+
*,
|
|
82
|
+
recursive: bool = False,
|
|
83
|
+
out_dir: Optional[Path] = None,
|
|
84
|
+
mirror: bool = False,
|
|
85
|
+
rename_template: Optional[str] = None,
|
|
86
|
+
skip_existing: bool = False,
|
|
87
|
+
newer_only: bool = False,
|
|
88
|
+
base_dir: Optional[Path] = None,
|
|
89
|
+
) -> list[Job]:
|
|
90
|
+
"""
|
|
91
|
+
Resolve input patterns / directories into a list of Job objects,
|
|
92
|
+
each with its input_path, computed output_path, and conversion path.
|
|
93
|
+
"""
|
|
94
|
+
raw_files: list[Path] = []
|
|
95
|
+
|
|
96
|
+
for pattern in patterns:
|
|
97
|
+
p = Path(pattern)
|
|
98
|
+
if p.is_dir():
|
|
99
|
+
if recursive:
|
|
100
|
+
raw_files.extend(f for f in p.rglob("*") if f.is_file())
|
|
101
|
+
else:
|
|
102
|
+
raw_files.extend(f for f in p.iterdir() if f.is_file())
|
|
103
|
+
else:
|
|
104
|
+
expanded = glob.glob(pattern, recursive=recursive)
|
|
105
|
+
raw_files.extend(Path(f) for f in expanded if Path(f).is_file())
|
|
106
|
+
|
|
107
|
+
# Deduplicate, preserving order
|
|
108
|
+
seen: set[Path] = set()
|
|
109
|
+
files: list[Path] = []
|
|
110
|
+
for f in raw_files:
|
|
111
|
+
resolved = f.resolve()
|
|
112
|
+
if resolved not in seen:
|
|
113
|
+
seen.add(resolved)
|
|
114
|
+
files.append(f)
|
|
115
|
+
|
|
116
|
+
target_fmt = target_fmt.lower().lstrip(".")
|
|
117
|
+
jobs: list[Job] = []
|
|
118
|
+
|
|
119
|
+
for file in files:
|
|
120
|
+
src_fmt = detect_format(file)
|
|
121
|
+
if src_fmt == target_fmt:
|
|
122
|
+
# Same format → skip
|
|
123
|
+
jobs.append(Job(
|
|
124
|
+
input_path=file,
|
|
125
|
+
output_path=file,
|
|
126
|
+
src_fmt=src_fmt,
|
|
127
|
+
dst_fmt=target_fmt,
|
|
128
|
+
conv_path=[],
|
|
129
|
+
state=JobState.SKIPPED,
|
|
130
|
+
error="same format",
|
|
131
|
+
))
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
conv_path = registry.find_path(src_fmt, target_fmt)
|
|
135
|
+
if conv_path is None:
|
|
136
|
+
jobs.append(Job(
|
|
137
|
+
input_path=file,
|
|
138
|
+
output_path=file,
|
|
139
|
+
src_fmt=src_fmt,
|
|
140
|
+
dst_fmt=target_fmt,
|
|
141
|
+
conv_path=[],
|
|
142
|
+
state=JobState.SKIPPED,
|
|
143
|
+
error=f"no route from .{src_fmt}",
|
|
144
|
+
))
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
output_path = _compute_output(
|
|
148
|
+
file, target_fmt,
|
|
149
|
+
out_dir=out_dir, mirror=mirror,
|
|
150
|
+
rename_template=rename_template,
|
|
151
|
+
base_dir=base_dir,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if skip_existing and output_path.exists():
|
|
155
|
+
jobs.append(Job(
|
|
156
|
+
input_path=file, output_path=output_path,
|
|
157
|
+
src_fmt=src_fmt, dst_fmt=target_fmt, conv_path=conv_path,
|
|
158
|
+
state=JobState.SKIPPED, error="output exists",
|
|
159
|
+
))
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if newer_only and output_path.exists():
|
|
163
|
+
if output_path.stat().st_mtime >= file.stat().st_mtime:
|
|
164
|
+
jobs.append(Job(
|
|
165
|
+
input_path=file, output_path=output_path,
|
|
166
|
+
src_fmt=src_fmt, dst_fmt=target_fmt, conv_path=conv_path,
|
|
167
|
+
state=JobState.SKIPPED, error="output is newer",
|
|
168
|
+
))
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
jobs.append(Job(
|
|
172
|
+
input_path=file, output_path=output_path,
|
|
173
|
+
src_fmt=src_fmt, dst_fmt=target_fmt, conv_path=conv_path,
|
|
174
|
+
input_size=file.stat().st_size if file.exists() else 0,
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
return jobs
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _compute_output(
|
|
181
|
+
input_path: Path,
|
|
182
|
+
target_fmt: str,
|
|
183
|
+
*,
|
|
184
|
+
out_dir: Optional[Path] = None,
|
|
185
|
+
mirror: bool = False,
|
|
186
|
+
rename_template: Optional[str] = None,
|
|
187
|
+
base_dir: Optional[Path] = None,
|
|
188
|
+
) -> Path:
|
|
189
|
+
stem = input_path.stem
|
|
190
|
+
src_fmt = detect_format(input_path)
|
|
191
|
+
|
|
192
|
+
if rename_template:
|
|
193
|
+
name = rename_template.format(
|
|
194
|
+
stem=stem, fmt=target_fmt, src_fmt=src_fmt,
|
|
195
|
+
) + f".{target_fmt}"
|
|
196
|
+
else:
|
|
197
|
+
name = f"{stem}.{target_fmt}"
|
|
198
|
+
|
|
199
|
+
if out_dir:
|
|
200
|
+
if mirror and base_dir:
|
|
201
|
+
try:
|
|
202
|
+
rel = input_path.parent.relative_to(base_dir)
|
|
203
|
+
except ValueError:
|
|
204
|
+
rel = Path()
|
|
205
|
+
dest = out_dir / rel / name
|
|
206
|
+
else:
|
|
207
|
+
dest = out_dir / name
|
|
208
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
return dest
|
|
210
|
+
|
|
211
|
+
return input_path.parent / name
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ── per-job conversion (runs in a thread) ─────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def _convert_one(
|
|
217
|
+
job: Job,
|
|
218
|
+
options_dict: dict[str, Any],
|
|
219
|
+
lock: threading.Lock,
|
|
220
|
+
) -> Job:
|
|
221
|
+
"""Run the full conversion chain for a single job, updating its state."""
|
|
222
|
+
with lock:
|
|
223
|
+
job.state = JobState.RUNNING
|
|
224
|
+
|
|
225
|
+
start = time.perf_counter()
|
|
226
|
+
current_input = job.input_path
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
230
|
+
for i, spec in enumerate(job.conv_path):
|
|
231
|
+
is_last = i == len(job.conv_path) - 1
|
|
232
|
+
hop_out = (
|
|
233
|
+
job.output_path if is_last
|
|
234
|
+
else Path(tmpdir) / f"hop{i}.{spec.dst}"
|
|
235
|
+
)
|
|
236
|
+
hop_options = {
|
|
237
|
+
opt.name: options_dict.get(opt.name, opt.default)
|
|
238
|
+
for opt in spec.options
|
|
239
|
+
}
|
|
240
|
+
result = spec.func(current_input, hop_out, **hop_options)
|
|
241
|
+
current_input = result.output
|
|
242
|
+
except Exception as exc:
|
|
243
|
+
with lock:
|
|
244
|
+
job.state = JobState.FAILED
|
|
245
|
+
job.elapsed = time.perf_counter() - start
|
|
246
|
+
job.error = str(exc)
|
|
247
|
+
return job
|
|
248
|
+
|
|
249
|
+
with lock:
|
|
250
|
+
job.state = JobState.DONE
|
|
251
|
+
job.elapsed = time.perf_counter() - start
|
|
252
|
+
if job.output_path.exists():
|
|
253
|
+
job.output_size = job.output_path.stat().st_size
|
|
254
|
+
|
|
255
|
+
return job
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── size formatting ───────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def _fmt_size(n: int) -> str:
|
|
261
|
+
if n == 0:
|
|
262
|
+
return ""
|
|
263
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
264
|
+
if n < 1024:
|
|
265
|
+
return f"{n:.1f}{unit}" if unit != "B" else f"{n}{unit}"
|
|
266
|
+
n /= 1024
|
|
267
|
+
return f"{n:.1f}TB"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ── live display ──────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
_SYMBOLS = {
|
|
273
|
+
JobState.WAITING: "[dim]·[/dim]",
|
|
274
|
+
JobState.RUNNING: "[cyan]▶[/cyan]",
|
|
275
|
+
JobState.DONE: "[green]✓[/green]",
|
|
276
|
+
JobState.FAILED: "[red]✗[/red]",
|
|
277
|
+
JobState.SKIPPED: "[yellow]⊘[/yellow]",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
_STATUS_STYLE = {
|
|
281
|
+
JobState.WAITING: "dim",
|
|
282
|
+
JobState.RUNNING: "cyan",
|
|
283
|
+
JobState.DONE: "green",
|
|
284
|
+
JobState.FAILED: "red",
|
|
285
|
+
JobState.SKIPPED: "yellow",
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _build_display(jobs: list[Job], target_fmt: str, elapsed: float) -> RichTable:
|
|
290
|
+
"""Build the full live display table."""
|
|
291
|
+
done_count = sum(1 for j in jobs if j.state == JobState.DONE)
|
|
292
|
+
fail_count = sum(1 for j in jobs if j.state == JobState.FAILED)
|
|
293
|
+
skip_count = sum(1 for j in jobs if j.state == JobState.SKIPPED)
|
|
294
|
+
total = len(jobs)
|
|
295
|
+
convertible = total - skip_count
|
|
296
|
+
|
|
297
|
+
tbl = RichTable(
|
|
298
|
+
box=box.ROUNDED, border_style="cyan",
|
|
299
|
+
title=f"morph batch — {total} files → {target_fmt}",
|
|
300
|
+
title_style="bold cyan",
|
|
301
|
+
caption=(
|
|
302
|
+
f"[green]✓ {done_count}[/green] "
|
|
303
|
+
f"[red]✗ {fail_count}[/red] "
|
|
304
|
+
f"[yellow]⊘ {skip_count}[/yellow] "
|
|
305
|
+
f"[dim]⏱ {elapsed:.1f}s[/dim]"
|
|
306
|
+
),
|
|
307
|
+
show_edge=True,
|
|
308
|
+
pad_edge=True,
|
|
309
|
+
)
|
|
310
|
+
tbl.add_column("", width=2, no_wrap=True)
|
|
311
|
+
tbl.add_column("File", style="white", ratio=3, no_wrap=True)
|
|
312
|
+
tbl.add_column("Status", ratio=2, no_wrap=True)
|
|
313
|
+
tbl.add_column("Time", justify="right", width=8, no_wrap=True)
|
|
314
|
+
tbl.add_column("Size", justify="right", width=18, no_wrap=True)
|
|
315
|
+
|
|
316
|
+
for j in jobs:
|
|
317
|
+
sym = _SYMBOLS[j.state]
|
|
318
|
+
style = _STATUS_STYLE[j.state]
|
|
319
|
+
|
|
320
|
+
name = j.input_path.name
|
|
321
|
+
if len(name) > 35:
|
|
322
|
+
name = name[:32] + "..."
|
|
323
|
+
|
|
324
|
+
if j.state == JobState.DONE:
|
|
325
|
+
status = "done"
|
|
326
|
+
time_str = f"{j.elapsed:.1f}s"
|
|
327
|
+
in_s = _fmt_size(j.input_size)
|
|
328
|
+
out_s = _fmt_size(j.output_size)
|
|
329
|
+
size_str = f"{in_s} → {out_s}" if in_s else ""
|
|
330
|
+
elif j.state == JobState.RUNNING:
|
|
331
|
+
status = "converting…"
|
|
332
|
+
time_str = ""
|
|
333
|
+
size_str = _fmt_size(j.input_size)
|
|
334
|
+
elif j.state == JobState.FAILED:
|
|
335
|
+
status = j.error or "error"
|
|
336
|
+
if len(status) > 30:
|
|
337
|
+
status = status[:27] + "..."
|
|
338
|
+
time_str = f"{j.elapsed:.1f}s"
|
|
339
|
+
size_str = ""
|
|
340
|
+
elif j.state == JobState.SKIPPED:
|
|
341
|
+
status = j.error or "skipped"
|
|
342
|
+
time_str = ""
|
|
343
|
+
size_str = ""
|
|
344
|
+
else:
|
|
345
|
+
status = "waiting"
|
|
346
|
+
time_str = ""
|
|
347
|
+
size_str = ""
|
|
348
|
+
|
|
349
|
+
tbl.add_row(sym, name, f"[{style}]{status}[/{style}]", time_str, size_str)
|
|
350
|
+
|
|
351
|
+
return tbl
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ── main entry point ──────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
def run_batch(
|
|
357
|
+
jobs: list[Job],
|
|
358
|
+
target_fmt: str,
|
|
359
|
+
options_dict: dict[str, Any],
|
|
360
|
+
console: Console,
|
|
361
|
+
*,
|
|
362
|
+
workers: int = 4,
|
|
363
|
+
fail_fast: bool = False,
|
|
364
|
+
error_log: Optional[Path] = None,
|
|
365
|
+
quiet: bool = False,
|
|
366
|
+
) -> BatchResult:
|
|
367
|
+
"""
|
|
368
|
+
Execute all convertible jobs in parallel with a live Rich display,
|
|
369
|
+
returning a BatchResult summary.
|
|
370
|
+
"""
|
|
371
|
+
batch_id = generate_batch_id()
|
|
372
|
+
lock = threading.Lock()
|
|
373
|
+
start_time = time.perf_counter()
|
|
374
|
+
|
|
375
|
+
convertible = [j for j in jobs if j.state == JobState.WAITING]
|
|
376
|
+
result = BatchResult(total=len(jobs), skipped=sum(1 for j in jobs if j.state == JobState.SKIPPED))
|
|
377
|
+
|
|
378
|
+
if not convertible:
|
|
379
|
+
if not quiet:
|
|
380
|
+
console.print("[warning]No files to convert.[/warning]")
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
# Pre-check dependencies for all unique routes
|
|
384
|
+
needed: set[str] = set()
|
|
385
|
+
for j in convertible:
|
|
386
|
+
needed.update(registry.required_binaries(j.conv_path))
|
|
387
|
+
if needed and not deps.ensure_all(list(needed), console, assume_yes=False):
|
|
388
|
+
console.print("[error]✗ Required dependency not available — aborting batch.[/error]")
|
|
389
|
+
return result
|
|
390
|
+
|
|
391
|
+
# Error log file handle
|
|
392
|
+
err_fh = None
|
|
393
|
+
if error_log:
|
|
394
|
+
try:
|
|
395
|
+
err_fh = open(error_log, "a", encoding="utf-8")
|
|
396
|
+
except OSError:
|
|
397
|
+
console.print(f"[warning]Could not open error log: {error_log}[/warning]")
|
|
398
|
+
|
|
399
|
+
stop_flag = threading.Event()
|
|
400
|
+
|
|
401
|
+
def _submit_and_track(executor: ThreadPoolExecutor) -> None:
|
|
402
|
+
futures: dict[Future, Job] = {}
|
|
403
|
+
for j in convertible:
|
|
404
|
+
if stop_flag.is_set():
|
|
405
|
+
break
|
|
406
|
+
f = executor.submit(_convert_one, j, options_dict, lock)
|
|
407
|
+
futures[f] = j
|
|
408
|
+
|
|
409
|
+
for future in as_completed(futures):
|
|
410
|
+
j = futures[future]
|
|
411
|
+
try:
|
|
412
|
+
future.result()
|
|
413
|
+
except Exception as exc:
|
|
414
|
+
with lock:
|
|
415
|
+
j.state = JobState.FAILED
|
|
416
|
+
j.error = str(exc)
|
|
417
|
+
|
|
418
|
+
# History
|
|
419
|
+
route = " → ".join([j.src_fmt] + [s.dst for s in j.conv_path])
|
|
420
|
+
backends = ", ".join(dict.fromkeys(s.backend for s in j.conv_path))
|
|
421
|
+
entry = make_entry(
|
|
422
|
+
j.input_path, j.src_fmt, j.output_path, j.dst_fmt,
|
|
423
|
+
route, backends, j.state == JobState.DONE, j.elapsed,
|
|
424
|
+
mode="batch", batch_id=batch_id,
|
|
425
|
+
error=j.error,
|
|
426
|
+
)
|
|
427
|
+
append_entry(entry)
|
|
428
|
+
|
|
429
|
+
if j.state == JobState.FAILED:
|
|
430
|
+
if err_fh:
|
|
431
|
+
try:
|
|
432
|
+
err_fh.write(f"{j.input_path}: {j.error}\n")
|
|
433
|
+
err_fh.flush()
|
|
434
|
+
except OSError:
|
|
435
|
+
pass
|
|
436
|
+
if fail_fast:
|
|
437
|
+
stop_flag.set()
|
|
438
|
+
|
|
439
|
+
if quiet:
|
|
440
|
+
# No live display — just run and print summary
|
|
441
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
442
|
+
_submit_and_track(executor)
|
|
443
|
+
else:
|
|
444
|
+
with Live(
|
|
445
|
+
_build_display(jobs, target_fmt, 0),
|
|
446
|
+
console=console, refresh_per_second=5, transient=False,
|
|
447
|
+
) as live:
|
|
448
|
+
def _refresh_loop() -> None:
|
|
449
|
+
while not _done.is_set():
|
|
450
|
+
elapsed = time.perf_counter() - start_time
|
|
451
|
+
live.update(_build_display(jobs, target_fmt, elapsed))
|
|
452
|
+
_done.wait(0.2)
|
|
453
|
+
elapsed = time.perf_counter() - start_time
|
|
454
|
+
live.update(_build_display(jobs, target_fmt, elapsed))
|
|
455
|
+
|
|
456
|
+
_done = threading.Event()
|
|
457
|
+
refresh_thread = threading.Thread(target=_refresh_loop, daemon=True)
|
|
458
|
+
refresh_thread.start()
|
|
459
|
+
|
|
460
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
461
|
+
_submit_and_track(executor)
|
|
462
|
+
|
|
463
|
+
_done.set()
|
|
464
|
+
refresh_thread.join(timeout=2)
|
|
465
|
+
|
|
466
|
+
if err_fh:
|
|
467
|
+
err_fh.close()
|
|
468
|
+
|
|
469
|
+
total_elapsed = time.perf_counter() - start_time
|
|
470
|
+
result.converted = sum(1 for j in jobs if j.state == JobState.DONE)
|
|
471
|
+
result.failed = sum(1 for j in jobs if j.state == JobState.FAILED)
|
|
472
|
+
result.elapsed = total_elapsed
|
|
473
|
+
result.errors = [(str(j.input_path.name), j.error or "unknown") for j in jobs if j.state == JobState.FAILED]
|
|
474
|
+
|
|
475
|
+
# Summary panel
|
|
476
|
+
if not quiet:
|
|
477
|
+
console.print()
|
|
478
|
+
parts = [
|
|
479
|
+
f"[green]✓ {result.converted} converted[/green]",
|
|
480
|
+
f"[red]✗ {result.failed} failed[/red]",
|
|
481
|
+
]
|
|
482
|
+
if result.skipped:
|
|
483
|
+
parts.append(f"[yellow]⊘ {result.skipped} skipped[/yellow]")
|
|
484
|
+
parts.append(f"[dim]⏱ {result.elapsed:.1f}s[/dim]")
|
|
485
|
+
console.print(Panel.fit(" ".join(parts), title="morph batch — complete", border_style="cyan"))
|
|
486
|
+
|
|
487
|
+
if result.errors:
|
|
488
|
+
console.print()
|
|
489
|
+
for name, err in result.errors:
|
|
490
|
+
console.print(f" [red]✗[/red] {name} — {err}")
|
|
491
|
+
|
|
492
|
+
return result
|