PyVideoKit-Libs 0.2.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,55 @@
1
+ from .ffmpeg_utils import (
2
+ FFmpegError,
3
+ validate_input_file,
4
+ require_tools,
5
+ require_video_codec,
6
+ require_audio_stream,
7
+ probe_duration,
8
+ probe_stream_props,
9
+ probe_video_codec,
10
+ probe_has_audio,
11
+ parse_time_to_seconds,
12
+ seconds_to_hms,
13
+ make_output_path,
14
+ resolve_output_path,
15
+ which,
16
+ run_command,
17
+ run_ffmpeg_with_progress,
18
+ )
19
+ from .apply_vhs_effect import apply_vhs_effect, build_vhs_filter
20
+ from .concat_videos import join_videos, check_stream_compatibility
21
+ from .convert_to_ffv1 import convert_to_ffv1
22
+ from .extract_audio import extract_audio
23
+ from .fade_video import fade_video
24
+ from .prepare_youtube import prepare_youtube
25
+ from .trim_video import trim_video
26
+
27
+ __version__ = "0.2.0"
28
+
29
+ __all__ = [
30
+ "FFmpegError",
31
+ "validate_input_file",
32
+ "require_tools",
33
+ "require_video_codec",
34
+ "require_audio_stream",
35
+ "probe_duration",
36
+ "probe_stream_props",
37
+ "probe_video_codec",
38
+ "probe_has_audio",
39
+ "parse_time_to_seconds",
40
+ "seconds_to_hms",
41
+ "make_output_path",
42
+ "resolve_output_path",
43
+ "which",
44
+ "run_command",
45
+ "run_ffmpeg_with_progress",
46
+ "apply_vhs_effect",
47
+ "build_vhs_filter",
48
+ "join_videos",
49
+ "check_stream_compatibility",
50
+ "convert_to_ffv1",
51
+ "extract_audio",
52
+ "fade_video",
53
+ "prepare_youtube",
54
+ "trim_video",
55
+ ]
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import math
4
+ import tempfile
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from . import ffmpeg_utils as utils
9
+
10
+
11
+ SAMPLE_RATE = 48000
12
+ BIT_DEPTH = 32
13
+ CHANNELS = 2
14
+ LOW_PASS_FREQ = 4000
15
+ IN_VOL = 1.0
16
+ OUT_VOL = 0.077
17
+ DB_VOLUME = math.pow(10, -22.75 / 20.0)
18
+
19
+ V_CODEC = "ffv1"
20
+ A_CODEC = "pcm_s16le"
21
+ PIX_FMT = "yuv420p"
22
+ VID_COLORSPACE = "bt709"
23
+
24
+
25
+ def build_vhs_filter(width: int, height: int) -> str:
26
+ """Build a resolution-independent VHS filtergraph preserving the original aspect ratio."""
27
+ chroma_w = width // 2
28
+ chroma_h = height // 2
29
+ luma_sq = max(2, round(width * 0.4) // 2 * 2)
30
+ chroma_sq = max(2, round(chroma_w * 0.125) // 2 * 2)
31
+ return (
32
+ f"format=yuv420p, split=3 [a][b][c]; "
33
+ f"[a] extractplanes=y [y]; "
34
+ f"[b] extractplanes=u [u]; "
35
+ f"[c] extractplanes=v [v]; "
36
+ f"[y] scale={luma_sq}:{height}, scale={width}:{height} [luma_scaled]; "
37
+ f"[u] scale={chroma_sq}:{chroma_h}, scale={chroma_w}:{chroma_h} [u_scaled]; "
38
+ f"[v] scale={chroma_sq}:{chroma_h}, scale={chroma_w}:{chroma_h} [v_scaled]; "
39
+ f"[luma_scaled][u_scaled][v_scaled] mergeplanes=0x001020:yuv420p [merged]; "
40
+ f"[merged] eq=saturation=0.75, noise=alls=5:allf=t, "
41
+ f"geq='lum(X+5.5*(random(floor(Y/96))-0.5),Y)':cb='cb(X,Y)':cr='cr(X,Y)' [outv]"
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class TempFiles:
47
+ vhs: Path
48
+ wav: Path
49
+ wav_fx: Path
50
+ noise: Path
51
+ mix: Path
52
+
53
+ @classmethod
54
+ def from_dir(cls, d: Path) -> "TempFiles":
55
+ def mktmp(suffix: str) -> Path:
56
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=d) as f:
57
+ return Path(f.name)
58
+ return cls(
59
+ vhs=mktmp(".mkv"),
60
+ wav=mktmp(".wav"),
61
+ wav_fx=mktmp(".wav"),
62
+ noise=mktmp(".wav"),
63
+ mix=mktmp(".wav"),
64
+ )
65
+
66
+ def cleanup(self) -> None:
67
+ for f in (self.noise, self.mix, self.vhs, self.wav_fx, self.wav):
68
+ try:
69
+ f.unlink(missing_ok=True)
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ def _step_0_vhs_fx(
75
+ input_path: Path,
76
+ tmp: TempFiles,
77
+ props: dict,
78
+ on_progress: Callable[[float], None] | None = None,
79
+ ) -> None:
80
+ vhs_filter = build_vhs_filter(props["width"], props["height"])
81
+ cmd = [
82
+ "ffmpeg", "-hide_banner", "-y",
83
+ "-i", str(input_path),
84
+ "-filter_complex", vhs_filter,
85
+ "-c:v", V_CODEC, "-pix_fmt", PIX_FMT,
86
+ "-colorspace", VID_COLORSPACE,
87
+ "-color_primaries", VID_COLORSPACE,
88
+ "-color_trc", VID_COLORSPACE,
89
+ "-map", "[outv]", str(tmp.vhs),
90
+ "-map", "a", "-c:a", A_CODEC, str(tmp.wav),
91
+ ]
92
+ utils.run_ffmpeg_with_progress(cmd, duration=props.get("duration", 0.0), on_progress=on_progress)
93
+
94
+
95
+ def _step_1_audio_fx(tmp: TempFiles, duration: float) -> None:
96
+ utils.run_command([
97
+ "sox", "-n",
98
+ "-r", str(SAMPLE_RATE), "-b", str(BIT_DEPTH),
99
+ "-e", "floating-point", "-c", str(CHANNELS),
100
+ str(tmp.noise), "synth", str(duration), "brownnoise", "vol", f"{DB_VOLUME}",
101
+ ])
102
+ utils.run_command([
103
+ "sox", "-m",
104
+ "-v", f"{IN_VOL}", str(tmp.wav),
105
+ "-v", f"{OUT_VOL}", str(tmp.noise),
106
+ str(tmp.mix),
107
+ ])
108
+ utils.run_command([
109
+ "sox", str(tmp.mix), str(tmp.wav_fx), "lowpass", str(LOW_PASS_FREQ),
110
+ ])
111
+
112
+
113
+ def _step_2_map_inputs(input_path: Path, tmp: TempFiles, duration: float, output: str | None = None) -> Path:
114
+ base_name = input_path.stem
115
+ for ch in ':*?"<>|':
116
+ base_name = base_name.replace(ch, "-")
117
+ default_path = tmp.vhs.parent / f"{base_name}.mkv"
118
+ final_path = utils.resolve_output_path(default_path, output)
119
+ cmd = [
120
+ "ffmpeg", "-hide_banner", "-y",
121
+ "-i", str(tmp.vhs),
122
+ "-i", str(tmp.wav_fx),
123
+ "-map", "0:v", "-map", "1:a",
124
+ "-c", "copy",
125
+ str(final_path),
126
+ ]
127
+ utils.run_ffmpeg_with_progress(cmd, duration=duration)
128
+ return final_path
129
+
130
+
131
+ def apply_vhs_effect(
132
+ input_path: Path,
133
+ output: str | None = None,
134
+ on_progress: Callable[[float], None] | None = None,
135
+ ) -> Path:
136
+ utils.require_audio_stream(input_path)
137
+ props = utils.probe_stream_props(input_path)
138
+ tmp = TempFiles.from_dir(input_path.parent)
139
+ try:
140
+ _step_0_vhs_fx(input_path, tmp, props, on_progress)
141
+ _step_1_audio_fx(tmp, props.get("duration", 0.0))
142
+ return _step_2_map_inputs(input_path, tmp, props.get("duration", 0.0), output)
143
+ finally:
144
+ tmp.cleanup()
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import tempfile
4
+ from collections.abc import Callable
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from . import ffmpeg_utils as utils
8
+
9
+
10
+ def _build_concat_file(video_paths: list[Path]) -> Path:
11
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8")
12
+ with tmp as f:
13
+ for p in video_paths:
14
+ # Escape single quotes for ffmpeg concat file format
15
+ path_str = str(p.resolve()).replace("'", r"'\''")
16
+ f.write(f"file '{path_str}'\n")
17
+ return Path(tmp.name)
18
+
19
+
20
+ def _build_output_path(first_video: Path) -> Path:
21
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
22
+ ext = first_video.suffix or ".mkv"
23
+ return first_video.with_name(f"joined_{timestamp}{ext}")
24
+
25
+
26
+ def check_stream_compatibility(video_paths: list[Path], all_props: list[dict]) -> None:
27
+ """Raise ValueError if files have incompatible streams for stream-copy concat."""
28
+ ref = all_props[0]
29
+ if not ref:
30
+ return
31
+ keys = ("vcodec", "width", "height", "fps", "acodec", "sample_rate", "channels")
32
+ for path, props in zip(video_paths[1:], all_props[1:]):
33
+ mismatches = [
34
+ f" {k}: {video_paths[0].name}={ref[k]!r} vs {path.name}={props.get(k)!r}"
35
+ for k in keys
36
+ if k in ref and props.get(k) != ref[k]
37
+ ]
38
+ if mismatches:
39
+ raise ValueError(
40
+ "Incompatible streams for lossless concat:\n" + "\n".join(mismatches)
41
+ )
42
+
43
+
44
+ def join_videos(
45
+ video_paths: list[Path],
46
+ output: str | None = None,
47
+ on_progress: Callable[[float], None] | None = None,
48
+ ) -> Path:
49
+ if len(video_paths) < 2:
50
+ raise ValueError("Need at least two input videos.")
51
+ all_props = [utils.probe_stream_props(p) for p in video_paths]
52
+ check_stream_compatibility(video_paths, all_props)
53
+ total = sum(p.get("duration", 0.0) for p in all_props)
54
+ concat_file = _build_concat_file(video_paths)
55
+ output_path = utils.resolve_output_path(_build_output_path(video_paths[0]), output)
56
+ cmd = [
57
+ "ffmpeg", "-f", "concat", "-safe", "0",
58
+ "-i", str(concat_file),
59
+ "-c", "copy", str(output_path),
60
+ ]
61
+ try:
62
+ utils.run_ffmpeg_with_progress(cmd, duration=total, on_progress=on_progress)
63
+ finally:
64
+ concat_file.unlink(missing_ok=True)
65
+ return output_path
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from . import ffmpeg_utils as utils
6
+
7
+
8
+ def convert_to_ffv1(
9
+ input_path: Path,
10
+ fps: int = 60,
11
+ output: str | None = None,
12
+ on_progress: Callable[[float], None] | None = None,
13
+ ) -> Path:
14
+ duration = utils.probe_duration(input_path)
15
+ output_path = utils.resolve_output_path(
16
+ utils.make_output_path(input_path, suffix="_ffv1", ext=".mkv"), output
17
+ )
18
+ cmd = [
19
+ "ffmpeg", "-hide_banner", "-y",
20
+ "-i", str(input_path),
21
+ "-map", "0",
22
+ "-c:v", "ffv1",
23
+ "-r", str(fps),
24
+ "-c:a", "pcm_s16le",
25
+ str(output_path),
26
+ ]
27
+ utils.run_ffmpeg_with_progress(cmd, duration=duration, on_progress=on_progress)
28
+ return output_path
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from . import ffmpeg_utils as utils
6
+
7
+
8
+ def extract_audio(
9
+ input_path: Path,
10
+ output: str | None = None,
11
+ on_progress: Callable[[float], None] | None = None,
12
+ ) -> Path:
13
+ duration = utils.probe_duration(input_path)
14
+ output_path = utils.resolve_output_path(
15
+ utils.make_output_path(input_path, ext=".wav"), output
16
+ )
17
+ cmd = [
18
+ "ffmpeg", "-hide_banner", "-y",
19
+ "-i", str(input_path),
20
+ "-vn",
21
+ "-c:a", "pcm_s16le",
22
+ str(output_path),
23
+ ]
24
+ utils.run_ffmpeg_with_progress(cmd, duration=duration, on_progress=on_progress)
25
+ return output_path
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from . import ffmpeg_utils as utils
6
+
7
+
8
+ def _build_fade_cmd(
9
+ input_path: Path,
10
+ fade_in: float | None,
11
+ fade_out: float | None,
12
+ total_duration: float,
13
+ output_path: Path,
14
+ fps: int,
15
+ ) -> list[str]:
16
+ v_filters = [f"fps={fps}"]
17
+ if fade_in is not None:
18
+ v_filters.append(f"fade=t=in:st=0:d={fade_in:.3f}")
19
+ if fade_out is not None:
20
+ start_out = max(0.0, total_duration - fade_out)
21
+ v_filters.append(f"fade=t=out:st={start_out:.3f}:d={fade_out:.3f}")
22
+ v_filters.append("format=yuv420p")
23
+
24
+ a_filters = []
25
+ if fade_in is not None:
26
+ a_filters.append(f"afade=t=in:st=0:d={fade_in:.3f}")
27
+ if fade_out is not None:
28
+ a_start_out = max(0.0, total_duration - fade_out)
29
+ a_filters.append(f"afade=t=out:st={a_start_out:.3f}:d={fade_out:.3f}")
30
+ af_chain = ",".join(a_filters) if a_filters else None
31
+
32
+ cmd = [
33
+ "ffmpeg", "-hide_banner", "-y",
34
+ "-i", str(input_path),
35
+ "-c:v", "ffv1",
36
+ "-pix_fmt", "yuv420p",
37
+ "-r", str(fps),
38
+ "-vf", ",".join(v_filters),
39
+ ]
40
+ if af_chain:
41
+ cmd += ["-c:a", "pcm_s16le", "-af", af_chain]
42
+ else:
43
+ cmd += ["-c:a", "pcm_s16le"]
44
+ cmd.append(str(output_path))
45
+ return cmd
46
+
47
+
48
+ def fade_video(
49
+ input_path: Path,
50
+ fade_in: float | None = None,
51
+ fade_out: float | None = None,
52
+ fps: int = 60,
53
+ output: str | None = None,
54
+ on_progress: Callable[[float], None] | None = None,
55
+ ) -> Path:
56
+ utils.require_video_codec(
57
+ input_path, "ffv1",
58
+ hint="Run convert_to_ffv1() on the source file first.",
59
+ )
60
+ utils.require_audio_stream(input_path)
61
+ if fade_in is None and fade_out is None:
62
+ raise ValueError("Specify at least one of fade_in or fade_out.")
63
+ total_duration = utils.probe_duration(input_path)
64
+ if total_duration <= 0:
65
+ raise ValueError("Could not determine video duration.")
66
+ output_path = utils.resolve_output_path(
67
+ utils.make_output_path(input_path, suffix="_fade"), output
68
+ )
69
+ cmd = _build_fade_cmd(input_path, fade_in, fade_out, total_duration, output_path, fps)
70
+ utils.run_ffmpeg_with_progress(cmd, duration=total_duration, on_progress=on_progress)
71
+ return output_path
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+
10
+ class FFmpegError(Exception):
11
+ def __init__(self, returncode: int, error_lines: list[str]) -> None:
12
+ self.returncode = returncode
13
+ self.error_lines = error_lines
14
+ super().__init__(f"FFmpeg exited with code {returncode}")
15
+
16
+
17
+ def which(cmd: str) -> str | None:
18
+ return shutil.which(cmd)
19
+
20
+
21
+ def has_ffprobe() -> bool:
22
+ return which("ffprobe") is not None
23
+
24
+
25
+ def parse_time_to_seconds(ts: str) -> float | None:
26
+ """Parse SS, MM:SS, or HH:MM:SS(.ms) into total seconds; returns None for empty input."""
27
+ ts = (ts or "").strip()
28
+ if ts == "":
29
+ return None
30
+ if ts.count(":") == 0:
31
+ return float(ts)
32
+ parts = [float(p) for p in ts.split(":")]
33
+ if len(parts) == 2:
34
+ mm, ss = parts
35
+ return mm * 60 + ss
36
+ if len(parts) == 3:
37
+ hh, mm, ss = parts
38
+ return hh * 3600 + mm * 60 + ss
39
+ raise ValueError(f"Invalid time format: {ts}")
40
+
41
+
42
+ def seconds_to_hms(seconds: float) -> str:
43
+ """Convert seconds to HH:MM:SS.mmm string."""
44
+ seconds = max(0.0, float(seconds))
45
+ hh = int(seconds // 3600)
46
+ mm = int((seconds % 3600) // 60)
47
+ ss = seconds - (hh * 3600 + mm * 60)
48
+ return f"{hh:02d}:{mm:02d}:{ss:06.3f}"
49
+
50
+
51
+ def probe_video_codec(path: Path) -> str:
52
+ """Return the codec name of the first video stream, or '' if none."""
53
+ if not has_ffprobe():
54
+ return ""
55
+ try:
56
+ out = subprocess.check_output(
57
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
58
+ "-show_entries", "stream=codec_name",
59
+ "-of", "default=nk=1:nw=1", str(path)],
60
+ stderr=subprocess.DEVNULL,
61
+ ).decode().strip()
62
+ return out
63
+ except Exception:
64
+ return ""
65
+
66
+
67
+ def probe_has_audio(path: Path) -> bool:
68
+ """Return True if the file has at least one audio stream."""
69
+ if not has_ffprobe():
70
+ return False
71
+ try:
72
+ out = subprocess.check_output(
73
+ ["ffprobe", "-v", "error", "-select_streams", "a:0",
74
+ "-show_entries", "stream=codec_name",
75
+ "-of", "default=nk=1:nw=1", str(path)],
76
+ stderr=subprocess.DEVNULL,
77
+ ).decode().strip()
78
+ return bool(out)
79
+ except Exception:
80
+ return False
81
+
82
+
83
+ def probe_stream_props(path: Path) -> dict:
84
+ """Return stream properties and duration in a single ffprobe call."""
85
+ if not has_ffprobe():
86
+ return {}
87
+ try:
88
+ out = subprocess.check_output(
89
+ ["ffprobe", "-v", "error",
90
+ "-show_entries",
91
+ "format=duration:stream=codec_name,width,height,r_frame_rate,sample_rate,channels",
92
+ "-of", "json", str(path)],
93
+ stderr=subprocess.DEVNULL,
94
+ ).decode()
95
+ data = json.loads(out)
96
+ props: dict = {}
97
+ for stream in data.get("streams", []):
98
+ if "width" in stream:
99
+ props["vcodec"] = stream.get("codec_name", "")
100
+ props["width"] = stream.get("width")
101
+ props["height"] = stream.get("height")
102
+ props["fps"] = stream.get("r_frame_rate", "")
103
+ elif "sample_rate" in stream:
104
+ props["acodec"] = stream.get("codec_name", "")
105
+ props["sample_rate"] = stream.get("sample_rate")
106
+ props["channels"] = stream.get("channels")
107
+ duration_str = data.get("format", {}).get("duration")
108
+ if duration_str:
109
+ props["duration"] = float(duration_str)
110
+ return props
111
+ except Exception:
112
+ return {}
113
+
114
+
115
+ def probe_duration(path: Path) -> float:
116
+ """Return the duration of a media file in seconds, or 0.0 on failure."""
117
+ if not has_ffprobe():
118
+ return 0.0
119
+ try:
120
+ out = subprocess.check_output(
121
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
122
+ "-of", "default=nk=1:nw=1", str(path)],
123
+ stderr=subprocess.DEVNULL,
124
+ ).decode().strip()
125
+ return float(out)
126
+ except Exception:
127
+ return 0.0
128
+
129
+
130
+ def validate_input_file(path: str | Path) -> Path:
131
+ """Resolve and validate that an input file exists; raise FileNotFoundError if not."""
132
+ p = Path(path).expanduser().resolve()
133
+ if not p.exists():
134
+ raise FileNotFoundError(f"File not found: {p}")
135
+ return p
136
+
137
+
138
+ def require_tools(*names: str) -> None:
139
+ """Check that all required external tools are in PATH; raise RuntimeError if any are missing."""
140
+ missing = [n for n in names if which(n) is None]
141
+ if missing:
142
+ raise RuntimeError(f"Required tools not found in PATH: {', '.join(missing)}")
143
+
144
+
145
+ def require_video_codec(path: Path, codec: str, hint: str = "") -> None:
146
+ """Raise ValueError if the file's video codec doesn't match the expected one."""
147
+ actual = probe_video_codec(path)
148
+ if actual != codec:
149
+ msg = f"'{path.name}' has codec '{actual or 'none'}', expected '{codec}'."
150
+ if hint:
151
+ msg += f" {hint}"
152
+ raise ValueError(msg)
153
+
154
+
155
+ def require_audio_stream(path: Path) -> None:
156
+ """Raise ValueError if the file has no audio stream."""
157
+ if not probe_has_audio(path):
158
+ raise ValueError(f"'{path.name}' has no audio stream.")
159
+
160
+
161
+ def resolve_output_path(default: Path, output: str | None) -> Path:
162
+ """Return default if output is None, place inside dir if dir, else treat as explicit path."""
163
+ if output is None:
164
+ return default
165
+ out = Path(output).expanduser()
166
+ if out.is_dir():
167
+ return out / default.name
168
+ return out
169
+
170
+
171
+ def make_output_path(input_path: Path, suffix: str = "", ext: str | None = None) -> Path:
172
+ """Build an output path in the same directory as input with optional suffix/extension."""
173
+ name = input_path.stem + suffix
174
+ extension = ext if ext is not None else input_path.suffix
175
+ return input_path.with_name(name + extension)
176
+
177
+
178
+ def run_command(cmd: list[str]) -> None:
179
+ """Run a subprocess command; raises CalledProcessError on failure."""
180
+ subprocess.run(cmd, check=True)
181
+
182
+
183
+ def run_ffmpeg_with_progress(
184
+ cmd: list[str],
185
+ duration: float = 0.0,
186
+ on_progress: Callable[[float], None] | None = None,
187
+ ) -> None:
188
+ """Run an FFmpeg command, calling on_progress(pct) as it encodes. Raises FFmpegError on failure."""
189
+ if "-progress" not in cmd:
190
+ full_cmd = cmd[:1] + ["-progress", "pipe:1", "-nostats", "-loglevel", "error"] + cmd[1:]
191
+ else:
192
+ full_cmd = cmd
193
+
194
+ try:
195
+ proc = subprocess.Popen(
196
+ full_cmd,
197
+ stdout=subprocess.PIPE,
198
+ stderr=subprocess.STDOUT,
199
+ bufsize=1,
200
+ universal_newlines=True,
201
+ )
202
+ except FileNotFoundError:
203
+ raise RuntimeError("ffmpeg not found in PATH")
204
+
205
+ last_pct = -1.0
206
+ error_lines: list[str] = []
207
+
208
+ for line in proc.stdout or []:
209
+ line = line.strip()
210
+ if not line:
211
+ continue
212
+ if line.startswith("out_time_ms="):
213
+ try:
214
+ ms = int(line.split("=", 1)[1])
215
+ out_time_sec = ms / 1_000_000.0
216
+ if duration > 0:
217
+ pct = min(100.0, (out_time_sec / duration) * 100.0)
218
+ else:
219
+ pct = min(100.0, (out_time_sec % 10) * 10.0)
220
+ if on_progress and (pct - last_pct >= 1.0 or pct >= 100.0):
221
+ last_pct = pct
222
+ on_progress(pct)
223
+ except ValueError:
224
+ pass
225
+ elif line.startswith("progress=end"):
226
+ break
227
+ elif " " in line or line.startswith("["):
228
+ # FFmpeg error/warning messages contain spaces or start with [codec @ addr]
229
+ error_lines.append(line)
230
+
231
+ rc = proc.wait()
232
+ if rc != 0:
233
+ raise FFmpegError(rc, error_lines)
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from . import ffmpeg_utils as utils
6
+
7
+
8
+ def prepare_youtube(
9
+ input_path: Path,
10
+ output: str | None = None,
11
+ on_progress: Callable[[float], None] | None = None,
12
+ ) -> Path:
13
+ utils.require_video_codec(
14
+ input_path, "ffv1",
15
+ hint="Run convert_to_ffv1() on the source file first.",
16
+ )
17
+ duration = utils.probe_duration(input_path)
18
+ output_path = utils.resolve_output_path(
19
+ utils.make_output_path(input_path, suffix="_youtube", ext=".mov"), output
20
+ )
21
+ cmd = [
22
+ "ffmpeg", "-hide_banner", "-y",
23
+ "-i", str(input_path),
24
+ "-map", "0",
25
+ "-vf", "scale=-1:2160", # YouTube gives better bitrate to 4K uploads
26
+ "-c:v", "prores_ks",
27
+ "-profile:v", "3", # ProRes 422 HQ
28
+ "-pix_fmt", "yuv422p10le",
29
+ "-c:a", "pcm_s16le",
30
+ str(output_path),
31
+ ]
32
+ utils.run_ffmpeg_with_progress(cmd, duration=duration, on_progress=on_progress)
33
+ return output_path
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from collections.abc import Callable
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from . import ffmpeg_utils as utils
7
+
8
+
9
+ def _build_output_path(input_path: Path) -> Path:
10
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
11
+ return input_path.with_name(f"{ts}{input_path.suffix}")
12
+
13
+
14
+ def trim_video(
15
+ input_path: Path,
16
+ start: float,
17
+ end: float,
18
+ output: str | None = None,
19
+ on_progress: Callable[[float], None] | None = None,
20
+ ) -> Path:
21
+ if end <= start:
22
+ raise ValueError("end time must be greater than start time.")
23
+ file_dur = utils.probe_duration(input_path)
24
+ if file_dur > 0 and start > file_dur:
25
+ raise ValueError(f"start ({start:.3f}s) exceeds input duration ({file_dur:.3f}s).")
26
+ duration = end - start
27
+ output_path = utils.resolve_output_path(_build_output_path(input_path), output)
28
+ cmd = [
29
+ "ffmpeg", "-hide_banner", "-y",
30
+ "-ss", utils.seconds_to_hms(start),
31
+ "-i", str(input_path),
32
+ "-t", utils.seconds_to_hms(duration),
33
+ "-c", "copy",
34
+ str(output_path),
35
+ ]
36
+ utils.run_ffmpeg_with_progress(cmd, duration=duration, on_progress=on_progress)
37
+ return output_path