Compare commits
4
Commits
main
..
004556c4a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
004556c4a7 | ||
|
|
58e66996cc | ||
|
|
ff610350fc | ||
|
|
5ac5b6615b |
No files matched your search
@@ -20,7 +20,7 @@ def extract_audio_segment_file(video: Path, start_time: float, end_time: float,
|
|||||||
which makes the operation fast and preserves the original audio quality.
|
which makes the operation fast and preserves the original audio quality.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
video: Path to the source video file.
|
German_Videout_mkv: Path to the source video file.
|
||||||
start_time: Start time of the audio segment (seconds or FFmpeg time format).
|
start_time: Start time of the audio segment (seconds or FFmpeg time format).
|
||||||
end_time: End time of the audio segment.
|
end_time: End time of the audio segment.
|
||||||
out_audio: Path to the output audio file.
|
out_audio: Path to the output audio file.
|
||||||
|
|||||||
@@ -223,12 +223,7 @@ def process_one(German_Video: Path, Quality_Video: Path, output_dir: Path, offse
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Configure these paths for your own legally owned media.
|
output_dir = "/home/honney/mount/Media/tmp/serien/ready/Classroom of the Elite/Season 01"
|
||||||
# Placeholders below: first dir holds the dubbed (e.g. German) source,
|
|
||||||
# second dir holds the high-quality video source.
|
|
||||||
output_dir = "./output_dir"
|
|
||||||
dubbed_dir = "./dubbed_source/Season 01"
|
|
||||||
quality_dir = "./high_quality/S01"
|
|
||||||
if not os.path.isdir(output_dir):
|
if not os.path.isdir(output_dir):
|
||||||
raise NotADirectoryError(f"Output directory is not a valid Directory: {output_dir}")
|
raise NotADirectoryError(f"Output directory is not a valid Directory: {output_dir}")
|
||||||
start_num = 2
|
start_num = 2
|
||||||
@@ -241,12 +236,12 @@ def main():
|
|||||||
for i in tqdm(episodes, desc="Episodes"):
|
for i in tqdm(episodes, desc="Episodes"):
|
||||||
# i =1
|
# i =1
|
||||||
# if i:
|
# if i:
|
||||||
german_pattern = f"{dubbed_dir}/Example Series S01E{i:02}.mkv"
|
german_pattern = f"/home/honney/mount/Media/tmp/serien/ger_low_quality/Classroom of the Elite (2017-2024) [imdbid-tt7263328]/Season 01/Classroom of the Elite S01E0{i:02}.mkv"
|
||||||
if os.path.isfile(german_pattern):
|
if os.path.isfile(german_pattern):
|
||||||
German_Video = [german_pattern]
|
German_Video = [german_pattern]
|
||||||
else:
|
else:
|
||||||
German_Video = glob.glob(german_pattern)
|
German_Video = glob.glob(german_pattern)
|
||||||
quality_pattern = f"{quality_dir}/E{i:02}*"
|
quality_pattern = f"/home/honney/mount/Media/tmp/serien/high_quality/Classroom of the Elite/S01/E{i:02}*"
|
||||||
if os.path.isfile(quality_pattern):
|
if os.path.isfile(quality_pattern):
|
||||||
Quality_Video = [quality_pattern]
|
Quality_Video = [quality_pattern]
|
||||||
else:
|
else:
|
||||||
|
|||||||
+3
-2
@@ -10,7 +10,8 @@ from typing import Tuple, List
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
def get_chapters(file: Path) -> List[Tuple[float, float]]:
|
def get_chapters(file: Path) -> List[Tuple[float, float]]:
|
||||||
"""Return list of (start_time, duration) for each chapter."""
|
"""Return list of (start_ti
|
||||||
|
from .intro_outro importme, duration) for each chapter."""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe",
|
"ffprobe",
|
||||||
"-i", str(file),
|
"-i", str(file),
|
||||||
@@ -65,5 +66,5 @@ def get_intro_outro(file: Path, length: float = 90.0) -> List[Tuple[float, float
|
|||||||
### https://github.com/jonbarrow/open-anime-timestamps?utm_source=chatgpt.com
|
### https://github.com/jonbarrow/open-anime-timestamps?utm_source=chatgpt.com
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
file = "./output_dir/episode.mkv"
|
file = "/home/honney/projects/mkv/output_dir/08.mkv"
|
||||||
print(get_intro_outro(Path(file)))
|
print(get_intro_outro(Path(file)))
|
||||||
@@ -1,176 +1 @@
|
|||||||
# anime_dub_sync
|
This script needs ffmpeg and mkvtoolkit/mkvtoolnix installed to work
|
||||||
|
|
||||||
Transfer a dubbed audio track (e.g. German) from a low-quality source file onto a
|
|
||||||
high-quality video file, with automatic A/V offset detection and verification.
|
|
||||||
|
|
||||||
Only use this with sources you legally own. All paths and titles in this repo
|
|
||||||
are generic placeholders — do not commit real media filenames, absolute local
|
|
||||||
paths, or release-group names.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
System tools (must be on `PATH`):
|
|
||||||
|
|
||||||
- `ffmpeg` + `ffprobe`
|
|
||||||
- `mkvmerge` (from MKVToolNix)
|
|
||||||
|
|
||||||
Python (>= 3.10):
|
|
||||||
|
|
||||||
- `opencv-python`, `scipy`, `numpy`, `soundfile`, `tqdn`
|
|
||||||
- see `requirements.txt`
|
|
||||||
|
|
||||||
Install:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Repository layout
|
|
||||||
|
|
||||||
```text
|
|
||||||
main.py # batch + single-episode orchestration
|
|
||||||
utils.py # subprocess helper
|
|
||||||
audio/
|
|
||||||
__init__.py
|
|
||||||
audio_utils.py # ffmpeg extraction, concat, shifting, mux helpers
|
|
||||||
audio_cross_correlation.py # intro/outro audio offset measurement
|
|
||||||
video/
|
|
||||||
__init__.py
|
|
||||||
video_utils.py # ffprobe wrapper
|
|
||||||
scene_change_cross_correlation.py # primary video offset detection
|
|
||||||
temporal_cross_correlation.py # alternative/legacy motion-signal method
|
|
||||||
mkv/
|
|
||||||
__init__.py
|
|
||||||
chapter.py # chapter parsing, intro/outro candidate search
|
|
||||||
intro_outro.py # final sync verification
|
|
||||||
check_intro_outro.py # CLI wrapper for verification
|
|
||||||
```
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
### 1. Video offset detection (`video/`)
|
|
||||||
|
|
||||||
`video/scene_change_cross_correlation.py:70` — `corelate_videos(vid1, vid2, fps)`:
|
|
||||||
|
|
||||||
1. `get_scene_change_signal` decodes each video with OpenCV at 160x90 grayscale,
|
|
||||||
computes `mean(diff^2)` per frame. Cuts produce large spikes.
|
|
||||||
2. `resample_signal` resamples both signals to a common FPS with
|
|
||||||
`scipy.signal.resample`.
|
|
||||||
3. Both signals are z-normalized, then `scipy.signal.correlate(mode='full')`
|
|
||||||
finds the lag with peak correlation.
|
|
||||||
4. Lag in samples / FPS = offset in seconds. Positive means `vid1` starts
|
|
||||||
after `vid2`.
|
|
||||||
|
|
||||||
`video/video_utils.py:14` — `probe_video(path)` returns
|
|
||||||
`(fps, duration, codec, start_time)` via `ffprobe -show_streams -show_format`.
|
|
||||||
|
|
||||||
`video/temporal_cross_correlation.py:52` — `find_temporal_offset` is a similar
|
|
||||||
motion-signal correlator. It is exported but not used by `main.py`; the active
|
|
||||||
path is `corelate_videos`.
|
|
||||||
|
|
||||||
### 2. Audio splicing (`audio/`)
|
|
||||||
|
|
||||||
`audio/audio_utils.py`:
|
|
||||||
|
|
||||||
- `extract_audio_segment_file(video, start, end, out)` — extracts `0:a:0`
|
|
||||||
with ffmpeg to stereo AAC `.mka`.
|
|
||||||
- `extract_audio_segment_tmp(file, (start, duration) | start, end, stream)` —
|
|
||||||
extracts a stream to a temp 44.1 kHz PCM WAV for analysis.
|
|
||||||
- `get_audio_duration(file)` — parses `DURATION` tag via ffprobe.
|
|
||||||
- `stick_together_audio(left, right, out)` — concatenates with the ffmpeg
|
|
||||||
concat demuxer (`-c copy`, no re-encode).
|
|
||||||
- `add_silence(file, offset)` — `adelay` (offset > 0, silence at start) or
|
|
||||||
`apad` (offset < 0, silence at end), re-encoded to Opus.
|
|
||||||
- `replace_with_shifted_mka(mkv, mka, audioline, offset)` — physically shifts
|
|
||||||
with `adelay` / `atrim+asetpts`, re-encodes to Opus 160k, replaces the track
|
|
||||||
with `mkvmerge`, tags it `ger` / `German (Opus Stereo)`.
|
|
||||||
- `get_audio_streams`, `get_track_info`, `shift_audio` — stream enumeration
|
|
||||||
and in-place shift helpers.
|
|
||||||
|
|
||||||
`audio/audio_cross_correlation.py:39` — `get_audio_offset(file1, file2, time)`:
|
|
||||||
decodes to mono 44.1 kHz WAV, mean-normalizes, `scipy.signal.correlate`, returns
|
|
||||||
lag / sample-rate in seconds.
|
|
||||||
|
|
||||||
### 3. Intro/outro verification (`mkv/`)
|
|
||||||
|
|
||||||
`mkv/chapter.py:12` — `get_chapters(file)` lists `(start, duration)` via
|
|
||||||
`ffprobe -show_chapters`.
|
|
||||||
|
|
||||||
`mkv/chapter.py:35` — `get_intro_outro(file, length=90.0)` keeps chapters with
|
|
||||||
duration within `[length/2, length*2]`. Expects exactly 2 (intro + outro).
|
|
||||||
|
|
||||||
`mkv/intro_outro.py:8` — `check_intro_outro_sync(file, length, uncertainty,
|
|
||||||
max_offset)`:
|
|
||||||
|
|
||||||
1. Trims `uncertainty` (default 10%) off each intro/outro edge.
|
|
||||||
2. Extracts first and last audio streams for intro and outro.
|
|
||||||
3. Audio-correlates intro pair and outro pair.
|
|
||||||
4. Returns `(True, mean_offset, last_stream)` if
|
|
||||||
`|mean(outro_offsets) - mean(intro_offsets)| < max_offset` (default 0.1 s),
|
|
||||||
else `(False, None, None)`.
|
|
||||||
|
|
||||||
### 4. Orchestration (`main.py`)
|
|
||||||
|
|
||||||
`main.py:18` — `mux_audio_into_output()` runs `mkvmerge` to add the synced
|
|
||||||
`.mka` as a German track.
|
|
||||||
|
|
||||||
`main.py:37` — `process_one(German_Video, Quality_Video, output_dir,
|
|
||||||
offset=None)` runs 9 `tqdm` steps:
|
|
||||||
|
|
||||||
1. `Probe video` — probe both inputs.
|
|
||||||
2. `Compute offset` — `corelate_videos`, or use supplied `offset`.
|
|
||||||
3. `Extract pre beginning` — head of quality audio before dub starts
|
|
||||||
(skipped if < 0.05 s).
|
|
||||||
4. `Extract german audio` — main dubbed segment.
|
|
||||||
5. `Extract post end` — tail of quality audio (skipped if < 0.5 s,
|
|
||||||
appended only if > `MIN_END_DUR = 2.5` s).
|
|
||||||
6. `Combine audio` — concat head + dub + tail; falls back to `add_silence`.
|
|
||||||
7. `Mux` — mkvmerge into `output_dir/<quality_stem>.mkv`.
|
|
||||||
8. `Cleanup` — removes temp `_original_audio*.mka`.
|
|
||||||
9. `Final check` — `check_intro_outro_sync`; sub-frame residual is fixed with
|
|
||||||
`replace_with_shifted_mka` if `|offset| > 1/fps`. Failures are renamed to
|
|
||||||
`[FAILED]_*.mkv`, chapterless files to `[NO CHAPTER]_*.mkv`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Single episode (output dir as 3rd arg, else `../output_dir`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py <dubbed_source.mkv> <high_quality_source.mkv> [output_dir]
|
|
||||||
python main.py dubbed_ep01.mkv quality_ep01.mkv ./output_dir
|
|
||||||
```
|
|
||||||
|
|
||||||
With explicit offset in seconds (bypass video correlation), pass it as 4th
|
|
||||||
argument handling inside `process_one` — currently CLI uses auto-detect; edit
|
|
||||||
or call the function directly for manual offsets.
|
|
||||||
|
|
||||||
Batch: edit `main()` in `main.py:225` (`output_dir`, `dubbed_dir`,
|
|
||||||
`quality_dir`, episode range), then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify an existing file without re-muxing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python mkv/check_intro_outro.py ./output_dir/episode.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
- `main.py:16` `MIN_END_DUR = 2.5` — minimum tail length to append.
|
|
||||||
- `main.py:14` `DEBUG = False` — enable ffmpeg segment logging.
|
|
||||||
- `mkv/intro_outro.py:8` `length=90.0, uncertainty=0.1, max_offset=0.1` —
|
|
||||||
expected OP/ED length and tolerances.
|
|
||||||
|
|
||||||
## Notes and limitations
|
|
||||||
|
|
||||||
- Needs chapter marks to run the final check; files without them are kept
|
|
||||||
with a `[NO CHAPTER]_` prefix and return `False`.
|
|
||||||
- Scene-change correlation assumes both files are the same episode cut.
|
|
||||||
Different cuts, missing scenes, or very dark/flat video degrade the peak.
|
|
||||||
- `utils.py:4` `run(cmd)` prints combined stdout on failure, then re-raises
|
|
||||||
`CalledProcessError`.
|
|
||||||
- Do not commit `.mkv` / `.mka` media or absolute filesystem paths. Keep
|
|
||||||
`output_dir/` and temp audio out of git.
|
|
||||||
@@ -100,8 +100,8 @@ def corelate_videos(vid1: Path, vid2: Path, fps: float) -> float:
|
|||||||
|
|
||||||
# --- Example Usage ---
|
# --- Example Usage ---
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
video_path_1 = Path("./examples/high_quality_episode.mkv")
|
video_path_1 = Path("/home/honney/projects/mkv/Maid-sama/11_org.mkv")
|
||||||
video_path_2 = Path("./examples/dubbed_episode.mkv")
|
video_path_2 = Path("/home/honney/projects/mkv/Maid-sama/11_ger.mkv")
|
||||||
|
|
||||||
target_fps, _, _, _ = probe_video(video_path_1)
|
target_fps, _, _, _ = probe_video(video_path_1)
|
||||||
|
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ if __name__ == '__main__':
|
|||||||
offset_f, offset_t = find_temporal_offset(Path(sys.argv[1]), Path(sys.argv[2]))
|
offset_f, offset_t = find_temporal_offset(Path(sys.argv[1]), Path(sys.argv[2]))
|
||||||
print(f"Offset between Videos:\n{offset_f} Frames\n{offset_t} s")
|
print(f"Offset between Videos:\n{offset_f} Frames\n{offset_t} s")
|
||||||
elif len(sys.argv) == 1:
|
elif len(sys.argv) == 1:
|
||||||
file1 = "./examples/high_quality_episode.mkv"
|
file1 = "/home/honney/projects/mkv/Maid-sama/11_org.mkv"
|
||||||
file2 = "./examples/dubbed_episode.mkv"
|
file2 = "/home/honney/projects/mkv/Maid-sama/11_ger.mkv"
|
||||||
if not os.path.isfile(file1):
|
if not os.path.isfile(file1):
|
||||||
raise ValueError(f"{file1} is not a valid file path")
|
raise ValueError(f"{file1} is not a valid file path")
|
||||||
if not os.path.isfile(file2):
|
if not os.path.isfile(file2):
|
||||||
|
|||||||
Reference in new issue
Block a user