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.
- pyvideokit_libs/__init__.py +55 -0
- pyvideokit_libs/apply_vhs_effect.py +144 -0
- pyvideokit_libs/concat_videos.py +65 -0
- pyvideokit_libs/convert_to_ffv1.py +28 -0
- pyvideokit_libs/extract_audio.py +25 -0
- pyvideokit_libs/fade_video.py +71 -0
- pyvideokit_libs/ffmpeg_utils.py +233 -0
- pyvideokit_libs/prepare_youtube.py +33 -0
- pyvideokit_libs/trim_video.py +37 -0
- pyvideokit_libs-0.2.0.dist-info/METADATA +877 -0
- pyvideokit_libs-0.2.0.dist-info/RECORD +14 -0
- pyvideokit_libs-0.2.0.dist-info/WHEEL +5 -0
- pyvideokit_libs-0.2.0.dist-info/licenses/LICENSE +674 -0
- pyvideokit_libs-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|