Skip to content

Commit f8b992a

Browse files
committed
Add new muxtools
1 parent db363d2 commit f8b992a

6 files changed

Lines changed: 500 additions & 202 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.13"
77
dependencies = [
88
"aiohttp>=3.13.0",
9+
"opencv-python>=4.13.0.90",
910
"pydantic>=2.12.0",
1011
"typer>=0.17.4",
1112
]

src/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
from .app import app as app
2-
from .backup import find_hardlinks # noqa: F401
3-
from .jellyfin import add_all_subdirectories_to_library # noqa: F401
1+
from .app import app as app # noqa: F401
2+
from .backup import * # noqa: F401,F403
3+
from .jellyfin import * # noqa: F401,F403
4+
from .muxtools import * # noqa: F401,F403

src/muxtools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .find_change_from_start import find_change_from_start as find_change_from_start
2+
from .create_offset_mkv import create_offset_mkv as create_offset_mkv

src/muxtools/create_offset_mkv.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import asyncio
2+
from typing import Annotated
3+
4+
from typer import Argument
5+
from ..app import app
6+
from .find_change_from_start import find_change_from_start_inner
7+
8+
9+
async def create_offset_mkv_inner(bd_path: str, target_path: str, output_path: str):
10+
bd_offset_frame, bd_fps = find_change_from_start_inner(bd_path)
11+
if bd_offset_frame == -1:
12+
raise ValueError("No significant change found in BD video.")
13+
bd_offset_seconds = bd_offset_frame / bd_fps
14+
target_offset_frame, target_fps = find_change_from_start_inner(target_path)
15+
if target_offset_frame == -1:
16+
raise ValueError("No significant change found in target video.")
17+
target_offset_seconds = target_offset_frame / target_fps
18+
# Positive if BD starts later, negative if earlier
19+
offset_seconds = round(bd_offset_seconds - target_offset_seconds, 5)
20+
print(
21+
f"Calculated offset: {offset_seconds} seconds, adjusting all non-video streams accordingly."
22+
)
23+
ffmpeg_command = [
24+
"ffmpeg",
25+
"-y",
26+
"-loglevel",
27+
"quiet",
28+
"-itsoffset",
29+
str(offset_seconds),
30+
"-i",
31+
target_path,
32+
"-map",
33+
"0",
34+
"-map",
35+
"-0:v", # Exclude original video stream
36+
"-map_metadata",
37+
"0",
38+
"-c",
39+
"copy",
40+
output_path,
41+
]
42+
proc = await asyncio.create_subprocess_exec(
43+
*ffmpeg_command, stdout=asyncio.subprocess.PIPE
44+
)
45+
await proc.communicate()
46+
if proc.returncode != 0:
47+
raise RuntimeError(f"ffmpeg command failed with return code {proc.returncode}")
48+
49+
50+
@app.command()
51+
def create_offset_mkv(
52+
bd_path: Annotated[
53+
str,
54+
Argument(
55+
help="Path to the Blu-ray video file to analyze for offset.",
56+
exists=True,
57+
file_okay=True,
58+
dir_okay=False,
59+
readable=True,
60+
resolve_path=True,
61+
),
62+
],
63+
target_path: Annotated[
64+
str,
65+
Argument(
66+
help="Path to the target video file to adjust.",
67+
exists=True,
68+
file_okay=True,
69+
dir_okay=False,
70+
readable=True,
71+
resolve_path=True,
72+
),
73+
],
74+
output_path: Annotated[
75+
str,
76+
Argument(
77+
help="Path to save the output MKV file with adjusted timing.",
78+
),
79+
],
80+
):
81+
"""Create an MKV file with adjusted timing based on offset from Blu-ray video.
82+
83+
:param bd_path: Path to the Blu-ray video file to analyze for offset.
84+
:param target_path: Path to the target video file to adjust.
85+
:param output_path: Path to save the output MKV file with adjusted timing.
86+
"""
87+
asyncio.run(
88+
create_offset_mkv_inner(
89+
bd_path=bd_path, target_path=target_path, output_path=output_path
90+
)
91+
)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import cv2
2+
3+
from typing import Annotated
4+
5+
from typer import Argument, Option
6+
from ..app import app
7+
8+
9+
def find_change_from_start_inner(
10+
video_path, threshold=0, min_changed_pixels=50
11+
) -> tuple[int, float]:
12+
"""
13+
Consumes video one frame at a time to find where it diverges from Frame 1.
14+
15+
Args:
16+
video_path (str): Path to video file.
17+
threshold (int): Sensitivity (0-255). Lower = detects subtle changes.
18+
Higher = ignores compression artifacts.
19+
min_changed_pixels (int): How many pixels must change to trigger detection.
20+
21+
Returns:
22+
tuple[int, float]: Frame number where change is detected and FPS of the video.
23+
Returns -1 if no significant change is found.
24+
"""
25+
26+
# 1. Open the video stream
27+
cap = cv2.VideoCapture(video_path)
28+
29+
try:
30+
if not cap.isOpened():
31+
raise FileNotFoundError(f"Could not open video file: {video_path}")
32+
33+
# 2. Read Frame 1 (The Reference)
34+
ret, frame1 = cap.read()
35+
if not ret:
36+
raise ValueError("Video file is empty or unreadable.")
37+
38+
# Convert to grayscale to reduce complexity and ignore color noise
39+
frame1_gray = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY)
40+
41+
# Optional: Apply slight blur to reduce compression artifact noise
42+
frame1_gray = cv2.GaussianBlur(frame1_gray, (21, 21), 0)
43+
44+
frame_count = 1
45+
fps = cap.get(cv2.CAP_PROP_FPS)
46+
47+
# 3. Loop through the stream one frame at a time
48+
while True:
49+
# returns (bool, numpy_array)
50+
ret, current_frame = cap.read()
51+
52+
# If no frame is returned, we reached the end of the video
53+
if not ret:
54+
return -1, fps # No significant change found
55+
56+
frame_count += 1
57+
58+
# Convert current frame to grayscale
59+
current_gray = cv2.cvtColor(current_frame, cv2.COLOR_BGR2GRAY)
60+
current_gray = cv2.GaussianBlur(current_gray, (21, 21), 0)
61+
62+
# 4. Compute Absolute Difference between Frame 1 and Current Frame
63+
# This creates an image where black pixels means "no change"
64+
# and white pixels means "change"
65+
delta = cv2.absdiff(frame1_gray, current_gray)
66+
67+
# 5. Apply Threshold
68+
# Any pixel difference < threshold becomes 0 (black).
69+
# Any pixel difference > threshold becomes 255 (white).
70+
thresh_img = cv2.threshold(delta, threshold, 255, cv2.THRESH_BINARY)[1]
71+
72+
# 6. Check if enough pixels have changed
73+
# We count the white pixels in the threshold image
74+
changed_pixels = cv2.countNonZero(thresh_img)
75+
76+
if changed_pixels > min_changed_pixels:
77+
# Optional: Save the frame to verify
78+
# cv2.imwrite(f"change_detected_frame_{frame_count}.jpg", current_frame)
79+
return frame_count, fps
80+
81+
# 7. Release resources
82+
finally:
83+
cap.release()
84+
85+
86+
@app.command()
87+
def find_change_from_start(
88+
video_path: Annotated[
89+
str,
90+
Argument(
91+
exists=True,
92+
file_okay=True,
93+
dir_okay=False,
94+
readable=True,
95+
resolve_path=True,
96+
help="Path to the video file to analyze.",
97+
),
98+
],
99+
threshold: int = Option(
100+
default=0,
101+
help="Sensitivity (0-255). Lower = detects subtle changes. Higher = ignores compression artifacts.",
102+
),
103+
min_changed_pixels: int = Option(
104+
default=50,
105+
help="How many pixels must change to trigger detection.",
106+
),
107+
):
108+
"""Find where a video diverges from its first frame.
109+
110+
:param video_path: Path to the video file to analyze.
111+
:param threshold: Sensitivity (0-255). Lower = detects subtle changes. Higher = ignores compression artifacts.
112+
:param min_changed_pixels: How many pixels must change to trigger detection.
113+
"""
114+
frame_diff_at, fps = find_change_from_start_inner(
115+
video_path=video_path,
116+
threshold=threshold,
117+
min_changed_pixels=min_changed_pixels,
118+
)
119+
frame_diff_at_secs = frame_diff_at / fps if frame_diff_at != -1 else -1
120+
if frame_diff_at == -1:
121+
print("No significant change detected in the video.")
122+
else:
123+
print(
124+
f"Significant change detected at frame {frame_diff_at} ({frame_diff_at_secs} seconds)."
125+
)

0 commit comments

Comments
 (0)