Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
86b637c
Replace MoviePy with ffmpeg for massive performance improvement
cyberb Mar 24, 2026
cdcb119
Download video via HLS when storage mp4 is audio-only
cyberb Mar 25, 2026
18d46b9
Fix OOM crash during audio merge by batching ffmpeg amix
cyberb Mar 25, 2026
b3c6a7b
Fix 3x duration inflation from overlapping parallel video streams
cyberb Mar 25, 2026
12db22d
Fix disk exhaustion: use compressed m4a instead of WAVs for audio merge
cyberb Mar 25, 2026
03db61e
Handle corrupt downloads: validate, retry, and skip bad files
cyberb Mar 26, 2026
49620da
Add presentation slide overlay to video output
cyberb Mar 26, 2026
f1f7b8c
Fix resolution detection: use largest segment, enforce 640x360 minimum
cyberb Mar 27, 2026
e44dd00
Prefer presenter in dedup: use conf_id segment count as priority
cyberb Mar 28, 2026
4d0b100
Fix stuck slide segment encoding for long durations
cyberb Mar 28, 2026
f95646e
Add NVENC GPU encoding support with automatic detection
cyberb Mar 28, 2026
c3fa7ed
Use CUDA GPU filters for overlay compositing when available
cyberb Mar 31, 2026
5e71b2d
Fix webcam framerate + add grid layout for multi-webcam recordings
cyberb Apr 2, 2026
d24229e
fix: skip audio in grid composite - audio handled by separate merge p…
cyberb Apr 2, 2026
3ed3539
fix: dedup by total duration instead of segment count
cyberb Apr 2, 2026
5cec9f9
fix: prefer ADMIN user webcam in dedup, pass is_admin through pipeline
cyberb Apr 3, 2026
3ae30f7
fix: prevent duration inflation from overlapping segments + improve w…
cyberb Apr 3, 2026
c06005e
Fix grid layout crash and silent audio in merged videos
cyberb Apr 4, 2026
2f8e54c
Fix audio: remove volume over-amplification, filter silent segments
cyberb Apr 4, 2026
24b1bf4
Lower silent audio threshold from -80 to -88 dB
cyberb Apr 4, 2026
49480e5
Remove silent audio filter — unnecessary without volume amplification
cyberb Apr 4, 2026
e483fe0
Add active speaker switching for slide presentations
cyberb Apr 4, 2026
e614b83
Cap target resolution at 720p to prevent OOM kills on long lectures
cyberb Apr 5, 2026
dd04fb4
Use libx264 for slide compositing to avoid OOM on 8GB RAM
cyberb Apr 5, 2026
bd4647b
Lower resolution cap to 480p and use ultrafast preset to fix OOM
cyberb Apr 5, 2026
05a3255
Chunked slide compositing to prevent OOM, restore GPU encoding
cyberb Apr 6, 2026
8bbfe96
Fix CUDA overlay detection skipped when encoder_fast called first
cyberb Apr 6, 2026
75b4457
Parallelize speaker detection audio analysis with 4 threads
cyberb Apr 6, 2026
e720279
Cap concat output at total_duration to prevent inflated video length
cyberb Apr 6, 2026
682c617
Fix duration inflation: track intended timeline instead of file duration
cyberb Apr 6, 2026
16849da
Fix slide compositing: correct layout and force 25fps
cyberb Apr 6, 2026
b4ce2f1
Mix admin audio into non-admin speaker segments
cyberb Apr 7, 2026
97062e7
Always show admin webcam, add participant below when they speak
cyberb Apr 7, 2026
7613e3c
Handle vstack failure gracefully in speaker switching
cyberb Apr 7, 2026
3336a7c
Fix missing defaultdict import and early return types
cyberb Apr 7, 2026
32e9379
Mix ALL webcam audio in speaker segments, not just admin+participant
cyberb Apr 7, 2026
b7f4c05
Revert speaker switching, use dedup + mix all webcam audio
cyberb Apr 7, 2026
9eb487d
Only add webcam files with audio streams to audio mix
cyberb Apr 8, 2026
9ac85a2
Extract audio from webcam files before adding to mix
cyberb Apr 8, 2026
f3d1f62
Refactor into two-phase pipeline: analyze then execute
cyberb Apr 8, 2026
2053d7c
Fix video/audio timeline mismatch: pad short segments, validate manifest
cyberb Apr 8, 2026
8479c2f
Move grid planning to phase 1, handle failures gracefully in phase 2
cyberb Apr 9, 2026
a00246f
Include audio in grid composite, add has_audio to grid sources
cyberb Apr 9, 2026
54a27a4
Fix tuple unpack for 5-element annotated grid data
cyberb Apr 9, 2026
25fa3b4
Use API mediasession duration for accurate timing, include has_audio …
cyberb Apr 9, 2026
6c3b623
Add trailing black gap for grid recordings to preserve audio
cyberb Apr 9, 2026
b491096
Fill grid timeline gaps in phase 1 to match audio timeline
cyberb Apr 9, 2026
58053d3
Extract webcam audio for grid recordings too
cyberb Apr 10, 2026
730c6b7
Refactor processor into composed classes with tests
cyberb Apr 10, 2026
2772f7d
Add __pycache__ to gitignore
cyberb Apr 10, 2026
d9f9b0d
Remove webcam audio extraction for grid — causes echo
cyberb Apr 10, 2026
8be1473
Extract audio from non-primary grid webcams, add audio pipeline tests
cyberb Apr 10, 2026
644e782
Extract audio from non-primary grid webcams, add audio pipeline tests
cyberb Apr 10, 2026
9a8a657
Rewrite audio pipeline to use mediasession events like JS player
cyberb Apr 10, 2026
dca9856
Fix silent audio: always download via HLS, not raw storage URL
cyberb Apr 12, 2026
b8cc77f
Fix NameError: remove stale overlap_strategy reference in log line
cyberb Apr 12, 2026
12f48d0
Add presenter layout: admin full-screen, participant PIP top-right
cyberb Apr 12, 2026
2b74d27
Refactor grid.py into GridLayout, PresenterLayout, and facade
cyberb Apr 12, 2026
2b669fc
Fix audio overlay crash: handle missing audio and sample rate mismatch
cyberb Apr 13, 2026
480a6b3
Detect screen shares from API screensharing events, not audio heuristic
cyberb Apr 13, 2026
bc9b12a
Fix OOM in ensure_audio: bound silent anullsrc to video duration
cyberb May 18, 2026
804d9bd
Fix ensure_audio OOM: cap muxer interleave buffer, finite anullsrc
cyberb May 19, 2026
dbf831a
Fail loudly on normalize past EOF; reject empty input in ensure_audio
cyberb May 20, 2026
1e5e796
Stop tracking tests/__pycache__; ignore *.pyc and *.egg-info/
cyberb May 20, 2026
8c5198f
Retry slide composite chunk on NVENC OOM-kill with libx264 fallback
cyberb May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
*/
*.log
*.mp4
*.mp3
!mtslinker
!get_sessionId.mp4
!setup.py
__pycache__/
*.pyc
*.egg-info/
14 changes: 9 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ FROM python:3.13-slim

WORKDIR /app

# Копируем только requirements.txt сначала для кэширования
# Install ffmpeg
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*

# Copy requirements first for caching
COPY requirements.txt .

# Устанавливаем зависимости
# Install dependencies
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt

# Копируем остальные файлы проекта
# Copy project files
COPY . .

# Устанавливаем пакет (если он setup как пакет)
# Install package
RUN if [ -f "setup.py" ]; then pip install --no-cache-dir .; fi

ENTRYPOINT ["mtslinker"]

2 changes: 1 addition & 1 deletion mtslinker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from mtslinker.utils import initialize_logger

initialize_logger()
initialize_logger()
175 changes: 175 additions & 0 deletions mtslinker/audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import logging
import os
import shutil
import subprocess
from typing import List, Union

from mtslinker.ffmpeg import FFmpegRunner
from mtslinker.prober import MediaProber
from mtslinker.timeline import AudioTrack


class AudioMerger:
"""Merges multiple audio tracks onto a video using batched amix."""

BATCH_SIZE = 8

def __init__(self, ffmpeg: FFmpegRunner, prober: MediaProber):
self.ffmpeg = ffmpeg
self.prober = prober

def merge(self, video_path: str,
audio_files: List[Union[AudioTrack, tuple]],
tmp_dir: str, output_path: str,
total_duration: float = 0) -> str:
video_duration = total_duration or self.prober.get_duration(video_path)

# Normalize to AudioTrack objects
tracks = []
for af in audio_files:
if isinstance(af, AudioTrack):
tracks.append(af)
else:
tracks.append(AudioTrack(path=af[0], start_time=af[1]))

# Step 1: Mix in batches with inline adelay
batch_outputs = []
total_batches = (len(tracks) + self.BATCH_SIZE - 1) // self.BATCH_SIZE
for batch_idx, batch_start in enumerate(
range(0, len(tracks), self.BATCH_SIZE)
):
batch = tracks[batch_start:batch_start + self.BATCH_SIZE]
batch_out = os.path.join(tmp_dir, f'audio_batch_{batch_idx}.m4a')

inputs = []
filter_parts = []
mix_labels = []
for j, track in enumerate(batch):
inputs.extend(['-i', track.path])
delay_ms = int(track.start_time * 1000)
label = f'a{j}'
filter_parts.append(
f'[{j}:a]adelay={delay_ms}|{delay_ms},'
f'apad=whole_dur={video_duration},'
f'atrim=0:{video_duration},'
f'asetpts=PTS-STARTPTS[{label}]'
)
mix_labels.append(f'[{label}]')

if len(batch) == 1:
filter_graph = filter_parts[0].rsplit('[', 1)[0]
else:
filter_graph = (
';'.join(filter_parts) + ';'
+ ''.join(mix_labels)
+ f'amix=inputs={len(batch)}:duration=longest:normalize=0'
)

try:
self.ffmpeg.run(
[
'ffmpeg', '-y', '-v', 'error',
*inputs,
'-filter_complex', filter_graph,
'-c:a', 'aac', '-b:a', '128k',
'-ar', '44100', '-ac', '2',
batch_out,
],
description=f'amix batch {batch_idx+1} '
f'({len(batch)} tracks, offset {batch[0].start_time:.0f}-'
f'{batch[-1].start_time:.0f}s)',
)
batch_outputs.append(batch_out)
except subprocess.CalledProcessError:
logging.warning(
f'Audio batch {batch_idx+1} failed, skipping {len(batch)} tracks'
)
logging.info(f'Audio batch {batch_idx+1}/{total_batches} done')

if not batch_outputs:
logging.warning('All audio batches failed, skipping audio overlay')
shutil.move(video_path, output_path)
return output_path

# Step 2: Tree-reduce batch outputs
round_num = 0
current_paths = batch_outputs
while len(current_paths) > 1:
round_num += 1
next_paths = []
for batch_start in range(0, len(current_paths), self.BATCH_SIZE):
batch = current_paths[batch_start:batch_start + self.BATCH_SIZE]
if len(batch) == 1:
next_paths.append(batch[0])
continue

batch_out = os.path.join(
tmp_dir, f'audio_reduce_r{round_num}_b{batch_start}.m4a'
)
inputs = []
for bp in batch:
inputs.extend(['-i', bp])

labels = ''.join(f'[{j}:a]' for j in range(len(batch)))
amix_filter = (
f'{labels}amix=inputs={len(batch)}'
f':duration=longest:normalize=0'
)

self.ffmpeg.run(
[
'ffmpeg', '-y', '-v', 'error',
*inputs,
'-filter_complex', amix_filter,
'-c:a', 'aac', '-b:a', '128k',
'-ar', '44100', '-ac', '2',
batch_out,
],
description=f'amix reduce round {round_num}, {len(batch)} tracks',
)
next_paths.append(batch_out)

for bp in batch:
try:
os.remove(bp)
except OSError:
pass

logging.info(
f'Audio reduce round {round_num}: {len(current_paths)} -> '
f'{len(next_paths)} tracks'
)
current_paths = next_paths

mixed_audio_path = current_paths[0]

# Step 3: Overlay mixed audio onto video
logging.info('Overlaying mixed audio onto video...')
video_has_audio = self.prober.has_audio(video_path)
if video_has_audio:
# Mix video's audio with merged audio, normalize sample rates
filter_graph = (
'[0:a]aresample=44100[va];'
'[va][1:a]amix=inputs=2:duration=first:normalize=0[aout]'
)
audio_map = ['-map', '[aout]']
else:
# Video has no audio, just use merged audio
audio_map = ['-map', '1:a']
filter_graph = None

cmd = [
'ffmpeg', '-y', '-v', 'warning',
'-i', video_path,
'-i', mixed_audio_path,
]
if filter_graph:
cmd.extend(['-filter_complex', filter_graph])
cmd.extend([
'-map', '0:v', *audio_map,
'-c:v', 'copy',
'-c:a', 'aac', '-b:a', '192k',
output_path,
])
self.ffmpeg.run(cmd, description='overlay mixed audio onto video')
return output_path
Loading