video-edit-cli 0.1.0__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.
video_editor/cli.py ADDED
@@ -0,0 +1,1021 @@
1
+ """video-edit-cli CLI: headless atomic subcommands with a structured JSON contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ import json
11
+
12
+ from video_editor import (
13
+ __version__,
14
+ assets,
15
+ inspection,
16
+ media,
17
+ plans,
18
+ profiles,
19
+ provenance,
20
+ reframe,
21
+ rendering,
22
+ result,
23
+ review,
24
+ subtitles,
25
+ sync,
26
+ workspace,
27
+ )
28
+ from video_editor.audio import analysis as audio_analysis
29
+ from video_editor.audio import comparison as audio_comparison
30
+ from video_editor.audio import denoise as audio_denoise
31
+ from video_editor.audio import mastering as audio_mastering
32
+ from video_editor.errors import VideoEditorError
33
+ from video_editor.transcription import base as transcription_base
34
+ from video_editor.transcription import views as transcript_views
35
+
36
+ Handler = Callable[[argparse.Namespace], tuple[dict[str, Any], list[dict[str, str]]]]
37
+
38
+
39
+ def _register_if_workspace(
40
+ args: argparse.Namespace, output: Path, kind: str, command: str
41
+ ) -> None:
42
+ root = getattr(args, "workspace", None)
43
+ if root is not None:
44
+ workspace.register_artifact(Path(root), output, kind, command)
45
+
46
+
47
+ def _derived_artifact(
48
+ args: argparse.Namespace,
49
+ command: str,
50
+ source: Path,
51
+ output: Path,
52
+ kind: str,
53
+ parameters: dict[str, Any],
54
+ tool_args: list[str],
55
+ ) -> list[dict[str, str]]:
56
+ sidecar = provenance.write_sidecar(
57
+ output, command, [source], parameters, [["ffmpeg", *tool_args]]
58
+ )
59
+ _register_if_workspace(args, output, kind, command)
60
+ return [result.artifact(output, kind), result.artifact(sidecar, "provenance")]
61
+
62
+
63
+ def cmd_workspace_init(
64
+ args: argparse.Namespace,
65
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
66
+ manifest = workspace.init(
67
+ Path(args.root),
68
+ [Path(p) for p in args.source],
69
+ list(args.role) if args.role else None,
70
+ )
71
+ root = Path(args.root).resolve()
72
+ return (
73
+ {
74
+ "workspace_id": manifest["workspace_id"],
75
+ "root": str(root),
76
+ "sources": manifest["sources"],
77
+ },
78
+ [result.artifact(root / "workspace.json", "workspace-manifest")],
79
+ )
80
+
81
+
82
+ def cmd_probe(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[str, str]]]:
83
+ return media.probe(Path(args.input)), []
84
+
85
+
86
+ def cmd_audio_extract(
87
+ args: argparse.Namespace,
88
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
89
+ source, output = Path(args.input), Path(args.output)
90
+ tool_args = inspection.extract_audio(source, output)
91
+ artifacts = _derived_artifact(
92
+ args, "audio extract", source, output, "audio", {}, tool_args
93
+ )
94
+ return {"output": str(output)}, artifacts
95
+
96
+
97
+ def cmd_proxy_create(
98
+ args: argparse.Namespace,
99
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
100
+ source, output = Path(args.input), Path(args.output)
101
+ tool_args = inspection.create_proxy(source, output, args.height)
102
+ artifacts = _derived_artifact(
103
+ args,
104
+ "proxy create",
105
+ source,
106
+ output,
107
+ "proxy",
108
+ {"height": args.height},
109
+ tool_args,
110
+ )
111
+ return {"output": str(output), "height": args.height}, artifacts
112
+
113
+
114
+ def cmd_frame_extract(
115
+ args: argparse.Namespace,
116
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
117
+ source, output = Path(args.input), Path(args.output)
118
+ tool_args = inspection.extract_frame(source, args.time, output)
119
+ artifacts = _derived_artifact(
120
+ args, "frame extract", source, output, "frame", {"time": args.time}, tool_args
121
+ )
122
+ return {"output": str(output), "time": args.time}, artifacts
123
+
124
+
125
+ def cmd_filmstrip_create(
126
+ args: argparse.Namespace,
127
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
128
+ source, output = Path(args.input), Path(args.output)
129
+ params = {
130
+ "start": args.start,
131
+ "end": args.end,
132
+ "columns": args.columns,
133
+ "frames": args.frames,
134
+ }
135
+ tool_args, tile_times = inspection.create_filmstrip(
136
+ source, args.start, args.end, output, args.columns, args.frames
137
+ )
138
+ artifacts = _derived_artifact(
139
+ args, "filmstrip create", source, output, "filmstrip", params, tool_args
140
+ )
141
+ return {"output": str(output), "tile_times": tile_times, **params}, artifacts
142
+
143
+
144
+ def cmd_waveform_create(
145
+ args: argparse.Namespace,
146
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
147
+ source, output = Path(args.input), Path(args.output)
148
+ params = {"start": args.start, "end": args.end}
149
+ tool_args = inspection.create_waveform(source, args.start, args.end, output)
150
+ artifacts = _derived_artifact(
151
+ args, "waveform create", source, output, "waveform", params, tool_args
152
+ )
153
+ return {"output": str(output), **params}, artifacts
154
+
155
+
156
+ def cmd_preview_create(
157
+ args: argparse.Namespace,
158
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
159
+ source, output = Path(args.input), Path(args.output)
160
+ params = {"start": args.start, "end": args.end}
161
+ tool_args = inspection.create_preview(source, args.start, args.end, output)
162
+ artifacts = _derived_artifact(
163
+ args, "preview create", source, output, "preview", params, tool_args
164
+ )
165
+ return {"output": str(output), **params}, artifacts
166
+
167
+
168
+ def cmd_transcript_create(
169
+ args: argparse.Namespace,
170
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
171
+ source, output = Path(args.input), Path(args.output)
172
+ backend = transcription_base.get_backend(
173
+ args.backend, Path(args.fixture) if args.fixture else None
174
+ )
175
+ document = transcription_base.transcribe_to_document(
176
+ backend, source, args.model, args.language, args.source_id
177
+ )
178
+ output.parent.mkdir(parents=True, exist_ok=True)
179
+ output.write_text(json.dumps(document, indent=2) + "\n")
180
+ sidecar = provenance.write_sidecar(
181
+ output,
182
+ "transcript create",
183
+ [source],
184
+ {
185
+ "backend": args.backend,
186
+ "model": document["model"],
187
+ "language": document["language"],
188
+ },
189
+ )
190
+ _register_if_workspace(args, output, "transcript", "transcript create")
191
+ return (
192
+ {
193
+ "output": str(output),
194
+ "backend": document["backend"],
195
+ "model": document["model"],
196
+ "language": document["language"],
197
+ "segment_count": len(document["segments"]),
198
+ "word_count": sum(len(s["words"]) for s in document["segments"]),
199
+ },
200
+ [result.artifact(output, "transcript"), result.artifact(sidecar, "provenance")],
201
+ )
202
+
203
+
204
+ def cmd_transcript_pack(
205
+ args: argparse.Namespace,
206
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
207
+ transcript_path, output = Path(args.transcript), Path(args.output)
208
+ document = transcript_views.load_transcript(transcript_path)
209
+ packed = transcript_views.pack(document)
210
+ output.parent.mkdir(parents=True, exist_ok=True)
211
+ output.write_text(packed)
212
+ _register_if_workspace(args, output, "transcript-packed", "transcript pack")
213
+ return (
214
+ {"output": str(output), "line_count": packed.count("\n")},
215
+ [result.artifact(output, "transcript-packed")],
216
+ )
217
+
218
+
219
+ def cmd_transcript_search(
220
+ args: argparse.Namespace,
221
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
222
+ document = transcript_views.load_transcript(Path(args.transcript))
223
+ matches = transcript_views.search(document, args.query, args.max_results)
224
+ return {"query": args.query, "match_count": len(matches), "matches": matches}, []
225
+
226
+
227
+ def cmd_plan_validate(
228
+ args: argparse.Namespace,
229
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
230
+ plan = plans.load(Path(args.plan))
231
+ summary = plans.validate(plan)
232
+ return {"valid": True, **summary}, []
233
+
234
+
235
+ def cmd_render_preview(
236
+ args: argparse.Namespace,
237
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
238
+ plan = plans.load(Path(args.plan))
239
+ output = Path(args.output)
240
+ manifest_path, manifest = rendering.render_preview(plan, output, args.height)
241
+ _register_if_workspace(args, output, "render-preview", "render preview")
242
+ return (
243
+ {
244
+ "output": str(output),
245
+ "manifest": str(manifest_path),
246
+ "plan_id": plan["plan_id"],
247
+ "output_duration": manifest["summary"]["output_duration"],
248
+ "clip_count": manifest["summary"]["clip_count"],
249
+ "boundaries": manifest["summary"]["boundaries"],
250
+ },
251
+ [
252
+ result.artifact(output, "render-preview"),
253
+ result.artifact(manifest_path, "render-manifest"),
254
+ ],
255
+ )
256
+
257
+
258
+ def cmd_cuts_list(
259
+ args: argparse.Namespace,
260
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
261
+ manifest = review.load_manifest(Path(args.manifest))
262
+ cuts = review.list_cuts(manifest)
263
+ return {"cut_count": len(cuts), "cuts": cuts}, []
264
+
265
+
266
+ def cmd_cut_inspect(
267
+ args: argparse.Namespace,
268
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
269
+ manifest = review.load_manifest(Path(args.manifest))
270
+ report = review.inspect_cut(
271
+ manifest,
272
+ args.cut,
273
+ Path(args.output_dir),
274
+ args.window,
275
+ Path(args.transcript) if args.transcript else None,
276
+ )
277
+ artifacts = [
278
+ result.artifact(path, kind) for kind, path in report["artifacts"].items()
279
+ ]
280
+ artifacts.append(result.artifact(report["report_path"], "cut-report"))
281
+ return report, artifacts
282
+
283
+
284
+ def cmd_audio_analyze(
285
+ args: argparse.Namespace,
286
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
287
+ return audio_analysis.analyze(Path(args.input)), []
288
+
289
+
290
+ def cmd_audio_master(
291
+ args: argparse.Namespace,
292
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
293
+ source, output = Path(args.input), Path(args.output)
294
+ tool_args, metrics = audio_mastering.master(
295
+ source,
296
+ output,
297
+ target_i=args.target_lufs,
298
+ target_tp=args.true_peak,
299
+ target_lra=args.lra,
300
+ highpass_hz=args.highpass,
301
+ compress=not args.no_compressor,
302
+ )
303
+ params = {
304
+ "target_lufs": args.target_lufs,
305
+ "true_peak": args.true_peak,
306
+ "lra": args.lra,
307
+ "highpass": args.highpass,
308
+ "compressor": not args.no_compressor,
309
+ }
310
+ artifacts = _derived_artifact(
311
+ args, "audio master", source, output, "audio-mastered", params, tool_args
312
+ )
313
+ return {"output": str(output), **metrics}, artifacts
314
+
315
+
316
+ def cmd_audio_denoise(
317
+ args: argparse.Namespace,
318
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
319
+ source, output = Path(args.input), Path(args.output)
320
+ details = audio_denoise.denoise(source, output, args.backend)
321
+ sidecar = provenance.write_sidecar(output, "audio denoise", [source], details)
322
+ _register_if_workspace(args, output, "audio-denoised", "audio denoise")
323
+ return (
324
+ {"output": str(output), **details},
325
+ [
326
+ result.artifact(output, "audio-denoised"),
327
+ result.artifact(sidecar, "provenance"),
328
+ ],
329
+ )
330
+
331
+
332
+ def cmd_audio_compare(
333
+ args: argparse.Namespace,
334
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
335
+ report = audio_comparison.compare(
336
+ [Path(p) for p in args.input],
337
+ Path(args.output_dir),
338
+ match_lufs=args.match_lufs,
339
+ sample_start=args.start,
340
+ sample_duration=args.duration,
341
+ )
342
+ artifacts = [
343
+ result.artifact(entry["ab_sample"], "ab-sample")
344
+ for entry in report["candidates"]
345
+ ]
346
+ artifacts.append(result.artifact(report["report_path"], "compare-report"))
347
+ return report, artifacts
348
+
349
+
350
+ def cmd_render_master(
351
+ args: argparse.Namespace,
352
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
353
+ plan = plans.load(Path(args.plan))
354
+ profile_document = profiles.load(Path(args.profile))
355
+ output = Path(args.output)
356
+ manifest_path, manifest = rendering.render_master(
357
+ plan, output, profile_document, args.profile_name
358
+ )
359
+ _register_if_workspace(args, output, "render-master", "render master")
360
+ return (
361
+ {
362
+ "output": str(output),
363
+ "manifest": str(manifest_path),
364
+ "plan_id": plan["plan_id"],
365
+ "profile": args.profile_name,
366
+ "output_duration": manifest["summary"]["output_duration"],
367
+ "boundaries": manifest["summary"]["boundaries"],
368
+ },
369
+ [
370
+ result.artifact(output, "render-master"),
371
+ result.artifact(manifest_path, "render-manifest"),
372
+ ],
373
+ )
374
+
375
+
376
+ def cmd_subtitles_create(
377
+ args: argparse.Namespace,
378
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
379
+ manifest = review.load_manifest(Path(args.manifest))
380
+ plan_sources = {s["id"]: s["path"] for s in manifest["sources"]}
381
+ transcripts: dict[str, Any] = {}
382
+ for transcript_path in args.transcript:
383
+ document = transcript_views.load_transcript(Path(transcript_path))
384
+ source_id = document["source"].get("source_id")
385
+ if source_id is None:
386
+ matches = [
387
+ sid
388
+ for sid, path in plan_sources.items()
389
+ if Path(path).resolve() == Path(document["source"]["path"]).resolve()
390
+ ]
391
+ if len(matches) != 1:
392
+ raise VideoEditorError(
393
+ "invalid-input",
394
+ f"cannot map transcript {transcript_path} to a plan source; "
395
+ "set source_id when creating the transcript",
396
+ exit_code=2,
397
+ )
398
+ source_id = matches[0]
399
+ transcripts[source_id] = document
400
+ cues = subtitles.map_cues(transcripts, manifest["segments"])
401
+ if not cues:
402
+ raise VideoEditorError(
403
+ "invalid-input",
404
+ "no transcript words fall inside the kept ranges",
405
+ exit_code=2,
406
+ )
407
+ srt_path, vtt_path = Path(args.output_srt), Path(args.output_vtt)
408
+ subtitles.write_srt(cues, srt_path)
409
+ subtitles.write_vtt(cues, vtt_path)
410
+ _register_if_workspace(args, srt_path, "subtitles-srt", "subtitles create")
411
+ return (
412
+ {
413
+ "cue_count": len(cues),
414
+ "first_cue_start": cues[0]["start"],
415
+ "last_cue_end": cues[-1]["end"],
416
+ "srt": str(srt_path),
417
+ "vtt": str(vtt_path),
418
+ },
419
+ [
420
+ result.artifact(srt_path, "subtitles-srt"),
421
+ result.artifact(vtt_path, "subtitles-vtt"),
422
+ ],
423
+ )
424
+
425
+
426
+ def cmd_subtitles_render(
427
+ args: argparse.Namespace,
428
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
429
+ video, subtitle_file, output = (
430
+ Path(args.input),
431
+ Path(args.subtitles),
432
+ Path(args.output),
433
+ )
434
+ tool_args = subtitles.render_subtitles(video, subtitle_file, output, args.mode)
435
+ sidecar = provenance.write_sidecar(
436
+ output,
437
+ "subtitles render",
438
+ [video, subtitle_file],
439
+ {"mode": args.mode},
440
+ [["ffmpeg", *tool_args]],
441
+ )
442
+ _register_if_workspace(args, output, "video-subtitled", "subtitles render")
443
+ return (
444
+ {"output": str(output), "mode": args.mode},
445
+ [
446
+ result.artifact(output, "video-subtitled"),
447
+ result.artifact(sidecar, "provenance"),
448
+ ],
449
+ )
450
+
451
+
452
+ def cmd_asset_inspect(
453
+ args: argparse.Namespace,
454
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
455
+ return assets.inspect_asset(Path(args.input)), []
456
+
457
+
458
+ def cmd_output_validate(
459
+ args: argparse.Namespace,
460
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
461
+ profile = None
462
+ if args.profile:
463
+ document = profiles.load(Path(args.profile))
464
+ profile = profiles.named_profile(document, args.profile_name)
465
+ report = assets.validate_output(
466
+ Path(args.input),
467
+ profile=profile,
468
+ expect_duration=args.expect_duration,
469
+ duration_tolerance=args.duration_tolerance,
470
+ loudness_tolerance=args.loudness_tolerance,
471
+ expect_subtitles=args.expect_subtitles,
472
+ subtitle_file=Path(args.subtitles) if args.subtitles else None,
473
+ )
474
+ return report, []
475
+
476
+
477
+ def cmd_sync_analyze(
478
+ args: argparse.Namespace,
479
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
480
+ return sync.analyze(Path(args.reference), Path(args.other), args.max_offset), []
481
+
482
+
483
+ def cmd_sync_apply(
484
+ args: argparse.Namespace,
485
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
486
+ source, output = Path(args.input), Path(args.output)
487
+ data = sync.apply(source, args.offset, output)
488
+ kind = "sync-mapping" if data["mode"] == "metadata" else "aligned-media"
489
+ artifacts = [result.artifact(output, kind)]
490
+ if data["mode"] == "trim":
491
+ sidecar = provenance.write_sidecar(
492
+ output, "sync apply", [source], {"offset": args.offset}
493
+ )
494
+ artifacts.append(result.artifact(sidecar, "provenance"))
495
+ _register_if_workspace(args, output, kind, "sync apply")
496
+ return data, artifacts
497
+
498
+
499
+ def cmd_reframe_preview(
500
+ args: argparse.Namespace,
501
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
502
+ source, output = Path(args.input), Path(args.output)
503
+ crop = reframe.parse_crop(args.crop)
504
+ canvas = reframe.parse_canvas(args.canvas)
505
+ params = {"start": args.start, "end": args.end, "crop": crop, "canvas": canvas}
506
+ tool_args = reframe.preview_reframe(
507
+ source, args.start, args.end, crop, canvas, output
508
+ )
509
+ artifacts = _derived_artifact(
510
+ args, "reframe preview", source, output, "reframe-preview", params, tool_args
511
+ )
512
+ return {"output": str(output), **params}, artifacts
513
+
514
+
515
+ def cmd_short_create_plan(
516
+ args: argparse.Namespace,
517
+ ) -> tuple[dict[str, Any], list[dict[str, str]]]:
518
+ output = Path(args.output)
519
+ plan = reframe.derive_short_plan(
520
+ Path(args.input),
521
+ args.source_id,
522
+ args.start,
523
+ args.end,
524
+ reframe.parse_canvas(args.canvas),
525
+ args.reason,
526
+ crop=reframe.parse_crop(args.crop) if args.crop else None,
527
+ parent_plan=args.parent_plan,
528
+ created_by=args.created_by,
529
+ )
530
+ plans.validate(plan)
531
+ output.parent.mkdir(parents=True, exist_ok=True)
532
+ output.write_text(json.dumps(plan, indent=2) + "\n")
533
+ _register_if_workspace(args, output, "edit-plan", "short create-plan")
534
+ return (
535
+ {
536
+ "output": str(output),
537
+ "plan_id": plan["plan_id"],
538
+ "canvas": plan["output_canvas"],
539
+ "range": {"start": args.start, "end": args.end},
540
+ },
541
+ [result.artifact(output, "edit-plan")],
542
+ )
543
+
544
+
545
+ def _add_io(parser: argparse.ArgumentParser, needs_output: bool = True) -> None:
546
+ parser.add_argument("--input", required=True, help="path to the source media file")
547
+ if needs_output:
548
+ parser.add_argument(
549
+ "--output", required=True, help="path for the new derived file"
550
+ )
551
+ parser.add_argument(
552
+ "--workspace",
553
+ help="optional workspace root; records the derived artifact in workspace.json",
554
+ )
555
+
556
+
557
+ def _add_range(parser: argparse.ArgumentParser) -> None:
558
+ parser.add_argument(
559
+ "--start", type=float, required=True, help="range start in seconds"
560
+ )
561
+ parser.add_argument("--end", type=float, required=True, help="range end in seconds")
562
+
563
+
564
+ def build_parser() -> argparse.ArgumentParser:
565
+ parser = argparse.ArgumentParser(
566
+ prog="video-edit-cli",
567
+ description=(
568
+ "Atomic, non-interactive video-editing primitives for agents. "
569
+ "Every command prints one JSON result object on stdout."
570
+ ),
571
+ )
572
+ parser.add_argument("--version", action="version", version=__version__)
573
+ top = parser.add_subparsers(dest="group", required=True)
574
+
575
+ ws = top.add_parser("workspace", help="manage an editing workspace").add_subparsers(
576
+ dest="action", required=True
577
+ )
578
+ ws_init = ws.add_parser(
579
+ "init", help="create a workspace and register immutable sources"
580
+ )
581
+ ws_init.add_argument(
582
+ "--root", required=True, help="directory to create the workspace in"
583
+ )
584
+ ws_init.add_argument(
585
+ "--source",
586
+ action="append",
587
+ required=True,
588
+ help="source media path (repeatable)",
589
+ )
590
+ ws_init.add_argument(
591
+ "--role", action="append", help="role label per source, in --source order"
592
+ )
593
+ ws_init.set_defaults(handler=cmd_workspace_init, command_name="workspace init")
594
+
595
+ probe = top.add_parser("probe", help="inspect streams and container metadata")
596
+ probe.add_argument("--input", required=True, help="path to the media file")
597
+ probe.set_defaults(handler=cmd_probe, command_name="probe")
598
+
599
+ audio = top.add_parser("audio", help="audio operations").add_subparsers(
600
+ dest="action", required=True
601
+ )
602
+ extract = audio.add_parser("extract", help="extract lossless canonical WAV audio")
603
+ _add_io(extract)
604
+ extract.set_defaults(handler=cmd_audio_extract, command_name="audio extract")
605
+
606
+ analyze = audio.add_parser(
607
+ "analyze", help="measure loudness, peaks, clipping, silence, bandwidth"
608
+ )
609
+ analyze.add_argument(
610
+ "--input", required=True, help="audio or video file to measure"
611
+ )
612
+ analyze.set_defaults(handler=cmd_audio_analyze, command_name="audio analyze")
613
+
614
+ master = audio.add_parser(
615
+ "master", help="deterministic mastering with two-pass loudness normalization"
616
+ )
617
+ _add_io(master)
618
+ master.add_argument(
619
+ "--target-lufs", type=float, default=-16.0, help="integrated target"
620
+ )
621
+ master.add_argument(
622
+ "--true-peak", type=float, default=-1.5, help="true-peak ceiling dBTP"
623
+ )
624
+ master.add_argument("--lra", type=float, default=11.0, help="loudness range target")
625
+ master.add_argument("--highpass", type=int, default=70, help="rumble highpass Hz")
626
+ master.add_argument(
627
+ "--no-compressor", action="store_true", help="skip the gentle speech compressor"
628
+ )
629
+ master.set_defaults(handler=cmd_audio_master, command_name="audio master")
630
+
631
+ denoise = audio.add_parser(
632
+ "denoise", help="run one explicitly selected local denoising backend"
633
+ )
634
+ _add_io(denoise)
635
+ denoise.add_argument(
636
+ "--backend",
637
+ required=True,
638
+ choices=["deepfilternet"],
639
+ help="denoising backend (explicit; never applied implicitly)",
640
+ )
641
+ denoise.set_defaults(handler=cmd_audio_denoise, command_name="audio denoise")
642
+
643
+ compare = audio.add_parser(
644
+ "compare", help="loudness-matched A/B artifacts and metrics for candidates"
645
+ )
646
+ compare.add_argument(
647
+ "--input",
648
+ action="append",
649
+ required=True,
650
+ help="candidate audio (repeat 2+ times)",
651
+ )
652
+ compare.add_argument(
653
+ "--output-dir", required=True, help="directory for samples + report"
654
+ )
655
+ compare.add_argument("--match-lufs", type=float, default=-20.0)
656
+ compare.add_argument(
657
+ "--start", type=float, default=0.0, help="excerpt start seconds"
658
+ )
659
+ compare.add_argument("--duration", type=float, default=12.0, help="excerpt seconds")
660
+ compare.set_defaults(handler=cmd_audio_compare, command_name="audio compare")
661
+
662
+ proxy = top.add_parser("proxy", help="inspection proxies").add_subparsers(
663
+ dest="action", required=True
664
+ )
665
+ proxy_create = proxy.add_parser("create", help="create a low-resolution proxy")
666
+ _add_io(proxy_create)
667
+ proxy_create.add_argument(
668
+ "--height", type=int, default=360, help="proxy height in pixels"
669
+ )
670
+ proxy_create.set_defaults(handler=cmd_proxy_create, command_name="proxy create")
671
+
672
+ frame = top.add_parser("frame", help="single frames").add_subparsers(
673
+ dest="action", required=True
674
+ )
675
+ frame_extract = frame.add_parser(
676
+ "extract", help="extract one frame at a source time"
677
+ )
678
+ _add_io(frame_extract)
679
+ frame_extract.add_argument(
680
+ "--time", type=float, required=True, help="source time in seconds"
681
+ )
682
+ frame_extract.set_defaults(handler=cmd_frame_extract, command_name="frame extract")
683
+
684
+ filmstrip = top.add_parser("filmstrip", help="contact sheets").add_subparsers(
685
+ dest="action", required=True
686
+ )
687
+ filmstrip_create = filmstrip.add_parser(
688
+ "create", help="create a timestamped contact sheet for a range"
689
+ )
690
+ _add_io(filmstrip_create)
691
+ _add_range(filmstrip_create)
692
+ filmstrip_create.add_argument(
693
+ "--columns", type=int, default=6, help="tiles per row"
694
+ )
695
+ filmstrip_create.add_argument(
696
+ "--frames", type=int, default=12, help="frames to sample"
697
+ )
698
+ filmstrip_create.set_defaults(
699
+ handler=cmd_filmstrip_create, command_name="filmstrip create"
700
+ )
701
+
702
+ waveform = top.add_parser("waveform", help="waveform images").add_subparsers(
703
+ dest="action", required=True
704
+ )
705
+ waveform_create = waveform.add_parser(
706
+ "create", help="render a waveform for a range"
707
+ )
708
+ _add_io(waveform_create)
709
+ _add_range(waveform_create)
710
+ waveform_create.set_defaults(
711
+ handler=cmd_waveform_create, command_name="waveform create"
712
+ )
713
+
714
+ preview = top.add_parser("preview", help="range previews").add_subparsers(
715
+ dest="action", required=True
716
+ )
717
+ preview_create = preview.add_parser(
718
+ "create", help="render a short low-cost preview of a range"
719
+ )
720
+ _add_io(preview_create)
721
+ _add_range(preview_create)
722
+ preview_create.set_defaults(
723
+ handler=cmd_preview_create, command_name="preview create"
724
+ )
725
+
726
+ transcript = top.add_parser(
727
+ "transcript", help="word-level transcripts (JSON is authoritative)"
728
+ ).add_subparsers(dest="action", required=True)
729
+ t_create = transcript.add_parser(
730
+ "create", help="transcribe with a local backend and write detailed JSON"
731
+ )
732
+ _add_io(t_create)
733
+ t_create.add_argument(
734
+ "--backend",
735
+ default="mlx-whisper",
736
+ choices=["mlx-whisper", "fixture"],
737
+ help="transcription backend (fixture replays a prepared raw JSON, for tests/dev)",
738
+ )
739
+ t_create.add_argument("--model", help="backend model name")
740
+ t_create.add_argument("--language", help="spoken language hint (e.g. en, da)")
741
+ t_create.add_argument(
742
+ "--fixture", help="raw transcription JSON for --backend fixture"
743
+ )
744
+ t_create.add_argument(
745
+ "--source-id", help="workspace source id to record in the transcript"
746
+ )
747
+ t_create.set_defaults(
748
+ handler=cmd_transcript_create, command_name="transcript create"
749
+ )
750
+
751
+ t_pack = transcript.add_parser(
752
+ "pack", help="derive the compact agent-readable text view"
753
+ )
754
+ t_pack.add_argument(
755
+ "--transcript", required=True, help="authoritative transcript JSON path"
756
+ )
757
+ t_pack.add_argument("--output", required=True, help="path for the packed text view")
758
+ t_pack.add_argument(
759
+ "--workspace",
760
+ help="optional workspace root; records the artifact in workspace.json",
761
+ )
762
+ t_pack.set_defaults(handler=cmd_transcript_pack, command_name="transcript pack")
763
+
764
+ t_search = transcript.add_parser(
765
+ "search", help="find time-aligned matches for a spoken phrase"
766
+ )
767
+ t_search.add_argument(
768
+ "--transcript", required=True, help="authoritative transcript JSON path"
769
+ )
770
+ t_search.add_argument("--query", required=True, help="phrase to search for")
771
+ t_search.add_argument("--max-results", type=int, default=20)
772
+ t_search.set_defaults(
773
+ handler=cmd_transcript_search, command_name="transcript search"
774
+ )
775
+
776
+ plan = top.add_parser("plan", help="edit plans").add_subparsers(
777
+ dest="action", required=True
778
+ )
779
+ p_validate = plan.add_parser(
780
+ "validate", help="validate a plan's schema, references, and ranges"
781
+ )
782
+ p_validate.add_argument("--plan", required=True, help="edit-plan JSON path")
783
+ p_validate.set_defaults(handler=cmd_plan_validate, command_name="plan validate")
784
+
785
+ render = top.add_parser("render", help="render validated plans").add_subparsers(
786
+ dest="action", required=True
787
+ )
788
+ r_preview = render.add_parser(
789
+ "preview", help="render a plan with the low-cost preview profile"
790
+ )
791
+ r_preview.add_argument("--plan", required=True, help="edit-plan JSON path")
792
+ r_preview.add_argument(
793
+ "--output", required=True, help="path for the rendered preview"
794
+ )
795
+ r_preview.add_argument(
796
+ "--height", type=int, default=360, help="preview height in pixels"
797
+ )
798
+ r_preview.add_argument(
799
+ "--workspace",
800
+ help="optional workspace root; records the artifact in workspace.json",
801
+ )
802
+ r_preview.set_defaults(handler=cmd_render_preview, command_name="render preview")
803
+
804
+ r_master = render.add_parser(
805
+ "master", help="render a plan using a named external project profile"
806
+ )
807
+ r_master.add_argument("--plan", required=True, help="edit-plan JSON path")
808
+ r_master.add_argument("--profile", required=True, help="project profile YAML path")
809
+ r_master.add_argument(
810
+ "--profile-name", required=True, help="named render profile inside the YAML"
811
+ )
812
+ r_master.add_argument("--output", required=True, help="path for the master render")
813
+ r_master.add_argument(
814
+ "--workspace",
815
+ help="optional workspace root; records the artifact in workspace.json",
816
+ )
817
+ r_master.set_defaults(handler=cmd_render_master, command_name="render master")
818
+
819
+ cuts = top.add_parser("cuts", help="edit boundaries of a render").add_subparsers(
820
+ dest="action", required=True
821
+ )
822
+ c_list = cuts.add_parser(
823
+ "list", help="enumerate cut boundaries from a render manifest"
824
+ )
825
+ c_list.add_argument("--manifest", required=True, help="render manifest JSON path")
826
+ c_list.set_defaults(handler=cmd_cuts_list, command_name="cuts list")
827
+
828
+ cut = top.add_parser("cut", help="single-boundary review").add_subparsers(
829
+ dest="action", required=True
830
+ )
831
+ c_inspect = cut.add_parser(
832
+ "inspect",
833
+ help="gather frames, waveform, preview, transcript context, and checks around one cut",
834
+ )
835
+ c_inspect.add_argument(
836
+ "--manifest", required=True, help="render manifest JSON path"
837
+ )
838
+ c_inspect.add_argument(
839
+ "--cut", type=int, required=True, help="cut index from `cuts list`"
840
+ )
841
+ c_inspect.add_argument(
842
+ "--output-dir", required=True, help="directory for the evidence bundle"
843
+ )
844
+ c_inspect.add_argument(
845
+ "--window", type=float, default=2.0, help="seconds of context on each side"
846
+ )
847
+ c_inspect.add_argument(
848
+ "--transcript", help="source transcript JSON for clipped-word checks"
849
+ )
850
+ c_inspect.set_defaults(handler=cmd_cut_inspect, command_name="cut inspect")
851
+
852
+ subs = top.add_parser(
853
+ "subtitles", help="subtitle derivation and rendering"
854
+ ).add_subparsers(dest="action", required=True)
855
+ s_create = subs.add_parser(
856
+ "create", help="derive SRT+WebVTT from transcripts mapped through a render"
857
+ )
858
+ s_create.add_argument("--manifest", required=True, help="render manifest JSON path")
859
+ s_create.add_argument(
860
+ "--transcript",
861
+ action="append",
862
+ required=True,
863
+ help="source transcript JSON (repeatable, one per source)",
864
+ )
865
+ s_create.add_argument("--output-srt", required=True)
866
+ s_create.add_argument("--output-vtt", required=True)
867
+ s_create.add_argument(
868
+ "--workspace",
869
+ help="optional workspace root; records the artifact in workspace.json",
870
+ )
871
+ s_create.set_defaults(handler=cmd_subtitles_create, command_name="subtitles create")
872
+
873
+ s_render = subs.add_parser("render", help="mux or burn subtitles into a video")
874
+ s_render.add_argument("--input", required=True, help="video path")
875
+ s_render.add_argument("--subtitles", required=True, help="SRT or VTT path")
876
+ s_render.add_argument("--output", required=True)
877
+ s_render.add_argument("--mode", default="mux", choices=["mux", "burn"])
878
+ s_render.add_argument(
879
+ "--workspace",
880
+ help="optional workspace root; records the artifact in workspace.json",
881
+ )
882
+ s_render.set_defaults(handler=cmd_subtitles_render, command_name="subtitles render")
883
+
884
+ asset = top.add_parser("asset", help="external assets").add_subparsers(
885
+ dest="action", required=True
886
+ )
887
+ a_inspect = asset.add_parser(
888
+ "inspect",
889
+ help="validate an intro, outro, music, font, image, or subtitle asset",
890
+ )
891
+ a_inspect.add_argument("--input", required=True, help="asset path")
892
+ a_inspect.set_defaults(handler=cmd_asset_inspect, command_name="asset inspect")
893
+
894
+ out = top.add_parser("output", help="final output checks").add_subparsers(
895
+ dest="action", required=True
896
+ )
897
+ o_validate = out.add_parser(
898
+ "validate", help="check streams, canvas, duration, loudness, subtitles"
899
+ )
900
+ o_validate.add_argument("--input", required=True, help="rendered output path")
901
+ o_validate.add_argument("--profile", help="project profile YAML path")
902
+ o_validate.add_argument(
903
+ "--profile-name", default="master", help="named profile to validate against"
904
+ )
905
+ o_validate.add_argument(
906
+ "--expect-duration", type=float, help="expected duration seconds"
907
+ )
908
+ o_validate.add_argument("--duration-tolerance", type=float, default=0.5)
909
+ o_validate.add_argument("--loudness-tolerance", type=float, default=1.5)
910
+ o_validate.add_argument(
911
+ "--expect-subtitles", action="store_true", help="require a subtitle stream"
912
+ )
913
+ o_validate.add_argument(
914
+ "--subtitles", help="subtitle file to check against duration"
915
+ )
916
+ o_validate.set_defaults(handler=cmd_output_validate, command_name="output validate")
917
+
918
+ sync_group = top.add_parser(
919
+ "sync", help="multi-source synchronization"
920
+ ).add_subparsers(dest="action", required=True)
921
+ sy_analyze = sync_group.add_parser(
922
+ "analyze", help="estimate the audio offset between two sources (evidence only)"
923
+ )
924
+ sy_analyze.add_argument("--reference", required=True, help="reference source path")
925
+ sy_analyze.add_argument(
926
+ "--other", required=True, help="source to align to the reference"
927
+ )
928
+ sy_analyze.add_argument(
929
+ "--max-offset",
930
+ type=float,
931
+ default=30.0,
932
+ help="largest offset to search, seconds",
933
+ )
934
+ sy_analyze.set_defaults(handler=cmd_sync_analyze, command_name="sync analyze")
935
+
936
+ sy_apply = sync_group.add_parser(
937
+ "apply",
938
+ help="create an aligned derivative or .json mapping from an approved offset",
939
+ )
940
+ sy_apply.add_argument("--input", required=True, help="source to align")
941
+ sy_apply.add_argument(
942
+ "--offset", type=float, required=True, help="approved offset seconds"
943
+ )
944
+ sy_apply.add_argument(
945
+ "--output",
946
+ required=True,
947
+ help="aligned media path, or .json for mapping metadata",
948
+ )
949
+ sy_apply.add_argument(
950
+ "--workspace",
951
+ help="optional workspace root; records the artifact in workspace.json",
952
+ )
953
+ sy_apply.set_defaults(handler=cmd_sync_apply, command_name="sync apply")
954
+
955
+ reframe_group = top.add_parser(
956
+ "reframe", help="crop/reframe previews"
957
+ ).add_subparsers(dest="action", required=True)
958
+ rf_preview = reframe_group.add_parser(
959
+ "preview", help="preview an explicit crop scaled onto a canvas"
960
+ )
961
+ _add_io(rf_preview)
962
+ _add_range(rf_preview)
963
+ rf_preview.add_argument(
964
+ "--crop", required=True, help="x:y:width:height in source pixels"
965
+ )
966
+ rf_preview.add_argument(
967
+ "--canvas", required=True, help="output canvas WIDTHxHEIGHT"
968
+ )
969
+ rf_preview.set_defaults(handler=cmd_reframe_preview, command_name="reframe preview")
970
+
971
+ short = top.add_parser("short", help="derived short-form plans").add_subparsers(
972
+ dest="action", required=True
973
+ )
974
+ sh_plan = short.add_parser(
975
+ "create-plan",
976
+ help="derive an editable vertical plan from an explicit source range "
977
+ "(you choose the range; this command never picks highlights)",
978
+ )
979
+ sh_plan.add_argument("--input", required=True, help="source media path")
980
+ sh_plan.add_argument(
981
+ "--source-id", default="src-1", help="source id used inside the plan"
982
+ )
983
+ sh_plan.add_argument("--start", type=float, required=True)
984
+ sh_plan.add_argument("--end", type=float, required=True)
985
+ sh_plan.add_argument(
986
+ "--canvas", default="1080x1920", help="vertical canvas WIDTHxHEIGHT"
987
+ )
988
+ sh_plan.add_argument("--crop", help="optional x:y:width:height framing")
989
+ sh_plan.add_argument(
990
+ "--reason", required=True, help="editorial reason for choosing this range"
991
+ )
992
+ sh_plan.add_argument("--parent-plan", help="plan id this short derives from")
993
+ sh_plan.add_argument("--created-by", help="authoring agent identifier")
994
+ sh_plan.add_argument("--output", required=True, help="path for the new plan JSON")
995
+ sh_plan.add_argument(
996
+ "--workspace",
997
+ help="optional workspace root; records the artifact in workspace.json",
998
+ )
999
+ sh_plan.set_defaults(
1000
+ handler=cmd_short_create_plan, command_name="short create-plan"
1001
+ )
1002
+
1003
+ return parser
1004
+
1005
+
1006
+ def run(argv: list[str] | None = None) -> int:
1007
+ parser = build_parser()
1008
+ args = parser.parse_args(argv)
1009
+ command_name: str = args.command_name
1010
+ handler: Handler = args.handler
1011
+ try:
1012
+ data, artifacts = handler(args)
1013
+ except VideoEditorError as exc:
1014
+ result.emit_error(command_name, exc.code, exc.message)
1015
+ return exc.exit_code
1016
+ result.emit_success(command_name, data, artifacts)
1017
+ return 0
1018
+
1019
+
1020
+ def main() -> None:
1021
+ sys.exit(run())