video-edit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
video_editor/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Structured errors with stable codes for the CLI contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class VideoEditorError(Exception):
7
+ """Failure with a stable machine-readable code and actionable message."""
8
+
9
+ def __init__(self, code: str, message: str, *, exit_code: int = 1) -> None:
10
+ super().__init__(message)
11
+ self.code = code
12
+ self.message = message
13
+ self.exit_code = exit_code
14
+
15
+
16
+ class MissingBinaryError(VideoEditorError):
17
+ def __init__(self, binary: str) -> None:
18
+ super().__init__(
19
+ "missing-binary",
20
+ f"required external binary '{binary}' was not found on PATH; "
21
+ f"install FFmpeg (e.g. `brew install ffmpeg`) and retry",
22
+ exit_code=3,
23
+ )
24
+
25
+
26
+ class InvalidInputError(VideoEditorError):
27
+ def __init__(self, message: str) -> None:
28
+ super().__init__("invalid-input", message, exit_code=2)
29
+
30
+
31
+ class ToolFailureError(VideoEditorError):
32
+ def __init__(self, message: str) -> None:
33
+ super().__init__("tool-failure", message, exit_code=4)
video_editor/ffmpeg.py ADDED
@@ -0,0 +1,48 @@
1
+ """Locate and run the required external FFmpeg binaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ from video_editor.errors import MissingBinaryError, ToolFailureError
11
+
12
+
13
+ def binary_path(name: str) -> str:
14
+ path = shutil.which(name)
15
+ if path is None:
16
+ raise MissingBinaryError(name)
17
+ return path
18
+
19
+
20
+ def run(binary: str, args: list[str]) -> subprocess.CompletedProcess[str]:
21
+ """Run ffmpeg/ffprobe non-interactively; raise ToolFailureError on non-zero exit."""
22
+ path = binary_path(binary)
23
+ cmd = [path, *args]
24
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
25
+ if proc.returncode != 0:
26
+ tail = proc.stderr.strip().splitlines()[-8:]
27
+ raise ToolFailureError(
28
+ f"{binary} exited with {proc.returncode}: " + " | ".join(tail)
29
+ )
30
+ return proc
31
+
32
+
33
+ def run_ffmpeg(args: list[str]) -> subprocess.CompletedProcess[str]:
34
+ return run("ffmpeg", ["-hide_banner", "-nostdin", "-y", *args])
35
+
36
+
37
+ def ffprobe_json(args: list[str]) -> dict[str, Any]:
38
+ proc = run("ffprobe", ["-v", "error", "-of", "json", *args])
39
+ try:
40
+ result: dict[str, Any] = json.loads(proc.stdout)
41
+ except json.JSONDecodeError as exc:
42
+ raise ToolFailureError(f"ffprobe produced invalid JSON: {exc}") from exc
43
+ return result
44
+
45
+
46
+ def version(binary: str) -> str:
47
+ proc = run(binary, ["-version"])
48
+ return proc.stdout.splitlines()[0] if proc.stdout else "unknown"
@@ -0,0 +1,168 @@
1
+ """Derived inspection artifacts: audio, proxies, frames, filmstrips, waveforms, previews.
2
+
3
+ Every function writes new files, never touches inputs, and returns the ffmpeg
4
+ argument lists it ran so callers can record provenance.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from video_editor import ffmpeg, media
12
+ from video_editor.errors import InvalidInputError
13
+
14
+
15
+ def _prepare_output(output: Path) -> None:
16
+ output.parent.mkdir(parents=True, exist_ok=True)
17
+
18
+
19
+ def extract_audio(source: Path, output: Path) -> list[str]:
20
+ """Extract the first audio stream as lossless PCM WAV at its native sample rate."""
21
+ info = media.probe(source)
22
+ if not any(s["type"] == "audio" for s in info["streams"]):
23
+ raise InvalidInputError(f"no audio stream in {source}")
24
+ if output.suffix.lower() != ".wav":
25
+ raise InvalidInputError("audio extract output must be a .wav path")
26
+ _prepare_output(output)
27
+ args = ["-i", str(source), "-vn", "-map", "0:a:0", "-c:a", "pcm_s24le", str(output)]
28
+ ffmpeg.run_ffmpeg(args)
29
+ return args
30
+
31
+
32
+ def create_proxy(source: Path, output: Path, height: int = 360) -> list[str]:
33
+ """Create a low-cost H.264 proxy for inspection."""
34
+ info = media.probe(source)
35
+ if not any(s["type"] == "video" for s in info["streams"]):
36
+ raise InvalidInputError(f"no video stream in {source}")
37
+ _prepare_output(output)
38
+ args = [
39
+ "-i",
40
+ str(source),
41
+ "-vf",
42
+ f"scale=-2:{height}",
43
+ "-c:v",
44
+ "libx264",
45
+ "-preset",
46
+ "veryfast",
47
+ "-crf",
48
+ "28",
49
+ "-c:a",
50
+ "aac",
51
+ "-b:a",
52
+ "96k",
53
+ str(output),
54
+ ]
55
+ ffmpeg.run_ffmpeg(args)
56
+ return args
57
+
58
+
59
+ def extract_frame(source: Path, time: float, output: Path) -> list[str]:
60
+ """Extract a single frame at the requested source time."""
61
+ duration = media.duration_of(source)
62
+ if time < 0 or time >= duration:
63
+ raise InvalidInputError(
64
+ f"time {time}s is outside media duration {duration:.3f}s"
65
+ )
66
+ _prepare_output(output)
67
+ args = ["-ss", f"{time:.3f}", "-i", str(source), "-frames:v", "1", str(output)]
68
+ ffmpeg.run_ffmpeg(args)
69
+ return args
70
+
71
+
72
+ def create_filmstrip(
73
+ source: Path,
74
+ start: float,
75
+ end: float,
76
+ output: Path,
77
+ columns: int = 6,
78
+ frames: int = 12,
79
+ ) -> tuple[list[str], list[float]]:
80
+ """Create a contact sheet of `frames` evenly sampled frames from [start, end).
81
+
82
+ Returns the ffmpeg args and the source times of the sampled tiles
83
+ (row-major order), so callers can map tiles back to timestamps.
84
+ """
85
+ media.require_range(source, start, end)
86
+ if frames < 1 or columns < 1:
87
+ raise InvalidInputError("frames and columns must be >= 1")
88
+ _prepare_output(output)
89
+ span = end - start
90
+ fps = frames / span
91
+ rows = -((-frames) // columns)
92
+ times = [round(start + i / fps, 3) for i in range(frames)]
93
+ args = [
94
+ "-ss",
95
+ f"{start:.3f}",
96
+ "-t",
97
+ f"{span:.3f}",
98
+ "-i",
99
+ str(source),
100
+ "-vf",
101
+ f"fps={fps:.6f},scale=320:-2,tile={columns}x{rows}",
102
+ "-frames:v",
103
+ "1",
104
+ str(output),
105
+ ]
106
+ ffmpeg.run_ffmpeg(args)
107
+ return args, times
108
+
109
+
110
+ def create_waveform(
111
+ source: Path,
112
+ start: float,
113
+ end: float,
114
+ output: Path,
115
+ width: int = 1600,
116
+ height: int = 300,
117
+ ) -> list[str]:
118
+ """Render a waveform image for the requested audio range."""
119
+ media.require_range(source, start, end)
120
+ _prepare_output(output)
121
+ span = end - start
122
+ args = [
123
+ "-ss",
124
+ f"{start:.3f}",
125
+ "-t",
126
+ f"{span:.3f}",
127
+ "-i",
128
+ str(source),
129
+ "-filter_complex",
130
+ f"[0:a:0]showwavespic=s={width}x{height}:split_channels=1[w]",
131
+ "-map",
132
+ "[w]",
133
+ "-frames:v",
134
+ "1",
135
+ str(output),
136
+ ]
137
+ ffmpeg.run_ffmpeg(args)
138
+ return args
139
+
140
+
141
+ def create_preview(source: Path, start: float, end: float, output: Path) -> list[str]:
142
+ """Render a short low-cost preview of a range without altering any plan."""
143
+ media.require_range(source, start, end)
144
+ _prepare_output(output)
145
+ span = end - start
146
+ info = media.probe(source)
147
+ has_video = any(s["type"] == "video" for s in info["streams"])
148
+ args = ["-ss", f"{start:.3f}", "-t", f"{span:.3f}", "-i", str(source)]
149
+ if has_video:
150
+ args += [
151
+ "-vf",
152
+ "scale=-2:360",
153
+ "-c:v",
154
+ "libx264",
155
+ "-preset",
156
+ "veryfast",
157
+ "-crf",
158
+ "28",
159
+ "-c:a",
160
+ "aac",
161
+ "-b:a",
162
+ "96k",
163
+ ]
164
+ else:
165
+ args += ["-c:a", "aac", "-b:a", "128k"]
166
+ args.append(str(output))
167
+ ffmpeg.run_ffmpeg(args)
168
+ return args
video_editor/media.py ADDED
@@ -0,0 +1,92 @@
1
+ """Normalized media inspection built on ffprobe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fractions import Fraction
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from video_editor import ffmpeg
10
+ from video_editor.errors import InvalidInputError
11
+
12
+
13
+ def _parse_rate(rate: str | None) -> float | None:
14
+ if not rate or rate in ("0/0", "N/A"):
15
+ return None
16
+ try:
17
+ return float(Fraction(rate))
18
+ except (ValueError, ZeroDivisionError):
19
+ return None
20
+
21
+
22
+ def _float(value: Any) -> float | None:
23
+ try:
24
+ return float(value)
25
+ except (TypeError, ValueError):
26
+ return None
27
+
28
+
29
+ def probe(path: Path) -> dict[str, Any]:
30
+ """Return normalized container and per-stream metadata for one media file."""
31
+ if not path.is_file():
32
+ raise InvalidInputError(f"input file not found: {path}")
33
+ raw = ffmpeg.ffprobe_json(["-show_format", "-show_streams", str(path)])
34
+ fmt = raw.get("format", {})
35
+ streams: list[dict[str, Any]] = []
36
+ for stream in raw.get("streams", []):
37
+ kind = stream.get("codec_type")
38
+ normalized: dict[str, Any] = {
39
+ "index": stream.get("index"),
40
+ "type": kind,
41
+ "codec": stream.get("codec_name"),
42
+ "duration_seconds": _float(stream.get("duration")),
43
+ }
44
+ if kind == "video":
45
+ normalized.update(
46
+ {
47
+ "width": stream.get("width"),
48
+ "height": stream.get("height"),
49
+ "frame_rate": _parse_rate(stream.get("r_frame_rate")),
50
+ "pixel_format": stream.get("pix_fmt"),
51
+ }
52
+ )
53
+ elif kind == "audio":
54
+ normalized.update(
55
+ {
56
+ "sample_rate": int(stream["sample_rate"])
57
+ if stream.get("sample_rate")
58
+ else None,
59
+ "channels": stream.get("channels"),
60
+ "channel_layout": stream.get("channel_layout"),
61
+ }
62
+ )
63
+ streams.append(normalized)
64
+ return {
65
+ "path": str(path.resolve()),
66
+ "container": fmt.get("format_name"),
67
+ "duration_seconds": _float(fmt.get("duration")),
68
+ "size_bytes": int(fmt["size"]) if fmt.get("size") else None,
69
+ "bit_rate": int(fmt["bit_rate"]) if fmt.get("bit_rate") else None,
70
+ "streams": streams,
71
+ }
72
+
73
+
74
+ def duration_of(path: Path) -> float:
75
+ info = probe(path)
76
+ duration = info["duration_seconds"]
77
+ if duration is None:
78
+ raise InvalidInputError(f"could not determine duration of {path}")
79
+ return float(duration)
80
+
81
+
82
+ def require_range(path: Path, start: float, end: float) -> None:
83
+ """Validate that [start, end) is a sane range within the media duration."""
84
+ if start < 0 or end <= start:
85
+ raise InvalidInputError(
86
+ f"invalid range [{start}, {end}): start must be >= 0 and end > start"
87
+ )
88
+ duration = duration_of(path)
89
+ if start >= duration:
90
+ raise InvalidInputError(
91
+ f"range start {start}s is beyond media duration {duration:.3f}s"
92
+ )
video_editor/plans.py ADDED
@@ -0,0 +1,90 @@
1
+ """Edit-plan loading and validation.
2
+
3
+ A plan is an ordered keep-list: each timeline clip keeps [in, out) of a source,
4
+ with an editorial reason. Validation is structural (schema) plus semantic
5
+ (references, ranges against real media, ordering/overlap per source).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from video_editor import media, schemas
15
+ from video_editor.errors import InvalidInputError
16
+
17
+
18
+ def load(path: Path) -> dict[str, Any]:
19
+ if not path.is_file():
20
+ raise InvalidInputError(f"plan file not found: {path}")
21
+ try:
22
+ plan: dict[str, Any] = json.loads(path.read_text())
23
+ except json.JSONDecodeError as exc:
24
+ raise InvalidInputError(f"plan {path} is not valid JSON: {exc}") from exc
25
+ return plan
26
+
27
+
28
+ def validate(plan: dict[str, Any]) -> dict[str, Any]:
29
+ """Validate a plan; return summary data (durations, boundaries)."""
30
+ schemas.validate(plan, "edit-plan.schema.json")
31
+
32
+ errors: list[str] = []
33
+ sources: dict[str, Path] = {}
34
+ durations: dict[str, float] = {}
35
+ for record in plan["sources"]:
36
+ if record["id"] in sources:
37
+ errors.append(f"duplicate source id '{record['id']}'")
38
+ continue
39
+ source_path = Path(record["path"])
40
+ sources[record["id"]] = source_path
41
+ if not source_path.is_file():
42
+ errors.append(f"source '{record['id']}' file not found: {source_path}")
43
+
44
+ if errors:
45
+ raise InvalidInputError("invalid plan: " + "; ".join(errors))
46
+
47
+ for source_id, source_path in sources.items():
48
+ durations[source_id] = media.duration_of(source_path)
49
+
50
+ last_out: dict[str, float] = {}
51
+ output_time = 0.0
52
+ boundaries: list[float] = []
53
+ for index, clip in enumerate(plan["timeline"]):
54
+ label = f"timeline[{index}]"
55
+ source_id = clip["source"]
56
+ if source_id not in sources:
57
+ errors.append(f"{label}: unknown source '{source_id}'")
58
+ continue
59
+ video_source = clip.get("video_source")
60
+ if video_source is not None and video_source not in sources:
61
+ errors.append(f"{label}: unknown video_source '{video_source}'")
62
+ clip_in, clip_out = clip["in"], clip["out"]
63
+ if clip_in >= clip_out:
64
+ errors.append(f"{label}: in ({clip_in}) must be < out ({clip_out})")
65
+ continue
66
+ duration = durations[source_id]
67
+ if clip_out > duration + 0.05:
68
+ errors.append(
69
+ f"{label}: out ({clip_out}) exceeds source '{source_id}' "
70
+ f"duration ({duration:.3f})"
71
+ )
72
+ continue
73
+ if source_id in last_out and clip_in < last_out[source_id]:
74
+ errors.append(
75
+ f"{label}: overlaps or reorders earlier clip on source "
76
+ f"'{source_id}' (in {clip_in} < previous out {last_out[source_id]})"
77
+ )
78
+ last_out[source_id] = max(last_out.get(source_id, 0.0), clip_out)
79
+ output_time += clip_out - clip_in
80
+ boundaries.append(round(output_time, 6))
81
+
82
+ if errors:
83
+ raise InvalidInputError("invalid plan: " + "; ".join(errors))
84
+
85
+ return {
86
+ "plan_id": plan["plan_id"],
87
+ "clip_count": len(plan["timeline"]),
88
+ "output_duration": round(output_time, 6),
89
+ "boundaries": boundaries[:-1],
90
+ }
@@ -0,0 +1,58 @@
1
+ """External project profiles: explicit YAML passed by path, never discovered.
2
+
3
+ Project identity (canvases, codecs, loudness targets, assets, music, fonts,
4
+ camera aliases) lives here, outside the skill and the package defaults.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+ from video_editor import schemas
15
+ from video_editor.errors import InvalidInputError
16
+
17
+
18
+ def load(path: Path) -> dict[str, Any]:
19
+ if not path.is_file():
20
+ raise InvalidInputError(f"project profile not found: {path}")
21
+ try:
22
+ document = yaml.safe_load(path.read_text())
23
+ except yaml.YAMLError as exc:
24
+ raise InvalidInputError(
25
+ f"project profile {path} is not valid YAML: {exc}"
26
+ ) from exc
27
+ if not isinstance(document, dict):
28
+ raise InvalidInputError(f"project profile {path} must be a YAML mapping")
29
+ schemas.validate(document, "project-profile.schema.json")
30
+
31
+ base = path.parent
32
+ music = document.get("music")
33
+ if music:
34
+ music_path = (base / music["path"]).resolve()
35
+ if not music_path.is_file():
36
+ raise InvalidInputError(f"profile music file not found: {music_path}")
37
+ music["path"] = str(music_path)
38
+ for section in ("assets", "fonts"):
39
+ mapping = document.get(section) or {}
40
+ for key, rel in mapping.items():
41
+ resolved = (base / rel).resolve()
42
+ if not resolved.is_file():
43
+ raise InvalidInputError(
44
+ f"profile {section[:-1]} '{key}' file not found: {resolved}"
45
+ )
46
+ mapping[key] = str(resolved)
47
+ return document
48
+
49
+
50
+ def named_profile(document: dict[str, Any], name: str) -> dict[str, Any]:
51
+ profiles = document["profiles"]
52
+ if name not in profiles:
53
+ known = ", ".join(sorted(profiles))
54
+ raise InvalidInputError(
55
+ f"unknown render profile '{name}' (profile declares: {known})"
56
+ )
57
+ profile: dict[str, Any] = profiles[name]
58
+ return profile
@@ -0,0 +1,49 @@
1
+ """Provenance sidecars: enough recorded detail to reproduce any derived artifact."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from video_editor import SCHEMA_VERSION
12
+ from video_editor import ffmpeg
13
+
14
+
15
+ def sha256_file(path: Path) -> str:
16
+ digest = hashlib.sha256()
17
+ with path.open("rb") as fh:
18
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
19
+ digest.update(chunk)
20
+ return digest.hexdigest()
21
+
22
+
23
+ def utc_now() -> str:
24
+ return datetime.now(timezone.utc).isoformat()
25
+
26
+
27
+ def write_sidecar(
28
+ output: Path,
29
+ command: str,
30
+ inputs: list[Path],
31
+ parameters: dict[str, Any],
32
+ tool_commands: list[list[str]] | None = None,
33
+ ) -> Path:
34
+ """Write `<output>.provenance.json` describing how `output` was produced."""
35
+ record = {
36
+ "schema_version": SCHEMA_VERSION,
37
+ "command": command,
38
+ "created_at": utc_now(),
39
+ "inputs": [
40
+ {"path": str(p), "sha256": sha256_file(p)} for p in inputs if p.is_file()
41
+ ],
42
+ "parameters": parameters,
43
+ "tools": {name: ffmpeg.version(name) for name in ("ffmpeg", "ffprobe")},
44
+ "tool_commands": tool_commands or [],
45
+ "output": {"path": str(output), "sha256": sha256_file(output)},
46
+ }
47
+ sidecar = output.with_name(output.name + ".provenance.json")
48
+ sidecar.write_text(json.dumps(record, indent=2) + "\n")
49
+ return sidecar
@@ -0,0 +1,119 @@
1
+ """Explicit crop/reframe previews and vertical plan derivation.
2
+
3
+ The agent decides where to look; these helpers only execute explicit
4
+ instructions — `derive_short_plan` never chooses the highlight.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from video_editor import SCHEMA_VERSION, ffmpeg, media
15
+ from video_editor.errors import InvalidInputError
16
+
17
+
18
+ def parse_crop(text: str) -> dict[str, int]:
19
+ match = re.fullmatch(r"(\d+):(\d+):(\d+):(\d+)", text)
20
+ if not match:
21
+ raise InvalidInputError(f"crop must be x:y:width:height, got '{text}'")
22
+ x, y, width, height = (int(g) for g in match.groups())
23
+ return {"x": x, "y": y, "width": width, "height": height}
24
+
25
+
26
+ def parse_canvas(text: str) -> dict[str, int]:
27
+ match = re.fullmatch(r"(\d+)x(\d+)", text)
28
+ if not match:
29
+ raise InvalidInputError(f"canvas must be WIDTHxHEIGHT, got '{text}'")
30
+ return {"width": int(match.group(1)), "height": int(match.group(2))}
31
+
32
+
33
+ def preview_reframe(
34
+ source: Path,
35
+ start: float,
36
+ end: float,
37
+ crop: dict[str, int],
38
+ canvas: dict[str, int],
39
+ output: Path,
40
+ ) -> list[str]:
41
+ """Render a short preview of one explicit crop scaled onto a canvas."""
42
+ media.require_range(source, start, end)
43
+ info = media.probe(source)
44
+ video = next((s for s in info["streams"] if s["type"] == "video"), None)
45
+ if video is None:
46
+ raise InvalidInputError(f"no video stream in {source}")
47
+ if (
48
+ crop["x"] + crop["width"] > video["width"]
49
+ or crop["y"] + crop["height"] > video["height"]
50
+ ):
51
+ raise InvalidInputError(
52
+ f"crop {crop} exceeds source frame {video['width']}x{video['height']}"
53
+ )
54
+ output.parent.mkdir(parents=True, exist_ok=True)
55
+ span = end - start
56
+ args = [
57
+ "-ss",
58
+ f"{start:.3f}",
59
+ "-t",
60
+ f"{span:.3f}",
61
+ "-i",
62
+ str(source),
63
+ "-vf",
64
+ (
65
+ f"crop={crop['width']}:{crop['height']}:{crop['x']}:{crop['y']},"
66
+ f"scale={canvas['width']}:{canvas['height']}:force_original_aspect_ratio=decrease,"
67
+ f"pad={canvas['width']}:{canvas['height']}:(ow-iw)/2:(oh-ih)/2,setsar=1"
68
+ ),
69
+ "-c:v",
70
+ "libx264",
71
+ "-preset",
72
+ "veryfast",
73
+ "-crf",
74
+ "26",
75
+ "-c:a",
76
+ "aac",
77
+ "-b:a",
78
+ "96k",
79
+ str(output),
80
+ ]
81
+ ffmpeg.run_ffmpeg(args)
82
+ return args
83
+
84
+
85
+ def derive_short_plan(
86
+ source_path: Path,
87
+ source_id: str,
88
+ start: float,
89
+ end: float,
90
+ canvas: dict[str, int],
91
+ reason: str,
92
+ crop: dict[str, int] | None = None,
93
+ parent_plan: str | None = None,
94
+ created_by: str | None = None,
95
+ ) -> dict[str, Any]:
96
+ """New editable vertical plan for one explicit, agent-chosen source range."""
97
+ media.require_range(source_path, start, end)
98
+ duration = media.duration_of(source_path)
99
+ if end > duration + 0.05:
100
+ raise InvalidInputError(
101
+ f"range end {end}s exceeds source duration {duration:.3f}s"
102
+ )
103
+ clip: dict[str, Any] = {
104
+ "source": source_id,
105
+ "in": start,
106
+ "out": end,
107
+ "reason": reason,
108
+ }
109
+ if crop:
110
+ clip["crop"] = crop
111
+ return {
112
+ "schema_version": SCHEMA_VERSION,
113
+ "plan_id": f"short-{uuid.uuid4().hex[:8]}",
114
+ "created_by": created_by,
115
+ "parent_plan": parent_plan,
116
+ "output_canvas": {**canvas, "frame_rate": None},
117
+ "sources": [{"id": source_id, "path": str(source_path.resolve())}],
118
+ "timeline": [clip],
119
+ }