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/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
|
+
}
|
video_editor/profiles.py
ADDED
|
@@ -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
|
video_editor/reframe.py
ADDED
|
@@ -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
|
+
}
|