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.
@@ -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
+ }