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_edit_cli-0.1.0.dist-info/METADATA +68 -0
- video_edit_cli-0.1.0.dist-info/RECORD +37 -0
- video_edit_cli-0.1.0.dist-info/WHEEL +4 -0
- video_edit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- video_editor/__init__.py +5 -0
- video_editor/assets.py +133 -0
- video_editor/audio/__init__.py +1 -0
- video_editor/audio/analysis.py +147 -0
- video_editor/audio/comparison.py +71 -0
- video_editor/audio/denoise.py +63 -0
- video_editor/audio/mastering.py +77 -0
- video_editor/cli.py +1021 -0
- video_editor/errors.py +33 -0
- video_editor/ffmpeg.py +48 -0
- video_editor/inspection.py +168 -0
- video_editor/media.py +92 -0
- video_editor/plans.py +90 -0
- video_editor/profiles.py +58 -0
- video_editor/provenance.py +49 -0
- video_editor/reframe.py +119 -0
- video_editor/rendering.py +328 -0
- video_editor/result.py +42 -0
- video_editor/review.py +169 -0
- video_editor/schemas/__init__.py +29 -0
- video_editor/schemas/edit-plan.schema.json +69 -0
- video_editor/schemas/project-profile.schema.json +86 -0
- video_editor/schemas/result.schema.json +42 -0
- video_editor/schemas/transcript.schema.json +49 -0
- video_editor/schemas/workspace.schema.json +39 -0
- video_editor/subtitles.py +137 -0
- video_editor/sync.py +149 -0
- video_editor/transcription/__init__.py +1 -0
- video_editor/transcription/base.py +73 -0
- video_editor/transcription/fixture.py +33 -0
- video_editor/transcription/mlx_whisper.py +68 -0
- video_editor/transcription/views.py +85 -0
- video_editor/workspace.py +105 -0
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())
|