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
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Compile validated edit plans into deterministic FFmpeg renders.
|
|
2
|
+
|
|
3
|
+
Each render writes the output plus a `<output>.manifest.json` recording the
|
|
4
|
+
plan, cut boundaries with their source mapping, and provenance — enough for
|
|
5
|
+
`cuts list` / `cut inspect` to enumerate and review every boundary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
from video_editor import SCHEMA_VERSION, ffmpeg, plans, profiles
|
|
17
|
+
from video_editor.audio.analysis import measure_loudness
|
|
18
|
+
from video_editor.provenance import sha256_file, utc_now
|
|
19
|
+
|
|
20
|
+
PREVIEW_HEIGHT = 360
|
|
21
|
+
PREVIEW_FPS = 30
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _clip_segments(plan: dict[str, Any]) -> list[dict[str, Any]]:
|
|
25
|
+
"""Output-timeline segments: clip + output_start/output_end."""
|
|
26
|
+
segments = []
|
|
27
|
+
cursor = 0.0
|
|
28
|
+
for index, clip in enumerate(plan["timeline"]):
|
|
29
|
+
duration = clip["out"] - clip["in"]
|
|
30
|
+
segments.append(
|
|
31
|
+
{
|
|
32
|
+
"index": index,
|
|
33
|
+
"source": clip["source"],
|
|
34
|
+
"video_source": clip.get("video_source"),
|
|
35
|
+
"in": clip["in"],
|
|
36
|
+
"out": clip["out"],
|
|
37
|
+
"reason": clip["reason"],
|
|
38
|
+
"output_start": round(cursor, 6),
|
|
39
|
+
"output_end": round(cursor + duration, 6),
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
cursor += duration
|
|
43
|
+
return segments
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _concat_args(
|
|
47
|
+
plan: dict[str, Any],
|
|
48
|
+
sources: dict[str, str],
|
|
49
|
+
video_filter: str,
|
|
50
|
+
fps: float,
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
"""Input args + filter_complex for the per-clip trim/normalize/concat graph."""
|
|
53
|
+
args: list[str] = []
|
|
54
|
+
filters: list[str] = []
|
|
55
|
+
concat_inputs: list[str] = []
|
|
56
|
+
input_index = 0
|
|
57
|
+
for i, clip in enumerate(plan["timeline"]):
|
|
58
|
+
span = clip["out"] - clip["in"]
|
|
59
|
+
args += [
|
|
60
|
+
"-ss",
|
|
61
|
+
f"{clip['in']:.6f}",
|
|
62
|
+
"-t",
|
|
63
|
+
f"{span:.6f}",
|
|
64
|
+
"-i",
|
|
65
|
+
str(sources[clip["source"]]),
|
|
66
|
+
]
|
|
67
|
+
audio_index = input_index
|
|
68
|
+
input_index += 1
|
|
69
|
+
video_id = clip.get("video_source")
|
|
70
|
+
if video_id and video_id != clip["source"]:
|
|
71
|
+
args += [
|
|
72
|
+
"-ss",
|
|
73
|
+
f"{clip['in']:.6f}",
|
|
74
|
+
"-t",
|
|
75
|
+
f"{span:.6f}",
|
|
76
|
+
"-i",
|
|
77
|
+
str(sources[video_id]),
|
|
78
|
+
]
|
|
79
|
+
video_index = input_index
|
|
80
|
+
input_index += 1
|
|
81
|
+
else:
|
|
82
|
+
video_index = audio_index
|
|
83
|
+
crop = clip.get("crop")
|
|
84
|
+
crop_filter = (
|
|
85
|
+
f"crop={crop['width']}:{crop['height']}:{crop['x']}:{crop['y']},"
|
|
86
|
+
if crop
|
|
87
|
+
else ""
|
|
88
|
+
)
|
|
89
|
+
gain = clip.get("gain_db")
|
|
90
|
+
gain_filter = f",volume={gain}dB" if gain else ""
|
|
91
|
+
filters.append(
|
|
92
|
+
f"[{video_index}:v]{crop_filter}{video_filter},setsar=1,fps={fps},"
|
|
93
|
+
f"format=yuv420p[v{i}]"
|
|
94
|
+
)
|
|
95
|
+
filters.append(
|
|
96
|
+
f"[{audio_index}:a]aresample=48000,"
|
|
97
|
+
f"aformat=channel_layouts=stereo{gain_filter}[a{i}]"
|
|
98
|
+
)
|
|
99
|
+
concat_inputs.append(f"[v{i}][a{i}]")
|
|
100
|
+
n = len(plan["timeline"])
|
|
101
|
+
filter_complex = (
|
|
102
|
+
";".join(filters)
|
|
103
|
+
+ ";"
|
|
104
|
+
+ "".join(concat_inputs)
|
|
105
|
+
+ f"concat=n={n}:v=1:a=1[outv][outa]"
|
|
106
|
+
)
|
|
107
|
+
return [
|
|
108
|
+
*args,
|
|
109
|
+
"-filter_complex",
|
|
110
|
+
filter_complex,
|
|
111
|
+
"-map",
|
|
112
|
+
"[outv]",
|
|
113
|
+
"-map",
|
|
114
|
+
"[outa]",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _write_manifest(
|
|
119
|
+
kind: str,
|
|
120
|
+
plan: dict[str, Any],
|
|
121
|
+
summary: dict[str, Any],
|
|
122
|
+
sources: dict[str, str],
|
|
123
|
+
output: Path,
|
|
124
|
+
tool_commands: list[list[str]],
|
|
125
|
+
profile_info: dict[str, Any],
|
|
126
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
127
|
+
manifest = {
|
|
128
|
+
"schema_version": SCHEMA_VERSION,
|
|
129
|
+
"kind": kind,
|
|
130
|
+
"created_at": utc_now(),
|
|
131
|
+
"plan": plan,
|
|
132
|
+
"summary": summary,
|
|
133
|
+
"segments": _clip_segments(plan),
|
|
134
|
+
"sources": [
|
|
135
|
+
{"id": sid, "path": str(path), "sha256": sha256_file(Path(path))}
|
|
136
|
+
for sid, path in sources.items()
|
|
137
|
+
],
|
|
138
|
+
"tools": {name: ffmpeg.version(name) for name in ("ffmpeg", "ffprobe")},
|
|
139
|
+
"tool_commands": tool_commands,
|
|
140
|
+
"output": {"path": str(output), "sha256": sha256_file(output)},
|
|
141
|
+
"profile": profile_info,
|
|
142
|
+
}
|
|
143
|
+
manifest_path = output.with_name(output.name + ".manifest.json")
|
|
144
|
+
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
|
145
|
+
return manifest_path, manifest
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def render_master(
|
|
149
|
+
plan: dict[str, Any],
|
|
150
|
+
output: Path,
|
|
151
|
+
profile_document: dict[str, Any],
|
|
152
|
+
profile_name: str,
|
|
153
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
154
|
+
"""Render a validated plan with a named external profile (canvas, codecs,
|
|
155
|
+
music/ducking, loudness), via a lossless-audio intermediate."""
|
|
156
|
+
summary = plans.validate(plan)
|
|
157
|
+
profile = profiles.named_profile(profile_document, profile_name)
|
|
158
|
+
sources = {record["id"]: record["path"] for record in plan["sources"]}
|
|
159
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
|
|
161
|
+
canvas = profile["canvas"]
|
|
162
|
+
width, height = canvas["width"], canvas["height"]
|
|
163
|
+
fps = canvas.get("frame_rate") or 30
|
|
164
|
+
vcodec = profile.get("video_codec") or {}
|
|
165
|
+
acodec = profile.get("audio_codec") or {}
|
|
166
|
+
loudness_target = profile.get("loudness") or {}
|
|
167
|
+
commands: list[list[str]] = []
|
|
168
|
+
|
|
169
|
+
video_filter = (
|
|
170
|
+
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
|
171
|
+
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
|
172
|
+
)
|
|
173
|
+
with tempfile.TemporaryDirectory(prefix="video-edit-cli-master-") as tmp:
|
|
174
|
+
intermediate = Path(tmp) / "intermediate.mov"
|
|
175
|
+
args = _concat_args(plan, sources, video_filter, fps) + [
|
|
176
|
+
"-c:v",
|
|
177
|
+
"libx264",
|
|
178
|
+
"-preset",
|
|
179
|
+
"veryfast",
|
|
180
|
+
"-crf",
|
|
181
|
+
"14",
|
|
182
|
+
"-c:a",
|
|
183
|
+
"pcm_s24le",
|
|
184
|
+
str(intermediate),
|
|
185
|
+
]
|
|
186
|
+
ffmpeg.run_ffmpeg(args)
|
|
187
|
+
commands.append(["ffmpeg", *args])
|
|
188
|
+
|
|
189
|
+
mixed = Path(tmp) / "mixed.wav"
|
|
190
|
+
music = profile_document.get("music")
|
|
191
|
+
duration = summary["output_duration"]
|
|
192
|
+
if music:
|
|
193
|
+
ducking = music.get("ducking") or {}
|
|
194
|
+
threshold = ducking.get("threshold", 0.02)
|
|
195
|
+
ratio = ducking.get("ratio", 8)
|
|
196
|
+
gain = music.get("gain_db", -18)
|
|
197
|
+
mix_args = [
|
|
198
|
+
"-i",
|
|
199
|
+
str(intermediate),
|
|
200
|
+
"-stream_loop",
|
|
201
|
+
"-1",
|
|
202
|
+
"-i",
|
|
203
|
+
str(music["path"]),
|
|
204
|
+
"-filter_complex",
|
|
205
|
+
(
|
|
206
|
+
f"[1:a]atrim=0:{duration:.6f},aresample=48000,"
|
|
207
|
+
f"aformat=channel_layouts=stereo,volume={gain}dB[m];"
|
|
208
|
+
f"[0:a]asplit=2[key][dry];"
|
|
209
|
+
f"[m][key]sidechaincompress=threshold={threshold}:ratio={ratio}:"
|
|
210
|
+
f"attack=5:release=250[duck];"
|
|
211
|
+
f"[dry][duck]amix=inputs=2:duration=first:normalize=0[aout]"
|
|
212
|
+
),
|
|
213
|
+
"-map",
|
|
214
|
+
"[aout]",
|
|
215
|
+
"-c:a",
|
|
216
|
+
"pcm_s24le",
|
|
217
|
+
str(mixed),
|
|
218
|
+
]
|
|
219
|
+
else:
|
|
220
|
+
mix_args = ["-i", str(intermediate), "-vn", "-c:a", "pcm_s24le", str(mixed)]
|
|
221
|
+
ffmpeg.run_ffmpeg(mix_args)
|
|
222
|
+
commands.append(["ffmpeg", *mix_args])
|
|
223
|
+
|
|
224
|
+
target_i = loudness_target.get("integrated_lufs", -16.0)
|
|
225
|
+
target_tp = loudness_target.get("true_peak_dbtp", -1.5)
|
|
226
|
+
target_lra = loudness_target.get("lra", 11.0)
|
|
227
|
+
measured = measure_loudness(mixed, target_i, target_tp, target_lra)["raw"]
|
|
228
|
+
loudnorm = (
|
|
229
|
+
f"loudnorm=I={target_i}:TP={target_tp}:LRA={target_lra}:"
|
|
230
|
+
f"measured_I={measured['input_i']}:measured_TP={measured['input_tp']}:"
|
|
231
|
+
f"measured_LRA={measured['input_lra']}:"
|
|
232
|
+
f"measured_thresh={measured['input_thresh']}:"
|
|
233
|
+
f"offset={measured['target_offset']}:linear=true"
|
|
234
|
+
)
|
|
235
|
+
final_args = [
|
|
236
|
+
"-i",
|
|
237
|
+
str(intermediate),
|
|
238
|
+
"-i",
|
|
239
|
+
str(mixed),
|
|
240
|
+
"-map",
|
|
241
|
+
"0:v",
|
|
242
|
+
"-map",
|
|
243
|
+
"1:a",
|
|
244
|
+
"-af",
|
|
245
|
+
loudnorm,
|
|
246
|
+
"-c:v",
|
|
247
|
+
vcodec.get("name", "libx264"),
|
|
248
|
+
"-preset",
|
|
249
|
+
str(vcodec.get("preset", "medium")),
|
|
250
|
+
"-crf",
|
|
251
|
+
str(vcodec.get("crf", 20)),
|
|
252
|
+
"-c:a",
|
|
253
|
+
acodec.get("name", "aac"),
|
|
254
|
+
"-b:a",
|
|
255
|
+
acodec.get("bitrate", "192k"),
|
|
256
|
+
"-ar",
|
|
257
|
+
"48000",
|
|
258
|
+
str(output),
|
|
259
|
+
]
|
|
260
|
+
ffmpeg.run_ffmpeg(final_args)
|
|
261
|
+
commands.append(["ffmpeg", *final_args])
|
|
262
|
+
|
|
263
|
+
return _write_manifest(
|
|
264
|
+
"render-master",
|
|
265
|
+
plan,
|
|
266
|
+
summary,
|
|
267
|
+
sources,
|
|
268
|
+
output,
|
|
269
|
+
commands,
|
|
270
|
+
{
|
|
271
|
+
"name": profile_name,
|
|
272
|
+
"canvas": canvas,
|
|
273
|
+
"loudness": loudness_target,
|
|
274
|
+
"music": bool(profile_document.get("music")),
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def render_preview(
|
|
280
|
+
plan: dict[str, Any], output: Path, height: int = PREVIEW_HEIGHT
|
|
281
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
282
|
+
"""Render a low-cost rough cut of a validated plan; returns (manifest_path, manifest)."""
|
|
283
|
+
summary = plans.validate(plan)
|
|
284
|
+
sources = {record["id"]: record["path"] for record in plan["sources"]}
|
|
285
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
|
|
287
|
+
canvas = plan.get("output_canvas")
|
|
288
|
+
if canvas:
|
|
289
|
+
# A plan-declared canvas (e.g. a vertical short) wins over the preview
|
|
290
|
+
# height so the preview shows the real framing.
|
|
291
|
+
video_filter = (
|
|
292
|
+
f"scale={canvas['width']}:{canvas['height']}:"
|
|
293
|
+
f"force_original_aspect_ratio=decrease,"
|
|
294
|
+
f"pad={canvas['width']}:{canvas['height']}:(ow-iw)/2:(oh-ih)/2"
|
|
295
|
+
)
|
|
296
|
+
fps = canvas.get("frame_rate") or PREVIEW_FPS
|
|
297
|
+
else:
|
|
298
|
+
# Concat requires identical frame sizes, so normalize every clip to one
|
|
299
|
+
# 16:9 letterboxed preview canvas even when sources differ.
|
|
300
|
+
width = int(height * 16 / 9) // 2 * 2
|
|
301
|
+
video_filter = (
|
|
302
|
+
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
|
303
|
+
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
|
304
|
+
)
|
|
305
|
+
fps = PREVIEW_FPS
|
|
306
|
+
args = _concat_args(plan, sources, video_filter, fps) + [
|
|
307
|
+
"-c:v",
|
|
308
|
+
"libx264",
|
|
309
|
+
"-preset",
|
|
310
|
+
"veryfast",
|
|
311
|
+
"-crf",
|
|
312
|
+
"26",
|
|
313
|
+
"-c:a",
|
|
314
|
+
"aac",
|
|
315
|
+
"-b:a",
|
|
316
|
+
"128k",
|
|
317
|
+
str(output),
|
|
318
|
+
]
|
|
319
|
+
ffmpeg.run_ffmpeg(args)
|
|
320
|
+
return _write_manifest(
|
|
321
|
+
"render-preview",
|
|
322
|
+
plan,
|
|
323
|
+
summary,
|
|
324
|
+
sources,
|
|
325
|
+
output,
|
|
326
|
+
[["ffmpeg", *args]],
|
|
327
|
+
{"height": height, "fps": fps, "canvas": canvas, "codec": "h264/aac"},
|
|
328
|
+
)
|
video_editor/result.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Command result envelope: one JSON object on stdout per command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from video_editor import SCHEMA_VERSION
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def artifact(path: Path | str, kind: str) -> dict[str, str]:
|
|
14
|
+
return {"path": str(path), "kind": kind}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def emit_success(
|
|
18
|
+
command: str,
|
|
19
|
+
data: dict[str, Any],
|
|
20
|
+
artifacts: list[dict[str, str]] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
envelope = {
|
|
23
|
+
"ok": True,
|
|
24
|
+
"command": command,
|
|
25
|
+
"schema_version": SCHEMA_VERSION,
|
|
26
|
+
"artifacts": artifacts or [],
|
|
27
|
+
"data": data,
|
|
28
|
+
}
|
|
29
|
+
json.dump(envelope, sys.stdout, indent=2)
|
|
30
|
+
sys.stdout.write("\n")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def emit_error(command: str, code: str, message: str) -> None:
|
|
34
|
+
envelope = {
|
|
35
|
+
"ok": False,
|
|
36
|
+
"command": command,
|
|
37
|
+
"schema_version": SCHEMA_VERSION,
|
|
38
|
+
"error": {"code": code, "message": message},
|
|
39
|
+
}
|
|
40
|
+
json.dump(envelope, sys.stdout, indent=2)
|
|
41
|
+
sys.stdout.write("\n")
|
|
42
|
+
print(f"error [{code}]: {message}", file=sys.stderr)
|
video_editor/review.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Cut review: enumerate render boundaries and gather evidence around each one.
|
|
2
|
+
|
|
3
|
+
Works from a render manifest (written by rendering). Continuity checks are
|
|
4
|
+
deterministic signals (black frames, silence gaps, words clipped by the cut);
|
|
5
|
+
they guide review but the agent judges the evidence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from video_editor import ffmpeg, inspection
|
|
16
|
+
from video_editor.errors import InvalidInputError
|
|
17
|
+
from video_editor.transcription import views as transcript_views
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_manifest(path: Path) -> dict[str, Any]:
|
|
21
|
+
if not path.is_file():
|
|
22
|
+
raise InvalidInputError(f"render manifest not found: {path}")
|
|
23
|
+
manifest: dict[str, Any] = json.loads(path.read_text())
|
|
24
|
+
if "segments" not in manifest or "output" not in manifest:
|
|
25
|
+
raise InvalidInputError(f"{path} is not a render manifest")
|
|
26
|
+
return manifest
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def list_cuts(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
30
|
+
"""Each cut joins segment i (before) and segment i+1 (after)."""
|
|
31
|
+
segments = manifest["segments"]
|
|
32
|
+
cuts = []
|
|
33
|
+
for i in range(len(segments) - 1):
|
|
34
|
+
before, after = segments[i], segments[i + 1]
|
|
35
|
+
cuts.append(
|
|
36
|
+
{
|
|
37
|
+
"cut_index": i,
|
|
38
|
+
"output_time": before["output_end"],
|
|
39
|
+
"before": {
|
|
40
|
+
"segment_index": before["index"],
|
|
41
|
+
"source": before["source"],
|
|
42
|
+
"source_out": before["out"],
|
|
43
|
+
"reason": before["reason"],
|
|
44
|
+
},
|
|
45
|
+
"after": {
|
|
46
|
+
"segment_index": after["index"],
|
|
47
|
+
"source": after["source"],
|
|
48
|
+
"source_in": after["in"],
|
|
49
|
+
"reason": after["reason"],
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
return cuts
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _detect(render: Path, start: float, end: float, filters: dict[str, str]) -> str:
|
|
57
|
+
"""Run detection filters over a render range and return ffmpeg's stderr log."""
|
|
58
|
+
span = end - start
|
|
59
|
+
args = ["-ss", f"{start:.3f}", "-t", f"{span:.3f}", "-i", str(render)]
|
|
60
|
+
if "video" in filters:
|
|
61
|
+
args += ["-vf", filters["video"]]
|
|
62
|
+
if "audio" in filters:
|
|
63
|
+
args += ["-af", filters["audio"]]
|
|
64
|
+
args += ["-f", "null", "-"]
|
|
65
|
+
proc = ffmpeg.run("ffmpeg", ["-hide_banner", "-nostdin", "-y", *args])
|
|
66
|
+
return proc.stderr
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _clipped_word(
|
|
70
|
+
transcript: dict[str, Any] | None, source_time: float
|
|
71
|
+
) -> dict[str, Any] | None:
|
|
72
|
+
"""Word whose span straddles `source_time` in the source transcript."""
|
|
73
|
+
if transcript is None:
|
|
74
|
+
return None
|
|
75
|
+
for segment in transcript["segments"]:
|
|
76
|
+
for word in segment["words"]:
|
|
77
|
+
if word["start"] < source_time < word["end"]:
|
|
78
|
+
return {
|
|
79
|
+
"text": word["text"],
|
|
80
|
+
"start": word["start"],
|
|
81
|
+
"end": word["end"],
|
|
82
|
+
}
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def inspect_cut(
|
|
87
|
+
manifest: dict[str, Any],
|
|
88
|
+
cut_index: int,
|
|
89
|
+
output_dir: Path,
|
|
90
|
+
window: float = 2.0,
|
|
91
|
+
transcript_path: Path | None = None,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
cuts = list_cuts(manifest)
|
|
94
|
+
if not 0 <= cut_index < len(cuts):
|
|
95
|
+
raise InvalidInputError(
|
|
96
|
+
f"cut index {cut_index} out of range (render has {len(cuts)} cuts)"
|
|
97
|
+
)
|
|
98
|
+
cut = cuts[cut_index]
|
|
99
|
+
render = Path(manifest["output"]["path"])
|
|
100
|
+
if not render.is_file():
|
|
101
|
+
raise InvalidInputError(f"rendered output not found: {render}")
|
|
102
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
boundary = cut["output_time"]
|
|
105
|
+
total = manifest["segments"][-1]["output_end"]
|
|
106
|
+
start = max(0.0, boundary - window)
|
|
107
|
+
end = min(total, boundary + window)
|
|
108
|
+
|
|
109
|
+
prefix = output_dir / f"cut-{cut_index}"
|
|
110
|
+
artifacts: dict[str, str] = {}
|
|
111
|
+
|
|
112
|
+
_, _times = inspection.create_filmstrip(
|
|
113
|
+
render, start, end, Path(f"{prefix}-filmstrip.png"), columns=6, frames=12
|
|
114
|
+
)
|
|
115
|
+
artifacts["filmstrip"] = f"{prefix}-filmstrip.png"
|
|
116
|
+
inspection.create_waveform(render, start, end, Path(f"{prefix}-waveform.png"))
|
|
117
|
+
artifacts["waveform"] = f"{prefix}-waveform.png"
|
|
118
|
+
inspection.create_preview(render, start, end, Path(f"{prefix}-preview.mp4"))
|
|
119
|
+
artifacts["preview"] = f"{prefix}-preview.mp4"
|
|
120
|
+
frame_offset = min(0.04, total - boundary)
|
|
121
|
+
inspection.extract_frame(
|
|
122
|
+
render, max(0.0, boundary - 0.04), Path(f"{prefix}-frame-before.png")
|
|
123
|
+
)
|
|
124
|
+
artifacts["frame_before"] = f"{prefix}-frame-before.png"
|
|
125
|
+
inspection.extract_frame(
|
|
126
|
+
render,
|
|
127
|
+
min(total - 0.001, boundary + frame_offset),
|
|
128
|
+
Path(f"{prefix}-frame-after.png"),
|
|
129
|
+
)
|
|
130
|
+
artifacts["frame_after"] = f"{prefix}-frame-after.png"
|
|
131
|
+
|
|
132
|
+
log = _detect(
|
|
133
|
+
render,
|
|
134
|
+
start,
|
|
135
|
+
end,
|
|
136
|
+
{"video": "blackdetect=d=0.1", "audio": "silencedetect=n=-45dB:d=0.4"},
|
|
137
|
+
)
|
|
138
|
+
black_events = re.findall(r"black_start:(\d+\.?\d*)", log)
|
|
139
|
+
silence_events = re.findall(r"silence_start: (-?\d+\.?\d*)", log)
|
|
140
|
+
|
|
141
|
+
transcript = (
|
|
142
|
+
transcript_views.load_transcript(transcript_path) if transcript_path else None
|
|
143
|
+
)
|
|
144
|
+
clipped_before = _clipped_word(transcript, cut["before"]["source_out"])
|
|
145
|
+
clipped_after = _clipped_word(transcript, cut["after"]["source_in"])
|
|
146
|
+
|
|
147
|
+
checks = {
|
|
148
|
+
"black_frames_near_cut": [float(t) + start for t in black_events],
|
|
149
|
+
"silence_near_cut": [float(t) + start for t in silence_events],
|
|
150
|
+
"clipped_word_before": clipped_before,
|
|
151
|
+
"clipped_word_after": clipped_after,
|
|
152
|
+
}
|
|
153
|
+
passed = (
|
|
154
|
+
not checks["black_frames_near_cut"]
|
|
155
|
+
and clipped_before is None
|
|
156
|
+
and clipped_after is None
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
report = {
|
|
160
|
+
"cut": cut,
|
|
161
|
+
"window": {"start": start, "end": end},
|
|
162
|
+
"artifacts": artifacts,
|
|
163
|
+
"checks": checks,
|
|
164
|
+
"passed": passed,
|
|
165
|
+
}
|
|
166
|
+
report_path = Path(f"{prefix}-report.json")
|
|
167
|
+
report_path.write_text(json.dumps(report, indent=2) + "\n")
|
|
168
|
+
report["report_path"] = str(report_path)
|
|
169
|
+
return report
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""JSON schemas validated at command boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import jsonschema
|
|
11
|
+
|
|
12
|
+
from video_editor.errors import InvalidInputError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@cache
|
|
16
|
+
def load(name: str) -> dict[str, Any]:
|
|
17
|
+
text = resources.files("video_editor.schemas").joinpath(name).read_text()
|
|
18
|
+
schema: dict[str, Any] = json.loads(text)
|
|
19
|
+
return schema
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate(instance: Any, schema_name: str) -> None:
|
|
23
|
+
try:
|
|
24
|
+
jsonschema.validate(instance, load(schema_name))
|
|
25
|
+
except jsonschema.ValidationError as exc:
|
|
26
|
+
location = "/".join(str(p) for p in exc.absolute_path) or "<root>"
|
|
27
|
+
raise InvalidInputError(
|
|
28
|
+
f"schema validation failed against {schema_name} at {location}: {exc.message}"
|
|
29
|
+
) from exc
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "video-edit-cli/edit-plan.schema.json",
|
|
4
|
+
"title": "Agent-authored edit plan",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema_version", "plan_id", "sources", "timeline"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": {"type": "string"},
|
|
10
|
+
"plan_id": {"type": "string"},
|
|
11
|
+
"workspace": {"type": ["string", "null"]},
|
|
12
|
+
"target_profile": {"type": ["string", "null"]},
|
|
13
|
+
"created_by": {"type": ["string", "null"]},
|
|
14
|
+
"parent_plan": {"type": ["string", "null"]},
|
|
15
|
+
"notes": {"type": ["string", "null"]},
|
|
16
|
+
"output_canvas": {
|
|
17
|
+
"type": ["object", "null"],
|
|
18
|
+
"required": ["width", "height"],
|
|
19
|
+
"additionalProperties": false,
|
|
20
|
+
"properties": {
|
|
21
|
+
"width": {"type": "integer", "minimum": 16},
|
|
22
|
+
"height": {"type": "integer", "minimum": 16},
|
|
23
|
+
"frame_rate": {"type": ["number", "null"]}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"sources": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"minItems": 1,
|
|
29
|
+
"items": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["id", "path"],
|
|
32
|
+
"additionalProperties": false,
|
|
33
|
+
"properties": {
|
|
34
|
+
"id": {"type": "string"},
|
|
35
|
+
"path": {"type": "string"},
|
|
36
|
+
"role": {"type": ["string", "null"]}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"timeline": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"minItems": 1,
|
|
43
|
+
"items": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"required": ["source", "in", "out", "reason"],
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"properties": {
|
|
48
|
+
"source": {"type": "string"},
|
|
49
|
+
"in": {"type": "number", "minimum": 0},
|
|
50
|
+
"out": {"type": "number", "exclusiveMinimum": 0},
|
|
51
|
+
"reason": {"type": "string", "minLength": 1},
|
|
52
|
+
"video_source": {"type": ["string", "null"]},
|
|
53
|
+
"crop": {
|
|
54
|
+
"type": ["object", "null"],
|
|
55
|
+
"required": ["x", "y", "width", "height"],
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"properties": {
|
|
58
|
+
"x": {"type": "integer", "minimum": 0},
|
|
59
|
+
"y": {"type": "integer", "minimum": 0},
|
|
60
|
+
"width": {"type": "integer", "minimum": 16},
|
|
61
|
+
"height": {"type": "integer", "minimum": 16}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"gain_db": {"type": ["number", "null"]}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|