From 46e36122d27df97d5f3ac19e52b244d23ab6cc83 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 11:18:01 +0200 Subject: [PATCH 01/38] Fix CLI Tools --- cmd/raw-recording-tools/README.md | 421 ++++++++++ cmd/raw-recording-tools/completion.go | 397 ++++++++++ cmd/raw-recording-tools/extract_audio.go | 126 +++ cmd/raw-recording-tools/extract_track.go | 349 ++++++++ cmd/raw-recording-tools/extract_video.go | 134 ++++ cmd/raw-recording-tools/list_tracks.go | 247 ++++++ cmd/raw-recording-tools/main.go | 183 +++++ cmd/raw-recording-tools/metadata.go | 348 ++++++++ cmd/raw-recording-tools/mix_audio.go | 228 ++++++ cmd/raw-recording-tools/mux_av.go | 390 +++++++++ cmd/raw-recording-tools/process_all.go | 204 +++++ cmd/raw-recording-tools/raw-recorder/raw.go | 47 ++ .../rawsdputil/sdp_writer.go | 55 ++ cmd/raw-recording-tools/webm/converter.go | 236 ++++++ .../webm/cursor_gstreamer_webm_recorder.go | 749 ++++++++++++++++++ .../webm/cursor_webm_recorder.go | 252 ++++++ cmd/raw-recording-tools/webm/helper.go | 161 ++++ go.mod | 29 +- go.sum | 51 +- 19 files changed, 4602 insertions(+), 5 deletions(-) create mode 100644 cmd/raw-recording-tools/README.md create mode 100644 cmd/raw-recording-tools/completion.go create mode 100644 cmd/raw-recording-tools/extract_audio.go create mode 100644 cmd/raw-recording-tools/extract_track.go create mode 100644 cmd/raw-recording-tools/extract_video.go create mode 100644 cmd/raw-recording-tools/list_tracks.go create mode 100644 cmd/raw-recording-tools/main.go create mode 100644 cmd/raw-recording-tools/metadata.go create mode 100644 cmd/raw-recording-tools/mix_audio.go create mode 100644 cmd/raw-recording-tools/mux_av.go create mode 100644 cmd/raw-recording-tools/process_all.go create mode 100644 cmd/raw-recording-tools/raw-recorder/raw.go create mode 100644 cmd/raw-recording-tools/rawsdputil/sdp_writer.go create mode 100644 cmd/raw-recording-tools/webm/converter.go create mode 100644 cmd/raw-recording-tools/webm/cursor_gstreamer_webm_recorder.go create mode 100644 cmd/raw-recording-tools/webm/cursor_webm_recorder.go create mode 100644 cmd/raw-recording-tools/webm/helper.go diff --git a/cmd/raw-recording-tools/README.md b/cmd/raw-recording-tools/README.md new file mode 100644 index 0000000..61e2987 --- /dev/null +++ b/cmd/raw-recording-tools/README.md @@ -0,0 +1,421 @@ +# Raw-Tools CLI + +Post-processing tools for raw video call recordings with intelligent completion, validation, and advanced audio/video processing. + +## Features + +- **Discovery**: Use `list-tracks` to explore recording contents with screenshare detection +- **Smart Completion**: Shell completion with dynamic values from actual recordings +- **Validation**: Automatic validation of user inputs against available data +- **Multiple Formats**: Support for different output formats (table, JSON, completion) +- **Advanced Processing**: Extract, mux, mix and process audio/video with gap filling +- **Hybrid Architecture**: Optimized performance for different use cases + +## Commands + +### `list-tracks` - Discovery & Completion Hub + +The `list-tracks` command serves as both a discovery tool and completion engine for other commands. + +```bash +# Basic usage - see all tracks in table format (no --output needed) +raw-tools --inputFile recording.tar.gz list-tracks + +# Get JSON output for programmatic use +raw-tools --inputFile recording.tar.gz list-tracks --format json + +# Get completion-friendly lists +raw-tools --inputFile recording.tar.gz list-tracks --format users +raw-tools --inputFile recording.tar.gz list-tracks --format sessions +raw-tools --inputFile recording.tar.gz list-tracks --format tracks +``` + +**Options:** +- `--format ` - Output format: `table` (default), `json`, `users`, `sessions`, `tracks`, `completion` +- `--trackType ` - Filter by track type: `audio`, `video` (optional) +- `-h, --help` - Show help message + +**Output Formats:** +- `table` - Human-readable table with screenshare detection (default) +- `json` - Full metadata in JSON format for scripting +- `users` - List of user IDs only (for shell scripts) +- `sessions` - List of session IDs only (for automation) +- `tracks` - List of track IDs only (for filtering) +- `completion` - Shell completion format + +### `extract-audio` - Extract Audio Tracks + +Extract and convert individual audio tracks from raw recordings to WebM format. + +```bash +# Extract audio for all users +raw-tools --inputFile recording.zip --output ./output extract-audio --userId "*" + +# Extract audio for specific user with gap filling +raw-tools --inputFile recording.zip --output ./output extract-audio --userId user123 --fill_gaps + +# Extract audio for specific session +raw-tools --inputFile recording.zip --output ./output extract-audio --sessionId session456 + +# Extract specific track only +raw-tools --inputFile recording.zip --output ./output extract-audio --trackId track789 +``` + +**Options:** +- `--userId ` - Filter by user ID (`*` for all users, specific ID, or comma-separated list) +- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID, or comma-separated list) +- `--trackId ` - Filter by track ID (`*` for all tracks, specific ID, or comma-separated list) +- `--fill_gaps` - Fill temporal gaps between segments with silence (recommended for playback) +- `-h, --help` - Show help message + +**Hierarchical Filtering:** +- If `--userId` is `*`, `--sessionId` and `--trackId` are ignored (processes all users) +- If `--sessionId` is `*`, `--trackId` is ignored (processes all sessions for specified users) +- Specific IDs can be combined for precise extraction + +### `extract-video` - Extract Video Tracks + +Extract and convert individual video tracks from raw recordings to WebM format. + +```bash +# Extract video for all users +raw-tools --inputFile recording.zip --output ./output extract-video --userId "*" + +# Extract video for specific user with black frame filling +raw-tools --inputFile recording.zip --output ./output extract-video --userId user123 --fill_gaps + +# Extract screenshare video only +raw-tools --inputFile recording.zip --output ./output extract-video --userId user456 --fill_gaps +``` + +**Options:** +- `--userId ` - Filter by user ID (`*` for all users, specific ID, or comma-separated list) +- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID, or comma-separated list) +- `--trackId ` - Filter by track ID (`*` for all tracks, specific ID, or comma-separated list) +- `--fill_gaps` - Fill temporal gaps between segments with black frames (recommended for playback) +- `-h, --help` - Show help message + +**Video Processing:** +- Supports regular camera video and screenshare video +- Automatically detects and preserves video codec (VP8, VP9, H264, AV1) +- Gap filling generates black frames matching original video dimensions and framerate + +### `mux-av` - Mux Audio/Video + +Combine individual audio and video tracks with proper synchronization and timing offsets. + +```bash +# Mux audio/video for all users +raw-tools --inputFile recording.zip --output ./output mux-av --userId "*" + +# Mux for specific user with proper sync +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 + +# Mux for specific session +raw-tools --inputFile recording.zip --output ./output mux-av --sessionId session456 + +# Mux specific tracks with precise control +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --sessionId session456 +``` + +**Options:** +- `--userId ` - Filter by user ID (`*` for all users, specific ID for targeted muxing) +- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID for session-based muxing) +- `--trackId ` - Filter by track ID (rarely used, as muxing typically works with user/session pairs) +- `--media ` - Filter by media type: `user` (camera/microphone), `display` (screen sharing), or `both` (default) +- `-h, --help` - Show help message + +**Features:** +- Automatic timing synchronization between audio and video using RTCP timestamps +- Gap filling for seamless playback (always enabled for muxing) +- Single combined WebM output per user/session combination +- Intelligent offset calculation for perfect A/V sync +- Supports all video codecs (VP8, VP9, H264, AV1) with Opus audio +- Media type filtering ensures consistent pairing (user camera ↔ user microphone, display sharing ↔ display audio) + +**Media Type Examples:** +```bash +# Mux only user camera/microphone tracks +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media user + +# Mux only display sharing tracks +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media display + +# Mux both types with proper pairing (default) +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media both +``` + +### `mix-audio` - Mix Multiple Audio Tracks + +Mix audio from multiple users/sessions into a single synchronized audio file, perfect for conference call reconstruction. + +```bash +# Mix audio from all users (full conference call) +raw-tools --inputFile recording.zip --output ./output mix-audio + +# Mix audio from specific user across all sessions +raw-tools --inputFile recording.zip --output ./output mix-audio --userId user123 + +# Mix audio from specific session (all users in that session) +raw-tools --inputFile recording.zip --output ./output mix-audio --sessionId session456 + +# Mix specific tracks with fine control +raw-tools --inputFile recording.zip --output ./output mix-audio --userId user123 --sessionId session456 +``` + +**Options:** +- `--userId ` - Filter by user ID (`*` for all users, specific ID, or comma-separated list) +- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID, or comma-separated list) +- `--trackId ` - Filter by track ID (`*` for all tracks, specific ID for precise control) +- `--no-fill-gaps` - Disable gap filling (not recommended for mixing, gaps enabled by default) +- `-h, --help` - Show help message + +**Perfect for:** +- Conference call audio reconstruction with proper timing +- Multi-participant audio analysis and review +- Creating complete session audio timelines +- Audio synchronization testing and validation +- Podcast-style recordings from video calls + +**Advanced Mixing:** +- Uses FFmpeg adelay and amix filters for professional-quality mixing +- Automatic timing offset calculation based on segment metadata +- Gap filling with silence maintains temporal relationships +- Output: Single `mixed_audio.webm` file with all tracks properly synchronized + +### `process-all` - Complete Workflow + +Execute audio extraction, video extraction, and muxing in a single command - the all-in-one solution. + +```bash +# Process everything for all users +raw-tools --inputFile recording.zip --output ./output process-all + +# Process everything for specific user +raw-tools --inputFile recording.zip --output ./output process-all --userId user123 + +# Process specific session with all participants +raw-tools --inputFile recording.zip --output ./output process-all --sessionId session456 + +# Process specific tracks with full workflow +raw-tools --inputFile recording.zip --output ./output process-all --userId user123 --sessionId session456 +``` + +**Options:** +- `--userId ` - Filter by user ID (`*` for all users, specific ID for targeted processing) +- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID for session-based processing) +- `--trackId ` - Filter by track ID (`*` for all tracks, specific ID for precise control) +- `-h, --help` - Show help message + +**Workflow Steps:** +1. **Audio Extraction** - Extracts all matching audio tracks with gap filling enabled +2. **Video Extraction** - Extracts all matching video tracks with gap filling enabled +3. **Audio/Video Muxing** - Combines corresponding audio and video tracks with sync + +**Outputs:** +- Individual audio tracks (WebM format): `audio_userId_sessionId_trackId.webm` +- Individual video tracks (WebM format): `video_userId_sessionId_trackId.webm` +- Combined audio/video files (WebM format): `muxed_userId_sessionId_combined.webm` +- All files include gap filling for seamless playback +- Perfect for bulk processing and automated workflows + +## Completion Workflow Architecture + +### 1. Discovery Phase +```bash +# First, explore what's in your recording +raw-tools --inputFile recording.zip list-tracks + +# Example output with screenshare detection: +# USER ID SESSION ID TRACK ID TYPE SCREENSHARE CODEC SEGMENTS +# -------------------- -------------------- -------------------- ------- ------------ --------------- -------- +# user_abc123 session_xyz789 track_001 audio No audio/opus 3 +# user_abc123 session_xyz789 track_002 video No video/VP8 2 +# user_def456 session_xyz789 track_003 video Yes video/VP8 1 +``` + +### 2. Shell Completion Setup + +```bash +# Install completion for your shell +source <(raw-tools completion bash) # Bash +source <(raw-tools completion zsh) # Zsh +raw-tools completion fish | source # Fish +``` + +### 3. Dynamic Completion in Action + +With completion enabled, the CLI will: +- **Auto-complete commands** and flags +- **Dynamically suggest user IDs** from the actual recording +- **Validate inputs** against available data +- **Provide helpful error messages** with discovery hints + +```bash +# Tab completion will suggest actual user IDs from your recording +raw-tools --inputFile recording.zip --output ./out extract-audio --userId +# Shows: user_abc123 user_def456 * + +# Invalid inputs show helpful errors +raw-tools --inputFile recording.zip --output ./out extract-audio --userId invalid_user +# Error: userID 'invalid_user' not found in recording. Available users: user_abc123, user_def456 +# Tip: Use 'raw-tools --inputFile recording.zip --output ./out list-tracks --format users' to see available user IDs +``` + +### 4. Programmatic Integration + +```bash +# Get user IDs for scripts +USERS=$(raw-tools --inputFile recording.zip list-tracks --format users) + +# Process each user +for user in $USERS; do + raw-tools --inputFile recording.zip --output ./output extract-audio --userId "$user" --fill_gaps +done + +# Get JSON metadata for complex processing +raw-tools --inputFile recording.zip list-tracks --format json > metadata.json +``` + +## Workflow Examples + +### Example 1: Extract Audio for Each Participant + +```bash +# 1. Discover participants +raw-tools --inputFile call.zip list-tracks --format users + +# 2. Extract each participant's audio +for user in $(raw-tools --inputFile call.zip list-tracks --format users); do + echo "Extracting audio for user: $user" + raw-tools --inputFile call.zip --output ./extracted extract-audio --userId "$user" --fill_gaps +done +``` + +### Example 2: Quality Check Before Processing + +```bash +# 1. Get full metadata overview +raw-tools --inputFile recording.zip list-tracks --format json > recording_info.json + +# 2. Check track counts +audio_tracks=$(raw-tools --inputFile recording.zip list-tracks --trackType audio --format json | jq '.tracks | length') +video_tracks=$(raw-tools --inputFile recording.zip list-tracks --trackType video --format json | jq '.tracks | length') + +echo "Found $audio_tracks audio tracks and $video_tracks video tracks" + +# 3. Process only if we have both audio and video +if [ "$audio_tracks" -gt 0 ] && [ "$video_tracks" -gt 0 ]; then + raw-tools --inputFile recording.zip --output ./output mux-av --userId "*" +fi +``` + +### Example 3: Conference Call Audio Mixing + +```bash +# 1. Mix all participants into single audio file +raw-tools --inputFile conference.zip --output ./mixed mix-audio + +# 2. Mix specific users for focused conversation (individual commands) +raw-tools --inputFile conference.zip --output ./mixed mix-audio --userId user1 +raw-tools --inputFile conference.zip --output ./mixed mix-audio --userId user2 + +# 3. Create session-by-session mixed audio +for session in $(raw-tools --inputFile conference.zip list-tracks --format sessions); do + raw-tools --inputFile conference.zip --output "./mixed/$session" mix-audio --sessionId "$session" +done +``` + +### Example 4: Complete Processing Pipeline + +```bash +# All-in-one processing for the entire recording +raw-tools --inputFile recording.zip --output ./complete process-all + +# Results in: +# - ./complete/audio_*.webm (individual audio tracks) +# - ./complete/video_*.webm (individual video tracks) +# - ./complete/muxed_*.webm (combined A/V tracks) +``` + +### Example 5: Session-Based Processing + +```bash +# 1. Process each session separately +for session in $(raw-tools --inputFile recording.zip list-tracks --format sessions); do + echo "Processing session: $session" + + # Extract all audio from this session + raw-tools --inputFile recording.zip --output "./output/$session" extract-audio --sessionId "$session" --fill_gaps + + # Extract all video from this session + raw-tools --inputFile recording.zip --output "./output/$session" extract-video --sessionId "$session" --fill_gaps + + # Mux audio/video for this session + raw-tools --inputFile recording.zip --output "./output/$session" mux-av --sessionId "$session" +done +``` + +## Architecture & Performance + +### Hybrid Processing Architecture + +The tool uses an intelligent hybrid approach optimized for different use cases: + +**Fast Metadata Reading (`list-tracks`):** +- Direct tar.gz parsing for metadata-only operations +- Skips extraction of large media files (.rtpdump/.sdp) +- 10-50x faster than full extraction for discovery workflows + +**Full Processing (extraction commands):** +- Complete archive extraction to temporary directories +- Access to all media files for conversion and processing +- Unified processing pipeline for reliability + +### Command Categories + +1. **Discovery Commands** (`list-tracks`) + - Optimized for speed and shell completion + - Minimal resource usage + - Instant metadata access + +2. **Processing Commands** (`extract-*`, `mix-*`, `mux-*`, `process-all`) + - Full archive extraction and processing + - Complete media file access + - Advanced audio/video operations + +3. **Utility Commands** (`completion`, `help`) + - Shell integration and documentation + +## Benefits of the Architecture + +1. **Discoverability**: No need to guess user IDs, session IDs, or track IDs +2. **Performance**: Optimized operations for different use cases +3. **Validation**: Immediate feedback if specified IDs don't exist +4. **Efficiency**: Tab completion speeds up command construction +5. **Reliability**: Prevents typos and invalid commands +6. **Scriptability**: Programmatic access to metadata for automated workflows +7. **User Experience**: Helpful error messages with actionable suggestions +8. **Advanced Processing**: Conference call reconstruction and analysis capabilities + +## File Structure + +``` +cmd/raw-tools/ +├── main.go # Main CLI entry point and routing +├── metadata.go # Shared metadata parsing and filtering (hybrid architecture) +├── completion.go # Shell completion scripts generation +├── list_tracks.go # Discovery and completion command (optimized) +├── extract_audio.go # Audio extraction with validation +├── extract_video.go # Video extraction with validation +├── extract_track.go # Generic extraction logic (shared) +├── mix_audio.go # Multi-user audio mixing +├── mux_av.go # Audio/video synchronization and muxing +├── process_all.go # All-in-one processing workflow +└── README.md # This documentation +``` + +## Dependencies + +- **FFmpeg**: Required for media processing and conversion +- **Go 1.19+**: For building the CLI tool diff --git a/cmd/raw-recording-tools/completion.go b/cmd/raw-recording-tools/completion.go new file mode 100644 index 0000000..bfcb3cf --- /dev/null +++ b/cmd/raw-recording-tools/completion.go @@ -0,0 +1,397 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// generateCompletion generates shell completion scripts +func generateCompletion(shell string) { + switch shell { + case "bash": + generateBashCompletion() + case "zsh": + generateZshCompletion() + case "fish": + generateFishCompletion() + default: + _, _ = fmt.Fprintf(os.Stderr, "Unsupported shell: %s\n", shell) + _, _ = fmt.Fprintf(os.Stderr, "Supported shells: bash, zsh, fish\n") + os.Exit(1) + } +} + +// generateBashCompletion generates bash completion script +func generateBashCompletion() { + script := `#!/bin/bash + +_raw_tools_completion() { + local cur prev words cword + _init_completion || return + + # Complete subcommands + if [[ $cword -eq 1 ]]; then + COMPREPLY=($(compgen -W "list-tracks extract-audio extract-video mux-av help" -- "$cur")) + return + fi + + local cmd="${words[1]}" + + case "$prev" in + --inputFile) + COMPREPLY=($(compgen -f -X "!*.zip" -- "$cur")) + return + ;; + --output) + COMPREPLY=($(compgen -d -- "$cur")) + return + ;; + --format) + case "$cmd" in + list-tracks) + COMPREPLY=($(compgen -W "table json completion users sessions tracks" -- "$cur")) + ;; + esac + return + ;; + --trackType) + COMPREPLY=($(compgen -W "audio video" -- "$cur")) + return + ;; + --userId|--sessionId|--trackId) + # Dynamic completion using list-tracks + if [[ -n "${_RAW_TOOLS_INPUT_FILE:-}" ]]; then + local completion_type="" + case "$prev" in + --userId) completion_type="users" ;; + --sessionId) completion_type="sessions" ;; + --trackId) completion_type="tracks" ;; + esac + if [[ -n "$completion_type" ]]; then + local values=$(raw-tools --inputFile "$_RAW_TOOLS_INPUT_FILE" --output /tmp list-tracks --format "$completion_type" 2>/dev/null) + COMPREPLY=($(compgen -W "$values *" -- "$cur")) + fi + else + COMPREPLY=($(compgen -W "*" -- "$cur")) + fi + return + ;; + esac + + # Complete global flags + local global_flags="--inputFile --inputS3 --output --verbose --help" + local cmd_flags="" + + case "$cmd" in + list-tracks) + cmd_flags="--format --trackType --completionType" + ;; + extract-audio|extract-video) + cmd_flags="--userId --sessionId --trackId --fill_gaps" + ;; + mux-av) + cmd_flags="--userId --sessionId" + ;; + esac + + COMPREPLY=($(compgen -W "$global_flags $cmd_flags" -- "$cur")) +} + +# Store input file for dynamic completion +_raw_tools_set_input_file() { + local i + for (( i=1; i < ${#COMP_WORDS[@]}; i++ )); do + if [[ "${COMP_WORDS[i]}" == "--inputFile" && i+1 < ${#COMP_WORDS[@]} ]]; then + export _RAW_TOOLS_INPUT_FILE="${COMP_WORDS[i+1]}" + break + fi + done +} + +# Hook to set input file before completion +complete -F _raw_tools_completion raw-tools + +# Wrapper to set input file +_raw_tools_wrapper() { + _raw_tools_set_input_file + _raw_tools_completion "$@" +} + +complete -F _raw_tools_wrapper raw-tools` + + fmt.Println(script) +} + +// generateZshCompletion generates zsh completion script +func generateZshCompletion() { + script := `#compdef raw-tools + +_raw_tools() { + local context state line + typeset -A opt_args + + _arguments -C \ + '1: :_raw_tools_commands' \ + '*:: :->args' + + case $state in + args) + case $words[1] in + list-tracks) + _raw_tools_list_tracks + ;; + extract-audio|extract-video) + _raw_tools_extract + ;; + mux-av) + _raw_tools_mux_av + ;; + esac + ;; + esac +} + +_raw_tools_commands() { + local commands=( + 'list-tracks:List all tracks with metadata' + 'extract-audio:Generate playable audio files' + 'extract-video:Generate playable video files' + 'mux-av:Mux audio and video tracks' + 'help:Show help' + ) + _describe 'commands' commands +} + +_raw_tools_global_args() { + _arguments \ + '--inputFile[Specify raw recording zip file]:file:_files -g "*.zip"' \ + '--inputS3[Specify raw recording zip file on S3]:s3path:' \ + '--output[Specify output directory]:directory:_directories' \ + '--verbose[Enable verbose logging]' \ + '--help[Show help]' +} + +_raw_tools_list_tracks() { + _arguments \ + '--format[Output format]:format:(table json completion users sessions tracks)' \ + '--trackType[Filter by track type]:type:(audio video)' \ + '--completionType[Completion type]:type:(users sessions tracks)' \ + '*: :_raw_tools_global_args' +} + +_raw_tools_extract() { + _arguments \ + '--userId[User ID filter]:userid:_raw_tools_complete_users' \ + '--sessionId[Session ID filter]:sessionid:_raw_tools_complete_sessions' \ + '--trackId[Track ID filter]:trackid:_raw_tools_complete_tracks' \ + '--fill_gaps[Fill gaps with silence/black frames]' \ + '*: :_raw_tools_global_args' +} + +_raw_tools_mux_av() { + _arguments \ + '--userId[User ID filter]:userid:_raw_tools_complete_users' \ + '--sessionId[Session ID filter]:sessionid:_raw_tools_complete_sessions' \ + '*: :_raw_tools_global_args' +} + +# Dynamic completion helpers +_raw_tools_complete_users() { + local input_file + for ((i=1; i <= $#words; i++)); do + if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then + input_file=$words[i+1] + break + fi + done + + if [[ -n "$input_file" ]]; then + local users=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format users 2>/dev/null)) + _wanted users expl 'user ID' compadd "$@" "*" $users + else + _wanted users expl 'user ID' compadd "$@" "*" + fi +} + +_raw_tools_complete_sessions() { + local input_file + for ((i=1; i <= $#words; i++)); do + if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then + input_file=$words[i+1] + break + fi + done + + if [[ -n "$input_file" ]]; then + local sessions=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format sessions 2>/dev/null)) + _wanted sessions expl 'session ID' compadd "$@" "*" $sessions + else + _wanted sessions expl 'session ID' compadd "$@" "*" + fi +} + +_raw_tools_complete_tracks() { + local input_file + for ((i=1; i <= $#words; i++)); do + if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then + input_file=$words[i+1] + break + fi + done + + if [[ -n "$input_file" ]]; then + local tracks=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format tracks 2>/dev/null)) + _wanted tracks expl 'track ID' compadd "$@" "*" $tracks + else + _wanted tracks expl 'track ID' compadd "$@" "*" + fi +} + +_raw_tools "$@"` + + fmt.Println(script) +} + +// generateFishCompletion generates fish completion script +func generateFishCompletion() { + script := `# Fish completion for raw-tools + +# Complete commands +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'list-tracks' -d 'List all tracks with metadata' +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'extract-audio' -d 'Generate playable audio files' +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'extract-video' -d 'Generate playable video files' +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'mux-av' -d 'Mux audio and video tracks' +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'help' -d 'Show help' + +# Global options +complete -c raw-tools -l inputFile -d 'Specify raw recording zip file' -r -F +complete -c raw-tools -l inputS3 -d 'Specify raw recording zip file on S3' -r +complete -c raw-tools -l output -d 'Specify output directory' -r -a '(__fish_complete_directories)' +complete -c raw-tools -l verbose -d 'Enable verbose logging' +complete -c raw-tools -l help -d 'Show help' + +# list-tracks specific options +complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l format -d 'Output format' -r -a 'table json completion users sessions tracks' +complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l trackType -d 'Filter by track type' -r -a 'audio video' +complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l completionType -d 'Completion type' -r -a 'users sessions tracks' + +# extract commands specific options +complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l userId -d 'User ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l sessionId -d 'Session ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l trackId -d 'Track ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l fill_gaps -d 'Fill gaps' + +# mux-av specific options +complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l userId -d 'User ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l sessionId -d 'Session ID filter' -r` + + fmt.Println(script) +} + +// validateInputArgs validates input arguments using hierarchical logic +func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) error { + if globalArgs.InputFile == "" && globalArgs.InputS3 == "" { + return nil // Skip validation if no input specified yet + } + + // Parse metadata to validate arguments + logger := setupLogger(false) // Use non-verbose for validation + parser := NewMetadataParser(logger) + + var inputPath string + if globalArgs.InputFile != "" { + inputPath = globalArgs.InputFile + } else { + // TODO: Handle S3 validation + return nil + } + + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return fmt.Errorf("failed to parse recording for validation: %w", err) + } + + // Hierarchical validation logic + if userID == "*" { + // If userID is wildcard, sessionID and trackID are ignored (no validation needed) + return nil + } + + // Validate userID if not wildcard + if userID != "" { + found := false + for _, uid := range metadata.UserIDs { + if uid == userID { + found = true + break + } + } + if !found { + return fmt.Errorf("userID '%s' not found in recording. Available users: %s", + userID, strings.Join(metadata.UserIDs, ", ")) + } + } + + // If sessionID is wildcard, trackID is ignored + if sessionID == "*" { + return nil + } + + // Validate sessionID if not wildcard + if sessionID != "" { + // Check if this sessionID exists for the specified userID + found := false + for _, track := range metadata.Tracks { + if track.UserID == userID && track.SessionID == sessionID { + found = true + break + } + } + if !found { + // Get available sessions for this user + availableSessions := make([]string, 0) + seen := make(map[string]bool) + for _, track := range metadata.Tracks { + if track.UserID == userID && !seen[track.SessionID] { + availableSessions = append(availableSessions, track.SessionID) + seen[track.SessionID] = true + } + } + return fmt.Errorf("sessionID '%s' not found for userID '%s'. Available sessions for this user: %s", + sessionID, userID, strings.Join(availableSessions, ", ")) + } + } + + // If trackID is wildcard, no validation needed + if trackID == "*" { + return nil + } + + // Validate trackID if not wildcard + if trackID != "" { + // Check if this trackID exists for the specified userID/sessionID + found := false + for _, track := range metadata.Tracks { + if track.UserID == userID && track.SessionID == sessionID && track.TrackID == trackID { + found = true + break + } + } + if !found { + // Get available tracks for this user/session combination + availableTracks := make([]string, 0) + seen := make(map[string]bool) + for _, track := range metadata.Tracks { + if track.UserID == userID && track.SessionID == sessionID && !seen[track.TrackID] { + availableTracks = append(availableTracks, track.TrackID) + seen[track.TrackID] = true + } + } + return fmt.Errorf("trackID '%s' not found for userID '%s' and sessionID '%s'. Available tracks for this user/session: %s", + trackID, userID, sessionID, strings.Join(availableTracks, ", ")) + } + } + + return nil +} diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go new file mode 100644 index 0000000..212aaf6 --- /dev/null +++ b/cmd/raw-recording-tools/extract_audio.go @@ -0,0 +1,126 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/GetStream/getstream-go/v3" +) + +type ExtractAudioArgs struct { + UserID string + SessionID string + TrackID string + FillGaps bool +} + +func runExtractAudio(args []string, globalArgs *GlobalArgs) { + // Parse command-specific flags + fs := flag.NewFlagSet("extract-audio", flag.ExitOnError) + extractAudioArgs := &ExtractAudioArgs{} + fs.StringVar(&extractAudioArgs.UserID, "userId", "*", "Specify a userId or * for all") + fs.StringVar(&extractAudioArgs.SessionID, "sessionId", "*", "Specify a sessionId or * for all") + fs.StringVar(&extractAudioArgs.TrackID, "trackId", "*", "Specify a trackId or * for all") + fs.BoolVar(&extractAudioArgs.FillGaps, "fill_gaps", false, "Fix DTX shrink audio, and fill with silence when track was muted") + + // Check for help flag before parsing + for _, arg := range args { + if arg == "--help" || arg == "-h" { + printExtractAudioUsage() + return + } + } + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate global arguments + if err := validateGlobalArgs(globalArgs, "extract-audio"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + printExtractAudioUsage() + os.Exit(1) + } + + // Validate input arguments against actual recording data + if err := validateInputArgs(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID); err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + if globalArgs.InputFile != "" { + fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", + globalArgs.InputFile, globalArgs.Output) + } + os.Exit(1) + } + + // Setup logger + logger := setupLogger(globalArgs.Verbose) + + logger.Info("Starting extract-audio command") + + fmt.Printf("Extract audio command with hierarchical filtering:\n") + if globalArgs.InputFile != "" { + fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + } + if globalArgs.InputS3 != "" { + fmt.Printf(" Input S3: %s\n", globalArgs.InputS3) + } + fmt.Printf(" Output directory: %s\n", globalArgs.Output) + fmt.Printf(" User ID filter: %s\n", extractAudioArgs.UserID) + + if extractAudioArgs.UserID == "*" { + fmt.Printf(" → Processing ALL users (sessionId/trackId ignored)\n") + } else { + fmt.Printf(" Session ID filter: %s\n", extractAudioArgs.SessionID) + if extractAudioArgs.SessionID == "*" { + fmt.Printf(" → Processing ALL sessions for user '%s' (trackId ignored)\n", extractAudioArgs.UserID) + } else { + fmt.Printf(" Track ID filter: %s\n", extractAudioArgs.TrackID) + if extractAudioArgs.TrackID == "*" { + fmt.Printf(" → Processing ALL tracks for user '%s', session '%s'\n", extractAudioArgs.UserID, extractAudioArgs.SessionID) + } else { + fmt.Printf(" → Processing specific track '%s' for user '%s', session '%s'\n", extractAudioArgs.TrackID, extractAudioArgs.UserID, extractAudioArgs.SessionID) + } + } + } + fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) + + // Implement extract audio functionality + err := extractAudioTracks(globalArgs, extractAudioArgs, logger) + if err != nil { + logger.Error("Failed to extract audio: %v", err) + } + + logger.Info("Extract audio command completed") +} + +func printExtractAudioUsage() { + fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-audio [command options]\n\n") + fmt.Fprintf(os.Stderr, "Generate playable audio files from raw recording tracks.\n") + fmt.Fprintf(os.Stderr, "Supports formats: webm, mp3, and others.\n\n") + fmt.Fprintf(os.Stderr, "Command Options (Hierarchical Filtering):\n") + fmt.Fprintf(os.Stderr, " --userId Specify a userId or * for all (default: *)\n") + fmt.Fprintf(os.Stderr, " --sessionId Specify a sessionId or * for all (default: *)\n") + fmt.Fprintf(os.Stderr, " Ignored if --userId=*\n") + fmt.Fprintf(os.Stderr, " --trackId Specify a trackId or * for all (default: *)\n") + fmt.Fprintf(os.Stderr, " Ignored if --userId=* or --sessionId=*\n") + fmt.Fprintf(os.Stderr, " --fill_gaps Fix DTX shrink audio, fill with silence when muted\n\n") + fmt.Fprintf(os.Stderr, "Hierarchical Filtering Logic:\n") + fmt.Fprintf(os.Stderr, " --userId=* → Extract ALL users, sessions, tracks (sessionId/trackId ignored)\n") + fmt.Fprintf(os.Stderr, " --userId=user1 --sessionId=* → Extract ALL sessions/tracks for user1 (trackId ignored)\n") + fmt.Fprintf(os.Stderr, " --userId=user1 --sessionId=session1 --trackId=* → Extract ALL tracks for user1/session1\n") + fmt.Fprintf(os.Stderr, " --userId=user1 --sessionId=session1 --trackId=track1 → Extract specific track\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " # Extract audio for all users (sessionId/trackId ignored)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --userId '*'\n\n") + fmt.Fprintf(os.Stderr, " # Extract audio for specific user, all sessions (trackId ignored)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --userId user123 --sessionId '*'\n\n") + fmt.Fprintf(os.Stderr, " # Extract audio for specific user/session, all tracks\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --userId user123 --sessionId session456 --trackId '*'\n\n") + fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") +} + +func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, logger *getstream.DefaultLogger) error { + return extractTracks(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, "audio", "both", extractAudioArgs.FillGaps, logger) +} diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go new file mode 100644 index 0000000..a6b5261 --- /dev/null +++ b/cmd/raw-recording-tools/extract_track.go @@ -0,0 +1,349 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" +) + +// Generic track extraction function that works for both audio and video +func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID, trackType, mediaFilter string, fillGaps bool, logger *getstream.DefaultLogger) error { + var inputPath string + if globalArgs.InputFile != "" { + inputPath = globalArgs.InputFile + } else { + // TODO: Handle S3 input + return fmt.Errorf("S3 input not yet supported") + } + + // Extract to temp directory if needed (unified approach) + workingDir, cleanup, err := extractToTempDir(inputPath, logger) + if err != nil { + return fmt.Errorf("failed to prepare working directory: %w", err) + } + defer cleanup() + + // Parse metadata from the working directory + parser := NewMetadataParser(logger) + metadata, err := parser.ParseRecording(workingDir) + if err != nil { + return fmt.Errorf("failed to parse recording metadata: %w", err) + } + + // Filter tracks to specified type only and apply hierarchical filtering + filteredTracks := parser.FilterTracks(metadata.Tracks, userID, sessionID, trackID) + typedTracks := make([]TrackInfo, 0) + for _, track := range filteredTracks { + if track.TrackType == trackType { + // Apply media type filtering if specified + if mediaFilter != "" && mediaFilter != "both" { + if mediaFilter == "user" && track.IsScreenshare { + continue // Skip display tracks when only user requested + } + if mediaFilter == "display" && !track.IsScreenshare { + continue // Skip user tracks when only display requested + } + } + typedTracks = append(typedTracks, track) + } + } + + if len(typedTracks) == 0 { + logger.Warn("No %s tracks found matching the filter criteria", trackType) + return nil + } + + logger.Info("Found %d %s tracks to extract", len(typedTracks), trackType) + + // Create output directory if it doesn't exist + err = os.MkdirAll(globalArgs.Output, 0755) + if err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Extract and convert each track + for i, track := range typedTracks { + logger.Info("Processing %s track %d/%d: %s", trackType, i+1, len(typedTracks), track.TrackID) + + err = extractSingleTrackWithOptions(workingDir, track, globalArgs.Output, trackType, fillGaps, logger) + if err != nil { + logger.Error("Failed to extract %s track %s: %v", trackType, track.TrackID, err) + continue + } + } + + return nil +} + +func extractSingleTrackWithOptions(inputPath string, track TrackInfo, outputDir string, trackType string, fillGaps bool, logger *getstream.DefaultLogger) error { + // Create a temp directory for extraction and processing + tempDir, err := os.MkdirTemp("", fmt.Sprintf("%s-extract-*", trackType)) + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Copy track files from working directory (now always a directory) + err = copyTrackFiles(inputPath, track, tempDir, trackType) + if err != nil { + return fmt.Errorf("failed to copy track files: %w", err) + } + + // Convert using the WebM converter + err = webm.ConvertDirectory(tempDir, logger) + if err != nil { + return fmt.Errorf("failed to convert %s track: %w", trackType, err) + } + + // Find ALL generated .webm files + webmFiles, _ := filepath.Glob(filepath.Join(tempDir, "*.webm")) + if len(webmFiles) == 0 { + return fmt.Errorf("no webm output files found") + } + + logger.Info("Found %d WebM segment files for %s track %s", len(webmFiles), trackType, track.TrackID) + + // Create segments with timing info and fill gaps + finalFile, err := processSegmentsWithGapFilling(webmFiles, track, trackType, outputDir, fillGaps, logger) + if err != nil { + return fmt.Errorf("failed to process segments with gap filling: %w", err) + } + + logger.Info("Successfully extracted %s track to: %s", trackType, finalFile) + return nil +} + +// NOTE: extractTrackFiles removed - now always use copyTrackFiles since we always work with directories + +// copyTrackFiles copies the rtpdump and sdp files for a specific track to the destination directory +func copyTrackFiles(inputPath string, track TrackInfo, destDir string, trackType string) error { + // Walk through the input directory and copy files related to this track + return filepath.Walk(inputPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() { + fileName := info.Name() + // Check if this file belongs to our track (using trackType parameter) + if strings.Contains(fileName, track.TrackID) && strings.Contains(fileName, trackType) { + if strings.HasSuffix(fileName, ".rtpdump") || strings.HasSuffix(fileName, ".sdp") { + // Copy this file to destination + destPath := filepath.Join(destDir, fileName) + + err = copyFile(path, destPath) + if err != nil { + return err + } + } + } + } + return nil + }) +} + +// Helper function to copy a file +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = srcFile.WriteTo(dstFile) + return err +} + +// processSegmentsWithGapFilling processes webm segments, fills gaps if requested, and concatenates into final file +func processSegmentsWithGapFilling(webmFiles []string, track TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { + if len(webmFiles) == 1 { + // Single segment, just copy it with final name + finalName := fmt.Sprintf("%s_%s_%s_%s.webm", trackType, track.UserID, track.SessionID, track.TrackID) + finalPath := filepath.Join(outputDir, finalName) + err := copyFile(webmFiles[0], finalPath) + return finalPath, err + } + + // Multiple segments - sort, optionally fill gaps, and concatenate + if fillGaps { + logger.Info("Processing %d segments with gap filling for %s track %s", len(webmFiles), trackType, track.TrackID) + } else { + logger.Info("Processing %d segments (no gap filling) for %s track %s", len(webmFiles), trackType, track.TrackID) + } + + // Map webm files to their original segment timing using filenames + segmentMap := make(map[string]string) // originalFilename -> webmFilePath + for _, webmFile := range webmFiles { + // Extract original filename from webm filename (remove .webm, add .rtpdump) + baseName := strings.TrimSuffix(filepath.Base(webmFile), ".webm") + originalName := baseName + ".rtpdump" + segmentMap[originalName] = webmFile + } + + // Build list of files to concatenate (with optional gap fillers) + var filesToConcat []string + + for i, segment := range track.Segments { + // Add the segment file + filesToConcat = append(filesToConcat, segmentMap[segment.BaseFilename]) + + // Add gap filler if requested and there's a gap before the next segment + if fillGaps && i < track.SegmentCount-1 { + nextSegment := track.Segments[i+1] + gapDuration := FirstPacketNtpTimestamp(nextSegment) - LastPacketNtpTimestamp(segment) + + if gapDuration > 0 { // There's a gap + gapSeconds := float64(gapDuration) / 1000.0 + logger.Info("Detected %dms gap between segments, generating %s filler", gapDuration, trackType) + + // Create gap filler file + gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.webm", trackType, i)) + + if trackType == "audio" { + err := webm.GenerateSilence(gapFilePath, gapSeconds, logger) + if err != nil { + logger.Warn("Failed to generate silence, skipping gap: %v", err) + continue + } + } else if trackType == "video" { + // Use VP8 codec and 720p quality as defaults + err := webm.GenerateBlackVideo(gapFilePath, "video/VP8", gapSeconds, 1280, 720, 30, logger) + if err != nil { + logger.Warn("Failed to generate black video, skipping gap: %v", err) + continue + } + } + + filesToConcat = append(filesToConcat, gapFilePath) + } + } + } + + // Create final output file + finalName := fmt.Sprintf("%s_%s_%s_%s.webm", trackType, track.UserID, track.SessionID, track.TrackID) + finalPath := filepath.Join(outputDir, finalName) + + // Concatenate all segments (with gap fillers if any) + err := webm.ConcatFile(finalPath, filesToConcat, logger) + if err != nil { + return "", fmt.Errorf("failed to concatenate segments: %w", err) + } + + // Clean up temporary gap filler files + if fillGaps { + for _, file := range filesToConcat { + if strings.Contains(file, "gap_") { + os.Remove(file) + } + } + } + + if fillGaps { + logger.Info("Successfully concatenated %d segments with gap filling into %s", track.SegmentCount, finalPath) + } else { + logger.Info("Successfully concatenated %d segments into %s", track.SegmentCount, finalPath) + } + return finalPath, nil +} + +// extractToTempDir extracts archive to temp directory or returns the directory path +// Returns: (workingDir, cleanupFunc, error) +func extractToTempDir(inputPath string, logger *getstream.DefaultLogger) (string, func(), error) { + // If it's already a directory, just return it + if stat, err := os.Stat(inputPath); err == nil && stat.IsDir() { + logger.Debug("Input is already a directory: %s", inputPath) + return inputPath, func() {}, nil + } + + // If it's a tar.gz file, extract it to temp directory + if strings.HasSuffix(strings.ToLower(inputPath), ".tar.gz") { + logger.Info("Extracting tar.gz archive to temporary directory...") + + tempDir, err := os.MkdirTemp("", "raw-tools-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + cleanup := func() { + os.RemoveAll(tempDir) + } + + err = extractTarGzToDir(inputPath, tempDir, logger) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to extract tar.gz: %w", err) + } + + logger.Debug("Extracted archive to: %s", tempDir) + return tempDir, cleanup, nil + } + + return "", nil, fmt.Errorf("unsupported input format: %s (only tar.gz files and directories supported)", inputPath) +} + +// extractTarGzToDir extracts a tar.gz file to the specified directory +func extractTarGzToDir(tarGzPath, destDir string, logger *getstream.DefaultLogger) error { + file, err := os.Open(tarGzPath) + if err != nil { + return fmt.Errorf("failed to open tar.gz file: %w", err) + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar entry: %w", err) + } + + // Skip directories + if header.FileInfo().IsDir() { + continue + } + + // Create destination file + destPath := filepath.Join(destDir, header.Name) + + // Create directory structure if needed + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to create directory structure: %w", err) + } + + // Extract file + outFile, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + _, err = io.Copy(outFile, tarReader) + outFile.Close() + if err != nil { + return fmt.Errorf("failed to extract file %s: %w", destPath, err) + } + } + + return nil +} diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go new file mode 100644 index 0000000..3c4b218 --- /dev/null +++ b/cmd/raw-recording-tools/extract_video.go @@ -0,0 +1,134 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/GetStream/getstream-go/v3" +) + +type ExtractVideoArgs struct { + UserID string + SessionID string + TrackID string + FillGaps bool +} + +func runExtractVideo(args []string, globalArgs *GlobalArgs) { + // Parse command-specific flags + fs := flag.NewFlagSet("extract-video", flag.ExitOnError) + extractVideoArgs := &ExtractVideoArgs{} + fs.StringVar(&extractVideoArgs.UserID, "userId", "*", "Specify a userId or * for all") + fs.StringVar(&extractVideoArgs.SessionID, "sessionId", "*", "Specify a sessionId or * for all") + fs.StringVar(&extractVideoArgs.TrackID, "trackId", "*", "Specify a trackId or * for all") + fs.BoolVar(&extractVideoArgs.FillGaps, "fill_gaps", false, "Fill with black frame when track was muted") + + // Check for help flag before parsing + for _, arg := range args { + if arg == "--help" || arg == "-h" { + printExtractVideoUsage() + return + } + } + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate global arguments + if err := validateGlobalArgs(globalArgs, "extract-video"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + printExtractVideoUsage() + os.Exit(1) + } + + // Validate input arguments against actual recording data + if err := validateInputArgs(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID); err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + if globalArgs.InputFile != "" { + fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", + globalArgs.InputFile, globalArgs.Output) + } + os.Exit(1) + } + + // Setup logger + logger := setupLogger(globalArgs.Verbose) + + logger.Info("Starting extract-video command") + + // TODO: Implement extract video functionality + // This should: + // 1. Read the input file (zip or S3) + // 2. Filter tracks based on userId, sessionId, trackId + // 3. Extract video tracks and convert to playable format (webm, mp4, etc.) + // 4. Apply gap filling with black frames if requested + // 5. Save to output directory + + fmt.Printf("Extract video command with hierarchical filtering:\n") + if globalArgs.InputFile != "" { + fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + } + if globalArgs.InputS3 != "" { + fmt.Printf(" Input S3: %s\n", globalArgs.InputS3) + } + fmt.Printf(" Output directory: %s\n", globalArgs.Output) + fmt.Printf(" User ID filter: %s\n", extractVideoArgs.UserID) + + if extractVideoArgs.UserID == "*" { + fmt.Printf(" → Processing ALL users (sessionId/trackId ignored)\n") + } else { + fmt.Printf(" Session ID filter: %s\n", extractVideoArgs.SessionID) + if extractVideoArgs.SessionID == "*" { + fmt.Printf(" → Processing ALL sessions for user '%s' (trackId ignored)\n", extractVideoArgs.UserID) + } else { + fmt.Printf(" Track ID filter: %s\n", extractVideoArgs.TrackID) + if extractVideoArgs.TrackID == "*" { + fmt.Printf(" → Processing ALL tracks for user '%s', session '%s'\n", extractVideoArgs.UserID, extractVideoArgs.SessionID) + } else { + fmt.Printf(" → Processing specific track '%s' for user '%s', session '%s'\n", extractVideoArgs.TrackID, extractVideoArgs.UserID, extractVideoArgs.SessionID) + } + } + } + fmt.Printf(" Fill gaps: %t\n", extractVideoArgs.FillGaps) + + // Extract video tracks + if err := extractVideoTracks(globalArgs, extractVideoArgs, logger); err != nil { + logger.Error("Failed to extract video tracks: %v", err) + os.Exit(1) + } + + logger.Info("Extract video command completed successfully") +} + +func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, logger *getstream.DefaultLogger) error { + return extractTracks(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, "video", "both", extractVideoArgs.FillGaps, logger) +} + +func printExtractVideoUsage() { + fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-video [command options]\n\n") + fmt.Fprintf(os.Stderr, "Generate playable video files from raw recording tracks.\n") + fmt.Fprintf(os.Stderr, "Supports formats: webm, mp4, and others.\n\n") + fmt.Fprintf(os.Stderr, "Command Options (Hierarchical Filtering):\n") + fmt.Fprintf(os.Stderr, " --userId Specify a userId or * for all (default: *)\n") + fmt.Fprintf(os.Stderr, " --sessionId Specify a sessionId or * for all (default: *)\n") + fmt.Fprintf(os.Stderr, " Ignored if --userId=*\n") + fmt.Fprintf(os.Stderr, " --trackId Specify a trackId or * for all (default: *)\n") + fmt.Fprintf(os.Stderr, " Ignored if --userId=* or --sessionId=*\n") + fmt.Fprintf(os.Stderr, " --fill_gaps Fill with black frame when track was muted\n\n") + fmt.Fprintf(os.Stderr, "Hierarchical Filtering Logic:\n") + fmt.Fprintf(os.Stderr, " --userId=* → Extract ALL users, sessions, tracks (sessionId/trackId ignored)\n") + fmt.Fprintf(os.Stderr, " --userId=user1 --sessionId=* → Extract ALL sessions/tracks for user1 (trackId ignored)\n") + fmt.Fprintf(os.Stderr, " --userId=user1 --sessionId=session1 --trackId=* → Extract ALL tracks for user1/session1\n") + fmt.Fprintf(os.Stderr, " --userId=user1 --sessionId=session1 --trackId=track1 → Extract specific track\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " # Extract video for all users (sessionId/trackId ignored)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --userId '*'\n\n") + fmt.Fprintf(os.Stderr, " # Extract video for specific user, all sessions (trackId ignored)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --userId user123 --sessionId '*'\n\n") + fmt.Fprintf(os.Stderr, " # Extract video for specific user/session, all tracks\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --userId user123 --sessionId session456 --trackId '*'\n\n") + fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") +} diff --git a/cmd/raw-recording-tools/list_tracks.go b/cmd/raw-recording-tools/list_tracks.go new file mode 100644 index 0000000..6ed3850 --- /dev/null +++ b/cmd/raw-recording-tools/list_tracks.go @@ -0,0 +1,247 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" +) + +type ListTracksArgs struct { + Format string // "table", "json", "completion", "users", "sessions", "tracks" + TrackType string // Filter by track type: "audio", "video", or "" for all + CompletionType string // For completion format: "users", "sessions", "tracks" +} + +func runListTracks(args []string, globalArgs *GlobalArgs) { + // Parse command-specific flags + fs := flag.NewFlagSet("list-tracks", flag.ExitOnError) + listTracksArgs := &ListTracksArgs{} + fs.StringVar(&listTracksArgs.Format, "format", "table", "Output format: table, json, completion, users, sessions, tracks") + fs.StringVar(&listTracksArgs.TrackType, "trackType", "", "Filter by track type: audio, video") + fs.StringVar(&listTracksArgs.CompletionType, "completionType", "tracks", "For completion format: users, sessions, tracks") + + // Check for help flag before parsing + for _, arg := range args { + if arg == "--help" || arg == "-h" { + printListTracksUsage() + return + } + } + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate global arguments + if err := validateGlobalArgs(globalArgs, "list-tracks"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + printListTracksUsage() + os.Exit(1) + } + + // Setup logger + logger := setupLogger(globalArgs.Verbose) + + logger.Info("Starting list-tracks command") + + // Parse the recording metadata using efficient metadata-only approach + var inputPath string + if globalArgs.InputFile != "" { + inputPath = globalArgs.InputFile + } else { + // TODO: Handle S3 input + return // For now, only support local files + } + + // Use efficient metadata-only parsing (optimized for list-tracks) + parser := NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + logger.Error("Failed to parse recording: %v", err) + } + + // Filter tracks if track type is specified + tracks := metadata.Tracks + if listTracksArgs.TrackType != "" { + filteredTracks := make([]TrackInfo, 0) + for _, track := range tracks { + if track.TrackType == listTracksArgs.TrackType { + filteredTracks = append(filteredTracks, track) + } + } + tracks = filteredTracks + } + + // Output in requested format + switch listTracksArgs.Format { + case "table": + printTracksTable(tracks) + case "json": + printTracksJSON(metadata) + case "completion": + printCompletion(metadata, listTracksArgs.CompletionType) + case "users": + printUsers(metadata.UserIDs) + case "sessions": + printSessions(metadata.Sessions) + case "tracks": + printTrackIDs(tracks) + default: + fmt.Fprintf(os.Stderr, "Unknown format: %s\n", listTracksArgs.Format) + os.Exit(1) + } + + logger.Info("List tracks command completed") +} + +// printTracksTable prints tracks in a human-readable table format +func printTracksTable(tracks []TrackInfo) { + if len(tracks) == 0 { + fmt.Println("No tracks found.") + return + } + + // Print header + fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8s\n", "USER ID", "SESSION ID", "TRACK ID", "TYPE", "SCREENSHARE", "CODEC", "SEGMENTS") + fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8s\n", + strings.Repeat("-", 22), + strings.Repeat("-", 38), + strings.Repeat("-", 38), + strings.Repeat("-", 6), + strings.Repeat("-", 12), + strings.Repeat("-", 15), + strings.Repeat("-", 8)) + + // Print tracks + for _, track := range tracks { + screenshareStatus := "No" + if track.IsScreenshare { + screenshareStatus = "Yes" + } + fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8d\n", + truncateString(track.UserID, 22), + truncateString(track.SessionID, 38), + truncateString(track.TrackID, 38), + track.TrackType, + screenshareStatus, + track.Codec, + track.SegmentCount) + } +} + +// truncateString truncates a string to a maximum length, adding "..." if needed +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// printTracksJSON prints the full metadata in JSON format +func printTracksJSON(metadata *RecordingMetadata) { + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) + return + } + fmt.Println(string(data)) +} + +// printCompletion prints completion-friendly output +func printCompletion(metadata *RecordingMetadata, completionType string) { + switch completionType { + case "users": + printUsers(metadata.UserIDs) + case "sessions": + printSessions(metadata.Sessions) + case "tracks": + trackIDs := make([]string, 0) + for _, track := range metadata.Tracks { + trackIDs = append(trackIDs, track.TrackID) + } + // Remove duplicates and sort + uniqueTrackIDs := removeDuplicates(trackIDs) + sort.Strings(uniqueTrackIDs) + printTrackIDs(metadata.Tracks) + default: + fmt.Fprintf(os.Stderr, "Unknown completion type: %s\n", completionType) + } +} + +// printUsers prints user IDs, one per line +func printUsers(userIDs []string) { + sort.Strings(userIDs) + for _, userID := range userIDs { + fmt.Println(userID) + } +} + +// printSessions prints session IDs, one per line +func printSessions(sessions []string) { + sort.Strings(sessions) + for _, session := range sessions { + fmt.Println(session) + } +} + +// printTrackIDs prints unique track IDs, one per line +func printTrackIDs(tracks []TrackInfo) { + trackIDs := make([]string, 0) + seen := make(map[string]bool) + + for _, track := range tracks { + if !seen[track.TrackID] { + trackIDs = append(trackIDs, track.TrackID) + seen[track.TrackID] = true + } + } + + sort.Strings(trackIDs) + for _, trackID := range trackIDs { + fmt.Println(trackID) + } +} + +// removeDuplicates removes duplicate strings from a slice +func removeDuplicates(input []string) []string { + keys := make(map[string]bool) + result := make([]string, 0) + + for _, item := range input { + if !keys[item] { + keys[item] = true + result = append(result, item) + } + } + + return result +} + +func printListTracksUsage() { + fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] list-tracks [command options]\n\n") + fmt.Fprintf(os.Stderr, "List all tracks in the raw recording with their metadata.\n") + fmt.Fprintf(os.Stderr, "Note: --output is optional for this command (only displays information).\n\n") + fmt.Fprintf(os.Stderr, "Command Options:\n") + fmt.Fprintf(os.Stderr, " --format Output format (default: table)\n") + fmt.Fprintf(os.Stderr, " table - Human readable table\n") + fmt.Fprintf(os.Stderr, " json - JSON format\n") + fmt.Fprintf(os.Stderr, " users - List of user IDs only\n") + fmt.Fprintf(os.Stderr, " sessions - List of session IDs only\n") + fmt.Fprintf(os.Stderr, " tracks - List of track IDs only\n") + fmt.Fprintf(os.Stderr, " completion - Shell completion format\n") + fmt.Fprintf(os.Stderr, " --trackType Filter by track type: audio, video\n") + fmt.Fprintf(os.Stderr, " --completionType For completion format: users, sessions, tracks\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " # List all tracks in table format (no output directory needed)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks\n\n") + fmt.Fprintf(os.Stderr, " # Get JSON output for programmatic use\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks --format json\n\n") + fmt.Fprintf(os.Stderr, " # Get user IDs for completion\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks --format users\n") + fmt.Fprintf(os.Stderr, "\nGlobal Options: Use 'raw-tools --help' to see global options.\n") + +} diff --git a/cmd/raw-recording-tools/main.go b/cmd/raw-recording-tools/main.go new file mode 100644 index 0000000..3ce34ee --- /dev/null +++ b/cmd/raw-recording-tools/main.go @@ -0,0 +1,183 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/GetStream/getstream-go/v3" +) + +type GlobalArgs struct { + InputFile string + InputS3 string + Output string + Verbose bool +} + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + // Parse global flags first + globalArgs := &GlobalArgs{} + command, remainingArgs := parseGlobalFlags(os.Args[1:], globalArgs) + + if command == "" { + printUsage() + os.Exit(1) + } + + switch command { + case "list-tracks": + runListTracks(remainingArgs, globalArgs) + case "extract-audio": + runExtractAudio(remainingArgs, globalArgs) + case "extract-video": + runExtractVideo(remainingArgs, globalArgs) + case "mux-av": + runMuxAV(remainingArgs, globalArgs) + case "mix-audio": + runMixAudio(remainingArgs, globalArgs) + case "process-all": + runProcessAll(remainingArgs, globalArgs) + case "completion": + runCompletion(remainingArgs) + case "help", "-h", "--help": + printUsage() + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) + printUsage() + os.Exit(1) + } +} + +// parseGlobalFlags parses global flags and returns the command and remaining args +func parseGlobalFlags(args []string, globalArgs *GlobalArgs) (string, []string) { + fs := flag.NewFlagSet("global", flag.ContinueOnError) + + fs.StringVar(&globalArgs.InputFile, "inputFile", "", "Specify raw recording zip file on file system") + fs.StringVar(&globalArgs.InputS3, "inputS3", "", "Specify raw recording zip file on S3") + fs.StringVar(&globalArgs.Output, "output", "", "Specify an output directory") + fs.BoolVar(&globalArgs.Verbose, "verbose", false, "Enable verbose logging") + + // Find the command by looking for known commands + knownCommands := map[string]bool{ + "list-tracks": true, + "extract-audio": true, + "extract-video": true, + "mux-av": true, + "completion": true, + "help": true, + } + + commandIndex := -1 + for i, arg := range args { + if knownCommands[arg] { + commandIndex = i + break + } + } + + if commandIndex == -1 { + return "", nil + } + + // Parse global flags (everything before the command) + globalFlags := args[:commandIndex] + command := args[commandIndex] + remainingArgs := args[commandIndex+1:] + + err := fs.Parse(globalFlags) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing global flags: %v\n", err) + os.Exit(1) + } + + return command, remainingArgs +} + +func setupLogger(verbose bool) *getstream.DefaultLogger { + var level getstream.LogLevel + if verbose { + level = getstream.LogLevelDebug + } else { + level = getstream.LogLevelInfo + } + logger := getstream.NewDefaultLogger(os.Stderr, "", log.LstdFlags, level) + return logger +} + +func validateGlobalArgs(globalArgs *GlobalArgs, command string) error { + if globalArgs.InputFile == "" && globalArgs.InputS3 == "" { + return fmt.Errorf("either --inputFile or --inputS3 must be specified") + } + + if globalArgs.InputFile != "" && globalArgs.InputS3 != "" { + return fmt.Errorf("cannot specify both --inputFile and --inputS3") + } + + // --output is optional for list-tracks command (it only displays information) + if command != "list-tracks" && globalArgs.Output == "" { + return fmt.Errorf("--output directory must be specified") + } + + return nil +} + +func printUsage() { + fmt.Fprintf(os.Stderr, "Raw Recording Post Processing Tools\n\n") + fmt.Fprintf(os.Stderr, "Usage: %s [global options] [command options]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Global Options:\n") + fmt.Fprintf(os.Stderr, " --inputFile Specify raw recording zip file on file system\n") + fmt.Fprintf(os.Stderr, " --inputS3 Specify raw recording zip file on S3\n") + fmt.Fprintf(os.Stderr, " --output Specify an output directory (optional for list-tracks)\n") + fmt.Fprintf(os.Stderr, " --verbose Enable verbose logging\n\n") + fmt.Fprintf(os.Stderr, "Commands:\n") + fmt.Fprintf(os.Stderr, " list-tracks Return list of userId - sessionId - trackId - trackType\n") + fmt.Fprintf(os.Stderr, " extract-audio Generate a playable audio file (webm, mp3, ...)\n") + fmt.Fprintf(os.Stderr, " extract-video Generate a playable video file (webm, mp4, ...)\n") + fmt.Fprintf(os.Stderr, " mux-av Mux audio and video tracks\n") + fmt.Fprintf(os.Stderr, " mix-audio Mix multiple audio tracks into one file\n") + fmt.Fprintf(os.Stderr, " process-all Process audio, video, and mux (all-in-one)\n") + fmt.Fprintf(os.Stderr, " completion Generate shell completion scripts\n") + fmt.Fprintf(os.Stderr, " help Show this help message\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip list-tracks\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip --output ./out extract-audio --userId user123\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip --output ./out mix-audio --userId '*'\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s --verbose --inputFile recording.zip --output ./out mux-av --userId '*'\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Use '%s [global options] --help' for command-specific options.\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\nCompletion Setup:\n") + fmt.Fprintf(os.Stderr, " # Bash\n") + fmt.Fprintf(os.Stderr, " source <(%s completion bash)\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " # Zsh\n") + fmt.Fprintf(os.Stderr, " source <(%s completion zsh)\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " # Fish\n") + fmt.Fprintf(os.Stderr, " %s completion fish | source\n", os.Args[0]) +} + +func runCompletion(args []string) { + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "Usage: raw-tools completion \n") + fmt.Fprintf(os.Stderr, "Supported shells: bash, zsh, fish\n") + os.Exit(1) + } + + shell := args[0] + generateCompletion(shell) +} + +// getInputPath returns the input path from global args +func getInputPath(globalArgs *GlobalArgs) string { + if globalArgs.InputFile != "" { + return globalArgs.InputFile + } + if globalArgs.InputS3 != "" { + return globalArgs.InputS3 + } + return "" +} diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go new file mode 100644 index 0000000..1de848e --- /dev/null +++ b/cmd/raw-recording-tools/metadata.go @@ -0,0 +1,348 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/GetStream/getstream-go/v3" + rawrecorder "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/raw-recorder" +) + +// TrackInfo represents a single track with its metadata (deduplicated across segments) +type TrackInfo struct { + UserID string `json:"userId"` // participant_id from timing metadata + SessionID string `json:"sessionId"` // user_session_id from timing metadata + TrackID string `json:"trackId"` // track_id from segment + TrackType string `json:"trackType"` // "audio" or "video" (cleaned from TRACK_TYPE_*) + IsScreenshare bool `json:"isScreenshare"` // true if this is a screenshare track + Codec string `json:"codec"` // codec info + SegmentCount int `json:"segmentCount"` // number of segments for this track + Segments []rawrecorder.SegmentMetadata `json:"segments"` // list of filenames (for JSON output only) +} + +// RecordingMetadata contains all tracks and session information +type RecordingMetadata struct { + Tracks []TrackInfo `json:"tracks"` + UserIDs []string `json:"userIds"` + Sessions []string `json:"sessions"` +} + +// MetadataParser handles parsing of raw recording files +type MetadataParser struct { + logger *getstream.DefaultLogger +} + +// NewMetadataParser creates a new metadata parser +func NewMetadataParser(logger *getstream.DefaultLogger) *MetadataParser { + return &MetadataParser{ + logger: logger, + } +} + +// ParseRecording extracts metadata from a raw recording directory +// NOTE: Now simplified to only handle directories since we always extract to tempdir first +func (p *MetadataParser) ParseRecording(inputPath string) (*RecordingMetadata, error) { + return p.parseDirectory(inputPath) +} + +// ParseMetadataOnly efficiently extracts only metadata from archives (optimized for list-tracks) +// This is much faster than full extraction when you only need timing metadata +func (p *MetadataParser) ParseMetadataOnly(inputPath string) (*RecordingMetadata, error) { + // If it's already a directory, use the normal path + if stat, err := os.Stat(inputPath); err == nil && stat.IsDir() { + return p.parseDirectory(inputPath) + } + + // If it's a tar.gz file, use selective extraction (much faster) + if strings.HasSuffix(strings.ToLower(inputPath), ".tar.gz") { + return p.parseMetadataOnlyFromTarGz(inputPath) + } + + return nil, fmt.Errorf("unsupported input format: %s (only tar.gz files and directories supported)", inputPath) +} + +// parseDirectory processes a directory containing recording files +func (p *MetadataParser) parseDirectory(dirPath string) (*RecordingMetadata, error) { + metadata := &RecordingMetadata{ + Tracks: make([]TrackInfo, 0), + UserIDs: make([]string, 0), + Sessions: make([]string, 0), + } + + // Find and process timing metadata files + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), "_timing_metadata.json") { + p.logger.Debug("Processing metadata file: %s", path) + + data, err := os.ReadFile(path) + if err != nil { + p.logger.Warn("Failed to read metadata file %s: %v", path, err) + return nil + } + + tracks, err := p.parseTimingMetadataFile(data) + if err != nil { + p.logger.Warn("Failed to parse metadata file %s: %v", path, err) + return nil + } + + metadata.Tracks = append(metadata.Tracks, tracks...) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to process directory: %w", err) + } + + // Build unique lists + metadata.UserIDs = p.extractUniqueUserIDs(metadata.Tracks) + metadata.Sessions = p.extractUniqueSessions(metadata.Tracks) + + return metadata, nil +} + +// parseMetadataOnlyFromTarGz efficiently extracts only timing metadata from tar.gz files +// This is optimized for list-tracks - only reads JSON files, skips all .rtpdump/.sdp files +func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*RecordingMetadata, error) { + p.logger.Debug("Reading metadata directly from tar.gz (efficient mode): %s", tarGzPath) + + file, err := os.Open(tarGzPath) + if err != nil { + return nil, fmt.Errorf("failed to open tar.gz file: %w", err) + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + metadata := &RecordingMetadata{ + Tracks: make([]TrackInfo, 0), + UserIDs: make([]string, 0), + Sessions: make([]string, 0), + } + + filesRead := 0 + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } else if err != nil { + return nil, fmt.Errorf("failed to read tar entry: %w", err) + } else if header.FileInfo().IsDir() { + continue + } + + // Only process timing metadata JSON files (skip all .rtpdump/.sdp files) + if strings.HasSuffix(strings.ToLower(header.Name), "_timing_metadata.json") { + p.logger.Debug("Processing metadata file: %s", header.Name) + + data, err := io.ReadAll(tarReader) + if err != nil { + p.logger.Warn("Failed to read metadata file %s: %v", header.Name, err) + continue + } + + tracks, err := p.parseTimingMetadataFile(data) + if err != nil { + p.logger.Warn("Failed to parse metadata file %s: %v", header.Name, err) + continue + } + + metadata.Tracks = append(metadata.Tracks, tracks...) + filesRead++ + } + // Skip all other files (.rtpdump, .sdp, etc.) - huge efficiency gain! + } + + p.logger.Debug("Efficiently read %d metadata files from archive (skipped all media data files)", filesRead) + + // Extract unique user IDs and sessions + metadata.UserIDs = p.extractUniqueUserIDs(metadata.Tracks) + metadata.Sessions = p.extractUniqueSessions(metadata.Tracks) + + return metadata, nil +} + +// parseTimingMetadataFile parses a timing metadata JSON file and extracts tracks +func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]TrackInfo, error) { + var sessionMetadata rawrecorder.SessionTimingMetadata + err := json.Unmarshal(data, &sessionMetadata) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err) + } + + // Use a map to deduplicate tracks by unique key + trackMap := make(map[string]*TrackInfo) + + processSegment := func(segment rawrecorder.SegmentMetadata, trackType string) { + key := fmt.Sprintf("%s|%s|%s|%s", + sessionMetadata.ParticipantID, + sessionMetadata.UserSessionID, + segment.TrackID, + trackType) + + if existingTrack, exists := trackMap[key]; exists { + existingTrack.Segments = append(existingTrack.Segments, segment) + existingTrack.SegmentCount++ + } else { + // Create new track + track := &TrackInfo{ + UserID: sessionMetadata.ParticipantID, + SessionID: sessionMetadata.UserSessionID, + TrackID: segment.TrackID, + TrackType: p.cleanTrackType(segment.TrackType), + IsScreenshare: p.isScreenshareTrack(segment.TrackType), + Codec: segment.Codec, + SegmentCount: 1, + Segments: []rawrecorder.SegmentMetadata{segment}, + } + trackMap[key] = track + } + } + + // Process audio segments + for _, segment := range sessionMetadata.Segments.Audio { + processSegment(segment, p.cleanTrackType(segment.TrackType)) + } + + // Process video segments + for _, segment := range sessionMetadata.Segments.Video { + processSegment(segment, p.cleanTrackType(segment.TrackType)) + } + + // Convert map to slice + tracks := make([]TrackInfo, 0, len(trackMap)) + for _, track := range trackMap { + sort.Slice(track.Segments, func(i, j int) bool { + return track.Segments[i].StartTimestamp < track.Segments[j].StartTimestamp + }) + tracks = append(tracks, *track) + } + + return tracks, nil +} + +// isScreenshareTrack detects if a track is screenshare-related +func (p *MetadataParser) isScreenshareTrack(trackType string) bool { + return trackType == "TRACK_TYPE_SCREEN_SHARE_AUDIO" || trackType == "TRACK_TYPE_SCREEN_SHARE" +} + +// cleanTrackType converts TRACK_TYPE_* to simple "audio" or "video" +func (p *MetadataParser) cleanTrackType(trackType string) string { + switch trackType { + case "TRACK_TYPE_AUDIO", "TRACK_TYPE_SCREEN_SHARE_AUDIO": + return "audio" + case "TRACK_TYPE_VIDEO", "TRACK_TYPE_SCREEN_SHARE": + return "video" + default: + return strings.ToLower(trackType) + } +} + +// extractUniqueUserIDs returns a sorted list of unique user IDs +func (p *MetadataParser) extractUniqueUserIDs(tracks []TrackInfo) []string { + userIDMap := make(map[string]bool) + for _, track := range tracks { + userIDMap[track.UserID] = true + } + + userIDs := make([]string, 0, len(userIDMap)) + for userID := range userIDMap { + userIDs = append(userIDs, userID) + } + + return userIDs +} + +// NOTE: ExtractTrackFiles and extractTrackFromTarGz removed - no longer needed since we always work with directories + +// extractUniqueSessions returns a sorted list of unique session IDs +func (p *MetadataParser) extractUniqueSessions(tracks []TrackInfo) []string { + sessionMap := make(map[string]bool) + for _, track := range tracks { + sessionMap[track.SessionID] = true + } + + sessions := make([]string, 0, len(sessionMap)) + for session := range sessionMap { + sessions = append(sessions, session) + } + + return sessions +} + +// FilterTracks filters tracks based on hierarchical criteria +// If userID="*", sessionID and trackID are ignored (all users, sessions, tracks) +// If userID=specific, sessionID="*", trackID is ignored (specific user, all sessions/tracks) +// If userID=specific, sessionID=specific, trackID="*", all tracks for that user/session +func (p *MetadataParser) FilterTracks(tracks []TrackInfo, userID, sessionID, trackID string) []TrackInfo { + filtered := make([]TrackInfo, 0) + + for _, track := range tracks { + // Hierarchical filtering logic + if userID == "*" { + // If userID is wildcard, include all tracks regardless of sessionID/trackID + filtered = append(filtered, track) + } else if track.UserID == userID { + // User matches, check session level + if sessionID == "*" { + // If sessionID is wildcard, include all tracks for this user + filtered = append(filtered, track) + } else if track.SessionID == sessionID { + // Session matches, check track level + if trackID == "*" || track.TrackID == trackID { + // Include if trackID is wildcard or matches + filtered = append(filtered, track) + } + } + } + } + + return filtered +} + +func FirstPacketNtpTimestamp(segment rawrecorder.SegmentMetadata) int64 { + if segment.FirstRtcpNtpTimestamp != 0 && segment.FirstRtcpRtpTimestamp != 0 { + rtpNtpTs := (segment.FirstRtcpRtpTimestamp - segment.FirstRtpRtpTimestamp) / sampleRate(segment) + return segment.FirstRtcpNtpTimestamp - int64(rtpNtpTs) + } else { + return segment.StartTimestamp + } +} + +func LastPacketNtpTimestamp(segment rawrecorder.SegmentMetadata) int64 { + if segment.LastRtcpNtpTimestamp != 0 && segment.LastRtcpRtpTimestamp != 0 { + rtpNtpTs := (segment.LastRtpRtpTimestamp - segment.LastRtcpRtpTimestamp) / sampleRate(segment) + return segment.LastRtcpNtpTimestamp + int64(rtpNtpTs) + } else { + return segment.EndTimestamp + } +} + +func sampleRate(segment rawrecorder.SegmentMetadata) uint32 { + switch segment.TrackType { + case "TRACK_TYPE_AUDIO", + "TRACK_TYPE_SCREEN_SHARE_AUDIO": + return 48 + default: + return 90 + } +} diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go new file mode 100644 index 0000000..95fd596 --- /dev/null +++ b/cmd/raw-recording-tools/mix_audio.go @@ -0,0 +1,228 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" +) + +// MixAudioArgs represents the arguments for the mix-audio command +type MixAudioArgs struct { + UserID string // Optional: filter by specific user, * for all users + SessionID string // Optional: filter by specific session, * for all sessions + TrackID string // Optional: filter by specific track, * for all tracks + FillGaps bool // Whether to fill gaps with silence (always true for mixing) +} + +// AudioFileWithTiming represents an audio file with its timing information +type AudioFileWithTiming struct { + FilePath string // Path to the WebM audio file + UserID string // User who created this audio + SessionID string // Session ID + TrackID string // Track ID + StartOffsetMs int64 // When this audio should start in the final mix (milliseconds) + DurationMs int64 // Duration of the audio file + EndOffsetMs int64 // When this audio ends (StartOffsetMs + DurationMs) + TrackInfo *TrackInfo // Original track metadata +} + +// runMixAudio handles the mix-audio command +func runMixAudio(args []string, globalArgs *GlobalArgs) { + mixAudioArgs := &MixAudioArgs{ + UserID: "*", // Default: all users + SessionID: "*", // Default: all sessions + TrackID: "*", // Default: all tracks + FillGaps: true, // Always fill gaps for proper mixing + } + + // Parse command-line arguments + for i := 0; i < len(args); i++ { + switch args[i] { + case "--userId": + if i+1 < len(args) { + mixAudioArgs.UserID = args[i+1] + i++ + } else { + fmt.Fprintf(os.Stderr, "Error: --userId requires a value\n") + printMixAudioUsage() + os.Exit(1) + } + case "--sessionId": + if i+1 < len(args) { + mixAudioArgs.SessionID = args[i+1] + i++ + } else { + fmt.Fprintf(os.Stderr, "Error: --sessionId requires a value\n") + printMixAudioUsage() + os.Exit(1) + } + case "--trackId": + if i+1 < len(args) { + mixAudioArgs.TrackID = args[i+1] + i++ + } else { + fmt.Fprintf(os.Stderr, "Error: --trackId requires a value\n") + printMixAudioUsage() + os.Exit(1) + } + case "--no-fill-gaps": + mixAudioArgs.FillGaps = false + case "-h", "--help": + printMixAudioUsage() + return + default: + fmt.Fprintf(os.Stderr, "Unknown argument: %s\n", args[i]) + printMixAudioUsage() + os.Exit(1) + } + } + + // Setup logger + logger := setupLogger(globalArgs.Verbose) + logger.Info("Starting mix-audio command") + + // Execute the mix-audio operation + err := mixAllAudioTracks(globalArgs, mixAudioArgs, logger) + if err != nil { + logger.Error("Mix-audio failed: %v", err) + } + + logger.Info("Mix-audio command completed successfully") +} + +// mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic +func mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, logger *getstream.DefaultLogger) error { + // Step 1: Extract all matching audio tracks using existing extractTracks function + logger.Info("Step 1/2: Extracting all matching audio tracks...") + err := extractTracks(globalArgs, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, "audio", "user", mixAudioArgs.FillGaps, logger) + if err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } + + // Step 2: Find all extracted audio files and prepare them for mixing + logger.Info("Step 2/2: Discovering extracted files and mixing...") + audioFiles, err := discoverExtractedAudioFiles(globalArgs.Output, logger) + if err != nil { + return fmt.Errorf("failed to discover extracted audio files: %w", err) + } + + if len(audioFiles) == 0 { + return fmt.Errorf("no audio files were extracted - check your filter criteria") + } + + logger.Info("Found %d extracted audio files to mix", len(audioFiles)) + + // Step 3: Mix all discovered audio files using existing webm.MixAudioFiles + outputFile := filepath.Join(globalArgs.Output, "mixed_audio.webm") + + // Convert AudioFileWithTiming to the format expected by webm.MixAudioFiles + // webm.MixAudioFiles expects: map[string]int where key=filepath, value=offset_ms + fileOffsetMap := make(map[string]int) + for _, audioFile := range audioFiles { + fileOffsetMap[audioFile.FilePath] = int(audioFile.StartOffsetMs) + } + + err = webm.MixAudioFiles(outputFile, fileOffsetMap, logger) + if err != nil { + return fmt.Errorf("failed to mix audio files: %w", err) + } + + logger.Info("Successfully created mixed audio file: %s", outputFile) + + // Clean up individual audio files (optional) + for _, audioFile := range audioFiles { + if err := os.Remove(audioFile.FilePath); err != nil { + logger.Warn("Failed to clean up temporary file %s: %v", audioFile.FilePath, err) + } + } + + return nil +} + +// discoverExtractedAudioFiles finds all audio files that were extracted and prepares them for mixing +func discoverExtractedAudioFiles(outputDir string, logger *getstream.DefaultLogger) ([]AudioFileWithTiming, error) { + var audioFiles []AudioFileWithTiming + + // Find all .webm audio files in the output directory + err := filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Look for audio WebM files (created by extractTracks) + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".webm") && strings.Contains(strings.ToLower(info.Name()), "audio") { + logger.Debug("Found extracted audio file: %s", path) + + // Parse filename to extract timing info (if available) + audioFile := AudioFileWithTiming{ + FilePath: path, + StartOffsetMs: 0, // Will be calculated from metadata if needed + DurationMs: 0, // Will be calculated if needed + EndOffsetMs: 0, // Will be calculated if needed + } + + // Try to extract user/session/track info from filename + // Expected format: audio_userID_sessionID_trackID.webm + basename := filepath.Base(path) + basename = strings.TrimSuffix(basename, ".webm") + parts := strings.Split(basename, "_") + + if len(parts) >= 4 && parts[0] == "audio" { + audioFile.UserID = parts[1] + audioFile.SessionID = parts[2] + audioFile.TrackID = parts[3] + } + + audioFiles = append(audioFiles, audioFile) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to scan output directory: %w", err) + } + + // Sort by filename for consistent ordering + sort.Slice(audioFiles, func(i, j int) bool { + return audioFiles[i].FilePath < audioFiles[j].FilePath + }) + + return audioFiles, nil +} + +// Note: We removed mixAudioFilesUsingExistingLogic since we now use webm.MixAudioFiles directly + +// printMixAudioUsage prints the usage information for the mix-audio command +func printMixAudioUsage() { + fmt.Println("Usage: raw-tools [global-options] mix-audio [options]") + fmt.Println() + fmt.Println("Mix all audio tracks from multiple users/sessions into a single audio file") + fmt.Println("with proper timing synchronization (like a conference call recording).") + fmt.Println() + fmt.Println("Options:") + fmt.Println(" --userId Filter by user ID (* for all users, default: *)") + fmt.Println(" --sessionId Filter by session ID (* for all sessions, default: *)") + fmt.Println(" --trackId Filter by track ID (* for all tracks, default: *)") + fmt.Println(" --no-fill-gaps Don't fill gaps with silence (not recommended for mixing)") + fmt.Println(" -h, --help Show this help message") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" # Mix all audio tracks from all users and sessions") + fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio") + fmt.Println() + fmt.Println(" # Mix audio tracks from a specific user") + fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio --userId user123") + fmt.Println() + fmt.Println(" # Mix audio tracks from a specific session") + fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio --sessionId session456") + fmt.Println() + fmt.Println("Output:") + fmt.Println(" Creates 'mixed_audio.webm' - a single audio file containing all mixed tracks") + fmt.Println(" with proper timing synchronization based on the original recording timeline.") +} diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go new file mode 100644 index 0000000..3d45ad7 --- /dev/null +++ b/cmd/raw-recording-tools/mux_av.go @@ -0,0 +1,390 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" +) + +type MuxAVArgs struct { + UserID string + SessionID string + TrackID string + Media string // "user", "display", or "both" (default) +} + +func runMuxAV(args []string, globalArgs *GlobalArgs) { + // Parse command-specific flags + fs := flag.NewFlagSet("mux-av", flag.ExitOnError) + muxAVArgs := &MuxAVArgs{} + fs.StringVar(&muxAVArgs.UserID, "userId", "*", "Specify a userId or * for all") + fs.StringVar(&muxAVArgs.SessionID, "sessionId", "*", "Specify a sessionId or * for all") + fs.StringVar(&muxAVArgs.TrackID, "trackId", "*", "Specify a trackId or * for all") + fs.StringVar(&muxAVArgs.Media, "media", "both", "Filter by media type: 'user', 'display', or 'both'") + + // Check for help flag before parsing + for _, arg := range args { + if arg == "--help" || arg == "-h" { + printMuxAVUsage() + return + } + } + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate global arguments + if err := validateGlobalArgs(globalArgs, "mux-av"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + printMuxAVUsage() + os.Exit(1) + } + + // Validate input arguments against actual recording data + if err := validateInputArgs(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID); err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + if globalArgs.InputFile != "" { + fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", + globalArgs.InputFile, globalArgs.Output) + } + os.Exit(1) + } + + // Set up logger + logger := setupLogger(globalArgs.Verbose) + logger.Info("Starting mux-av command") + + // Display hierarchy information for user clarity + fmt.Printf("Mux audio and video command with hierarchical filtering:\n") + fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + fmt.Printf(" Output directory: %s\n", globalArgs.Output) + fmt.Printf(" User ID filter: %s\n", muxAVArgs.UserID) + fmt.Printf(" Session ID filter: %s\n", muxAVArgs.SessionID) + fmt.Printf(" Track ID filter: %s\n", muxAVArgs.TrackID) + fmt.Printf(" Media filter: %s\n", muxAVArgs.Media) + + if muxAVArgs.UserID == "*" { + fmt.Printf(" → Processing ALL users (sessionId/trackId ignored)\n") + } else if muxAVArgs.SessionID == "*" { + fmt.Printf(" → Processing ALL sessions for user '%s' (trackId ignored)\n", muxAVArgs.UserID) + } else if muxAVArgs.TrackID == "*" { + fmt.Printf(" → Processing ALL tracks for user '%s', session '%s'\n", muxAVArgs.UserID, muxAVArgs.SessionID) + } else { + fmt.Printf(" → Processing specific track for user '%s', session '%s', track '%s'\n", muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID) + } + + // Extract and mux audio/video tracks + if err := muxAudioVideoTracks(globalArgs, muxAVArgs, logger); err != nil { + logger.Error("Failed to mux audio/video tracks: %v", err) + os.Exit(1) + } + + logger.Info("Mux audio and video command completed successfully") +} + +func printMuxAVUsage() { + fmt.Printf("Usage: mux-av [OPTIONS]\n") + fmt.Printf("\nMux audio and video tracks into a single file\n") + fmt.Printf("\nOptions:\n") + fmt.Printf(" --userId STRING Specify a userId or * for all (default: \"*\")\n") + fmt.Printf(" --sessionId STRING Specify a sessionId or * for all (default: \"*\")\n") + fmt.Printf(" --trackId STRING Specify a trackId or * for all (default: \"*\")\n") + fmt.Printf(" --media STRING Filter by media type: 'user', 'display', or 'both' (default: \"both\")\n") + fmt.Printf("\nMedia Filtering:\n") + fmt.Printf(" --media user Only mux user camera audio/video pairs\n") + fmt.Printf(" --media display Only mux display sharing audio/video pairs\n") + fmt.Printf(" --media both Mux both types, but ensure consistent pairing (default)\n") +} + +func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, logger *getstream.DefaultLogger) error { + // Create a temporary directory for intermediate files + tempDir, err := os.MkdirTemp("", "mux-av-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Extract audio tracks with gap filling enabled + logger.Info("Extracting audio tracks with gap filling...") + err = extractTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, "audio", muxAVArgs.Media, true, logger) + if err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } + + // Extract video tracks with gap filling enabled + logger.Info("Extracting video tracks with gap filling...") + err = extractTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, "video", muxAVArgs.Media, true, logger) + if err != nil { + return fmt.Errorf("failed to extract video tracks: %w", err) + } + + // Find the generated audio and video WebM files + audioFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "audio_*.webm")) + if err != nil { + return fmt.Errorf("failed to find audio files: %w", err) + } + if len(audioFiles) == 0 { + return fmt.Errorf("no audio files generated") + } + + videoFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.webm")) + if err != nil { + return fmt.Errorf("failed to find video files: %w", err) + } + if len(videoFiles) == 0 { + return fmt.Errorf("no video files generated") + } + + logger.Info("Found %d audio files and %d video files to mux", len(audioFiles), len(videoFiles)) + + // Group files by media type for proper pairing + audioGroups, videoGroups, err := groupFilesByMediaType(globalArgs.InputFile, audioFiles, videoFiles, muxAVArgs.Media, logger) + if err != nil { + return fmt.Errorf("failed to group files by media type: %w", err) + } + + // Mux user tracks + if userAudio, userVideo := audioGroups["user"], videoGroups["user"]; len(userAudio) > 0 && len(userVideo) > 0 { + logger.Info("Muxing %d user audio/video pairs", len(userAudio)) + err = muxTrackPairs(globalArgs.InputFile, userAudio, userVideo, globalArgs.Output, "user", logger) + if err != nil { + logger.Error("Failed to mux user tracks: %v", err) + } + } + + // Mux display tracks + if displayAudio, displayVideo := audioGroups["display"], videoGroups["display"]; len(displayAudio) > 0 && len(displayVideo) > 0 { + logger.Info("Muxing %d display audio/video pairs", len(displayAudio)) + err = muxTrackPairs(globalArgs.InputFile, displayAudio, displayVideo, globalArgs.Output, "display", logger) + if err != nil { + logger.Error("Failed to mux display tracks: %v", err) + } + } + + return nil +} + +// calculateSyncOffsetFromFiles calculates sync offset between audio and video files using metadata +func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, logger *getstream.DefaultLogger) (int64, error) { + // Extract track IDs from filenames + audioTrackID := extractTrackIDFromFilename(audioFile) + videoTrackID := extractTrackIDFromFilename(videoFile) + + if audioTrackID == "" || videoTrackID == "" { + return 0, fmt.Errorf("could not extract track IDs from filenames") + } + + // Parse metadata to get timing information + parser := NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return 0, fmt.Errorf("failed to parse recording metadata: %w", err) + } + + // Find the audio and video tracks + var audioTrack, videoTrack *TrackInfo + for _, track := range metadata.Tracks { + if track.TrackID == audioTrackID && track.TrackType == "audio" { + audioTrack = &track + } + if track.TrackID == videoTrackID && track.TrackType == "video" { + videoTrack = &track + } + } + + if audioTrack == nil || videoTrack == nil { + return 0, fmt.Errorf("could not find matching tracks in metadata") + } + + // Calculate offset: positive means video starts before audio + audioTs := FirstPacketNtpTimestamp(audioTrack.Segments[0]) + videoTs := FirstPacketNtpTimestamp(videoTrack.Segments[0]) + offset := audioTs - videoTs + + logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", + audioTrack.Segments[0].StartTimestamp, audioTs, videoTrack.Segments[0].StartTimestamp, videoTs, offset)) + + return offset, nil +} + +// extractTrackIDFromFilename extracts track ID from generated filename +func extractTrackIDFromFilename(filename string) string { + // Filename format: {type}_{userId}_{sessionId}_{trackId}.webm + base := filepath.Base(filename) + base = strings.TrimSuffix(base, ".webm") + parts := strings.Split(base, "_") + if len(parts) >= 4 { + return parts[3] // trackId is the 4th part + } + return "" +} + +// generateMuxedFilename creates output filename for muxed file +func generateMuxedFilename(audioFile, videoFile, outputDir string) string { + // Extract common parts from audio filename + audioBase := filepath.Base(audioFile) + audioBase = strings.TrimSuffix(audioBase, ".webm") + + // Replace "audio_" with "muxed_" to create output name + muxedName := strings.Replace(audioBase, "audio_", "muxed_", 1) + ".webm" + + return filepath.Join(outputDir, muxedName) +} + +// groupFilesByMediaType groups audio and video files by media type (user vs display) +func groupFilesByMediaType(inputPath string, audioFiles, videoFiles []string, mediaFilter string, logger *getstream.DefaultLogger) (map[string][]string, map[string][]string, error) { + // Parse metadata to determine media types + parser := NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse metadata: %w", err) + } + + // Create track ID to screenshare type mapping + trackScreenshareMap := make(map[string]bool) + for _, track := range metadata.Tracks { + trackScreenshareMap[track.TrackID] = track.IsScreenshare + } + + // Group files by media type + audioGroups := map[string][]string{"user": {}, "display": {}} + videoGroups := map[string][]string{"user": {}, "display": {}} + + // Process audio files + for _, audioFile := range audioFiles { + trackID := extractTrackIDFromFilename(audioFile) + if trackID == "" { + logger.Warn("Could not extract track ID from audio file: %s", audioFile) + continue + } + + isScreenshare, exists := trackScreenshareMap[trackID] + if !exists { + logger.Warn("Track ID %s not found in metadata for audio file: %s", trackID, audioFile) + continue + } + + // Apply media filter + if mediaFilter == "user" && isScreenshare { + continue // Skip display tracks when only user requested + } + if mediaFilter == "display" && !isScreenshare { + continue // Skip user tracks when only display requested + } + + if isScreenshare { + audioGroups["display"] = append(audioGroups["display"], audioFile) + } else { + audioGroups["user"] = append(audioGroups["user"], audioFile) + } + } + + // Process video files + for _, videoFile := range videoFiles { + trackID := extractTrackIDFromFilename(videoFile) + if trackID == "" { + logger.Warn("Could not extract track ID from video file: %s", videoFile) + continue + } + + isScreenshare, exists := trackScreenshareMap[trackID] + if !exists { + logger.Warn("Track ID %s not found in metadata for video file: %s", trackID, videoFile) + continue + } + + // Apply media filter + if mediaFilter == "user" && isScreenshare { + continue // Skip display tracks when only user requested + } + if mediaFilter == "display" && !isScreenshare { + continue // Skip user tracks when only display requested + } + + if isScreenshare { + videoGroups["display"] = append(videoGroups["display"], videoFile) + } else { + videoGroups["user"] = append(videoGroups["user"], videoFile) + } + } + + logger.Info("Grouped files: user audio=%d, user video=%d, display audio=%d, display video=%d", + len(audioGroups["user"]), len(videoGroups["user"]), + len(audioGroups["display"]), len(videoGroups["display"])) + + return audioGroups, videoGroups, nil +} + +// muxTrackPairs muxes audio/video pairs of the same media type +func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, mediaTypeName string, logger *getstream.DefaultLogger) error { + minLen := len(audioFiles) + if len(videoFiles) < minLen { + minLen = len(videoFiles) + } + + if minLen == 0 { + logger.Warn("No %s audio/video pairs to mux", mediaTypeName) + return nil + } + + for i := 0; i < minLen; i++ { + audioFile := audioFiles[i] + videoFile := videoFiles[i] + + // Calculate sync offset using segment timing information + offset, err := calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile, logger) + if err != nil { + logger.Warn("Failed to calculate sync offset, using 0: %v", err) + offset = 0 + } + + // Generate output filename with media type indicator + outputFile := generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName) + + // Mux the audio and video files + logger.Info("Muxing %s %s + %s → %s (offset: %dms)", + mediaTypeName, filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) + + err = webm.MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) + if err != nil { + logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) + continue + } + + logger.Info("Successfully created %s muxed file: %s", mediaTypeName, outputFile) + + // Clean up individual track files to avoid clutter + //os.Remove(audioFile) + //os.Remove(videoFile) + } + + if len(audioFiles) != len(videoFiles) { + logger.Warn("Mismatched %s track counts: %d audio, %d video", mediaTypeName, len(audioFiles), len(videoFiles)) + } + + return nil +} + +// generateMediaAwareMuxedFilename creates output filename that indicates media type +func generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName string) string { + // Extract common parts from audio filename + audioBase := filepath.Base(audioFile) + audioBase = strings.TrimSuffix(audioBase, ".webm") + + // Replace "audio_" with "muxed_{mediaType}_" to create output name + var muxedName string + if mediaTypeName == "display" { + muxedName = strings.Replace(audioBase, "audio_", "muxed_display_", 1) + ".webm" + } else { + muxedName = strings.Replace(audioBase, "audio_", "muxed_", 1) + ".webm" + } + + return filepath.Join(outputDir, muxedName) +} diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go new file mode 100644 index 0000000..4d47ec4 --- /dev/null +++ b/cmd/raw-recording-tools/process_all.go @@ -0,0 +1,204 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" +) + +type ProcessAllArgs struct { + UserID string + SessionID string + TrackID string +} + +func runProcessAll(args []string, globalArgs *GlobalArgs) { + // Parse command-specific flags + fs := flag.NewFlagSet("process-all", flag.ExitOnError) + processAllArgs := &ProcessAllArgs{} + fs.StringVar(&processAllArgs.UserID, "userId", "*", "Specify a userId or * for all") + fs.StringVar(&processAllArgs.SessionID, "sessionId", "*", "Specify a sessionId or * for all") + fs.StringVar(&processAllArgs.TrackID, "trackId", "*", "Specify a trackId or * for all") + + // Check for help flag before parsing + for _, arg := range args { + if arg == "--help" || arg == "-h" { + printProcessAllUsage() + return + } + } + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate global arguments + if err := validateGlobalArgs(globalArgs, "process-all"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + printProcessAllUsage() + os.Exit(1) + } + + // Validate input arguments against actual recording data + if err := validateInputArgs(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID); err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + if globalArgs.InputFile != "" { + fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", + globalArgs.InputFile, globalArgs.Output) + } + os.Exit(1) + } + + // Set up logger + logger := setupLogger(globalArgs.Verbose) + logger.Info("Starting process-all command") + + // Display hierarchy information for user clarity + fmt.Printf("Process-all command (audio + video + mux) with hierarchical filtering:\n") + fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + fmt.Printf(" Output directory: %s\n", globalArgs.Output) + fmt.Printf(" User ID filter: %s\n", processAllArgs.UserID) + fmt.Printf(" Session ID filter: %s\n", processAllArgs.SessionID) + fmt.Printf(" Track ID filter: %s\n", processAllArgs.TrackID) + fmt.Printf(" Gap filling: always enabled\n") + + if processAllArgs.UserID == "*" { + fmt.Printf(" → Processing ALL users (sessionId/trackId ignored)\n") + } else if processAllArgs.SessionID == "*" { + fmt.Printf(" → Processing ALL sessions for user '%s' (trackId ignored)\n", processAllArgs.UserID) + } else if processAllArgs.TrackID == "*" { + fmt.Printf(" → Processing ALL tracks for user '%s', session '%s'\n", processAllArgs.UserID, processAllArgs.SessionID) + } else { + fmt.Printf(" → Processing specific track for user '%s', session '%s', track '%s'\n", processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID) + } + + // Process all tracks and mux them + if err := processAllTracks(globalArgs, processAllArgs, logger); err != nil { + logger.Error("Failed to process and mux tracks: %v", err) + os.Exit(1) + } + + logger.Info("Process-all command completed successfully") +} + +func printProcessAllUsage() { + fmt.Printf("Usage: process-all [OPTIONS]\n") + fmt.Printf("\nProcess audio, video, and mux them into combined files (all-in-one workflow)\n") + fmt.Printf("Outputs 3 files per session: audio WebM, video WebM, and muxed WebM\n") + fmt.Printf("Gap filling is always enabled for seamless playback.\n") + fmt.Printf("\nOptions:\n") + fmt.Printf(" --userId STRING Specify a userId or * for all (default: \"*\")\n") + fmt.Printf(" --sessionId STRING Specify a sessionId or * for all (default: \"*\")\n") + fmt.Printf(" --trackId STRING Specify a trackId or * for all (default: \"*\")\n") + fmt.Printf("\nOutput files per session:\n") + fmt.Printf(" audio_{userId}_{sessionId}_{trackId}.webm - Audio-only file\n") + fmt.Printf(" video_{userId}_{sessionId}_{trackId}.webm - Video-only file\n") + fmt.Printf(" muxed_{userId}_{sessionId}_{trackId}.webm - Combined audio+video file\n") +} + +func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, logger *getstream.DefaultLogger) error { + // Step 1: Extract audio tracks with gap filling + logger.Info("Step 1/3: Extracting audio tracks with gap filling...") + err := extractTracks(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, "audio", "both", true, logger) + if err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } + + // Step 2: Extract video tracks with gap filling + logger.Info("Step 2/3: Extracting video tracks with gap filling...") + err = extractTracks(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, "video", "both", true, logger) + if err != nil { + return fmt.Errorf("failed to extract video tracks: %w", err) + } + + // Step 3: Mux audio and video files (keeping originals) + logger.Info("Step 3/3: Muxing audio and video tracks...") + err = muxAudioVideoTracksKeepOriginals(globalArgs, processAllArgs, logger) + if err != nil { + return fmt.Errorf("failed to mux audio and video tracks: %w", err) + } + + // Report final output + audioFiles, _ := filepath.Glob(filepath.Join(globalArgs.Output, "audio_*.webm")) + videoFiles, _ := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.webm")) + muxedFiles, _ := filepath.Glob(filepath.Join(globalArgs.Output, "muxed_*.webm")) + + logger.Info("Process-all completed successfully:") + logger.Info(" - %d audio files", len(audioFiles)) + logger.Info(" - %d video files", len(videoFiles)) + logger.Info(" - %d muxed files", len(muxedFiles)) + + fmt.Printf("\n✅ Generated files in %s:\n", globalArgs.Output) + for _, file := range audioFiles { + fmt.Printf(" 🎵 %s\n", filepath.Base(file)) + } + for _, file := range videoFiles { + fmt.Printf(" 🎬 %s\n", filepath.Base(file)) + } + for _, file := range muxedFiles { + fmt.Printf(" 🎞️ %s\n", filepath.Base(file)) + } + + return nil +} + +// muxAudioVideoTracksKeepOriginals is like muxAudioVideoTracks but keeps the original audio/video files +func muxAudioVideoTracksKeepOriginals(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, logger *getstream.DefaultLogger) error { + // Find the generated audio and video WebM files + audioFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "audio_*.webm")) + if err != nil { + return fmt.Errorf("failed to find audio files: %w", err) + } + if len(audioFiles) == 0 { + return fmt.Errorf("no audio files generated") + } + + videoFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.webm")) + if err != nil { + return fmt.Errorf("failed to find video files: %w", err) + } + if len(videoFiles) == 0 { + return fmt.Errorf("no video files generated") + } + + logger.Info("Found %d audio files and %d video files to mux", len(audioFiles), len(videoFiles)) + + // Mux each audio/video pair + for i, audioFile := range audioFiles { + if i >= len(videoFiles) { + logger.Warn("No matching video file for audio file %s", audioFile) + continue + } + videoFile := videoFiles[i] + + // Calculate sync offset using segment timing information + offset, err := calculateSyncOffsetFromFiles(globalArgs.InputFile, audioFile, videoFile, logger) + if err != nil { + logger.Warn("Failed to calculate sync offset, using 0: %v", err) + offset = 0 + } + + // Generate output filename + outputFile := generateMuxedFilename(audioFile, videoFile, globalArgs.Output) + + // Mux the audio and video files + logger.Info("Muxing %s + %s → %s (offset: %dms)", + filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) + + err = webm.MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) + if err != nil { + logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) + continue + } + + logger.Info("Successfully created muxed file: %s", outputFile) + // NOTE: Unlike muxAudioVideoTracks, we DON'T clean up the individual files here + } + + return nil +} diff --git a/cmd/raw-recording-tools/raw-recorder/raw.go b/cmd/raw-recording-tools/raw-recorder/raw.go new file mode 100644 index 0000000..7bbd828 --- /dev/null +++ b/cmd/raw-recording-tools/raw-recorder/raw.go @@ -0,0 +1,47 @@ +package rawrecorder + +import "time" + +type SessionTimingMetadata struct { + CallID string `json:"call_id"` + CallSessionID string `json:"call_session_id"` + CallStartTime time.Time `json:"call_start_time"` + CallEndTime time.Time `json:"call_end_time,omitempty"` + ParticipantID string `json:"participant_id"` + UserSessionID string `json:"user_session_id"` + Segments struct { + Audio []SegmentMetadata `json:"audio"` + Video []SegmentMetadata `json:"video"` + } `json:"segments"` +} + +type SegmentMetadata struct { + // Global information + BaseFilename string `json:"base_filename"` + StartTimestamp int64 `json:"start_timestamp"` + EndTimestamp int64 `json:"end_timestamp,omitempty"` + StartOffsetMs int64 `json:"start_offset_ms"` + EndOffsetMs int64 `json:"end_offset_ms,omitempty"` + + // Packet timing information + FirstRtpRtpTimestamp uint32 `json:"first_rtp_rtp_timestamp"` + FirstRtpUnixTimestamp int64 `json:"first_rtp_unix_timestamp"` + LastRtpRtpTimestamp uint32 `json:"last_rtp_rtp_timestamp,omitempty"` + LastRtpUnixTimestamp int64 `json:"last_rtp_unix_timestamp,omitempty"` + FirstRtcpRtpTimestamp uint32 `json:"first_rtcp_rtp_timestamp,omitempty"` + FirstRtcpNtpTimestamp int64 `json:"first_rtcp_ntp_timestamp,omitempty"` + LastRtcpRtpTimestamp uint32 `json:"last_rtcp_rtp_timestamp,omitempty"` + LastRtcpNtpTimestamp int64 `json:"last_rtcp_ntp_timestamp,omitempty"` + + // Segment duration information + RtpDurationMs int64 `json:"rtp_duration_ms,omitempty"` + UnixDurationMs int64 `json:"unix_duration_ms,omitempty"` + DriftMs int64 `json:"drift_ms,omitempty"` + DriftRatePercent float64 `json:"drift_rate_percent,omitempty"` + + // Track information + SSRC uint32 `json:"ssrc"` + Codec string `json:"codec"` + TrackID string `json:"track_id"` + TrackType string `json:"track_type"` +} diff --git a/cmd/raw-recording-tools/rawsdputil/sdp_writer.go b/cmd/raw-recording-tools/rawsdputil/sdp_writer.go new file mode 100644 index 0000000..8aa2502 --- /dev/null +++ b/cmd/raw-recording-tools/rawsdputil/sdp_writer.go @@ -0,0 +1,55 @@ +package rawsdputil + +import ( + "fmt" + "os" + "strings" + + "github.com/pion/webrtc/v4" +) + +func ReadSDP(sdpFilePath string) (string, error) { + content, err := os.ReadFile(sdpFilePath) + if err != nil { + return "", fmt.Errorf("failed to read SDP file %s: %w", sdpFilePath, err) + } + return string(content), nil +} + +func ReplaceSDP(sdpContent string, port int) string { + lines := strings.Split(sdpContent, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "m=") { + // Parse the m= line: m= RTP/AVP + parts := strings.Fields(line) + if len(parts) >= 4 { + // Replace the port (second field) + parts[1] = fmt.Sprintf("%d", port) + lines[i] = strings.Join(parts, " ") + break + } + } + } + return strings.Join(lines, "\n") +} + +func MimeType(sdp string) (string, error) { + upper := strings.ToUpper(sdp) + if strings.Contains(upper, "VP9") { + return webrtc.MimeTypeVP9, nil + } + if strings.Contains(upper, "VP8") { + return webrtc.MimeTypeVP8, nil + } + if strings.Contains(upper, "AV1") { + return webrtc.MimeTypeAV1, nil + } + if strings.Contains(upper, "OPUS") { + return webrtc.MimeTypeOpus, nil + } + if strings.Contains(upper, "H264") { + return webrtc.MimeTypeH264, nil + } + + return "", fmt.Errorf("MimeType should be OPUS, VP8, VP9, AV1, H264") +} diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go new file mode 100644 index 0000000..1894276 --- /dev/null +++ b/cmd/raw-recording-tools/webm/converter.go @@ -0,0 +1,236 @@ +package webm + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/rawsdputil" + "github.com/pion/rtp" + "github.com/pion/rtp/codecs" + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media/rtpdump" + "github.com/pion/webrtc/v4/pkg/media/samplebuilder" +) + +const audioMaxLate = 100 // 2sec +const videoMaxLate = 500 // 2sec + +type RTPDump2WebMConverter struct { + logger *getstream.DefaultLogger + reader *rtpdump.Reader + recorder WebmRecorder + sampleBuilder *samplebuilder.SampleBuilder + + lastPkt *rtp.Packet + inserted uint16 +} + +type WebmRecorder interface { + OnRTP(pkt *rtp.Packet) error + PushRtpBuf(payload []byte) error + Close() error +} + +func newRTPDump2WebMConverter(logger *getstream.DefaultLogger) *RTPDump2WebMConverter { + return &RTPDump2WebMConverter{ + logger: logger, + } +} + +func ConvertDirectory(directory string, logger *getstream.DefaultLogger) error { + var rtpdumpFiles []string + + // Walk through directory to find .rtpdump files + err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".rtpdump") { + rtpdumpFiles = append(rtpdumpFiles, path) + } + + return nil + }) + if err != nil { + return err + } + + for _, rtpdumpFile := range rtpdumpFiles { + c := newRTPDump2WebMConverter(logger) + if err := c.ConvertFile(rtpdumpFile); err != nil { + c.logger.Error("Failed to convert %s: %v", rtpdumpFile, err) + continue + } + } + + return nil +} + +func (c *RTPDump2WebMConverter) ConvertFile(inputFile string) error { + c.logger.Info("Converting %s", inputFile) + + // Parse the RTP dump file + // Open the file + file, err := os.Open(inputFile) + if err != nil { + return fmt.Errorf("failed to open rtpdump file: %w", err) + } + defer file.Close() + + // Create standardized reader + reader, _, _ := rtpdump.NewReader(file) + c.reader = reader + + sdpContent, _ := rawsdputil.ReadSDP(strings.Replace(inputFile, ".rtpdump", ".sdp", 1)) + mType, _ := rawsdputil.MimeType(sdpContent) + + var recorder WebmRecorder + switch mType { + case webrtc.MimeTypeAV1, webrtc.MimeTypeVP9: + recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, ".rtpdump", ".webm", 1), sdpContent, c.logger) + default: + recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, ".rtpdump", ".webm", 1), sdpContent, c.logger) + } + if err != nil { + return fmt.Errorf("failed to create WebM recorder: %w", err) + } + defer recorder.Close() + + c.recorder = recorder + + options := samplebuilder.WithPacketReleaseHandler(func(pkt *rtp.Packet) { + pkt.SequenceNumber += c.inserted + + if c.lastPkt != nil { + if mType == webrtc.MimeTypeOpus { + tsDiff := pkt.Timestamp - c.lastPkt.Timestamp + if tsDiff > 960 { + + c.logger.Debug("Gap detected %v: %v", pkt, err) + + var toAdd uint16 + + // DTX detected, we need to insert packet + if uint32(pkt.SequenceNumber-c.lastPkt.SequenceNumber)*960 != tsDiff { + toAdd = uint16(tsDiff/960) - (pkt.SequenceNumber - c.lastPkt.SequenceNumber) + } + + // c.logger.Debugf("Inserting %d packets Previous inserting %s", toAdd, c.inserted) + + for i := 1; i <= int(toAdd); i++ { + ins := c.lastPkt.Clone() + ins.Payload = ins.Payload[:1] + ins.SequenceNumber += uint16(i) + ins.Timestamp += uint32(i) * 960 + + c.logger.Debug("Writing inserted Packet %v", ins) + e := c.recorder.OnRTP(ins) + if e != nil { + c.logger.Warn("Failed to record RTP packet %v: %v", pkt, err) + } + + // Need to compute new packet + } + c.inserted += toAdd + pkt.SequenceNumber += toAdd + // c.logger.Debugf("Inserted %d packets Previous inserting %s", toAdd, c.inserted) + } + } + + if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { + c.logger.Warn("Missing Detected Packet %v - %v", pkt, c.lastPkt) + } + } + + c.lastPkt = pkt + + c.logger.Debug("Writing real Packet %v", pkt) + e := c.recorder.OnRTP(pkt) + if e != nil { + c.logger.Warn("Failed to record RTP packet %v: %v", pkt, err) + } + }) + + // Initialize samplebuilder based on codec type + var sampleBuilder *samplebuilder.SampleBuilder + switch mType { + case webrtc.MimeTypeOpus: + sampleBuilder = samplebuilder.New(audioMaxLate, &codecs.OpusPacket{}, 48000, options) + case webrtc.MimeTypeVP8: + sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP8Packet{}, 90000, options) + case webrtc.MimeTypeVP9: + sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, options) + case webrtc.MimeTypeH264: + sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, options) + case webrtc.MimeTypeAV1: + sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, options) + default: + return fmt.Errorf("unsupported codec type: %s", mType) + } + c.sampleBuilder = sampleBuilder + + time.Sleep(1 * time.Second) + + // Convert and feed RTP packets + return c.feedPackets(reader) +} + +func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { + startTime := time.Now() + + for i := 0; ; i++ { + packet, err := reader.Next() + if err != nil { + if errors.Is(err, io.EOF) { + break + } else { + return err + } + } + + if packet.IsRTCP { + continue + } + + // Unmarshal the RTP packet from the raw payload + + if c.sampleBuilder == nil { + _ = c.recorder.PushRtpBuf(packet.Payload) + } else + // Unmarshal the RTP packet from the raw payload + { + rtpPacket := &rtp.Packet{} + if err := rtpPacket.Unmarshal(packet.Payload); err != nil { + c.logger.Warn("Failed to unmarshal RTP packet %d: %v", i, err) + continue + } + + c.sampleBuilder.Push(rtpPacket) + } + // Push packet to samplebuilder for reordering + + // Log progress + if i%100 == 0 && i > 0 { + c.logger.Info("Processed %d packets", i) + } + } + + if c.sampleBuilder != nil { + c.sampleBuilder.Flush() + } + + duration := time.Since(startTime) + c.logger.Info("Finished feeding packets in %v", duration) + + // Allow some time for the recorder to finalize + time.Sleep(2 * time.Second) + + return nil +} diff --git a/cmd/raw-recording-tools/webm/cursor_gstreamer_webm_recorder.go b/cmd/raw-recording-tools/webm/cursor_gstreamer_webm_recorder.go new file mode 100644 index 0000000..277324b --- /dev/null +++ b/cmd/raw-recording-tools/webm/cursor_gstreamer_webm_recorder.go @@ -0,0 +1,749 @@ +package webm + +import ( + "context" + "fmt" + "math/rand" + "net" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/GetStream/getstream-go/v3" + "github.com/pion/rtp" +) + +type CursorGstreamerWebmRecorder struct { + logger *getstream.DefaultLogger + outputPath string + conn *net.UDPConn + gstreamerCmd *exec.Cmd + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + port int + sdpFile *os.File + finalOutputPath string // Path for post-processed file with duration + tempOutputPath string // Path for temporary file before post-processing +} + +func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getstream.DefaultLogger) (*CursorGstreamerWebmRecorder, error) { + ctx, cancel := context.WithCancel(context.Background()) + + r := &CursorGstreamerWebmRecorder{ + logger: logger, + outputPath: outputPath, + ctx: ctx, + cancel: cancel, + } + + r.logger.Info("SDP created for GStreamer\n%s\n", sdpContent) + + // Set up UDP connections + r.port = rand.Intn(10000) + 10000 + if err := r.setupConnections(r.port); err != nil { + cancel() + return nil, err + } + + // Start GStreamer with codec detection + if err := r.startGStreamer(sdpContent, outputPath); err != nil { + cancel() + return nil, err + } + + return r, nil +} + +func (r *CursorGstreamerWebmRecorder) setupConnections(port int) error { + // Setup UDP connection + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:"+strconv.Itoa(port)) + if err != nil { + return err + } + conn, err := net.DialUDP("udp", nil, addr) + if err != nil { + return err + } + r.conn = conn + + return nil +} + +func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath string) error { + // Write SDP to a temporary file + sdpFile, err := os.CreateTemp("", "cursor_gstreamer_webm_*.sdp") + if err != nil { + return err + } + r.sdpFile = sdpFile + + if _, err := sdpFile.WriteString(sdpContent); err != nil { + sdpFile.Close() + return err + } + sdpFile.Close() + + // Determine codec from SDP content and build GStreamer arguments + isVP9 := strings.Contains(strings.ToUpper(sdpContent), "VP9") + isVP8 := strings.Contains(strings.ToUpper(sdpContent), "VP8") + isAV1 := strings.Contains(strings.ToUpper(sdpContent), "AV1") + isH264 := strings.Contains(strings.ToUpper(sdpContent), "H264") || strings.Contains(strings.ToUpper(sdpContent), "H.264") + isOpus := strings.Contains(strings.ToUpper(sdpContent), "OPUS") + + // Start with common GStreamer arguments optimized for RTP dump replay + args := []string{ + "--gst-debug-level=3", + "--gst-debug=udpsrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5", + } + + // Add UDP source with timestamp handling for RTP dump replay + args = append(args, + "-e", + "udpsrc", + fmt.Sprintf("port=%d", r.port), + "buffer-size=10000000", + "!", + ) + + // Build pipeline based on codec with simplified RTP timestamp handling for dump replay + // + // Simplified approach for RTP dump replay: + // - rtpjitterbuffer: Basic packet reordering with minimal interference + // - latency=0: No artificial latency, process packets as they come + // - mode=none: Don't override timing, let depayloaders handle it + // - do-retransmission=false: No retransmission for dump replay + // - Remove identity sync to avoid timing conflicts + // + // This approach focuses on preserving original RTP timestamps without + // artificial buffering that can interfere with dump replay timing. + if isH264 { + r.logger.Info("Detected H.264 codec, building H.264 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=H264,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtph264depay", "!", + "h264parse", "!", + "mp4mux", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if isVP9 { + r.logger.Info("Detected VP9 codec, building VP9 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=VP9,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=200", + "mode=none", + "do-retransmission=false", "!", + "rtpvp9depay", "!", + "vp9parse", "!", + "webmmux", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if isVP8 { + r.logger.Info("Detected VP8 codec, building VP8 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtpvp8depay", "!", + "vp8parse", "!", + "webmmux", "writing-app=GStreamer", "streamable=false", "min-index-interval=2000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if isAV1 { + r.logger.Info("Detected AV1 codec, building AV1 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=AV1,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtpav1depay", "!", + "av1parse", "!", + "webmmux", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if isOpus { + r.logger.Info("Detected Opus codec, building Opus pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=audio,encoding-name=OPUS,clock-rate=48000,payload=111", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtpopusdepay", "!", + "opusparse", "!", + "webmmux", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else { + // Default to VP8 if codec is not detected + r.logger.Info("Unknown or no codec detected, defaulting to VP8 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtpvp8depay", "!", + "vp8parse", "!", + "webmmux", "writing-app=GStreamer", "streamable=false", "min-index-interval=2000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } + + r.logger.Info("GStreamer pipeline: %s", strings.Join(args[3:], " ")) // Skip debug args for display + + r.gstreamerCmd = exec.Command("gst-launch-1.0", args...) + + // Redirect output for debugging + r.gstreamerCmd.Stdout = os.Stdout + r.gstreamerCmd.Stderr = os.Stderr + + // Start GStreamer process + if err := r.gstreamerCmd.Start(); err != nil { + return err + } + + r.logger.Info("GStreamer pipeline started with PID: %d", r.gstreamerCmd.Process.Pid) + + // Monitor the process in a goroutine + go func() { + if err := r.gstreamerCmd.Wait(); err != nil { + r.logger.Error("GStreamer process exited with error: %v", err) + } else { + r.logger.Info("GStreamer process exited normally") + } + }() + + return nil +} + +func (r *CursorGstreamerWebmRecorder) OnRTP(packet *rtp.Packet) error { + // Marshal RTP packet + buf, err := packet.Marshal() + if err != nil { + return err + } + + return r.PushRtpBuf(buf) +} + +func (r *CursorGstreamerWebmRecorder) PushRtpBuf(buf []byte) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Send RTP packet over UDP to GStreamer udpsrc + if r.conn != nil { + _, err := r.conn.Write(buf) + if err != nil { + // Log error but don't fail completely - some packet loss is acceptable + r.logger.Debug("Failed to write RTP packet: %v", err) + } + } + return nil +} + +func (r *CursorGstreamerWebmRecorder) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + + r.logger.Info("Closing GStreamer WebM recorder...") + + r.logger.Info("Closing GStreamer WebM recorder2222...") + + // Cancel context to stop background goroutines + if r.cancel != nil { + r.cancel() + } + + // Close UDP connection with goodbye message + if r.conn != nil { + r.logger.Info("Closing UDP connection...") + + // Send RTCP Goodbye packet to signal end of stream + //buf, _ := rtcp.Goodbye{ + // Sources: []uint32{1}, // fixed SSRC is ok + // Reason: "bye", + //}.Marshal() + //_, _ = r.conn.Write(buf) + + r.logger.Info("Goodbye sent") + + // Give some time for the goodbye packet to be processed + time.Sleep(1 * time.Second) + + _ = r.conn.Close() + r.conn = nil + r.logger.Info("UDP connection closed") + } + + // Gracefully stop GStreamer + if r.gstreamerCmd != nil && r.gstreamerCmd.Process != nil { + r.logger.Info("Stopping GStreamer process...") + + // Send EOS (End of Stream) signal to GStreamer + // GStreamer handles SIGINT gracefully and will finish writing the file + if err := r.gstreamerCmd.Process.Signal(os.Interrupt); err != nil { + r.logger.Error("Failed to send SIGINT to GStreamer: %v", err) + // If interrupt fails, force kill + r.gstreamerCmd.Process.Kill() + } else { + r.logger.Info("Sent SIGINT to GStreamer, waiting for graceful exit...") + + // Wait for graceful exit with timeout + done := make(chan error, 1) + go func() { + done <- r.gstreamerCmd.Wait() + }() + + select { + case <-time.After(15 * time.Second): + r.logger.Info("GStreamer exit timeout, force killing...") + // Timeout, force kill + r.gstreamerCmd.Process.Kill() + <-done // Wait for the kill to complete + case err := <-done: + if err != nil { + r.logger.Info("GStreamer exited with error: %v", err) + } else { + r.logger.Info("GStreamer exited gracefully") + } + } + } + } + + // Clean up temporary SDP file + if r.sdpFile != nil { + os.Remove(r.sdpFile.Name()) + r.sdpFile = nil + } + + // Post-process WebM to fix duration metadata if needed + if r.tempOutputPath != "" && r.finalOutputPath != "" { + r.logger.Info("Starting WebM duration post-processing...") + + // Choose post-processing approach based on temp file extension + if strings.HasSuffix(r.tempOutputPath, ".gst") { + // Simple approach for .gst files + if err := r.simpleWebMDurationFix(); err != nil { + r.logger.Error("Simple WebM duration fix failed: %v", err) + } + } else if strings.HasSuffix(r.tempOutputPath, ".direct") { + // Simple approach for .direct files (direct timing with post-processing) + if err := r.simpleWebMDurationFix(); err != nil { + r.logger.Error("Direct WebM duration fix failed: %v", err) + } + } else if strings.HasSuffix(r.tempOutputPath, ".minimal") { + // Simple approach for .minimal files (minimal timing with post-processing) + if err := r.simpleWebMDurationFix(); err != nil { + r.logger.Error("Minimal WebM duration fix failed: %v", err) + } + } else { + // Enhanced approach for .temp files + if err := r.postProcessWebMDuration(); err != nil { + r.logger.Error("Enhanced WebM post-processing failed: %v", err) + } + } + } + + r.logger.Info("GStreamer WebM recorder closed") + return nil +} + +// GetOutputPath returns the output file path (for compatibility) +func (r *CursorGstreamerWebmRecorder) GetOutputPath() string { + // Return final output path if post-processing is enabled, otherwise return original + if r.finalOutputPath != "" { + return r.finalOutputPath + } + return r.outputPath +} + +// IsRecording returns true if the recorder is currently active +func (r *CursorGstreamerWebmRecorder) IsRecording() bool { + r.mu.Lock() + defer r.mu.Unlock() + + return r.gstreamerCmd != nil && r.gstreamerCmd.Process != nil +} + +// BufferConfig holds the configuration for RTP jitter buffer settings +type BufferConfig struct { + Latency int // Buffer latency in milliseconds + MaxMisorderTime int // Maximum time to wait for out-of-order packets (ms) + MaxDropoutTime int // Maximum time before considering packet lost (ms) + RtxDelay int // Retransmission delay in milliseconds + DoLost bool // Generate lost packet events + DropOnLatency bool // Drop packets that arrive too late +} + +// DefaultBufferConfig returns optimized settings for RTP dump replay with reordering +func DefaultBufferConfig() BufferConfig { + return BufferConfig{ + Latency: 500, // 500ms buffer for reordering + MaxMisorderTime: 2000, // Wait up to 2 seconds for missing packets + MaxDropoutTime: 60000, // Consider packets lost after 60 seconds + RtxDelay: 40, // Request retransmission after 40ms + DoLost: true, // Generate lost packet events for debugging + DropOnLatency: false, // Don't drop packets, buffer them for proper ordering + } +} + +// RealtimeBufferConfig returns optimized settings for real-time streaming +func RealtimeBufferConfig() BufferConfig { + return BufferConfig{ + Latency: 100, // Lower latency for real-time + MaxMisorderTime: 500, // Shorter wait time + MaxDropoutTime: 5000, // Faster dropout detection + RtxDelay: 20, // Faster retransmission + DoLost: true, + DropOnLatency: true, // Drop late packets to maintain real-time performance + } +} + +// NewCursorGstreamerWebmRecorderNoJitterBuffer creates a recorder without jitter buffer for direct timing +// This bypasses all buffering and lets the depayloaders handle RTP timestamps directly. +func NewCursorGstreamerWebmRecorderNoJitterBuffer(outputPath, sdp string, port int) (*CursorGstreamerWebmRecorder, error) { + ctx, cancel := context.WithCancel(context.Background()) + + r := &CursorGstreamerWebmRecorder{ + outputPath: outputPath, + ctx: ctx, + cancel: cancel, + port: port, + } + + r.logger.Info("SDP created for GStreamer (no jitter buffer)\n%s\n", sdp) + + // Set up UDP connections + if err := r.setupConnections(port); err != nil { + cancel() + return nil, err + } + + // Start GStreamer without jitter buffer + if err := r.startGStreamerNoJitterBuffer(sdp, outputPath); err != nil { + cancel() + return nil, err + } + + return r, nil +} + +func (r *CursorGstreamerWebmRecorder) startGStreamerNoJitterBuffer(sdpContent, outputFilePath string) error { + // Write SDP to a temporary file + sdpFile, err := os.CreateTemp("", "cursor_gstreamer_webm_*.sdp") + if err != nil { + return err + } + r.sdpFile = sdpFile + + if _, err := sdpFile.WriteString(sdpContent); err != nil { + sdpFile.Close() + return err + } + sdpFile.Close() + + // Determine codec from SDP content + isVP9 := strings.Contains(strings.ToUpper(sdpContent), "VP9") + isVP8 := strings.Contains(strings.ToUpper(sdpContent), "VP8") + isAV1 := strings.Contains(strings.ToUpper(sdpContent), "AV1") + isH264 := strings.Contains(strings.ToUpper(sdpContent), "H264") || strings.Contains(strings.ToUpper(sdpContent), "H.264") + + // Start with common GStreamer arguments + args := []string{ + "--gst-debug-level=3", + "--gst-debug=udpsrc:5,rtp*:5,webm*:5", + "-e", // Enable EOS handling + } + + // Add UDP source with timing preservation for direct recording + args = append(args, + "udpsrc", + fmt.Sprintf("port=%d", r.port), + "buffer-size=10000000", + "!", + "queue", + "max-size-buffers=1000", + "max-size-time=10000000000", // 10 seconds of buffering + "!", + ) + + // Build pipeline based on codec WITHOUT jitter buffer + // Use provided output file path directly + if isH264 { + r.logger.Info("Detected H.264 codec, building direct H.264 pipeline...") + args = append(args, + "application/x-rtp,media=video,encoding-name=H264,clock-rate=90000", "!", + "rtph264depay", "!", + "h264parse", "!", + "identity", "sync=true", "!", // Force timing synchronization + "mp4mux", "faststart=true", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if isVP9 { + r.logger.Info("Detected VP9 codec, building direct VP9 pipeline...") + args = append(args, + "application/x-rtp,media=video,encoding-name=VP9,clock-rate=90000", "!", + "rtpvp9depay", "!", + "vp9parse", "!", + "identity", "sync=true", "!", // Force timing synchronization + "webmmux", "writing-app=GStreamer-Direct", "streamable=false", "min-index-interval=1000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if isVP8 { + r.logger.Info("Detected VP8 codec, building direct VP8 pipeline...") + args = append(args, + "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", + "rtpvp8depay", "!", + "vp8parse", "!", + "identity", "sync=true", "!", // Force timing synchronization + "webmmux", "writing-app=GStreamer-Direct", "streamable=false", "min-index-interval=1000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if isAV1 { + r.logger.Info("Detected AV1 codec, building direct AV1 pipeline...") + args = append(args, + "application/x-rtp,media=video,encoding-name=AV1,clock-rate=90000", "!", + "rtpav1depay", "!", + "av1parse", "!", + "identity", "sync=true", "!", // Force timing synchronization + "webmmux", "writing-app=GStreamer-Direct", "streamable=false", "min-index-interval=1000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else { + // Default to VP8 if codec is not detected + r.logger.Info("Unknown or no codec detected, defaulting to direct VP8 pipeline...") + args = append(args, + "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", + "rtpvp8depay", "!", + "vp8parse", "!", + "identity", "sync=true", "!", // Force timing synchronization + "webmmux", "writing-app=GStreamer-Direct", "streamable=false", "min-index-interval=1000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } + + r.logger.Info("GStreamer direct pipeline: %s", strings.Join(args[3:], " ")) + + r.gstreamerCmd = exec.CommandContext(r.ctx, "gst-launch-1.0", args...) + + // Redirect output for debugging + r.gstreamerCmd.Stdout = os.Stdout + r.gstreamerCmd.Stderr = os.Stderr + + // Start GStreamer process + if err := r.gstreamerCmd.Start(); err != nil { + return err + } + + r.logger.Info("GStreamer direct pipeline started with PID: %d", r.gstreamerCmd.Process.Pid) + + // Monitor the process in a goroutine + go func() { + if err := r.gstreamerCmd.Wait(); err != nil { + r.logger.Error("GStreamer process exited with error: %v", err) + } else { + r.logger.Info("GStreamer process exited normally") + } + }() + + return nil +} + +// postProcessWebMDuration fixes WebM duration metadata using FFmpeg +// This ensures the WebM file has proper duration information for browser playback +func (r *CursorGstreamerWebmRecorder) postProcessWebMDuration() error { + if r.tempOutputPath == "" || r.finalOutputPath == "" { + // No post-processing needed + return nil + } + + r.logger.Info("Post-processing WebM duration metadata...") + + // Check if temp file exists + if _, err := os.Stat(r.tempOutputPath); os.IsNotExist(err) { + r.logger.Warn("Temp file does not exist for post-processing: %s", r.tempOutputPath) + return nil + } + + // First get the duration from the file + durationCmd := exec.Command("ffprobe", + "-v", "quiet", + "-show_entries", "format=duration", + "-of", "csv=p=0", + r.tempOutputPath, + ) + + durationOutput, err := durationCmd.Output() + if err != nil { + r.logger.Error("Failed to get duration with ffprobe: %v", err) + } + + duration := strings.TrimSpace(string(durationOutput)) + r.logger.Info("Detected file duration: %s seconds", duration) + + // Use more aggressive FFmpeg approach to ensure duration is written to WebM header + cmd := exec.Command("ffmpeg", + "-i", r.tempOutputPath, + "-c:v", "copy", // Copy video stream + "-avoid_negative_ts", "make_zero", + "-fflags", "+genpts", // Generate presentation timestamps + "-f", "webm", // Force WebM format + "-write_crc32", "0", // Disable CRC for compatibility + "-cluster_size_limit", "2097152", // 2MB clusters for better seeking + "-cluster_time_limit", "5000", // 5 second clusters + "-y", // Overwrite output file + r.finalOutputPath, + ) + + // Set up logging + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + r.logger.Info("Running enhanced FFmpeg post-processing: %s", strings.Join(cmd.Args, " ")) + + // Run FFmpeg + if err := cmd.Run(); err != nil { + r.logger.Error("Enhanced FFmpeg post-processing failed: %v", err) + + // Try a simpler WebM remux approach + r.logger.Info("Trying fallback WebM remux...") + fallbackCmd := exec.Command("ffmpeg", + "-i", r.tempOutputPath, + "-c", "copy", + "-f", "webm", + "-y", + r.finalOutputPath, + ) + + fallbackCmd.Stdout = os.Stdout + fallbackCmd.Stderr = os.Stderr + + if fallbackErr := fallbackCmd.Run(); fallbackErr != nil { + r.logger.Error("Fallback FFmpeg also failed: %v", fallbackErr) + // Last resort - just move the file + return os.Rename(r.tempOutputPath, r.finalOutputPath) + } + } + + // Remove temporary file + os.Remove(r.tempOutputPath) + + r.logger.Info("WebM duration metadata fixed successfully") + return nil +} + +// NewCursorGstreamerWebmRecorderWithDurationFix creates a recorder that automatically fixes WebM duration +// This version writes to a temporary file and post-processes it to ensure proper duration metadata +func NewCursorGstreamerWebmRecorderWithDurationFix(outputPath, sdp string, port int) (*CursorGstreamerWebmRecorder, error) { + ctx, cancel := context.WithCancel(context.Background()) + + r := &CursorGstreamerWebmRecorder{ + outputPath: outputPath + ".temp", // Write to temp file first + ctx: ctx, + cancel: cancel, + port: port, + finalOutputPath: outputPath, + tempOutputPath: outputPath + ".temp", + } + + r.logger.Info("SDP created for GStreamer with duration fix\n%s\n", sdp) + + // Set up UDP connections + if err := r.setupConnections(port); err != nil { + cancel() + return nil, err + } + + // Start GStreamer + if err := r.startGStreamer(sdp, r.tempOutputPath); err != nil { + cancel() + return nil, err + } + + return r, nil +} + +// NewCursorGstreamerWebmRecorderSimpleDuration creates a recorder with the simplest duration fix +// This version uses a minimal FFmpeg remux specifically for WebM duration metadata +func NewCursorGstreamerWebmRecorderSimpleDuration(outputPath, sdp string, port int) (*CursorGstreamerWebmRecorder, error) { + ctx, cancel := context.WithCancel(context.Background()) + + r := &CursorGstreamerWebmRecorder{ + outputPath: outputPath + ".gst", + ctx: ctx, + cancel: cancel, + port: port, + finalOutputPath: outputPath, + tempOutputPath: outputPath + ".gst", + } + + r.logger.Info("Creating simple duration fix recorder\n%s\n", sdp) + + // Set up UDP connections + if err := r.setupConnections(port); err != nil { + cancel() + return nil, err + } + + // Start GStreamer + if err := r.startGStreamer(sdp, r.tempOutputPath); err != nil { + cancel() + return nil, err + } + + return r, nil +} + +// simpleWebMDurationFix performs a minimal FFmpeg remux to fix duration for browsers +func (r *CursorGstreamerWebmRecorder) simpleWebMDurationFix() error { + if r.tempOutputPath == "" || r.finalOutputPath == "" { + return nil + } + + r.logger.Info("Applying simple WebM duration fix...") + + // Check if temp file exists + if _, err := os.Stat(r.tempOutputPath); os.IsNotExist(err) { + r.logger.Warn("Source file does not exist: %s", r.tempOutputPath) + return nil + } + + // Simple FFmpeg remux that should preserve duration for browser playback + cmd := exec.Command("ffmpeg", + "-i", r.tempOutputPath, + "-c", "copy", // Copy all streams + "-f", "webm", // Ensure WebM format + "-avoid_negative_ts", "make_zero", + "-y", + r.finalOutputPath, + ) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + r.logger.Info("Running simple WebM fix: %s", strings.Join(cmd.Args, " ")) + + if err := cmd.Run(); err != nil { + r.logger.Error("Simple WebM fix failed: %v", err) + // Fall back to just moving the file + return os.Rename(r.tempOutputPath, r.finalOutputPath) + } + + // Remove temp file + os.Remove(r.tempOutputPath) + + r.logger.Info("Simple WebM duration fix completed") + return nil +} diff --git a/cmd/raw-recording-tools/webm/cursor_webm_recorder.go b/cmd/raw-recording-tools/webm/cursor_webm_recorder.go new file mode 100644 index 0000000..95dc53e --- /dev/null +++ b/cmd/raw-recording-tools/webm/cursor_webm_recorder.go @@ -0,0 +1,252 @@ +package webm + +import ( + "context" + "fmt" + "io" + "math/rand" + "net" + "os" + "os/exec" + "strconv" + "sync" + "time" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/rawsdputil" + "github.com/pion/rtcp" + "github.com/pion/rtp" +) + +type CursorWebmRecorder struct { + logger *getstream.DefaultLogger + outputPath string + conn *net.UDPConn + ffmpegCmd *exec.Cmd + stdin io.WriteCloser + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc +} + +func NewCursorWebmRecorder(outputPath, sdpContent string, logger *getstream.DefaultLogger) (*CursorWebmRecorder, error) { + ctx, cancel := context.WithCancel(context.Background()) + + r := &CursorWebmRecorder{ + logger: logger, + outputPath: outputPath, + ctx: ctx, + cancel: cancel, + } + + r.logger.Info("Sdp created \n%s\n", sdpContent) + + // Set up UDP connections + port := rand.Intn(10000) + 10000 + if err := r.setupConnections(port); err != nil { + cancel() + return nil, err + } + + // Start FFmpeg with codec detection + if err := r.startFFmpeg(outputPath, sdpContent, port); err != nil { + cancel() + return nil, err + } + + return r, nil +} + +func (r *CursorWebmRecorder) setupConnections(port int) error { + // Setup connection + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:"+strconv.Itoa(port)) + if err != nil { + return err + } + conn, err := net.DialUDP("udp", nil, addr) + if err != nil { + return err + } + r.conn = conn + + return nil +} + +func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port int) error { + + // Write SDP to a temporary file + sdpFile, err := os.CreateTemp("", "cursor_webm_*.sdp") + if err != nil { + return err + } + + if _, err := sdpFile.WriteString(rawsdputil.ReplaceSDP(sdpContent, port)); err != nil { + sdpFile.Close() + return err + } + sdpFile.Close() + + // Build FFmpeg command with optimized settings for single track recording + args := []string{ + "-threads", "1", + "-loglevel", "debug", + "-protocol_whitelist", "file,udp,rtp", + "-buffer_size", "10000000", + "-max_delay", "150000", + "-reorder_queue_size", "130", + "-i", sdpFile.Name(), + } + + //switch strings.ToLower(mimeType) { + //case "audio/opus": + // // For other codecs, use direct copy + args = append(args, "-c", "copy") + //default: + // // For other codecs, use direct copy + // args = append(args, "-c", "copy") + //} + //if isVP9 { + // // For VP9, avoid direct copy and use re-encoding with error resilience + // // This works around FFmpeg's experimental VP9 RTP support issues + // r.logger.Info("Detected VP9 codec, applying workarounds...") + // args = append(args, + // "-c:v", "libvpx-vp9", + // // "-error_resilience", "aggressive", + // "-err_detect", "ignore_err", + // "-fflags", "+genpts+igndts", + // "-avoid_negative_ts", "make_zero", + // // VP9-specific quality settings to handle corrupted frames + // "-crf", "30", + // "-row-mt", "1", + // "-frame-parallel", "1", + // ) + //} else if strings.Contains(strings.ToUpper(sdpContent), "AV1") { + // args = append(args, + // "-c:v", "libaom-av1", + // "-cpu-used", "8", + // "-usage", "realtime", + // ) + //} else if strings.Contains(strings.ToUpper(sdpContent), "OPUS") { + // args = append(args, "-fflags", "+genpts", "-use_wallclock_as_timestamps", "0", "-c:a", "copy") + //} else { + // // For other codecs, use direct copy + // args = append(args, "-c", "copy") + //} + + args = append(args, + "-y", + outputFilePath, + ) + + r.ffmpegCmd = exec.Command("ffmpeg", args...) + + // Redirect output for debugging + r.ffmpegCmd.Stdout = os.Stdout + r.ffmpegCmd.Stderr = os.Stderr + + // Create stdin pipe to send commands to FFmpeg + //var err error + r.stdin, err = r.ffmpegCmd.StdinPipe() + if err != nil { + fmt.Println("Error creating stdin pipe:", err) + } + + // Start FFmpeg process + if err := r.ffmpegCmd.Start(); err != nil { + return err + } + + return nil +} + +func (r *CursorWebmRecorder) OnRTP(packet *rtp.Packet) error { + // Marshal RTP packet + buf, err := packet.Marshal() + if err != nil { + return err + } + + return r.PushRtpBuf(buf) +} + +func (r *CursorWebmRecorder) PushRtpBuf(buf []byte) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Send RTP packet over UDP + if r.conn != nil { + _, _ = r.conn.Write(buf) + //if err != nil { + // return err) + //} + // r.logger.Info("Wrote packet to %s - %v", r.conn.LocalAddr().String(), err) + } + return nil +} + +func (r *CursorWebmRecorder) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + + // Cancel context to stop background goroutines + if r.cancel != nil { + r.cancel() + } + + r.logger.Info("Closing UPD connection...") + + // Close UDP connection by sending arbitrary RtcpBye (Ffmpeg is no able to end correctly) + if r.conn != nil { + buf, _ := rtcp.Goodbye{ + Sources: []uint32{1}, // fixed ssrc is ok + Reason: "bye", + }.Marshal() + _, _ = r.conn.Write(buf) + _ = r.conn.Close() + r.conn = nil + } + + r.logger.Info("UDP Connection closed...") + + time.Sleep(5 * time.Second) + + r.logger.Info("After sleep...") + + // Gracefully stop FFmpeg + if r.ffmpegCmd != nil && r.ffmpegCmd.Process != nil { + + // ✅ Gracefully stop FFmpeg by sending 'q' to stdin + //fmt.Println("Sending 'q' to FFmpeg...") + //_, _ = r.stdin.Write([]byte("q\n")) + //r.stdin.Close() + + // Send interrupt signal to FFmpeg process + r.logger.Info("Sending SIGTERM...") + + //if err := r.ffmpegCmd.Process.Signal(os.Interrupt); err != nil { + // // If interrupt fails, force kill + // r.ffmpegCmd.Process.Kill() + //} else { + + r.logger.Info("Waiting for SIGTERM...") + + // Wait for graceful exit with timeout + done := make(chan error, 1) + go func() { + done <- r.ffmpegCmd.Wait() + }() + + select { + case <-time.After(10 * time.Second): + r.logger.Info("Wait timetout for SIGTERM...") + + // Timeout, force kill + r.ffmpegCmd.Process.Kill() + case <-done: + r.logger.Info("Process exited succesfully SIGTERM...") + // Process exited gracefully + } + } + + return nil +} diff --git a/cmd/raw-recording-tools/webm/helper.go b/cmd/raw-recording-tools/webm/helper.go new file mode 100644 index 0000000..51ffe2f --- /dev/null +++ b/cmd/raw-recording-tools/webm/helper.go @@ -0,0 +1,161 @@ +package webm + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/GetStream/getstream-go/v3" +) + +const TmpDir = "/tmp" + +func ConcatFile(outputPath string, files []string, logger *getstream.DefaultLogger) error { + // Write to a temporary file + concatFile, err := os.CreateTemp(TmpDir, "concat_*.txt") + if err != nil { + return err + } + defer func() { + concatFile.Close() + // _ = os.Remove(concatFile.Name()) + }() + + for _, file := range files { + if _, err := concatFile.WriteString(fmt.Sprintf("file '%s'\n", file)); err != nil { + return err + } + } + + args := []string{} + args = append(args, "-f", "concat") + args = append(args, "-safe", "0") + args = append(args, "-i", concatFile.Name()) + args = append(args, "-c", "copy") + args = append(args, outputPath) + return runFFMEPGCpmmand(args, logger) +} + +func MuxFiles(fileName string, audioFile string, videoFile string, offsetMs float64, logger *getstream.DefaultLogger) error { + args := []string{} + + // Apply offset using itsoffset + // If offset is positive (video ahead), delay audio + // If offset is negative (audio ahead), delay video + if offsetMs != 0 { + offsetSeconds := offsetMs / 1000.0 + + if offsetMs > 0 { + // Video is ahead, delay audio + args = append(args, "-itsoffset", fmt.Sprintf("%.3f", offsetSeconds)) + args = append(args, "-i", audioFile) + args = append(args, "-i", videoFile) + } else { + args = append(args, "-i", audioFile) + args = append(args, "-itsoffset", fmt.Sprintf("%.3f", -offsetSeconds)) + args = append(args, "-i", videoFile) + } + } else { + args = append(args, "-i", audioFile) + args = append(args, "-i", videoFile) + } + + args = append(args, "-map", "0:a") + args = append(args, "-map", "1:v") + args = append(args, "-c", "copy") + args = append(args, fileName) + + return runFFMEPGCpmmand(args, logger) +} + +func MixAudioFiles(fileName string, files map[string]int, logger *getstream.DefaultLogger) error { + var args []string + args = append(args, "ffmpeg") + + i := 0 + var filterParts []string + var mixParts []string + for file, offset := range files { + args = append(args, "-i", file) + + if offset > 0 { + // for stereo: offset|offset + label := fmt.Sprintf("a%d", i) + filterParts = append(filterParts, + fmt.Sprintf("[%d:a]adelay=%d|%d[%s]", i, offset, offset, label)) + mixParts = append(mixParts, fmt.Sprintf("[%s]", label)) + } else { + mixParts = append(mixParts, fmt.Sprintf("[%d:a]", i)) + } + } + + // Build amix filter + filter := strings.Join(filterParts, "; ") + if filter != "" { + filter += "; " + } + filter += strings.Join(mixParts, "") + + fmt.Sprintf("amix=inputs=%d:normalize=0", len(files)) + + args = append(args, "-filter_complex", fmt.Sprintf("\"%s\"", filter)) + args = append(args, "-c:a", "libopus") + args = append(args, "-b:a", "128k") + args = append(args, fileName) + + fmt.Println(strings.Join(args, " ")) + + return runFFMEPGCpmmand(args, logger) +} + +func GenerateSilence(fileName string, duration float64, logger *getstream.DefaultLogger) error { + args := []string{} + args = append(args, "-f", "lavfi") + args = append(args, "-t", fmt.Sprintf("%.3f", duration)) + args = append(args, "-i", "anullsrc=cl=stereo:r=48000") + args = append(args, "-c:a", "libopus") + args = append(args, "-b:a", "32k") + args = append(args, fileName) + + return runFFMEPGCpmmand(args, logger) +} + +func GenerateBlackVideo(fileName, mimeType string, duration float64, width, height, frameRate int, logger *getstream.DefaultLogger) error { + var codecLib string + switch strings.ToLower(mimeType) { + case "video/vp8": + codecLib = "libvpx-vp9" + case "video/vp9": + codecLib = "libvpx-vp9" + case "video/h264": + codecLib = "libh264" + case "video/av1": + codecLib = "libav1" + } + + args := []string{} + args = append(args, "-f", "lavfi") + args = append(args, "-t", fmt.Sprintf("%.3f", duration)) + args = append(args, "-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%d", width, height, frameRate)) + args = append(args, "-c:v", codecLib) + args = append(args, "-b:v", "1M") + args = append(args, fileName) + + return runFFMEPGCpmmand(args, logger) +} + +func runFFMEPGCpmmand(args []string, logger *getstream.DefaultLogger) error { + cmd := exec.Command("ffmpeg", args...) + + // Capture output for debugging + output, err := cmd.CombinedOutput() + if err != nil { + logger.Error("FFmpeg command failed: %v", err) + logger.Error("FFmpeg output: %s", string(output)) + return fmt.Errorf("ffmpeg command failed: %w", err) + } + + logger.Info("Successfully ran ffmpeg: %s", args) + logger.Debug("FFmpeg output: %s", string(output)) + return nil +} diff --git a/go.mod b/go.mod index c0ff2e8..564ec16 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,36 @@ module github.com/GetStream/getstream-go/v3 -go 1.19 +go 1.21 + +toolchain go1.24.4 require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/joho/godotenv v1.5.1 - github.com/stretchr/testify v1.9.0 + github.com/pion/rtcp v1.2.15 + github.com/pion/rtp v1.8.23 + github.com/pion/webrtc/v4 v4.1.5 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v3 v3.0.7 // indirect + github.com/pion/ice/v4 v4.0.10 // indirect + github.com/pion/interceptor v0.1.41 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/sctp v1.8.39 // indirect + github.com/pion/sdp/v3 v3.0.16 // indirect + github.com/pion/srtp/v3 v3.0.8 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.8 // indirect + github.com/pion/turn/v4 v4.1.1 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 82f3ce6..462c262 100644 --- a/go.sum +++ b/go.sum @@ -6,11 +6,56 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= +github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/interceptor v0.1.41 h1:NpvX3HgWIukTf2yTBVjVGFXtpSpWgXjqz7IIpu7NsOw= +github.com/pion/interceptor v0.1.41/go.mod h1:nEt4187unvRXJFyjiw00GKo+kIuXMWQI9K89fsosDLY= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= +github.com/pion/rtp v1.8.23 h1:kxX3bN4nM97DPrVBGq5I/Xcl332HnTHeP1Swx3/MCnU= +github.com/pion/rtp v1.8.23/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= +github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= +github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= +github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc= +github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= +github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= +github.com/pion/webrtc/v4 v4.1.5 h1:hJqfKPdRAVcXV9rsg2xcCiuXuMJ38BLW/87GsYJUtUU= +github.com/pion/webrtc/v4 v4.1.5/go.mod h1:vzHh7egVnZRgkK83lYzciWVszdDs759y3/eyu6AvZRA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 77278c55ffab3492b7c25d3ce537cdf597fe1ce0 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 11:40:16 +0200 Subject: [PATCH 02/38] Fix CLI Tools using pointer --- cmd/raw-recording-tools/extract_track.go | 8 ++-- cmd/raw-recording-tools/list_tracks.go | 6 +-- cmd/raw-recording-tools/metadata.go | 50 ++++++++++----------- cmd/raw-recording-tools/mux_av.go | 4 +- cmd/raw-recording-tools/raw-recorder/raw.go | 4 +- 5 files changed, 36 insertions(+), 36 deletions(-) diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go index a6b5261..6d64bff 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/cmd/raw-recording-tools/extract_track.go @@ -39,7 +39,7 @@ func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID, trackType // Filter tracks to specified type only and apply hierarchical filtering filteredTracks := parser.FilterTracks(metadata.Tracks, userID, sessionID, trackID) - typedTracks := make([]TrackInfo, 0) + typedTracks := make([]*TrackInfo, 0) for _, track := range filteredTracks { if track.TrackType == trackType { // Apply media type filtering if specified @@ -82,7 +82,7 @@ func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID, trackType return nil } -func extractSingleTrackWithOptions(inputPath string, track TrackInfo, outputDir string, trackType string, fillGaps bool, logger *getstream.DefaultLogger) error { +func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir string, trackType string, fillGaps bool, logger *getstream.DefaultLogger) error { // Create a temp directory for extraction and processing tempDir, err := os.MkdirTemp("", fmt.Sprintf("%s-extract-*", trackType)) if err != nil { @@ -123,7 +123,7 @@ func extractSingleTrackWithOptions(inputPath string, track TrackInfo, outputDir // NOTE: extractTrackFiles removed - now always use copyTrackFiles since we always work with directories // copyTrackFiles copies the rtpdump and sdp files for a specific track to the destination directory -func copyTrackFiles(inputPath string, track TrackInfo, destDir string, trackType string) error { +func copyTrackFiles(inputPath string, track *TrackInfo, destDir string, trackType string) error { // Walk through the input directory and copy files related to this track return filepath.Walk(inputPath, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -168,7 +168,7 @@ func copyFile(src, dst string) error { } // processSegmentsWithGapFilling processes webm segments, fills gaps if requested, and concatenates into final file -func processSegmentsWithGapFilling(webmFiles []string, track TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { +func processSegmentsWithGapFilling(webmFiles []string, track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { if len(webmFiles) == 1 { // Single segment, just copy it with final name finalName := fmt.Sprintf("%s_%s_%s_%s.webm", trackType, track.UserID, track.SessionID, track.TrackID) diff --git a/cmd/raw-recording-tools/list_tracks.go b/cmd/raw-recording-tools/list_tracks.go index 6ed3850..d261f60 100644 --- a/cmd/raw-recording-tools/list_tracks.go +++ b/cmd/raw-recording-tools/list_tracks.go @@ -67,7 +67,7 @@ func runListTracks(args []string, globalArgs *GlobalArgs) { // Filter tracks if track type is specified tracks := metadata.Tracks if listTracksArgs.TrackType != "" { - filteredTracks := make([]TrackInfo, 0) + filteredTracks := make([]*TrackInfo, 0) for _, track := range tracks { if track.TrackType == listTracksArgs.TrackType { filteredTracks = append(filteredTracks, track) @@ -99,7 +99,7 @@ func runListTracks(args []string, globalArgs *GlobalArgs) { } // printTracksTable prints tracks in a human-readable table format -func printTracksTable(tracks []TrackInfo) { +func printTracksTable(tracks []*TrackInfo) { if len(tracks) == 0 { fmt.Println("No tracks found.") return @@ -189,7 +189,7 @@ func printSessions(sessions []string) { } // printTrackIDs prints unique track IDs, one per line -func printTrackIDs(tracks []TrackInfo) { +func printTrackIDs(tracks []*TrackInfo) { trackIDs := make([]string, 0) seen := make(map[string]bool) diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go index 1de848e..c2c5d95 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/cmd/raw-recording-tools/metadata.go @@ -17,21 +17,21 @@ import ( // TrackInfo represents a single track with its metadata (deduplicated across segments) type TrackInfo struct { - UserID string `json:"userId"` // participant_id from timing metadata - SessionID string `json:"sessionId"` // user_session_id from timing metadata - TrackID string `json:"trackId"` // track_id from segment - TrackType string `json:"trackType"` // "audio" or "video" (cleaned from TRACK_TYPE_*) - IsScreenshare bool `json:"isScreenshare"` // true if this is a screenshare track - Codec string `json:"codec"` // codec info - SegmentCount int `json:"segmentCount"` // number of segments for this track - Segments []rawrecorder.SegmentMetadata `json:"segments"` // list of filenames (for JSON output only) + UserID string `json:"userId"` // participant_id from timing metadata + SessionID string `json:"sessionId"` // user_session_id from timing metadata + TrackID string `json:"trackId"` // track_id from segment + TrackType string `json:"trackType"` // "audio" or "video" (cleaned from TRACK_TYPE_*) + IsScreenshare bool `json:"isScreenshare"` // true if this is a screenshare track + Codec string `json:"codec"` // codec info + SegmentCount int `json:"segmentCount"` // number of segments for this track + Segments []*rawrecorder.SegmentMetadata `json:"segments"` // list of filenames (for JSON output only) } // RecordingMetadata contains all tracks and session information type RecordingMetadata struct { - Tracks []TrackInfo `json:"tracks"` - UserIDs []string `json:"userIds"` - Sessions []string `json:"sessions"` + Tracks []*TrackInfo `json:"tracks"` + UserIDs []string `json:"userIds"` + Sessions []string `json:"sessions"` } // MetadataParser handles parsing of raw recording files @@ -71,7 +71,7 @@ func (p *MetadataParser) ParseMetadataOnly(inputPath string) (*RecordingMetadata // parseDirectory processes a directory containing recording files func (p *MetadataParser) parseDirectory(dirPath string) (*RecordingMetadata, error) { metadata := &RecordingMetadata{ - Tracks: make([]TrackInfo, 0), + Tracks: make([]*TrackInfo, 0), UserIDs: make([]string, 0), Sessions: make([]string, 0), } @@ -134,7 +134,7 @@ func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*Recordin tarReader := tar.NewReader(gzReader) metadata := &RecordingMetadata{ - Tracks: make([]TrackInfo, 0), + Tracks: make([]*TrackInfo, 0), UserIDs: make([]string, 0), Sessions: make([]string, 0), } @@ -182,7 +182,7 @@ func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*Recordin } // parseTimingMetadataFile parses a timing metadata JSON file and extracts tracks -func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]TrackInfo, error) { +func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, error) { var sessionMetadata rawrecorder.SessionTimingMetadata err := json.Unmarshal(data, &sessionMetadata) if err != nil { @@ -192,7 +192,7 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]TrackInfo, erro // Use a map to deduplicate tracks by unique key trackMap := make(map[string]*TrackInfo) - processSegment := func(segment rawrecorder.SegmentMetadata, trackType string) { + processSegment := func(segment *rawrecorder.SegmentMetadata, trackType string) { key := fmt.Sprintf("%s|%s|%s|%s", sessionMetadata.ParticipantID, sessionMetadata.UserSessionID, @@ -212,7 +212,7 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]TrackInfo, erro IsScreenshare: p.isScreenshareTrack(segment.TrackType), Codec: segment.Codec, SegmentCount: 1, - Segments: []rawrecorder.SegmentMetadata{segment}, + Segments: []*rawrecorder.SegmentMetadata{segment}, } trackMap[key] = track } @@ -229,12 +229,12 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]TrackInfo, erro } // Convert map to slice - tracks := make([]TrackInfo, 0, len(trackMap)) + tracks := make([]*TrackInfo, 0, len(trackMap)) for _, track := range trackMap { sort.Slice(track.Segments, func(i, j int) bool { return track.Segments[i].StartTimestamp < track.Segments[j].StartTimestamp }) - tracks = append(tracks, *track) + tracks = append(tracks, track) } return tracks, nil @@ -258,7 +258,7 @@ func (p *MetadataParser) cleanTrackType(trackType string) string { } // extractUniqueUserIDs returns a sorted list of unique user IDs -func (p *MetadataParser) extractUniqueUserIDs(tracks []TrackInfo) []string { +func (p *MetadataParser) extractUniqueUserIDs(tracks []*TrackInfo) []string { userIDMap := make(map[string]bool) for _, track := range tracks { userIDMap[track.UserID] = true @@ -275,7 +275,7 @@ func (p *MetadataParser) extractUniqueUserIDs(tracks []TrackInfo) []string { // NOTE: ExtractTrackFiles and extractTrackFromTarGz removed - no longer needed since we always work with directories // extractUniqueSessions returns a sorted list of unique session IDs -func (p *MetadataParser) extractUniqueSessions(tracks []TrackInfo) []string { +func (p *MetadataParser) extractUniqueSessions(tracks []*TrackInfo) []string { sessionMap := make(map[string]bool) for _, track := range tracks { sessionMap[track.SessionID] = true @@ -293,8 +293,8 @@ func (p *MetadataParser) extractUniqueSessions(tracks []TrackInfo) []string { // If userID="*", sessionID and trackID are ignored (all users, sessions, tracks) // If userID=specific, sessionID="*", trackID is ignored (specific user, all sessions/tracks) // If userID=specific, sessionID=specific, trackID="*", all tracks for that user/session -func (p *MetadataParser) FilterTracks(tracks []TrackInfo, userID, sessionID, trackID string) []TrackInfo { - filtered := make([]TrackInfo, 0) +func (p *MetadataParser) FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID string) []*TrackInfo { + filtered := make([]*TrackInfo, 0) for _, track := range tracks { // Hierarchical filtering logic @@ -319,7 +319,7 @@ func (p *MetadataParser) FilterTracks(tracks []TrackInfo, userID, sessionID, tra return filtered } -func FirstPacketNtpTimestamp(segment rawrecorder.SegmentMetadata) int64 { +func FirstPacketNtpTimestamp(segment *rawrecorder.SegmentMetadata) int64 { if segment.FirstRtcpNtpTimestamp != 0 && segment.FirstRtcpRtpTimestamp != 0 { rtpNtpTs := (segment.FirstRtcpRtpTimestamp - segment.FirstRtpRtpTimestamp) / sampleRate(segment) return segment.FirstRtcpNtpTimestamp - int64(rtpNtpTs) @@ -328,7 +328,7 @@ func FirstPacketNtpTimestamp(segment rawrecorder.SegmentMetadata) int64 { } } -func LastPacketNtpTimestamp(segment rawrecorder.SegmentMetadata) int64 { +func LastPacketNtpTimestamp(segment *rawrecorder.SegmentMetadata) int64 { if segment.LastRtcpNtpTimestamp != 0 && segment.LastRtcpRtpTimestamp != 0 { rtpNtpTs := (segment.LastRtpRtpTimestamp - segment.LastRtcpRtpTimestamp) / sampleRate(segment) return segment.LastRtcpNtpTimestamp + int64(rtpNtpTs) @@ -337,7 +337,7 @@ func LastPacketNtpTimestamp(segment rawrecorder.SegmentMetadata) int64 { } } -func sampleRate(segment rawrecorder.SegmentMetadata) uint32 { +func sampleRate(segment *rawrecorder.SegmentMetadata) uint32 { switch segment.TrackType { case "TRACK_TYPE_AUDIO", "TRACK_TYPE_SCREEN_SHARE_AUDIO": diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 3d45ad7..a1b9a6b 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -192,10 +192,10 @@ func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, logger var audioTrack, videoTrack *TrackInfo for _, track := range metadata.Tracks { if track.TrackID == audioTrackID && track.TrackType == "audio" { - audioTrack = &track + audioTrack = track } if track.TrackID == videoTrackID && track.TrackType == "video" { - videoTrack = &track + videoTrack = track } } diff --git a/cmd/raw-recording-tools/raw-recorder/raw.go b/cmd/raw-recording-tools/raw-recorder/raw.go index 7bbd828..ab6fad3 100644 --- a/cmd/raw-recording-tools/raw-recorder/raw.go +++ b/cmd/raw-recording-tools/raw-recorder/raw.go @@ -10,8 +10,8 @@ type SessionTimingMetadata struct { ParticipantID string `json:"participant_id"` UserSessionID string `json:"user_session_id"` Segments struct { - Audio []SegmentMetadata `json:"audio"` - Video []SegmentMetadata `json:"video"` + Audio []*SegmentMetadata `json:"audio"` + Video []*SegmentMetadata `json:"video"` } `json:"segments"` } From fab9a6065679a3f8e922d5d96d8cc4ace7d475ad Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 12:56:39 +0200 Subject: [PATCH 03/38] Cleaning up --- cmd/raw-recording-tools/metadata.go | 6 ++-- cmd/raw-recording-tools/mux_av.go | 9 +++-- cmd/raw-recording-tools/raw-recorder/raw.go | 33 +++++-------------- cmd/raw-recording-tools/webm/converter.go | 2 ++ .../webm/cursor_webm_recorder.go | 2 +- 5 files changed, 21 insertions(+), 31 deletions(-) diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go index c2c5d95..601f686 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/cmd/raw-recording-tools/metadata.go @@ -232,7 +232,7 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, err tracks := make([]*TrackInfo, 0, len(trackMap)) for _, track := range trackMap { sort.Slice(track.Segments, func(i, j int) bool { - return track.Segments[i].StartTimestamp < track.Segments[j].StartTimestamp + return track.Segments[i].FirstRtpUnixTimestamp < track.Segments[j].FirstRtpUnixTimestamp }) tracks = append(tracks, track) } @@ -324,7 +324,7 @@ func FirstPacketNtpTimestamp(segment *rawrecorder.SegmentMetadata) int64 { rtpNtpTs := (segment.FirstRtcpRtpTimestamp - segment.FirstRtpRtpTimestamp) / sampleRate(segment) return segment.FirstRtcpNtpTimestamp - int64(rtpNtpTs) } else { - return segment.StartTimestamp + return segment.FirstRtpUnixTimestamp } } @@ -333,7 +333,7 @@ func LastPacketNtpTimestamp(segment *rawrecorder.SegmentMetadata) int64 { rtpNtpTs := (segment.LastRtpRtpTimestamp - segment.LastRtcpRtpTimestamp) / sampleRate(segment) return segment.LastRtcpNtpTimestamp + int64(rtpNtpTs) } else { - return segment.EndTimestamp + return segment.LastRtpUnixTimestamp } } diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index a1b9a6b..9b9f256 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -209,7 +209,7 @@ func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, logger offset := audioTs - videoTs logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", - audioTrack.Segments[0].StartTimestamp, audioTs, videoTrack.Segments[0].StartTimestamp, videoTs, offset)) + audioTrack.Segments[0].FirstRtpUnixTimestamp, audioTs, videoTrack.Segments[0].FirstRtpUnixTimestamp, videoTs, offset)) return offset, nil } @@ -374,9 +374,14 @@ func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, // generateMediaAwareMuxedFilename creates output filename that indicates media type func generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName string) string { + suffix := ".webm" + if strings.HasSuffix(videoFile, ".mp4") { + suffix = ".mkv" + } + // Extract common parts from audio filename audioBase := filepath.Base(audioFile) - audioBase = strings.TrimSuffix(audioBase, ".webm") + audioBase = strings.TrimSuffix(audioBase, suffix) // Replace "audio_" with "muxed_{mediaType}_" to create output name var muxedName string diff --git a/cmd/raw-recording-tools/raw-recorder/raw.go b/cmd/raw-recording-tools/raw-recorder/raw.go index ab6fad3..cf67311 100644 --- a/cmd/raw-recording-tools/raw-recorder/raw.go +++ b/cmd/raw-recording-tools/raw-recorder/raw.go @@ -1,14 +1,8 @@ package rawrecorder -import "time" - type SessionTimingMetadata struct { - CallID string `json:"call_id"` - CallSessionID string `json:"call_session_id"` - CallStartTime time.Time `json:"call_start_time"` - CallEndTime time.Time `json:"call_end_time,omitempty"` - ParticipantID string `json:"participant_id"` - UserSessionID string `json:"user_session_id"` + ParticipantID string `json:"participant_id"` + UserSessionID string `json:"user_session_id"` Segments struct { Audio []*SegmentMetadata `json:"audio"` Video []*SegmentMetadata `json:"video"` @@ -17,11 +11,12 @@ type SessionTimingMetadata struct { type SegmentMetadata struct { // Global information - BaseFilename string `json:"base_filename"` - StartTimestamp int64 `json:"start_timestamp"` - EndTimestamp int64 `json:"end_timestamp,omitempty"` - StartOffsetMs int64 `json:"start_offset_ms"` - EndOffsetMs int64 `json:"end_offset_ms,omitempty"` + BaseFilename string `json:"base_filename"` + + // Track information + Codec string `json:"codec"` + TrackID string `json:"track_id"` + TrackType string `json:"track_type"` // Packet timing information FirstRtpRtpTimestamp uint32 `json:"first_rtp_rtp_timestamp"` @@ -32,16 +27,4 @@ type SegmentMetadata struct { FirstRtcpNtpTimestamp int64 `json:"first_rtcp_ntp_timestamp,omitempty"` LastRtcpRtpTimestamp uint32 `json:"last_rtcp_rtp_timestamp,omitempty"` LastRtcpNtpTimestamp int64 `json:"last_rtcp_ntp_timestamp,omitempty"` - - // Segment duration information - RtpDurationMs int64 `json:"rtp_duration_ms,omitempty"` - UnixDurationMs int64 `json:"unix_duration_ms,omitempty"` - DriftMs int64 `json:"drift_ms,omitempty"` - DriftRatePercent float64 `json:"drift_rate_percent,omitempty"` - - // Track information - SSRC uint32 `json:"ssrc"` - Codec string `json:"codec"` - TrackID string `json:"track_id"` - TrackType string `json:"track_type"` } diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go index 1894276..40bf566 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/cmd/raw-recording-tools/webm/converter.go @@ -95,6 +95,8 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string) error { switch mType { case webrtc.MimeTypeAV1, webrtc.MimeTypeVP9: recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, ".rtpdump", ".webm", 1), sdpContent, c.logger) + case webrtc.MimeTypeH264: + recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, ".rtpdump", ".mp4", 1), sdpContent, c.logger) default: recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, ".rtpdump", ".webm", 1), sdpContent, c.logger) } diff --git a/cmd/raw-recording-tools/webm/cursor_webm_recorder.go b/cmd/raw-recording-tools/webm/cursor_webm_recorder.go index 95dc53e..8fc6ef2 100644 --- a/cmd/raw-recording-tools/webm/cursor_webm_recorder.go +++ b/cmd/raw-recording-tools/webm/cursor_webm_recorder.go @@ -89,7 +89,7 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port // Build FFmpeg command with optimized settings for single track recording args := []string{ "-threads", "1", - "-loglevel", "debug", + // "-loglevel", "debug", "-protocol_whitelist", "file,udp,rtp", "-buffer_size", "10000000", "-max_delay", "150000", From f871051d3e7eb9bce6df133a6c2b2c74c76e2aa2 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 13:12:33 +0200 Subject: [PATCH 04/38] Update filtering logic --- cmd/raw-recording-tools/README.md | 49 ++++++++++++------------ cmd/raw-recording-tools/extract_audio.go | 6 +-- cmd/raw-recording-tools/extract_video.go | 6 +-- cmd/raw-recording-tools/metadata.go | 45 +++++++++++++--------- cmd/raw-recording-tools/mix_audio.go | 6 +-- cmd/raw-recording-tools/mux_av.go | 6 +-- cmd/raw-recording-tools/process_all.go | 6 +-- 7 files changed, 67 insertions(+), 57 deletions(-) diff --git a/cmd/raw-recording-tools/README.md b/cmd/raw-recording-tools/README.md index 61e2987..170ea1f 100644 --- a/cmd/raw-recording-tools/README.md +++ b/cmd/raw-recording-tools/README.md @@ -49,7 +49,7 @@ Extract and convert individual audio tracks from raw recordings to WebM format. ```bash # Extract audio for all users -raw-tools --inputFile recording.zip --output ./output extract-audio --userId "*" +raw-tools --inputFile recording.zip --output ./output extract-audio # Extract audio for specific user with gap filling raw-tools --inputFile recording.zip --output ./output extract-audio --userId user123 --fill_gaps @@ -62,16 +62,17 @@ raw-tools --inputFile recording.zip --output ./output extract-audio --trackId tr ``` **Options:** -- `--userId ` - Filter by user ID (`*` for all users, specific ID, or comma-separated list) -- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID, or comma-separated list) -- `--trackId ` - Filter by track ID (`*` for all tracks, specific ID, or comma-separated list) +- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) +- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) +- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) - `--fill_gaps` - Fill temporal gaps between segments with silence (recommended for playback) - `-h, --help` - Show help message -**Hierarchical Filtering:** -- If `--userId` is `*`, `--sessionId` and `--trackId` are ignored (processes all users) -- If `--sessionId` is `*`, `--trackId` is ignored (processes all sessions for specified users) -- Specific IDs can be combined for precise extraction +**Priority-Based Filtering:** +- `--trackId` has highest priority: if specified, only that track is processed +- `--sessionId` has medium priority: if specified (without trackId), all tracks for that session are processed +- `--userId` has lowest priority: if specified (without sessionId/trackId), all tracks for that user are processed +- If all are empty, all tracks are processed ### `extract-video` - Extract Video Tracks @@ -79,7 +80,7 @@ Extract and convert individual video tracks from raw recordings to WebM format. ```bash # Extract video for all users -raw-tools --inputFile recording.zip --output ./output extract-video --userId "*" +raw-tools --inputFile recording.zip --output ./output extract-video # Extract video for specific user with black frame filling raw-tools --inputFile recording.zip --output ./output extract-video --userId user123 --fill_gaps @@ -89,9 +90,9 @@ raw-tools --inputFile recording.zip --output ./output extract-video --userId use ``` **Options:** -- `--userId ` - Filter by user ID (`*` for all users, specific ID, or comma-separated list) -- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID, or comma-separated list) -- `--trackId ` - Filter by track ID (`*` for all tracks, specific ID, or comma-separated list) +- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) +- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) +- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) - `--fill_gaps` - Fill temporal gaps between segments with black frames (recommended for playback) - `-h, --help` - Show help message @@ -106,7 +107,7 @@ Combine individual audio and video tracks with proper synchronization and timing ```bash # Mux audio/video for all users -raw-tools --inputFile recording.zip --output ./output mux-av --userId "*" +raw-tools --inputFile recording.zip --output ./output mux-av # Mux for specific user with proper sync raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 @@ -119,9 +120,9 @@ raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 -- ``` **Options:** -- `--userId ` - Filter by user ID (`*` for all users, specific ID for targeted muxing) -- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID for session-based muxing) -- `--trackId ` - Filter by track ID (rarely used, as muxing typically works with user/session pairs) +- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) +- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) +- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) - `--media ` - Filter by media type: `user` (camera/microphone), `display` (screen sharing), or `both` (default) - `-h, --help` - Show help message @@ -164,9 +165,9 @@ raw-tools --inputFile recording.zip --output ./output mix-audio --userId user123 ``` **Options:** -- `--userId ` - Filter by user ID (`*` for all users, specific ID, or comma-separated list) -- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID, or comma-separated list) -- `--trackId ` - Filter by track ID (`*` for all tracks, specific ID for precise control) +- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) +- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) +- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) - `--no-fill-gaps` - Disable gap filling (not recommended for mixing, gaps enabled by default) - `-h, --help` - Show help message @@ -202,9 +203,9 @@ raw-tools --inputFile recording.zip --output ./output process-all --userId user1 ``` **Options:** -- `--userId ` - Filter by user ID (`*` for all users, specific ID for targeted processing) -- `--sessionId ` - Filter by session ID (`*` for all sessions, specific ID for session-based processing) -- `--trackId ` - Filter by track ID (`*` for all tracks, specific ID for precise control) +- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) +- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) +- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) - `-h, --help` - Show help message **Workflow Steps:** @@ -254,7 +255,7 @@ With completion enabled, the CLI will: ```bash # Tab completion will suggest actual user IDs from your recording raw-tools --inputFile recording.zip --output ./out extract-audio --userId -# Shows: user_abc123 user_def456 * +# Shows: user_abc123 user_def456 # Invalid inputs show helpful errors raw-tools --inputFile recording.zip --output ./out extract-audio --userId invalid_user @@ -306,7 +307,7 @@ echo "Found $audio_tracks audio tracks and $video_tracks video tracks" # 3. Process only if we have both audio and video if [ "$audio_tracks" -gt 0 ] && [ "$video_tracks" -gt 0 ]; then - raw-tools --inputFile recording.zip --output ./output mux-av --userId "*" + raw-tools --inputFile recording.zip --output ./output mux-av fi ``` diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index 212aaf6..3474f1f 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -19,9 +19,9 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs) { // Parse command-specific flags fs := flag.NewFlagSet("extract-audio", flag.ExitOnError) extractAudioArgs := &ExtractAudioArgs{} - fs.StringVar(&extractAudioArgs.UserID, "userId", "*", "Specify a userId or * for all") - fs.StringVar(&extractAudioArgs.SessionID, "sessionId", "*", "Specify a sessionId or * for all") - fs.StringVar(&extractAudioArgs.TrackID, "trackId", "*", "Specify a trackId or * for all") + fs.StringVar(&extractAudioArgs.UserID, "userId", "", "Specify a userId (empty for all)") + fs.StringVar(&extractAudioArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") + fs.StringVar(&extractAudioArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") fs.BoolVar(&extractAudioArgs.FillGaps, "fill_gaps", false, "Fix DTX shrink audio, and fill with silence when track was muted") // Check for help flag before parsing diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index 3c4b218..e71b07a 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -19,9 +19,9 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { // Parse command-specific flags fs := flag.NewFlagSet("extract-video", flag.ExitOnError) extractVideoArgs := &ExtractVideoArgs{} - fs.StringVar(&extractVideoArgs.UserID, "userId", "*", "Specify a userId or * for all") - fs.StringVar(&extractVideoArgs.SessionID, "sessionId", "*", "Specify a sessionId or * for all") - fs.StringVar(&extractVideoArgs.TrackID, "trackId", "*", "Specify a trackId or * for all") + fs.StringVar(&extractVideoArgs.UserID, "userId", "", "Specify a userId (empty for all)") + fs.StringVar(&extractVideoArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") + fs.StringVar(&extractVideoArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") fs.BoolVar(&extractVideoArgs.FillGaps, "fill_gaps", false, "Fill with black frame when track was muted") // Check for help flag before parsing diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go index 601f686..fa6077c 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/cmd/raw-recording-tools/metadata.go @@ -289,31 +289,40 @@ func (p *MetadataParser) extractUniqueSessions(tracks []*TrackInfo) []string { return sessions } -// FilterTracks filters tracks based on hierarchical criteria -// If userID="*", sessionID and trackID are ignored (all users, sessions, tracks) -// If userID=specific, sessionID="*", trackID is ignored (specific user, all sessions/tracks) -// If userID=specific, sessionID=specific, trackID="*", all tracks for that user/session +// FilterTracks filters tracks based on priority-based criteria +// Empty values are ignored, specific values must match +// Priority: trackID (highest) > sessionID > userID (lowest) +// If all are empty, all tracks are returned func (p *MetadataParser) FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID string) []*TrackInfo { filtered := make([]*TrackInfo, 0) for _, track := range tracks { - // Hierarchical filtering logic - if userID == "*" { - // If userID is wildcard, include all tracks regardless of sessionID/trackID - filtered = append(filtered, track) - } else if track.UserID == userID { - // User matches, check session level - if sessionID == "*" { - // If sessionID is wildcard, include all tracks for this user + // If trackID is specified, it has highest priority - only return that specific track + if trackID != "" { + if track.TrackID == trackID { filtered = append(filtered, track) - } else if track.SessionID == sessionID { - // Session matches, check track level - if trackID == "*" || track.TrackID == trackID { - // Include if trackID is wildcard or matches - filtered = append(filtered, track) - } } + continue + } + + // If sessionID is specified, return all tracks for that session + if sessionID != "" { + if track.SessionID == sessionID { + filtered = append(filtered, track) + } + continue } + + // If userID is specified, return all tracks for that user + if userID != "" { + if track.UserID == userID { + filtered = append(filtered, track) + } + continue + } + + // If all are empty, return all tracks + filtered = append(filtered, track) } return filtered diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index 95fd596..5ab5157 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -34,9 +34,9 @@ type AudioFileWithTiming struct { // runMixAudio handles the mix-audio command func runMixAudio(args []string, globalArgs *GlobalArgs) { mixAudioArgs := &MixAudioArgs{ - UserID: "*", // Default: all users - SessionID: "*", // Default: all sessions - TrackID: "*", // Default: all tracks + UserID: "", // Default: all users (empty) + SessionID: "", // Default: all sessions (empty) + TrackID: "", // Default: all tracks (empty) FillGaps: true, // Always fill gaps for proper mixing } diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 9b9f256..5d4d259 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -22,9 +22,9 @@ func runMuxAV(args []string, globalArgs *GlobalArgs) { // Parse command-specific flags fs := flag.NewFlagSet("mux-av", flag.ExitOnError) muxAVArgs := &MuxAVArgs{} - fs.StringVar(&muxAVArgs.UserID, "userId", "*", "Specify a userId or * for all") - fs.StringVar(&muxAVArgs.SessionID, "sessionId", "*", "Specify a sessionId or * for all") - fs.StringVar(&muxAVArgs.TrackID, "trackId", "*", "Specify a trackId or * for all") + fs.StringVar(&muxAVArgs.UserID, "userId", "", "Specify a userId (empty for all)") + fs.StringVar(&muxAVArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") + fs.StringVar(&muxAVArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") fs.StringVar(&muxAVArgs.Media, "media", "both", "Filter by media type: 'user', 'display', or 'both'") // Check for help flag before parsing diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 4d47ec4..bec9c6d 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -20,9 +20,9 @@ func runProcessAll(args []string, globalArgs *GlobalArgs) { // Parse command-specific flags fs := flag.NewFlagSet("process-all", flag.ExitOnError) processAllArgs := &ProcessAllArgs{} - fs.StringVar(&processAllArgs.UserID, "userId", "*", "Specify a userId or * for all") - fs.StringVar(&processAllArgs.SessionID, "sessionId", "*", "Specify a sessionId or * for all") - fs.StringVar(&processAllArgs.TrackID, "trackId", "*", "Specify a trackId or * for all") + fs.StringVar(&processAllArgs.UserID, "userId", "", "Specify a userId (empty for all)") + fs.StringVar(&processAllArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") + fs.StringVar(&processAllArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") // Check for help flag before parsing for _, arg := range args { From 7d35f95783ff3236c2fda3e54a08dd39937b8877 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 13:44:31 +0200 Subject: [PATCH 05/38] Update filtering logic --- cmd/raw-recording-tools/README.md | 46 ++++++---- cmd/raw-recording-tools/completion.go | 112 +++++++++-------------- cmd/raw-recording-tools/extract_audio.go | 20 ++-- cmd/raw-recording-tools/extract_video.go | 20 ++-- cmd/raw-recording-tools/metadata.go | 28 +++--- cmd/raw-recording-tools/mux_av.go | 14 +-- cmd/raw-recording-tools/process_all.go | 14 +-- 7 files changed, 109 insertions(+), 145 deletions(-) diff --git a/cmd/raw-recording-tools/README.md b/cmd/raw-recording-tools/README.md index 170ea1f..725a86d 100644 --- a/cmd/raw-recording-tools/README.md +++ b/cmd/raw-recording-tools/README.md @@ -62,17 +62,19 @@ raw-tools --inputFile recording.zip --output ./output extract-audio --trackId tr ``` **Options:** -- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) -- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) -- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time - `--fill_gaps` - Fill temporal gaps between segments with silence (recommended for playback) - `-h, --help` - Show help message -**Priority-Based Filtering:** -- `--trackId` has highest priority: if specified, only that track is processed -- `--sessionId` has medium priority: if specified (without trackId), all tracks for that session are processed -- `--userId` has lowest priority: if specified (without sessionId/trackId), all tracks for that user are processed -- If all are empty, all tracks are processed +**Mutually Exclusive Filtering:** +- Only one filter can be specified at a time: `--userId`, `--sessionId`, or `--trackId` +- `--trackId` returns exactly one track (the specified track) +- `--sessionId` returns all tracks for that session (multiple tracks possible) +- `--userId` returns all tracks for that user (multiple tracks possible) +- If no filter is specified, all tracks are processed ### `extract-video` - Extract Video Tracks @@ -90,9 +92,10 @@ raw-tools --inputFile recording.zip --output ./output extract-video --userId use ``` **Options:** -- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) -- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) -- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time - `--fill_gaps` - Fill temporal gaps between segments with black frames (recommended for playback) - `-h, --help` - Show help message @@ -120,9 +123,10 @@ raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 -- ``` **Options:** -- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) -- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) -- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time - `--media ` - Filter by media type: `user` (camera/microphone), `display` (screen sharing), or `both` (default) - `-h, --help` - Show help message @@ -165,9 +169,10 @@ raw-tools --inputFile recording.zip --output ./output mix-audio --userId user123 ``` **Options:** -- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) -- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) -- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time - `--no-fill-gaps` - Disable gap filling (not recommended for mixing, gaps enabled by default) - `-h, --help` - Show help message @@ -203,9 +208,10 @@ raw-tools --inputFile recording.zip --output ./output process-all --userId user1 ``` **Options:** -- `--userId ` - Filter by user ID (empty for all users, specific ID for that user only) -- `--sessionId ` - Filter by session ID (empty for all sessions, specific ID for that session only) -- `--trackId ` - Filter by track ID (empty for all tracks, specific ID for that track only) +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time - `-h, --help` - Show help message **Workflow Steps:** diff --git a/cmd/raw-recording-tools/completion.go b/cmd/raw-recording-tools/completion.go index bfcb3cf..c1e1612 100644 --- a/cmd/raw-recording-tools/completion.go +++ b/cmd/raw-recording-tools/completion.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os" - "strings" ) // generateCompletion generates shell completion scripts @@ -70,10 +69,10 @@ _raw_tools_completion() { esac if [[ -n "$completion_type" ]]; then local values=$(raw-tools --inputFile "$_RAW_TOOLS_INPUT_FILE" --output /tmp list-tracks --format "$completion_type" 2>/dev/null) - COMPREPLY=($(compgen -W "$values *" -- "$cur")) + COMPREPLY=($(compgen -W "$values" -- "$cur")) fi else - COMPREPLY=($(compgen -W "*" -- "$cur")) + COMPREPLY=() fi return ;; @@ -208,9 +207,9 @@ _raw_tools_complete_users() { if [[ -n "$input_file" ]]; then local users=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format users 2>/dev/null)) - _wanted users expl 'user ID' compadd "$@" "*" $users + _wanted users expl 'user ID' compadd "$@" $users else - _wanted users expl 'user ID' compadd "$@" "*" + _wanted users expl 'user ID' compadd "$@" fi } @@ -225,9 +224,9 @@ _raw_tools_complete_sessions() { if [[ -n "$input_file" ]]; then local sessions=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format sessions 2>/dev/null)) - _wanted sessions expl 'session ID' compadd "$@" "*" $sessions + _wanted sessions expl 'session ID' compadd "$@" $sessions else - _wanted sessions expl 'session ID' compadd "$@" "*" + _wanted sessions expl 'session ID' compadd "$@" fi } @@ -242,9 +241,9 @@ _raw_tools_complete_tracks() { if [[ -n "$input_file" ]]; then local tracks=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format tracks 2>/dev/null)) - _wanted tracks expl 'track ID' compadd "$@" "*" $tracks + _wanted tracks expl 'track ID' compadd "$@" $tracks else - _wanted tracks expl 'track ID' compadd "$@" "*" + _wanted tracks expl 'track ID' compadd "$@" fi } @@ -289,13 +288,35 @@ complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l sessionId -d 'S fmt.Println(script) } -// validateInputArgs validates input arguments using hierarchical logic +// validateInputArgs validates input arguments using mutually exclusive logic func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) error { if globalArgs.InputFile == "" && globalArgs.InputS3 == "" { return nil // Skip validation if no input specified yet } - // Parse metadata to validate arguments + // Count how many filters are specified + filtersCount := 0 + if userID != "" { + filtersCount++ + } + if sessionID != "" { + filtersCount++ + } + if trackID != "" { + filtersCount++ + } + + // Ensure filters are mutually exclusive + if filtersCount > 1 { + return fmt.Errorf("only one filter can be specified at a time: --userId, --sessionId, and --trackId are mutually exclusive") + } + + // If no filters specified, no validation needed + if filtersCount == 0 { + return nil + } + + // Parse metadata to validate the single specified argument logger := setupLogger(false) // Use non-verbose for validation parser := NewMetadataParser(logger) @@ -312,84 +333,39 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string return fmt.Errorf("failed to parse recording for validation: %w", err) } - // Hierarchical validation logic - if userID == "*" { - // If userID is wildcard, sessionID and trackID are ignored (no validation needed) - return nil - } - - // Validate userID if not wildcard - if userID != "" { + // Validate the single specified filter + if trackID != "" { found := false - for _, uid := range metadata.UserIDs { - if uid == userID { + for _, track := range metadata.Tracks { + if track.TrackID == trackID { found = true break } } if !found { - return fmt.Errorf("userID '%s' not found in recording. Available users: %s", - userID, strings.Join(metadata.UserIDs, ", ")) + return fmt.Errorf("trackID '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) } - } - - // If sessionID is wildcard, trackID is ignored - if sessionID == "*" { - return nil - } - - // Validate sessionID if not wildcard - if sessionID != "" { - // Check if this sessionID exists for the specified userID + } else if sessionID != "" { found := false for _, track := range metadata.Tracks { - if track.UserID == userID && track.SessionID == sessionID { + if track.SessionID == sessionID { found = true break } } if !found { - // Get available sessions for this user - availableSessions := make([]string, 0) - seen := make(map[string]bool) - for _, track := range metadata.Tracks { - if track.UserID == userID && !seen[track.SessionID] { - availableSessions = append(availableSessions, track.SessionID) - seen[track.SessionID] = true - } - } - return fmt.Errorf("sessionID '%s' not found for userID '%s'. Available sessions for this user: %s", - sessionID, userID, strings.Join(availableSessions, ", ")) + return fmt.Errorf("sessionID '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) } - } - - // If trackID is wildcard, no validation needed - if trackID == "*" { - return nil - } - - // Validate trackID if not wildcard - if trackID != "" { - // Check if this trackID exists for the specified userID/sessionID + } else if userID != "" { found := false - for _, track := range metadata.Tracks { - if track.UserID == userID && track.SessionID == sessionID && track.TrackID == trackID { + for _, uid := range metadata.UserIDs { + if uid == userID { found = true break } } if !found { - // Get available tracks for this user/session combination - availableTracks := make([]string, 0) - seen := make(map[string]bool) - for _, track := range metadata.Tracks { - if track.UserID == userID && track.SessionID == sessionID && !seen[track.TrackID] { - availableTracks = append(availableTracks, track.TrackID) - seen[track.TrackID] = true - } - } - return fmt.Errorf("trackID '%s' not found for userID '%s' and sessionID '%s'. Available tracks for this user/session: %s", - trackID, userID, sessionID, strings.Join(availableTracks, ", ")) + return fmt.Errorf("userID '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) } } diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index 3474f1f..0718169 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -69,20 +69,14 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs) { fmt.Printf(" Output directory: %s\n", globalArgs.Output) fmt.Printf(" User ID filter: %s\n", extractAudioArgs.UserID) - if extractAudioArgs.UserID == "*" { - fmt.Printf(" → Processing ALL users (sessionId/trackId ignored)\n") + if extractAudioArgs.TrackID != "" { + fmt.Printf(" → Processing specific track '%s'\n", extractAudioArgs.TrackID) + } else if extractAudioArgs.SessionID != "" { + fmt.Printf(" → Processing all audio tracks for session '%s'\n", extractAudioArgs.SessionID) + } else if extractAudioArgs.UserID != "" { + fmt.Printf(" → Processing all audio tracks for user '%s'\n", extractAudioArgs.UserID) } else { - fmt.Printf(" Session ID filter: %s\n", extractAudioArgs.SessionID) - if extractAudioArgs.SessionID == "*" { - fmt.Printf(" → Processing ALL sessions for user '%s' (trackId ignored)\n", extractAudioArgs.UserID) - } else { - fmt.Printf(" Track ID filter: %s\n", extractAudioArgs.TrackID) - if extractAudioArgs.TrackID == "*" { - fmt.Printf(" → Processing ALL tracks for user '%s', session '%s'\n", extractAudioArgs.UserID, extractAudioArgs.SessionID) - } else { - fmt.Printf(" → Processing specific track '%s' for user '%s', session '%s'\n", extractAudioArgs.TrackID, extractAudioArgs.UserID, extractAudioArgs.SessionID) - } - } + fmt.Printf(" → Processing all audio tracks (no filters)\n") } fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index e71b07a..dd439fe 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -77,20 +77,14 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { fmt.Printf(" Output directory: %s\n", globalArgs.Output) fmt.Printf(" User ID filter: %s\n", extractVideoArgs.UserID) - if extractVideoArgs.UserID == "*" { - fmt.Printf(" → Processing ALL users (sessionId/trackId ignored)\n") + if extractVideoArgs.TrackID != "" { + fmt.Printf(" → Processing specific track '%s'\n", extractVideoArgs.TrackID) + } else if extractVideoArgs.SessionID != "" { + fmt.Printf(" → Processing all video tracks for session '%s'\n", extractVideoArgs.SessionID) + } else if extractVideoArgs.UserID != "" { + fmt.Printf(" → Processing all video tracks for user '%s'\n", extractVideoArgs.UserID) } else { - fmt.Printf(" Session ID filter: %s\n", extractVideoArgs.SessionID) - if extractVideoArgs.SessionID == "*" { - fmt.Printf(" → Processing ALL sessions for user '%s' (trackId ignored)\n", extractVideoArgs.UserID) - } else { - fmt.Printf(" Track ID filter: %s\n", extractVideoArgs.TrackID) - if extractVideoArgs.TrackID == "*" { - fmt.Printf(" → Processing ALL tracks for user '%s', session '%s'\n", extractVideoArgs.UserID, extractVideoArgs.SessionID) - } else { - fmt.Printf(" → Processing specific track '%s' for user '%s', session '%s'\n", extractVideoArgs.TrackID, extractVideoArgs.UserID, extractVideoArgs.SessionID) - } - } + fmt.Printf(" → Processing all video tracks (no filters)\n") } fmt.Printf(" Fill gaps: %t\n", extractVideoArgs.FillGaps) diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go index fa6077c..3eccc33 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/cmd/raw-recording-tools/metadata.go @@ -289,40 +289,34 @@ func (p *MetadataParser) extractUniqueSessions(tracks []*TrackInfo) []string { return sessions } -// FilterTracks filters tracks based on priority-based criteria +// FilterTracks filters tracks based on mutually exclusive criteria +// Only one filter (userID, sessionID, or trackID) can be specified at a time // Empty values are ignored, specific values must match -// Priority: trackID (highest) > sessionID > userID (lowest) // If all are empty, all tracks are returned func (p *MetadataParser) FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID string) []*TrackInfo { filtered := make([]*TrackInfo, 0) for _, track := range tracks { - // If trackID is specified, it has highest priority - only return that specific track + // Apply the single specified filter (mutually exclusive) if trackID != "" { + // Filter by trackID - return only that specific track if track.TrackID == trackID { filtered = append(filtered, track) } - continue - } - - // If sessionID is specified, return all tracks for that session - if sessionID != "" { + } else if sessionID != "" { + // Filter by sessionID - return all tracks for that session if track.SessionID == sessionID { filtered = append(filtered, track) } - continue - } - - // If userID is specified, return all tracks for that user - if userID != "" { + } else if userID != "" { + // Filter by userID - return all tracks for that user if track.UserID == userID { filtered = append(filtered, track) } - continue + } else { + // No filters specified - return all tracks + filtered = append(filtered, track) } - - // If all are empty, return all tracks - filtered = append(filtered, track) } return filtered diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 5d4d259..ffe7c2a 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -70,14 +70,14 @@ func runMuxAV(args []string, globalArgs *GlobalArgs) { fmt.Printf(" Track ID filter: %s\n", muxAVArgs.TrackID) fmt.Printf(" Media filter: %s\n", muxAVArgs.Media) - if muxAVArgs.UserID == "*" { - fmt.Printf(" → Processing ALL users (sessionId/trackId ignored)\n") - } else if muxAVArgs.SessionID == "*" { - fmt.Printf(" → Processing ALL sessions for user '%s' (trackId ignored)\n", muxAVArgs.UserID) - } else if muxAVArgs.TrackID == "*" { - fmt.Printf(" → Processing ALL tracks for user '%s', session '%s'\n", muxAVArgs.UserID, muxAVArgs.SessionID) + if muxAVArgs.TrackID != "" { + fmt.Printf(" → Processing specific track '%s'\n", muxAVArgs.TrackID) + } else if muxAVArgs.SessionID != "" { + fmt.Printf(" → Processing all tracks for session '%s'\n", muxAVArgs.SessionID) + } else if muxAVArgs.UserID != "" { + fmt.Printf(" → Processing all tracks for user '%s'\n", muxAVArgs.UserID) } else { - fmt.Printf(" → Processing specific track for user '%s', session '%s', track '%s'\n", muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID) + fmt.Printf(" → Processing all tracks (no filters)\n") } // Extract and mux audio/video tracks diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index bec9c6d..87fbebe 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -67,14 +67,14 @@ func runProcessAll(args []string, globalArgs *GlobalArgs) { fmt.Printf(" Track ID filter: %s\n", processAllArgs.TrackID) fmt.Printf(" Gap filling: always enabled\n") - if processAllArgs.UserID == "*" { - fmt.Printf(" → Processing ALL users (sessionId/trackId ignored)\n") - } else if processAllArgs.SessionID == "*" { - fmt.Printf(" → Processing ALL sessions for user '%s' (trackId ignored)\n", processAllArgs.UserID) - } else if processAllArgs.TrackID == "*" { - fmt.Printf(" → Processing ALL tracks for user '%s', session '%s'\n", processAllArgs.UserID, processAllArgs.SessionID) + if processAllArgs.TrackID != "" { + fmt.Printf(" → Processing specific track '%s'\n", processAllArgs.TrackID) + } else if processAllArgs.SessionID != "" { + fmt.Printf(" → Processing all tracks for session '%s'\n", processAllArgs.SessionID) + } else if processAllArgs.UserID != "" { + fmt.Printf(" → Processing all tracks for user '%s'\n", processAllArgs.UserID) } else { - fmt.Printf(" → Processing specific track for user '%s', session '%s', track '%s'\n", processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID) + fmt.Printf(" → Processing all tracks (no filters)\n") } // Process all tracks and mux them From 9ee4f4c00d45dc7e75c50413e9ca94014368ecd7 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 14:47:03 +0200 Subject: [PATCH 06/38] Refactor Metadata --- cmd/raw-recording-tools/completion.go | 37 ++++++++--------- cmd/raw-recording-tools/constants.go | 6 +++ cmd/raw-recording-tools/extract_audio.go | 12 ++---- cmd/raw-recording-tools/extract_track.go | 48 +++++++++++------------ cmd/raw-recording-tools/extract_video.go | 11 ++---- cmd/raw-recording-tools/metadata.go | 8 +--- cmd/raw-recording-tools/mix_audio.go | 6 +-- cmd/raw-recording-tools/mux_av.go | 42 ++++++++++---------- cmd/raw-recording-tools/process_all.go | 11 +++--- cmd/raw-recording-tools/webm/converter.go | 4 +- 10 files changed, 84 insertions(+), 101 deletions(-) create mode 100644 cmd/raw-recording-tools/constants.go diff --git a/cmd/raw-recording-tools/completion.go b/cmd/raw-recording-tools/completion.go index c1e1612..c09f191 100644 --- a/cmd/raw-recording-tools/completion.go +++ b/cmd/raw-recording-tools/completion.go @@ -289,11 +289,7 @@ complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l sessionId -d 'S } // validateInputArgs validates input arguments using mutually exclusive logic -func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) error { - if globalArgs.InputFile == "" && globalArgs.InputS3 == "" { - return nil // Skip validation if no input specified yet - } - +func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*RecordingMetadata, error) { // Count how many filters are specified filtersCount := 0 if userID != "" { @@ -308,29 +304,28 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string // Ensure filters are mutually exclusive if filtersCount > 1 { - return fmt.Errorf("only one filter can be specified at a time: --userId, --sessionId, and --trackId are mutually exclusive") + return nil, fmt.Errorf("only one filter can be specified at a time: --userId, --sessionId, and --trackId are mutually exclusive") } - // If no filters specified, no validation needed - if filtersCount == 0 { - return nil - } - - // Parse metadata to validate the single specified argument - logger := setupLogger(false) // Use non-verbose for validation - parser := NewMetadataParser(logger) - var inputPath string if globalArgs.InputFile != "" { inputPath = globalArgs.InputFile } else { // TODO: Handle S3 validation - return nil + return nil, fmt.Errorf("Not implemented for now") } + // Parse metadata to validate the single specified argument + logger := setupLogger(false) // Use non-verbose for validation + parser := NewMetadataParser(logger) metadata, err := parser.ParseMetadataOnly(inputPath) if err != nil { - return fmt.Errorf("failed to parse recording for validation: %w", err) + return nil, fmt.Errorf("failed to parse recording for validation: %w", err) + } + + // If no filters specified, no validation needed + if filtersCount == 0 { + return metadata, nil } // Validate the single specified filter @@ -343,7 +338,7 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string } } if !found { - return fmt.Errorf("trackID '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) + return nil, fmt.Errorf("trackID '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) } } else if sessionID != "" { found := false @@ -354,7 +349,7 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string } } if !found { - return fmt.Errorf("sessionID '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) + return nil, fmt.Errorf("sessionID '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) } } else if userID != "" { found := false @@ -365,9 +360,9 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string } } if !found { - return fmt.Errorf("userID '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) + return nil, fmt.Errorf("userID '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) } } - return nil + return metadata, nil } diff --git a/cmd/raw-recording-tools/constants.go b/cmd/raw-recording-tools/constants.go new file mode 100644 index 0000000..5b3fe3e --- /dev/null +++ b/cmd/raw-recording-tools/constants.go @@ -0,0 +1,6 @@ +package main + +const ( + RtpDump = "rtpdump" + SuffixRtpDump = "." + RtpDump +) diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index 0718169..7e40c01 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -4,8 +4,6 @@ import ( "flag" "fmt" "os" - - "github.com/GetStream/getstream-go/v3" ) type ExtractAudioArgs struct { @@ -45,7 +43,8 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs) { } // Validate input arguments against actual recording data - if err := validateInputArgs(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID); err != nil { + metadata, err := validateInputArgs(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID) + if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) if globalArgs.InputFile != "" { fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", @@ -81,8 +80,7 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs) { fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) // Implement extract audio functionality - err := extractAudioTracks(globalArgs, extractAudioArgs, logger) - if err != nil { + if err := extractTracks(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, logger); err != nil { logger.Error("Failed to extract audio: %v", err) } @@ -114,7 +112,3 @@ func printExtractAudioUsage() { fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --userId user123 --sessionId session456 --trackId '*'\n\n") fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") } - -func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, logger *getstream.DefaultLogger) error { - return extractTracks(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, "audio", "both", extractAudioArgs.FillGaps, logger) -} diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go index 6d64bff..6d325c4 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/cmd/raw-recording-tools/extract_track.go @@ -14,7 +14,7 @@ import ( ) // Generic track extraction function that works for both audio and video -func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID, trackType, mediaFilter string, fillGaps bool, logger *getstream.DefaultLogger) error { +func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps bool, logger *getstream.DefaultLogger) error { var inputPath string if globalArgs.InputFile != "" { inputPath = globalArgs.InputFile @@ -30,15 +30,8 @@ func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID, trackType } defer cleanup() - // Parse metadata from the working directory - parser := NewMetadataParser(logger) - metadata, err := parser.ParseRecording(workingDir) - if err != nil { - return fmt.Errorf("failed to parse recording metadata: %w", err) - } - // Filter tracks to specified type only and apply hierarchical filtering - filteredTracks := parser.FilterTracks(metadata.Tracks, userID, sessionID, trackID) + filteredTracks := FilterTracks(metadata.Tracks, userID, sessionID, trackID) typedTracks := make([]*TrackInfo, 0) for _, track := range filteredTracks { if track.TrackType == trackType { @@ -103,15 +96,20 @@ func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir } // Find ALL generated .webm files - webmFiles, _ := filepath.Glob(filepath.Join(tempDir, "*.webm")) - if len(webmFiles) == 0 { - return fmt.Errorf("no webm output files found") + suffix := "webm" + files, _ := filepath.Glob(filepath.Join(tempDir, "*."+suffix)) + if len(files) == 0 { + suffix = "mp4" + files, _ = filepath.Glob(filepath.Join(tempDir, "*."+suffix)) + } + if len(files) == 0 { + return fmt.Errorf("no webm/mp4 output files found") } - logger.Info("Found %d WebM segment files for %s track %s", len(webmFiles), trackType, track.TrackID) + logger.Info("Found %d %s segment files for %s track %s", len(files), suffix, trackType, track.TrackID) // Create segments with timing info and fill gaps - finalFile, err := processSegmentsWithGapFilling(webmFiles, track, trackType, outputDir, fillGaps, logger) + finalFile, err := processSegmentsWithGapFilling(files, suffix, track, trackType, outputDir, fillGaps, logger) if err != nil { return fmt.Errorf("failed to process segments with gap filling: %w", err) } @@ -168,27 +166,27 @@ func copyFile(src, dst string) error { } // processSegmentsWithGapFilling processes webm segments, fills gaps if requested, and concatenates into final file -func processSegmentsWithGapFilling(webmFiles []string, track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { - if len(webmFiles) == 1 { +func processSegmentsWithGapFilling(files []string, suffix string, track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { + if len(files) == 1 { // Single segment, just copy it with final name - finalName := fmt.Sprintf("%s_%s_%s_%s.webm", trackType, track.UserID, track.SessionID, track.TrackID) + finalName := fmt.Sprintf("%s_%s_%s_%s.%s", trackType, track.UserID, track.SessionID, track.TrackID, suffix) finalPath := filepath.Join(outputDir, finalName) - err := copyFile(webmFiles[0], finalPath) + err := copyFile(files[0], finalPath) return finalPath, err } // Multiple segments - sort, optionally fill gaps, and concatenate if fillGaps { - logger.Info("Processing %d segments with gap filling for %s track %s", len(webmFiles), trackType, track.TrackID) + logger.Info("Processing %d segments with gap filling for %s track %s", len(files), trackType, track.TrackID) } else { - logger.Info("Processing %d segments (no gap filling) for %s track %s", len(webmFiles), trackType, track.TrackID) + logger.Info("Processing %d segments (no gap filling) for %s track %s", len(files), trackType, track.TrackID) } // Map webm files to their original segment timing using filenames segmentMap := make(map[string]string) // originalFilename -> webmFilePath - for _, webmFile := range webmFiles { + for _, webmFile := range files { // Extract original filename from webm filename (remove .webm, add .rtpdump) - baseName := strings.TrimSuffix(filepath.Base(webmFile), ".webm") + baseName := strings.TrimSuffix(filepath.Base(webmFile), "."+suffix) originalName := baseName + ".rtpdump" segmentMap[originalName] = webmFile } @@ -210,7 +208,7 @@ func processSegmentsWithGapFilling(webmFiles []string, track *TrackInfo, trackTy logger.Info("Detected %dms gap between segments, generating %s filler", gapDuration, trackType) // Create gap filler file - gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.webm", trackType, i)) + gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.%s", trackType, i, suffix)) if trackType == "audio" { err := webm.GenerateSilence(gapFilePath, gapSeconds, logger) @@ -219,8 +217,8 @@ func processSegmentsWithGapFilling(webmFiles []string, track *TrackInfo, trackTy continue } } else if trackType == "video" { - // Use VP8 codec and 720p quality as defaults - err := webm.GenerateBlackVideo(gapFilePath, "video/VP8", gapSeconds, 1280, 720, 30, logger) + // Use 720p quality as defaults + err := webm.GenerateBlackVideo(gapFilePath, track.Codec, gapSeconds, 1280, 720, 30, logger) if err != nil { logger.Warn("Failed to generate black video, skipping gap: %v", err) continue diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index dd439fe..134dcd9 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -4,8 +4,6 @@ import ( "flag" "fmt" "os" - - "github.com/GetStream/getstream-go/v3" ) type ExtractVideoArgs struct { @@ -45,7 +43,8 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { } // Validate input arguments against actual recording data - if err := validateInputArgs(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID); err != nil { + metadata, err := validateInputArgs(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID) + if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) if globalArgs.InputFile != "" { fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", @@ -89,7 +88,7 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { fmt.Printf(" Fill gaps: %t\n", extractVideoArgs.FillGaps) // Extract video tracks - if err := extractVideoTracks(globalArgs, extractVideoArgs, logger); err != nil { + if err := extractTracks(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, logger); err != nil { logger.Error("Failed to extract video tracks: %v", err) os.Exit(1) } @@ -97,10 +96,6 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { logger.Info("Extract video command completed successfully") } -func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, logger *getstream.DefaultLogger) error { - return extractTracks(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, "video", "both", extractVideoArgs.FillGaps, logger) -} - func printExtractVideoUsage() { fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-video [command options]\n\n") fmt.Fprintf(os.Stderr, "Generate playable video files from raw recording tracks.\n") diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go index 3eccc33..9f4888f 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/cmd/raw-recording-tools/metadata.go @@ -46,12 +46,6 @@ func NewMetadataParser(logger *getstream.DefaultLogger) *MetadataParser { } } -// ParseRecording extracts metadata from a raw recording directory -// NOTE: Now simplified to only handle directories since we always extract to tempdir first -func (p *MetadataParser) ParseRecording(inputPath string) (*RecordingMetadata, error) { - return p.parseDirectory(inputPath) -} - // ParseMetadataOnly efficiently extracts only metadata from archives (optimized for list-tracks) // This is much faster than full extraction when you only need timing metadata func (p *MetadataParser) ParseMetadataOnly(inputPath string) (*RecordingMetadata, error) { @@ -293,7 +287,7 @@ func (p *MetadataParser) extractUniqueSessions(tracks []*TrackInfo) []string { // Only one filter (userID, sessionID, or trackID) can be specified at a time // Empty values are ignored, specific values must match // If all are empty, all tracks are returned -func (p *MetadataParser) FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID string) []*TrackInfo { +func FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID string) []*TrackInfo { filtered := make([]*TrackInfo, 0) for _, track := range tracks { diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index 5ab5157..b3494ac 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -87,7 +87,7 @@ func runMixAudio(args []string, globalArgs *GlobalArgs) { logger.Info("Starting mix-audio command") // Execute the mix-audio operation - err := mixAllAudioTracks(globalArgs, mixAudioArgs, logger) + err := mixAllAudioTracks(globalArgs, mixAudioArgs, nil, logger) if err != nil { logger.Error("Mix-audio failed: %v", err) } @@ -96,10 +96,10 @@ func runMixAudio(args []string, globalArgs *GlobalArgs) { } // mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic -func mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, logger *getstream.DefaultLogger) error { +func mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Step 1: Extract all matching audio tracks using existing extractTracks function logger.Info("Step 1/2: Extracting all matching audio tracks...") - err := extractTracks(globalArgs, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, "audio", "user", mixAudioArgs.FillGaps, logger) + err := extractTracks(globalArgs, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, metadata, "audio", "user", mixAudioArgs.FillGaps, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index ffe7c2a..38297ba 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -48,7 +48,8 @@ func runMuxAV(args []string, globalArgs *GlobalArgs) { } // Validate input arguments against actual recording data - if err := validateInputArgs(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID); err != nil { + metadata, err := validateInputArgs(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID) + if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) if globalArgs.InputFile != "" { fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", @@ -81,7 +82,7 @@ func runMuxAV(args []string, globalArgs *GlobalArgs) { } // Extract and mux audio/video tracks - if err := muxAudioVideoTracks(globalArgs, muxAVArgs, logger); err != nil { + if err := muxAudioVideoTracks(globalArgs, muxAVArgs, metadata, logger); err != nil { logger.Error("Failed to mux audio/video tracks: %v", err) os.Exit(1) } @@ -103,7 +104,7 @@ func printMuxAVUsage() { fmt.Printf(" --media both Mux both types, but ensure consistent pairing (default)\n") } -func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, logger *getstream.DefaultLogger) error { +func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Create a temporary directory for intermediate files tempDir, err := os.MkdirTemp("", "mux-av-*") if err != nil { @@ -113,14 +114,14 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, logger *g // Extract audio tracks with gap filling enabled logger.Info("Extracting audio tracks with gap filling...") - err = extractTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, "audio", muxAVArgs.Media, true, logger) + err = extractTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "audio", muxAVArgs.Media, true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Extract video tracks with gap filling enabled logger.Info("Extracting video tracks with gap filling...") - err = extractTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, "video", muxAVArgs.Media, true, logger) + err = extractTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "video", muxAVArgs.Media, true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } @@ -134,7 +135,11 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, logger *g return fmt.Errorf("no audio files generated") } - videoFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.webm")) + webmVideoFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.webm")) + mp4VideoFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.mp4")) + + videoFiles := append(webmVideoFiles, mp4VideoFiles...) + if err != nil { return fmt.Errorf("failed to find video files: %w", err) } @@ -216,10 +221,10 @@ func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, logger // extractTrackIDFromFilename extracts track ID from generated filename func extractTrackIDFromFilename(filename string) string { - // Filename format: {type}_{userId}_{sessionId}_{trackId}.webm + // Filename format: {type}_{userId}_{sessionId}_{trackId}.suffix base := filepath.Base(filename) - base = strings.TrimSuffix(base, ".webm") - parts := strings.Split(base, "_") + split := strings.Split(base, ".") + parts := strings.Split(split[0], "_") if len(parts) >= 4 { return parts[3] // trackId is the 4th part } @@ -229,11 +234,11 @@ func extractTrackIDFromFilename(filename string) string { // generateMuxedFilename creates output filename for muxed file func generateMuxedFilename(audioFile, videoFile, outputDir string) string { // Extract common parts from audio filename - audioBase := filepath.Base(audioFile) - audioBase = strings.TrimSuffix(audioBase, ".webm") + videoBase := filepath.Base(videoFile) + split := strings.Split(videoBase, ".") // Replace "audio_" with "muxed_" to create output name - muxedName := strings.Replace(audioBase, "audio_", "muxed_", 1) + ".webm" + muxedName := strings.Replace(split[0], "audio_", "muxed_", 1) + "." + split[1] return filepath.Join(outputDir, muxedName) } @@ -374,21 +379,16 @@ func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, // generateMediaAwareMuxedFilename creates output filename that indicates media type func generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName string) string { - suffix := ".webm" - if strings.HasSuffix(videoFile, ".mp4") { - suffix = ".mkv" - } - // Extract common parts from audio filename - audioBase := filepath.Base(audioFile) - audioBase = strings.TrimSuffix(audioBase, suffix) + videoBase := filepath.Base(videoFile) + split := strings.Split(videoBase, ".") // Replace "audio_" with "muxed_{mediaType}_" to create output name var muxedName string if mediaTypeName == "display" { - muxedName = strings.Replace(audioBase, "audio_", "muxed_display_", 1) + ".webm" + muxedName = strings.Replace(split[0], "audio_", "muxed_display_", 1) + "." + split[1] } else { - muxedName = strings.Replace(audioBase, "audio_", "muxed_", 1) + ".webm" + muxedName = strings.Replace(split[0], "audio_", "muxed_", 1) + "." + split[1] } return filepath.Join(outputDir, muxedName) diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 87fbebe..0c67b33 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -45,7 +45,8 @@ func runProcessAll(args []string, globalArgs *GlobalArgs) { } // Validate input arguments against actual recording data - if err := validateInputArgs(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID); err != nil { + metadata, err := validateInputArgs(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID) + if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) if globalArgs.InputFile != "" { fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", @@ -78,7 +79,7 @@ func runProcessAll(args []string, globalArgs *GlobalArgs) { } // Process all tracks and mux them - if err := processAllTracks(globalArgs, processAllArgs, logger); err != nil { + if err := processAllTracks(globalArgs, processAllArgs, metadata, logger); err != nil { logger.Error("Failed to process and mux tracks: %v", err) os.Exit(1) } @@ -101,17 +102,17 @@ func printProcessAllUsage() { fmt.Printf(" muxed_{userId}_{sessionId}_{trackId}.webm - Combined audio+video file\n") } -func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, logger *getstream.DefaultLogger) error { +func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Step 1: Extract audio tracks with gap filling logger.Info("Step 1/3: Extracting audio tracks with gap filling...") - err := extractTracks(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, "audio", "both", true, logger) + err := extractTracks(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Step 2: Extract video tracks with gap filling logger.Info("Step 2/3: Extracting video tracks with gap filling...") - err = extractTracks(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, "video", "both", true, logger) + err = extractTracks(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "video", "both", true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go index 40bf566..3cb9350 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/cmd/raw-recording-tools/webm/converter.go @@ -18,8 +18,8 @@ import ( "github.com/pion/webrtc/v4/pkg/media/samplebuilder" ) -const audioMaxLate = 100 // 2sec -const videoMaxLate = 500 // 2sec +const audioMaxLate = 200 // 4sec +const videoMaxLate = 1000 // 4sec type RTPDump2WebMConverter struct { logger *getstream.DefaultLogger From 8f5e8033a1eaf5f3b587837e7843817f221e33c5 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 14:58:58 +0200 Subject: [PATCH 07/38] Refactor Metadata --- cmd/raw-recording-tools/mux_av.go | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 38297ba..1304b88 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -150,7 +150,7 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata logger.Info("Found %d audio files and %d video files to mux", len(audioFiles), len(videoFiles)) // Group files by media type for proper pairing - audioGroups, videoGroups, err := groupFilesByMediaType(globalArgs.InputFile, audioFiles, videoFiles, muxAVArgs.Media, logger) + audioGroups, videoGroups, err := groupFilesByMediaType(globalArgs.InputFile, audioFiles, videoFiles, muxAVArgs.Media, metadata, logger) if err != nil { return fmt.Errorf("failed to group files by media type: %w", err) } @@ -158,7 +158,7 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata // Mux user tracks if userAudio, userVideo := audioGroups["user"], videoGroups["user"]; len(userAudio) > 0 && len(userVideo) > 0 { logger.Info("Muxing %d user audio/video pairs", len(userAudio)) - err = muxTrackPairs(globalArgs.InputFile, userAudio, userVideo, globalArgs.Output, "user", logger) + err = muxTrackPairs(globalArgs.InputFile, userAudio, userVideo, globalArgs.Output, "user", metadata, logger) if err != nil { logger.Error("Failed to mux user tracks: %v", err) } @@ -167,7 +167,7 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata // Mux display tracks if displayAudio, displayVideo := audioGroups["display"], videoGroups["display"]; len(displayAudio) > 0 && len(displayVideo) > 0 { logger.Info("Muxing %d display audio/video pairs", len(displayAudio)) - err = muxTrackPairs(globalArgs.InputFile, displayAudio, displayVideo, globalArgs.Output, "display", logger) + err = muxTrackPairs(globalArgs.InputFile, displayAudio, displayVideo, globalArgs.Output, "display", metadata, logger) if err != nil { logger.Error("Failed to mux display tracks: %v", err) } @@ -177,7 +177,7 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata } // calculateSyncOffsetFromFiles calculates sync offset between audio and video files using metadata -func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, logger *getstream.DefaultLogger) (int64, error) { +func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) (int64, error) { // Extract track IDs from filenames audioTrackID := extractTrackIDFromFilename(audioFile) videoTrackID := extractTrackIDFromFilename(videoFile) @@ -186,13 +186,6 @@ func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, logger return 0, fmt.Errorf("could not extract track IDs from filenames") } - // Parse metadata to get timing information - parser := NewMetadataParser(logger) - metadata, err := parser.ParseMetadataOnly(inputPath) - if err != nil { - return 0, fmt.Errorf("failed to parse recording metadata: %w", err) - } - // Find the audio and video tracks var audioTrack, videoTrack *TrackInfo for _, track := range metadata.Tracks { @@ -244,14 +237,7 @@ func generateMuxedFilename(audioFile, videoFile, outputDir string) string { } // groupFilesByMediaType groups audio and video files by media type (user vs display) -func groupFilesByMediaType(inputPath string, audioFiles, videoFiles []string, mediaFilter string, logger *getstream.DefaultLogger) (map[string][]string, map[string][]string, error) { - // Parse metadata to determine media types - parser := NewMetadataParser(logger) - metadata, err := parser.ParseMetadataOnly(inputPath) - if err != nil { - return nil, nil, fmt.Errorf("failed to parse metadata: %w", err) - } - +func groupFilesByMediaType(inputPath string, audioFiles, videoFiles []string, mediaFilter string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) (map[string][]string, map[string][]string, error) { // Create track ID to screenshare type mapping trackScreenshareMap := make(map[string]bool) for _, track := range metadata.Tracks { @@ -328,7 +314,7 @@ func groupFilesByMediaType(inputPath string, audioFiles, videoFiles []string, me } // muxTrackPairs muxes audio/video pairs of the same media type -func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, mediaTypeName string, logger *getstream.DefaultLogger) error { +func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, mediaTypeName string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { minLen := len(audioFiles) if len(videoFiles) < minLen { minLen = len(videoFiles) @@ -344,7 +330,7 @@ func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, videoFile := videoFiles[i] // Calculate sync offset using segment timing information - offset, err := calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile, logger) + offset, err := calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile, metadata, logger) if err != nil { logger.Warn("Failed to calculate sync offset, using 0: %v", err) offset = 0 From 50dde29201ec66e0698276ffb44762ccafec83ea Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 14:59:14 +0200 Subject: [PATCH 08/38] Refactor Metadata --- cmd/raw-recording-tools/process_all.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 0c67b33..d82ba2c 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -119,7 +119,7 @@ func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, me // Step 3: Mux audio and video files (keeping originals) logger.Info("Step 3/3: Muxing audio and video tracks...") - err = muxAudioVideoTracksKeepOriginals(globalArgs, processAllArgs, logger) + err = muxAudioVideoTracksKeepOriginals(globalArgs, processAllArgs, metadata, logger) if err != nil { return fmt.Errorf("failed to mux audio and video tracks: %w", err) } @@ -149,7 +149,7 @@ func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, me } // muxAudioVideoTracksKeepOriginals is like muxAudioVideoTracks but keeps the original audio/video files -func muxAudioVideoTracksKeepOriginals(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, logger *getstream.DefaultLogger) error { +func muxAudioVideoTracksKeepOriginals(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Find the generated audio and video WebM files audioFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "audio_*.webm")) if err != nil { @@ -178,7 +178,7 @@ func muxAudioVideoTracksKeepOriginals(globalArgs *GlobalArgs, processAllArgs *Pr videoFile := videoFiles[i] // Calculate sync offset using segment timing information - offset, err := calculateSyncOffsetFromFiles(globalArgs.InputFile, audioFile, videoFile, logger) + offset, err := calculateSyncOffsetFromFiles(globalArgs.InputFile, audioFile, videoFile, metadata, logger) if err != nil { logger.Warn("Failed to calculate sync offset, using 0: %v", err) offset = 0 From 5924df8d4d6489b692ffdb6caee7596195802a4e Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 15:37:22 +0200 Subject: [PATCH 09/38] Refactor filter --- cmd/raw-recording-tools/extract_track.go | 26 +++++------------------- cmd/raw-recording-tools/list_tracks.go | 11 +--------- cmd/raw-recording-tools/metadata.go | 16 ++++++++++++++- 3 files changed, 21 insertions(+), 32 deletions(-) diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go index 6d325c4..ecb8eec 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/cmd/raw-recording-tools/extract_track.go @@ -31,29 +31,13 @@ func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID string, me defer cleanup() // Filter tracks to specified type only and apply hierarchical filtering - filteredTracks := FilterTracks(metadata.Tracks, userID, sessionID, trackID) - typedTracks := make([]*TrackInfo, 0) - for _, track := range filteredTracks { - if track.TrackType == trackType { - // Apply media type filtering if specified - if mediaFilter != "" && mediaFilter != "both" { - if mediaFilter == "user" && track.IsScreenshare { - continue // Skip display tracks when only user requested - } - if mediaFilter == "display" && !track.IsScreenshare { - continue // Skip user tracks when only display requested - } - } - typedTracks = append(typedTracks, track) - } - } - - if len(typedTracks) == 0 { + filteredTracks := FilterTracks(metadata.Tracks, userID, sessionID, trackID, trackType, mediaFilter) + if len(filteredTracks) == 0 { logger.Warn("No %s tracks found matching the filter criteria", trackType) return nil } - logger.Info("Found %d %s tracks to extract", len(typedTracks), trackType) + logger.Info("Found %d %s tracks to extract", len(filteredTracks), trackType) // Create output directory if it doesn't exist err = os.MkdirAll(globalArgs.Output, 0755) @@ -62,8 +46,8 @@ func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID string, me } // Extract and convert each track - for i, track := range typedTracks { - logger.Info("Processing %s track %d/%d: %s", trackType, i+1, len(typedTracks), track.TrackID) + for i, track := range filteredTracks { + logger.Info("Processing %s track %d/%d: %s", trackType, i+1, len(filteredTracks), track.TrackID) err = extractSingleTrackWithOptions(workingDir, track, globalArgs.Output, trackType, fillGaps, logger) if err != nil { diff --git a/cmd/raw-recording-tools/list_tracks.go b/cmd/raw-recording-tools/list_tracks.go index d261f60..97612ae 100644 --- a/cmd/raw-recording-tools/list_tracks.go +++ b/cmd/raw-recording-tools/list_tracks.go @@ -65,16 +65,7 @@ func runListTracks(args []string, globalArgs *GlobalArgs) { } // Filter tracks if track type is specified - tracks := metadata.Tracks - if listTracksArgs.TrackType != "" { - filteredTracks := make([]*TrackInfo, 0) - for _, track := range tracks { - if track.TrackType == listTracksArgs.TrackType { - filteredTracks = append(filteredTracks, track) - } - } - tracks = filteredTracks - } + tracks := FilterTracks(metadata.Tracks, "", "", "", listTracksArgs.TrackType, "") // Output in requested format switch listTracksArgs.Format { diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go index 9f4888f..4cbe3a9 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/cmd/raw-recording-tools/metadata.go @@ -287,10 +287,24 @@ func (p *MetadataParser) extractUniqueSessions(tracks []*TrackInfo) []string { // Only one filter (userID, sessionID, or trackID) can be specified at a time // Empty values are ignored, specific values must match // If all are empty, all tracks are returned -func FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID string) []*TrackInfo { +func FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID, trackType, mediaFilter string) []*TrackInfo { filtered := make([]*TrackInfo, 0) for _, track := range tracks { + if trackType != "" && track.TrackType != trackType { + continue // Skip tracks with wrong TrackType + } + + // Apply media type filtering if specified + if mediaFilter != "" && mediaFilter != "both" { + if mediaFilter == "user" && track.IsScreenshare { + continue // Skip display tracks when only user requested + } + if mediaFilter == "display" && !track.IsScreenshare { + continue // Skip user tracks when only display requested + } + } + // Apply the single specified filter (mutually exclusive) if trackID != "" { // Filter by trackID - return only that specific track From 2dfea1c1e39620851deee70de88a7c292e5d054c Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 18:07:15 +0200 Subject: [PATCH 10/38] Use OpusPacket duration with DTX packet insertion --- cmd/raw-recording-tools/mux_av.go | 7 ++- cmd/raw-recording-tools/webm/converter.go | 75 ++++++++++++++++++----- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 1304b88..4bba84e 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -365,6 +365,9 @@ func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, // generateMediaAwareMuxedFilename creates output filename that indicates media type func generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName string) string { + audioBase := filepath.Base(audioFile) + audioBase = strings.TrimSuffix(audioBase, ".webm") + // Extract common parts from audio filename videoBase := filepath.Base(videoFile) split := strings.Split(videoBase, ".") @@ -372,9 +375,9 @@ func generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeN // Replace "audio_" with "muxed_{mediaType}_" to create output name var muxedName string if mediaTypeName == "display" { - muxedName = strings.Replace(split[0], "audio_", "muxed_display_", 1) + "." + split[1] + muxedName = strings.Replace(audioBase, "audio_", "muxed_display_", 1) + "." + split[1] } else { - muxedName = strings.Replace(split[0], "audio_", "muxed_", 1) + "." + split[1] + muxedName = strings.Replace(audioBase, "audio_", "muxed_", 1) + "." + split[1] } return filepath.Join(outputDir, muxedName) diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go index 3cb9350..5781703 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/cmd/raw-recording-tools/webm/converter.go @@ -111,26 +111,30 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string) error { pkt.SequenceNumber += c.inserted if c.lastPkt != nil { - if mType == webrtc.MimeTypeOpus { - tsDiff := pkt.Timestamp - c.lastPkt.Timestamp - if tsDiff > 960 { + if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { + c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } - c.logger.Debug("Gap detected %v: %v", pkt, err) + if mType == webrtc.MimeTypeOpus { + tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover + lastPktDuration := opusPacketDurationMs(c.lastPkt.Payload) + rtpDuration := uint32(lastPktDuration * 48) + if tsDiff > rtpDuration { + // Calculate how many packets we need to insert, taking care of packet losses var toAdd uint16 - - // DTX detected, we need to insert packet - if uint32(pkt.SequenceNumber-c.lastPkt.SequenceNumber)*960 != tsDiff { - toAdd = uint16(tsDiff/960) - (pkt.SequenceNumber - c.lastPkt.SequenceNumber) + if uint32(pkt.SequenceNumber-c.lastPkt.SequenceNumber)*rtpDuration != tsDiff { // TODO handle rollover + toAdd = uint16(tsDiff/rtpDuration) - (pkt.SequenceNumber - c.lastPkt.SequenceNumber) } - // c.logger.Debugf("Inserting %d packets Previous inserting %s", toAdd, c.inserted) + c.logger.Info("Gap detected, inserting %d packets tsDiff %d, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", + toAdd, tsDiff, c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) for i := 1; i <= int(toAdd); i++ { ins := c.lastPkt.Clone() - ins.Payload = ins.Payload[:1] + ins.Payload = ins.Payload[:1] // Keeping only TOC byte ins.SequenceNumber += uint16(i) - ins.Timestamp += uint32(i) * 960 + ins.Timestamp += uint32(i) * rtpDuration c.logger.Debug("Writing inserted Packet %v", ins) e := c.recorder.OnRTP(ins) @@ -145,15 +149,11 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string) error { // c.logger.Debugf("Inserted %d packets Previous inserting %s", toAdd, c.inserted) } } - - if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { - c.logger.Warn("Missing Detected Packet %v - %v", pkt, c.lastPkt) - } } c.lastPkt = pkt - c.logger.Debug("Writing real Packet %v", pkt) + c.logger.Debug("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) e := c.recorder.OnRTP(pkt) if e != nil { c.logger.Warn("Failed to record RTP packet %v: %v", pkt, err) @@ -236,3 +236,46 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { return nil } + +func opusPacketDurationMs(packet []byte) int { + if len(packet) < 1 { + return 0 + } + toc := packet[0] + config := toc & 0x1F + c := (toc >> 6) & 0x03 + + frameDuration := 0 + switch { + case config <= 3: + frameDuration = 10 << (config & 0x03) // 10,20,40,60 + case config <= 7: + frameDuration = 10 << (config & 0x03) + case config <= 11: + frameDuration = 10 << (config & 0x03) + case config <= 13: + frameDuration = 10 << (config & 0x01) + case config <= 19: + frameDuration = 25 / 10 // 2.5ms + case config <= 23: + frameDuration = 5 + case config <= 27: + frameDuration = 10 + default: + frameDuration = 20 + } + + var frameCount int + switch c { + case 0: + frameCount = 1 + case 1, 2: + frameCount = 2 + case 3: + if len(packet) > 1 { + frameCount = int(packet[1] & 0x3F) + } + } + + return frameDuration * frameCount +} From f7e48300c02411d3a75b14cd20b13bf35b7526f1 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 18:38:37 +0200 Subject: [PATCH 11/38] Refactor Converter to simplify it --- cmd/raw-recording-tools/constants.go | 6 - cmd/raw-recording-tools/webm/constants.go | 15 ++ cmd/raw-recording-tools/webm/converter.go | 188 +++++++++++----------- 3 files changed, 105 insertions(+), 104 deletions(-) delete mode 100644 cmd/raw-recording-tools/constants.go create mode 100644 cmd/raw-recording-tools/webm/constants.go diff --git a/cmd/raw-recording-tools/constants.go b/cmd/raw-recording-tools/constants.go deleted file mode 100644 index 5b3fe3e..0000000 --- a/cmd/raw-recording-tools/constants.go +++ /dev/null @@ -1,6 +0,0 @@ -package main - -const ( - RtpDump = "rtpdump" - SuffixRtpDump = "." + RtpDump -) diff --git a/cmd/raw-recording-tools/webm/constants.go b/cmd/raw-recording-tools/webm/constants.go new file mode 100644 index 0000000..c91e756 --- /dev/null +++ b/cmd/raw-recording-tools/webm/constants.go @@ -0,0 +1,15 @@ +package webm + +const ( + RtpDump = "rtpdump" + SuffixRtpDump = "." + RtpDump + + Sdp = "sdp" + SuffixSdp = "." + Sdp + + Webm = "webm" + SuffixWebm = "." + Webm + + Mp4 = "mp4" + SuffixMp4 = "." + Mp4 +) diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go index 5781703..085dac0 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/cmd/raw-recording-tools/webm/converter.go @@ -52,7 +52,7 @@ func ConvertDirectory(directory string, logger *getstream.DefaultLogger) error { return err } - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".rtpdump") { + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), SuffixRtpDump) { rtpdumpFiles = append(rtpdumpFiles, path) } @@ -88,95 +88,35 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string) error { reader, _, _ := rtpdump.NewReader(file) c.reader = reader - sdpContent, _ := rawsdputil.ReadSDP(strings.Replace(inputFile, ".rtpdump", ".sdp", 1)) + sdpContent, _ := rawsdputil.ReadSDP(strings.Replace(inputFile, SuffixRtpDump, SuffixSdp, 1)) mType, _ := rawsdputil.MimeType(sdpContent) - var recorder WebmRecorder - switch mType { - case webrtc.MimeTypeAV1, webrtc.MimeTypeVP9: - recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, ".rtpdump", ".webm", 1), sdpContent, c.logger) - case webrtc.MimeTypeH264: - recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, ".rtpdump", ".mp4", 1), sdpContent, c.logger) - default: - recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, ".rtpdump", ".webm", 1), sdpContent, c.logger) - } - if err != nil { - return fmt.Errorf("failed to create WebM recorder: %w", err) - } - defer recorder.Close() - - c.recorder = recorder - - options := samplebuilder.WithPacketReleaseHandler(func(pkt *rtp.Packet) { - pkt.SequenceNumber += c.inserted - - if c.lastPkt != nil { - if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { - c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) - } - - if mType == webrtc.MimeTypeOpus { - tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover - lastPktDuration := opusPacketDurationMs(c.lastPkt.Payload) - rtpDuration := uint32(lastPktDuration * 48) - if tsDiff > rtpDuration { - - // Calculate how many packets we need to insert, taking care of packet losses - var toAdd uint16 - if uint32(pkt.SequenceNumber-c.lastPkt.SequenceNumber)*rtpDuration != tsDiff { // TODO handle rollover - toAdd = uint16(tsDiff/rtpDuration) - (pkt.SequenceNumber - c.lastPkt.SequenceNumber) - } - - c.logger.Info("Gap detected, inserting %d packets tsDiff %d, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", - toAdd, tsDiff, c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) - - for i := 1; i <= int(toAdd); i++ { - ins := c.lastPkt.Clone() - ins.Payload = ins.Payload[:1] // Keeping only TOC byte - ins.SequenceNumber += uint16(i) - ins.Timestamp += uint32(i) * rtpDuration + defaultReleasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) - c.logger.Debug("Writing inserted Packet %v", ins) - e := c.recorder.OnRTP(ins) - if e != nil { - c.logger.Warn("Failed to record RTP packet %v: %v", pkt, err) - } - - // Need to compute new packet - } - c.inserted += toAdd - pkt.SequenceNumber += toAdd - // c.logger.Debugf("Inserted %d packets Previous inserting %s", toAdd, c.inserted) - } - } - } - - c.lastPkt = pkt - - c.logger.Debug("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) - e := c.recorder.OnRTP(pkt) - if e != nil { - c.logger.Warn("Failed to record RTP packet %v: %v", pkt, err) - } - }) - - // Initialize samplebuilder based on codec type - var sampleBuilder *samplebuilder.SampleBuilder switch mType { - case webrtc.MimeTypeOpus: - sampleBuilder = samplebuilder.New(audioMaxLate, &codecs.OpusPacket{}, 48000, options) - case webrtc.MimeTypeVP8: - sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP8Packet{}, 90000, options) + case webrtc.MimeTypeAV1: + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, defaultReleasePacketHandler) + c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeVP9: - sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, options) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, defaultReleasePacketHandler) + c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeH264: - sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, options) - case webrtc.MimeTypeAV1: - sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, options) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, defaultReleasePacketHandler) + c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixMp4, 1), sdpContent, c.logger) + case webrtc.MimeTypeVP8: + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP8Packet{}, 90000, defaultReleasePacketHandler) + c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + case webrtc.MimeTypeOpus: + options := samplebuilder.WithPacketReleaseHandler(c.buildOpusReleasePacketHandler()) + c.sampleBuilder = samplebuilder.New(audioMaxLate, &codecs.OpusPacket{}, 48000, options) + c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) default: return fmt.Errorf("unsupported codec type: %s", mType) } - c.sampleBuilder = sampleBuilder + if err != nil { + return fmt.Errorf("failed to create WebM recorder: %w", err) + } + defer c.recorder.Close() time.Sleep(1 * time.Second) @@ -187,39 +127,34 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string) error { func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { startTime := time.Now() - for i := 0; ; i++ { + i := 0 + for ; ; i++ { packet, err := reader.Next() - if err != nil { - if errors.Is(err, io.EOF) { - break - } else { - return err - } - } - - if packet.IsRTCP { + if errors.Is(err, io.EOF) { + break + } else if err != nil { + return err + } else if packet.IsRTCP { continue } // Unmarshal the RTP packet from the raw payload - if c.sampleBuilder == nil { _ = c.recorder.PushRtpBuf(packet.Payload) - } else - // Unmarshal the RTP packet from the raw payload - { + } else { + // Unmarshal the RTP packet from the raw payload rtpPacket := &rtp.Packet{} if err := rtpPacket.Unmarshal(packet.Payload); err != nil { c.logger.Warn("Failed to unmarshal RTP packet %d: %v", i, err) continue } + // Push packet to samplebuilder for reordering c.sampleBuilder.Push(rtpPacket) } - // Push packet to samplebuilder for reordering // Log progress - if i%100 == 0 && i > 0 { + if i%2000 == 0 && i > 0 { c.logger.Info("Processed %d packets", i) } } @@ -229,7 +164,7 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { } duration := time.Since(startTime) - c.logger.Info("Finished feeding packets in %v", duration) + c.logger.Info("Finished feeding %d packets in %v", i, duration) // Allow some time for the recorder to finalize time.Sleep(2 * time.Second) @@ -237,6 +172,63 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { return nil } +func (c *RTPDump2WebMConverter) buildDefaultReleasePacketHandler() func(pkt *rtp.Packet) { + return func(pkt *rtp.Packet) { + if e := c.recorder.OnRTP(pkt); e != nil { + c.logger.Warn("Failed to record RTP packet %v: %v", pkt, e) + } + } +} + +func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Packet) { + return func(pkt *rtp.Packet) { + pkt.SequenceNumber += c.inserted + + if c.lastPkt != nil { + if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { + c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } + + tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover + lastPktDuration := opusPacketDurationMs(c.lastPkt.Payload) + rtpDuration := uint32(lastPktDuration * 48) + if tsDiff > rtpDuration { + + // Calculate how many packets we need to insert, taking care of packet losses + var toAdd uint16 + if uint32(pkt.SequenceNumber-c.lastPkt.SequenceNumber)*rtpDuration != tsDiff { // TODO handle rollover + toAdd = uint16(tsDiff/rtpDuration) - (pkt.SequenceNumber - c.lastPkt.SequenceNumber) + } + + c.logger.Info("Gap detected, inserting %d packets tsDiff %d, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", + toAdd, tsDiff, c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + + for i := 1; i <= int(toAdd); i++ { + ins := c.lastPkt.Clone() + ins.Payload = ins.Payload[:1] // Keeping only TOC byte + ins.SequenceNumber += uint16(i) + ins.Timestamp += uint32(i) * rtpDuration + + c.logger.Debug("Writing inserted Packet %v", ins) + if e := c.recorder.OnRTP(ins); e != nil { + c.logger.Warn("Failed to record RTP packet %v: %v", pkt, e) + } + } + + c.inserted += toAdd + pkt.SequenceNumber += toAdd + } + } + + c.lastPkt = pkt + + c.logger.Debug("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) + if e := c.recorder.OnRTP(pkt); e != nil { + c.logger.Warn("Failed to record RTP packet %v: %v", pkt, e) + } + } +} + func opusPacketDurationMs(packet []byte) int { if len(packet) < 1 { return 0 From 292bb3bda2b3d73defdd85da8e5f30f9f919258b Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 23:27:58 +0200 Subject: [PATCH 12/38] logs --- cmd/raw-recording-tools/webm/converter.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go index 085dac0..77c0992 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/cmd/raw-recording-tools/webm/converter.go @@ -175,7 +175,7 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { func (c *RTPDump2WebMConverter) buildDefaultReleasePacketHandler() func(pkt *rtp.Packet) { return func(pkt *rtp.Packet) { if e := c.recorder.OnRTP(pkt); e != nil { - c.logger.Warn("Failed to record RTP packet %v: %v", pkt, e) + c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) } } } @@ -211,7 +211,7 @@ func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Pa c.logger.Debug("Writing inserted Packet %v", ins) if e := c.recorder.OnRTP(ins); e != nil { - c.logger.Warn("Failed to record RTP packet %v: %v", pkt, e) + c.logger.Warn("Failed to record inserted RTP packet SeqNum: %d RtpTs: %d: %v", ins.SequenceNumber, ins.Timestamp, e) } } @@ -224,7 +224,7 @@ func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Pa c.logger.Debug("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) if e := c.recorder.OnRTP(pkt); e != nil { - c.logger.Warn("Failed to record RTP packet %v: %v", pkt, e) + c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) } } } From fb414790d9727895d9a99fe6f733cb9ce2529899 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 23:50:36 +0200 Subject: [PATCH 13/38] Validate GlobalArgs and extractToDI --- cmd/raw-recording-tools/extract_audio.go | 27 ++++++++++++------ cmd/raw-recording-tools/extract_track.go | 25 ++--------------- cmd/raw-recording-tools/extract_video.go | 35 +++++++++++++----------- cmd/raw-recording-tools/list_tracks.go | 7 ----- cmd/raw-recording-tools/main.go | 18 +++++------- cmd/raw-recording-tools/mux_av.go | 23 ++++++++++------ cmd/raw-recording-tools/process_all.go | 23 ++++++++++------ 7 files changed, 75 insertions(+), 83 deletions(-) diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index 7e40c01..5dd5e80 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -4,6 +4,8 @@ import ( "flag" "fmt" "os" + + "github.com/GetStream/getstream-go/v3" ) type ExtractAudioArgs struct { @@ -35,13 +37,6 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Validate global arguments - if err := validateGlobalArgs(globalArgs, "extract-audio"); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - printExtractAudioUsage() - os.Exit(1) - } - // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID) if err != nil { @@ -80,7 +75,7 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs) { fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) // Implement extract audio functionality - if err := extractTracks(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, logger); err != nil { + if err := extractAudioTracks(globalArgs, extractAudioArgs, metadata, logger); err != nil { logger.Error("Failed to extract audio: %v", err) } @@ -112,3 +107,19 @@ func printExtractAudioUsage() { fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --userId user123 --sessionId session456 --trackId '*'\n\n") fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") } + +func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Extract to temp directory if needed (unified approach) + workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) + if err != nil { + return fmt.Errorf("failed to prepare working directory: %w", err) + } + defer cleanup() + + // Create output directory if it doesn't exist + if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + return extractTracks(workingDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, logger) +} diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go index ecb8eec..a46e638 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/cmd/raw-recording-tools/extract_track.go @@ -14,22 +14,7 @@ import ( ) // Generic track extraction function that works for both audio and video -func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps bool, logger *getstream.DefaultLogger) error { - var inputPath string - if globalArgs.InputFile != "" { - inputPath = globalArgs.InputFile - } else { - // TODO: Handle S3 input - return fmt.Errorf("S3 input not yet supported") - } - - // Extract to temp directory if needed (unified approach) - workingDir, cleanup, err := extractToTempDir(inputPath, logger) - if err != nil { - return fmt.Errorf("failed to prepare working directory: %w", err) - } - defer cleanup() - +func extractTracks(workingDir, outputDir, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps bool, logger *getstream.DefaultLogger) error { // Filter tracks to specified type only and apply hierarchical filtering filteredTracks := FilterTracks(metadata.Tracks, userID, sessionID, trackID, trackType, mediaFilter) if len(filteredTracks) == 0 { @@ -39,17 +24,11 @@ func extractTracks(globalArgs *GlobalArgs, userID, sessionID, trackID string, me logger.Info("Found %d %s tracks to extract", len(filteredTracks), trackType) - // Create output directory if it doesn't exist - err = os.MkdirAll(globalArgs.Output, 0755) - if err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - // Extract and convert each track for i, track := range filteredTracks { logger.Info("Processing %s track %d/%d: %s", trackType, i+1, len(filteredTracks), track.TrackID) - err = extractSingleTrackWithOptions(workingDir, track, globalArgs.Output, trackType, fillGaps, logger) + err := extractSingleTrackWithOptions(workingDir, track, outputDir, trackType, fillGaps, logger) if err != nil { logger.Error("Failed to extract %s track %s: %v", trackType, track.TrackID, err) continue diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index 134dcd9..cd378ab 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -4,6 +4,8 @@ import ( "flag" "fmt" "os" + + "github.com/GetStream/getstream-go/v3" ) type ExtractVideoArgs struct { @@ -35,13 +37,6 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Validate global arguments - if err := validateGlobalArgs(globalArgs, "extract-video"); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - printExtractVideoUsage() - os.Exit(1) - } - // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID) if err != nil { @@ -58,14 +53,6 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { logger.Info("Starting extract-video command") - // TODO: Implement extract video functionality - // This should: - // 1. Read the input file (zip or S3) - // 2. Filter tracks based on userId, sessionId, trackId - // 3. Extract video tracks and convert to playable format (webm, mp4, etc.) - // 4. Apply gap filling with black frames if requested - // 5. Save to output directory - fmt.Printf("Extract video command with hierarchical filtering:\n") if globalArgs.InputFile != "" { fmt.Printf(" Input file: %s\n", globalArgs.InputFile) @@ -88,7 +75,7 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { fmt.Printf(" Fill gaps: %t\n", extractVideoArgs.FillGaps) // Extract video tracks - if err := extractTracks(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, logger); err != nil { + if err := extractVideoTracks(globalArgs, extractVideoArgs, metadata, logger); err != nil { logger.Error("Failed to extract video tracks: %v", err) os.Exit(1) } @@ -121,3 +108,19 @@ func printExtractVideoUsage() { fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --userId user123 --sessionId session456 --trackId '*'\n\n") fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") } + +func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Extract to temp directory if needed (unified approach) + workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) + if err != nil { + return fmt.Errorf("failed to prepare working directory: %w", err) + } + defer cleanup() + + // Create output directory if it doesn't exist + if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + return extractTracks(workingDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, logger) +} diff --git a/cmd/raw-recording-tools/list_tracks.go b/cmd/raw-recording-tools/list_tracks.go index 97612ae..91b7357 100644 --- a/cmd/raw-recording-tools/list_tracks.go +++ b/cmd/raw-recording-tools/list_tracks.go @@ -36,13 +36,6 @@ func runListTracks(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Validate global arguments - if err := validateGlobalArgs(globalArgs, "list-tracks"); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - printListTracksUsage() - os.Exit(1) - } - // Setup logger logger := setupLogger(globalArgs.Verbose) diff --git a/cmd/raw-recording-tools/main.go b/cmd/raw-recording-tools/main.go index 3ce34ee..5c69ee6 100644 --- a/cmd/raw-recording-tools/main.go +++ b/cmd/raw-recording-tools/main.go @@ -97,6 +97,13 @@ func parseGlobalFlags(args []string, globalArgs *GlobalArgs) (string, []string) os.Exit(1) } + // Validate global arguments + if e := validateGlobalArgs(globalArgs, command); e != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", e) + printUsage() + os.Exit(1) + } + return command, remainingArgs } @@ -170,14 +177,3 @@ func runCompletion(args []string) { shell := args[0] generateCompletion(shell) } - -// getInputPath returns the input path from global args -func getInputPath(globalArgs *GlobalArgs) string { - if globalArgs.InputFile != "" { - return globalArgs.InputFile - } - if globalArgs.InputS3 != "" { - return globalArgs.InputS3 - } - return "" -} diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 4bba84e..ed1c30b 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -40,13 +40,6 @@ func runMuxAV(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Validate global arguments - if err := validateGlobalArgs(globalArgs, "mux-av"); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - printMuxAVUsage() - os.Exit(1) - } - // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID) if err != nil { @@ -105,6 +98,18 @@ func printMuxAVUsage() { } func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Extract to temp directory if needed (unified approach) + workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) + if err != nil { + return fmt.Errorf("failed to prepare working directory: %w", err) + } + defer cleanup() + + // Create output directory if it doesn't exist + if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + // Create a temporary directory for intermediate files tempDir, err := os.MkdirTemp("", "mux-av-*") if err != nil { @@ -114,14 +119,14 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata // Extract audio tracks with gap filling enabled logger.Info("Extracting audio tracks with gap filling...") - err = extractTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "audio", muxAVArgs.Media, true, logger) + err = extractTracks(workingDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "audio", muxAVArgs.Media, true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Extract video tracks with gap filling enabled logger.Info("Extracting video tracks with gap filling...") - err = extractTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "video", muxAVArgs.Media, true, logger) + err = extractTracks(workingDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "video", muxAVArgs.Media, true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index d82ba2c..adea246 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -37,13 +37,6 @@ func runProcessAll(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Validate global arguments - if err := validateGlobalArgs(globalArgs, "process-all"); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - printProcessAllUsage() - os.Exit(1) - } - // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID) if err != nil { @@ -103,16 +96,28 @@ func printProcessAllUsage() { } func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Extract to temp directory if needed (unified approach) + workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) + if err != nil { + return fmt.Errorf("failed to prepare working directory: %w", err) + } + defer cleanup() + + // Create output directory if it doesn't exist + if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + // Step 1: Extract audio tracks with gap filling logger.Info("Step 1/3: Extracting audio tracks with gap filling...") - err := extractTracks(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, logger) + err = extractTracks(workingDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Step 2: Extract video tracks with gap filling logger.Info("Step 2/3: Extracting video tracks with gap filling...") - err = extractTracks(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "video", "both", true, logger) + err = extractTracks(workingDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "video", "both", true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } From ea510d136e0ffa384eb6b1c4a98676fa745ba068 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 9 Oct 2025 23:57:55 +0200 Subject: [PATCH 14/38] Validate GlobalArgs and extractToDI --- cmd/raw-recording-tools/mix_audio.go | 63 ++++++++++------------------ 1 file changed, 22 insertions(+), 41 deletions(-) diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index b3494ac..5a42ae3 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -40,46 +40,15 @@ func runMixAudio(args []string, globalArgs *GlobalArgs) { FillGaps: true, // Always fill gaps for proper mixing } - // Parse command-line arguments - for i := 0; i < len(args); i++ { - switch args[i] { - case "--userId": - if i+1 < len(args) { - mixAudioArgs.UserID = args[i+1] - i++ - } else { - fmt.Fprintf(os.Stderr, "Error: --userId requires a value\n") - printMixAudioUsage() - os.Exit(1) - } - case "--sessionId": - if i+1 < len(args) { - mixAudioArgs.SessionID = args[i+1] - i++ - } else { - fmt.Fprintf(os.Stderr, "Error: --sessionId requires a value\n") - printMixAudioUsage() - os.Exit(1) - } - case "--trackId": - if i+1 < len(args) { - mixAudioArgs.TrackID = args[i+1] - i++ - } else { - fmt.Fprintf(os.Stderr, "Error: --trackId requires a value\n") - printMixAudioUsage() - os.Exit(1) - } - case "--no-fill-gaps": - mixAudioArgs.FillGaps = false - case "-h", "--help": - printMixAudioUsage() - return - default: - fmt.Fprintf(os.Stderr, "Unknown argument: %s\n", args[i]) - printMixAudioUsage() - os.Exit(1) + // Validate input arguments against actual recording data + metadata, err := validateInputArgs(globalArgs, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID) + if err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + if globalArgs.InputFile != "" { + fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", + globalArgs.InputFile, globalArgs.Output) } + os.Exit(1) } // Setup logger @@ -87,7 +56,7 @@ func runMixAudio(args []string, globalArgs *GlobalArgs) { logger.Info("Starting mix-audio command") // Execute the mix-audio operation - err := mixAllAudioTracks(globalArgs, mixAudioArgs, nil, logger) + err = mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, logger) if err != nil { logger.Error("Mix-audio failed: %v", err) } @@ -97,9 +66,21 @@ func runMixAudio(args []string, globalArgs *GlobalArgs) { // mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic func mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Extract to temp directory if needed (unified approach) + workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) + if err != nil { + return fmt.Errorf("failed to prepare working directory: %w", err) + } + defer cleanup() + + // Create output directory if it doesn't exist + if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + // Step 1: Extract all matching audio tracks using existing extractTracks function logger.Info("Step 1/2: Extracting all matching audio tracks...") - err := extractTracks(globalArgs, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, metadata, "audio", "user", mixAudioArgs.FillGaps, logger) + err = extractTracks(workingDir, globalArgs.Output, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, metadata, "audio", "user", mixAudioArgs.FillGaps, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } From 27f37e0363408f7d3898f93d8fccaced8699333c Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 00:09:44 +0200 Subject: [PATCH 15/38] Help --- cmd/raw-recording-tools/extract_audio.go | 10 ++-------- cmd/raw-recording-tools/extract_video.go | 10 ++-------- cmd/raw-recording-tools/list_tracks.go | 10 ++-------- cmd/raw-recording-tools/main.go | 9 +++++++++ cmd/raw-recording-tools/mix_audio.go | 2 ++ cmd/raw-recording-tools/mux_av.go | 10 ++-------- cmd/raw-recording-tools/process_all.go | 10 ++-------- 7 files changed, 21 insertions(+), 40 deletions(-) diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index 5dd5e80..fbe96bc 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -16,6 +16,8 @@ type ExtractAudioArgs struct { } func runExtractAudio(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, printExtractAudioUsage) + // Parse command-specific flags fs := flag.NewFlagSet("extract-audio", flag.ExitOnError) extractAudioArgs := &ExtractAudioArgs{} @@ -24,14 +26,6 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs) { fs.StringVar(&extractAudioArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") fs.BoolVar(&extractAudioArgs.FillGaps, "fill_gaps", false, "Fix DTX shrink audio, and fill with silence when track was muted") - // Check for help flag before parsing - for _, arg := range args { - if arg == "--help" || arg == "-h" { - printExtractAudioUsage() - return - } - } - if err := fs.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) os.Exit(1) diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index cd378ab..49755c1 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -16,6 +16,8 @@ type ExtractVideoArgs struct { } func runExtractVideo(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, printExtractVideoUsage) + // Parse command-specific flags fs := flag.NewFlagSet("extract-video", flag.ExitOnError) extractVideoArgs := &ExtractVideoArgs{} @@ -24,14 +26,6 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { fs.StringVar(&extractVideoArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") fs.BoolVar(&extractVideoArgs.FillGaps, "fill_gaps", false, "Fill with black frame when track was muted") - // Check for help flag before parsing - for _, arg := range args { - if arg == "--help" || arg == "-h" { - printExtractVideoUsage() - return - } - } - if err := fs.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) os.Exit(1) diff --git a/cmd/raw-recording-tools/list_tracks.go b/cmd/raw-recording-tools/list_tracks.go index 91b7357..ae21d14 100644 --- a/cmd/raw-recording-tools/list_tracks.go +++ b/cmd/raw-recording-tools/list_tracks.go @@ -16,6 +16,8 @@ type ListTracksArgs struct { } func runListTracks(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, printListTracksUsage) + // Parse command-specific flags fs := flag.NewFlagSet("list-tracks", flag.ExitOnError) listTracksArgs := &ListTracksArgs{} @@ -23,14 +25,6 @@ func runListTracks(args []string, globalArgs *GlobalArgs) { fs.StringVar(&listTracksArgs.TrackType, "trackType", "", "Filter by track type: audio, video") fs.StringVar(&listTracksArgs.CompletionType, "completionType", "tracks", "For completion format: users, sessions, tracks") - // Check for help flag before parsing - for _, arg := range args { - if arg == "--help" || arg == "-h" { - printListTracksUsage() - return - } - } - if err := fs.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) os.Exit(1) diff --git a/cmd/raw-recording-tools/main.go b/cmd/raw-recording-tools/main.go index 5c69ee6..5a0f569 100644 --- a/cmd/raw-recording-tools/main.go +++ b/cmd/raw-recording-tools/main.go @@ -167,6 +167,15 @@ func printUsage() { fmt.Fprintf(os.Stderr, " %s completion fish | source\n", os.Args[0]) } +func printHelpIfAsked(args []string, fn func()) { + // Check for help flag before parsing + for _, arg := range args { + if arg == "--help" || arg == "-h" { + fn() + os.Exit(0) + } + } +} func runCompletion(args []string) { if len(args) == 0 { fmt.Fprintf(os.Stderr, "Usage: raw-tools completion \n") diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index 5a42ae3..a673aa3 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -33,6 +33,8 @@ type AudioFileWithTiming struct { // runMixAudio handles the mix-audio command func runMixAudio(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, printMixAudioUsage) + mixAudioArgs := &MixAudioArgs{ UserID: "", // Default: all users (empty) SessionID: "", // Default: all sessions (empty) diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index ed1c30b..bba9f58 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -19,6 +19,8 @@ type MuxAVArgs struct { } func runMuxAV(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, printMuxAVUsage) + // Parse command-specific flags fs := flag.NewFlagSet("mux-av", flag.ExitOnError) muxAVArgs := &MuxAVArgs{} @@ -27,14 +29,6 @@ func runMuxAV(args []string, globalArgs *GlobalArgs) { fs.StringVar(&muxAVArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") fs.StringVar(&muxAVArgs.Media, "media", "both", "Filter by media type: 'user', 'display', or 'both'") - // Check for help flag before parsing - for _, arg := range args { - if arg == "--help" || arg == "-h" { - printMuxAVUsage() - return - } - } - if err := fs.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) os.Exit(1) diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index adea246..37c156b 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -17,6 +17,8 @@ type ProcessAllArgs struct { } func runProcessAll(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, printProcessAllUsage) + // Parse command-specific flags fs := flag.NewFlagSet("process-all", flag.ExitOnError) processAllArgs := &ProcessAllArgs{} @@ -24,14 +26,6 @@ func runProcessAll(args []string, globalArgs *GlobalArgs) { fs.StringVar(&processAllArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") fs.StringVar(&processAllArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") - // Check for help flag before parsing - for _, arg := range args { - if arg == "--help" || arg == "-h" { - printProcessAllUsage() - return - } - } - if err := fs.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) os.Exit(1) From e25a15bcc7d2776a640d7376ccf648eca228389f Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 00:30:00 +0200 Subject: [PATCH 16/38] Create Dirs only once --- cmd/raw-recording-tools/extract_audio.go | 19 +-------- cmd/raw-recording-tools/extract_video.go | 19 +-------- cmd/raw-recording-tools/main.go | 50 +++++++++++++++++++----- cmd/raw-recording-tools/mix_audio.go | 18 +-------- cmd/raw-recording-tools/mux_av.go | 20 ++-------- cmd/raw-recording-tools/process_all.go | 20 ++-------- 6 files changed, 53 insertions(+), 93 deletions(-) diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index fbe96bc..20d5569 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -15,7 +15,7 @@ type ExtractAudioArgs struct { FillGaps bool } -func runExtractAudio(args []string, globalArgs *GlobalArgs) { +func runExtractAudio(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { printHelpIfAsked(args, printExtractAudioUsage) // Parse command-specific flags @@ -42,9 +42,6 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Setup logger - logger := setupLogger(globalArgs.Verbose) - logger.Info("Starting extract-audio command") fmt.Printf("Extract audio command with hierarchical filtering:\n") @@ -103,17 +100,5 @@ func printExtractAudioUsage() { } func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Extract to temp directory if needed (unified approach) - workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) - if err != nil { - return fmt.Errorf("failed to prepare working directory: %w", err) - } - defer cleanup() - - // Create output directory if it doesn't exist - if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - return extractTracks(workingDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, logger) + return extractTracks(globalArgs.WorkDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, logger) } diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index 49755c1..2ac2d28 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -15,7 +15,7 @@ type ExtractVideoArgs struct { FillGaps bool } -func runExtractVideo(args []string, globalArgs *GlobalArgs) { +func runExtractVideo(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { printHelpIfAsked(args, printExtractVideoUsage) // Parse command-specific flags @@ -42,9 +42,6 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Setup logger - logger := setupLogger(globalArgs.Verbose) - logger.Info("Starting extract-video command") fmt.Printf("Extract video command with hierarchical filtering:\n") @@ -104,17 +101,5 @@ func printExtractVideoUsage() { } func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Extract to temp directory if needed (unified approach) - workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) - if err != nil { - return fmt.Errorf("failed to prepare working directory: %w", err) - } - defer cleanup() - - // Create output directory if it doesn't exist - if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - return extractTracks(workingDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, logger) + return extractTracks(globalArgs.WorkDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, logger) } diff --git a/cmd/raw-recording-tools/main.go b/cmd/raw-recording-tools/main.go index 5a0f569..10f2676 100644 --- a/cmd/raw-recording-tools/main.go +++ b/cmd/raw-recording-tools/main.go @@ -14,6 +14,8 @@ type GlobalArgs struct { InputS3 string Output string Verbose bool + + WorkDir string } func main() { @@ -31,28 +33,56 @@ func main() { os.Exit(1) } + // Setup logger + logger := setupLogger(globalArgs.Verbose) + switch command { case "list-tracks": runListTracks(remainingArgs, globalArgs) + case "completion": + runCompletion(remainingArgs) + case "help", "-h", "--help": + printUsage() + default: + if e := processCommand(command, globalArgs, remainingArgs, logger); e != nil { + logger.Error("Error processing command %s - %v", command, e) + os.Exit(1) + } + } +} + +func processCommand(command string, globalArgs *GlobalArgs, remainingArgs []string, logger *getstream.DefaultLogger) error { + // Extract to temp directory if needed (unified approach) + workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) + if err != nil { + return fmt.Errorf("failed to prepare working directory: %w", err) + } + defer cleanup() + globalArgs.WorkDir = workingDir + + // Create output directory if it doesn't exist + if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + switch command { case "extract-audio": - runExtractAudio(remainingArgs, globalArgs) + runExtractAudio(remainingArgs, globalArgs, logger) case "extract-video": - runExtractVideo(remainingArgs, globalArgs) + runExtractVideo(remainingArgs, globalArgs, logger) case "mux-av": - runMuxAV(remainingArgs, globalArgs) + runMuxAV(remainingArgs, globalArgs, logger) case "mix-audio": - runMixAudio(remainingArgs, globalArgs) + runMixAudio(remainingArgs, globalArgs, logger) case "process-all": - runProcessAll(remainingArgs, globalArgs) - case "completion": - runCompletion(remainingArgs) - case "help", "-h", "--help": - printUsage() + runProcessAll(remainingArgs, globalArgs, logger) default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) printUsage() os.Exit(1) } + + return nil } // parseGlobalFlags parses global flags and returns the command and remaining args @@ -70,6 +100,8 @@ func parseGlobalFlags(args []string, globalArgs *GlobalArgs) (string, []string) "extract-audio": true, "extract-video": true, "mux-av": true, + "mix-audio": true, + "process-all": true, "completion": true, "help": true, } diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index a673aa3..1eb30e2 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -32,7 +32,7 @@ type AudioFileWithTiming struct { } // runMixAudio handles the mix-audio command -func runMixAudio(args []string, globalArgs *GlobalArgs) { +func runMixAudio(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { printHelpIfAsked(args, printMixAudioUsage) mixAudioArgs := &MixAudioArgs{ @@ -53,8 +53,6 @@ func runMixAudio(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Setup logger - logger := setupLogger(globalArgs.Verbose) logger.Info("Starting mix-audio command") // Execute the mix-audio operation @@ -68,21 +66,9 @@ func runMixAudio(args []string, globalArgs *GlobalArgs) { // mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic func mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Extract to temp directory if needed (unified approach) - workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) - if err != nil { - return fmt.Errorf("failed to prepare working directory: %w", err) - } - defer cleanup() - - // Create output directory if it doesn't exist - if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - // Step 1: Extract all matching audio tracks using existing extractTracks function logger.Info("Step 1/2: Extracting all matching audio tracks...") - err = extractTracks(workingDir, globalArgs.Output, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, metadata, "audio", "user", mixAudioArgs.FillGaps, logger) + err := extractTracks(globalArgs.WorkDir, globalArgs.Output, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, metadata, "audio", "user", mixAudioArgs.FillGaps, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index bba9f58..9bdb561 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -18,7 +18,7 @@ type MuxAVArgs struct { Media string // "user", "display", or "both" (default) } -func runMuxAV(args []string, globalArgs *GlobalArgs) { +func runMuxAV(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { printHelpIfAsked(args, printMuxAVUsage) // Parse command-specific flags @@ -45,8 +45,6 @@ func runMuxAV(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Set up logger - logger := setupLogger(globalArgs.Verbose) logger.Info("Starting mux-av command") // Display hierarchy information for user clarity @@ -92,18 +90,6 @@ func printMuxAVUsage() { } func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Extract to temp directory if needed (unified approach) - workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) - if err != nil { - return fmt.Errorf("failed to prepare working directory: %w", err) - } - defer cleanup() - - // Create output directory if it doesn't exist - if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - // Create a temporary directory for intermediate files tempDir, err := os.MkdirTemp("", "mux-av-*") if err != nil { @@ -113,14 +99,14 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata // Extract audio tracks with gap filling enabled logger.Info("Extracting audio tracks with gap filling...") - err = extractTracks(workingDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "audio", muxAVArgs.Media, true, logger) + err = extractTracks(globalArgs.WorkDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "audio", muxAVArgs.Media, true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Extract video tracks with gap filling enabled logger.Info("Extracting video tracks with gap filling...") - err = extractTracks(workingDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "video", muxAVArgs.Media, true, logger) + err = extractTracks(globalArgs.WorkDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "video", muxAVArgs.Media, true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 37c156b..5868d70 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -16,7 +16,7 @@ type ProcessAllArgs struct { TrackID string } -func runProcessAll(args []string, globalArgs *GlobalArgs) { +func runProcessAll(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { printHelpIfAsked(args, printProcessAllUsage) // Parse command-specific flags @@ -42,8 +42,6 @@ func runProcessAll(args []string, globalArgs *GlobalArgs) { os.Exit(1) } - // Set up logger - logger := setupLogger(globalArgs.Verbose) logger.Info("Starting process-all command") // Display hierarchy information for user clarity @@ -90,28 +88,16 @@ func printProcessAllUsage() { } func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Extract to temp directory if needed (unified approach) - workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) - if err != nil { - return fmt.Errorf("failed to prepare working directory: %w", err) - } - defer cleanup() - - // Create output directory if it doesn't exist - if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - // Step 1: Extract audio tracks with gap filling logger.Info("Step 1/3: Extracting audio tracks with gap filling...") - err = extractTracks(workingDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, logger) + err := extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Step 2: Extract video tracks with gap filling logger.Info("Step 2/3: Extracting video tracks with gap filling...") - err = extractTracks(workingDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "video", "both", true, logger) + err = extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "video", "both", true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } From 9348d657afc8d4ae9b13d211754813e9c4f4453b Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 00:38:43 +0200 Subject: [PATCH 17/38] Remove logs --- cmd/raw-recording-tools/extract_audio.go | 8 ++------ cmd/raw-recording-tools/extract_video.go | 8 ++------ cmd/raw-recording-tools/mix_audio.go | 10 +++------- cmd/raw-recording-tools/mux_av.go | 4 ---- cmd/raw-recording-tools/process_all.go | 4 ---- 5 files changed, 7 insertions(+), 27 deletions(-) diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index 20d5569..80659d9 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -35,10 +35,6 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs, logger *getstream.De metadata, err := validateInputArgs(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID) if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - if globalArgs.InputFile != "" { - fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", - globalArgs.InputFile, globalArgs.Output) - } os.Exit(1) } @@ -66,8 +62,8 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs, logger *getstream.De fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) // Implement extract audio functionality - if err := extractAudioTracks(globalArgs, extractAudioArgs, metadata, logger); err != nil { - logger.Error("Failed to extract audio: %v", err) + if e := extractAudioTracks(globalArgs, extractAudioArgs, metadata, logger); e != nil { + logger.Error("Failed to extract audio: %v", e) } logger.Info("Extract audio command completed") diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index 2ac2d28..cdb7286 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -35,10 +35,6 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs, logger *getstream.De metadata, err := validateInputArgs(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID) if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - if globalArgs.InputFile != "" { - fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", - globalArgs.InputFile, globalArgs.Output) - } os.Exit(1) } @@ -66,8 +62,8 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs, logger *getstream.De fmt.Printf(" Fill gaps: %t\n", extractVideoArgs.FillGaps) // Extract video tracks - if err := extractVideoTracks(globalArgs, extractVideoArgs, metadata, logger); err != nil { - logger.Error("Failed to extract video tracks: %v", err) + if e := extractVideoTracks(globalArgs, extractVideoArgs, metadata, logger); e != nil { + logger.Error("Failed to extract video tracks: %v", e) os.Exit(1) } diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index 1eb30e2..e54dbc0 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -46,19 +46,15 @@ func runMixAudio(args []string, globalArgs *GlobalArgs, logger *getstream.Defaul metadata, err := validateInputArgs(globalArgs, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID) if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - if globalArgs.InputFile != "" { - fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", - globalArgs.InputFile, globalArgs.Output) - } os.Exit(1) } logger.Info("Starting mix-audio command") // Execute the mix-audio operation - err = mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, logger) - if err != nil { - logger.Error("Mix-audio failed: %v", err) + if e := mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, logger); e != nil { + logger.Error("Mix-audio failed: %v", e) + os.Exit(1) } logger.Info("Mix-audio command completed successfully") diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 9bdb561..082dc92 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -38,10 +38,6 @@ func runMuxAV(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLo metadata, err := validateInputArgs(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID) if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - if globalArgs.InputFile != "" { - fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", - globalArgs.InputFile, globalArgs.Output) - } os.Exit(1) } diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 5868d70..2841b09 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -35,10 +35,6 @@ func runProcessAll(args []string, globalArgs *GlobalArgs, logger *getstream.Defa metadata, err := validateInputArgs(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID) if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - if globalArgs.InputFile != "" { - fmt.Fprintf(os.Stderr, "\nTip: Use 'raw-tools --inputFile %s --output %s list-tracks --format users' to see available user IDs\n", - globalArgs.InputFile, globalArgs.Output) - } os.Exit(1) } From 023ea16eb364ac5c1d29baf49dc803ec63f07145 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 01:09:44 +0200 Subject: [PATCH 18/38] Refactor --- cmd/raw-recording-tools/completion.go | 79 ---------------- cmd/raw-recording-tools/extract_audio.go | 33 ++++--- cmd/raw-recording-tools/extract_track.go | 92 ------------------- cmd/raw-recording-tools/extract_video.go | 35 +++++--- cmd/raw-recording-tools/input.go | 102 +++++++++++++++++++++ cmd/raw-recording-tools/list_tracks.go | 59 ++++++------ cmd/raw-recording-tools/main.go | 109 ++++++++++++++++++++--- cmd/raw-recording-tools/mix_audio.go | 28 +++--- cmd/raw-recording-tools/mux_av.go | 38 ++++---- cmd/raw-recording-tools/process_all.go | 28 +++--- 10 files changed, 337 insertions(+), 266 deletions(-) create mode 100644 cmd/raw-recording-tools/input.go diff --git a/cmd/raw-recording-tools/completion.go b/cmd/raw-recording-tools/completion.go index c09f191..df0662f 100644 --- a/cmd/raw-recording-tools/completion.go +++ b/cmd/raw-recording-tools/completion.go @@ -287,82 +287,3 @@ complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l sessionId -d 'S fmt.Println(script) } - -// validateInputArgs validates input arguments using mutually exclusive logic -func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*RecordingMetadata, error) { - // Count how many filters are specified - filtersCount := 0 - if userID != "" { - filtersCount++ - } - if sessionID != "" { - filtersCount++ - } - if trackID != "" { - filtersCount++ - } - - // Ensure filters are mutually exclusive - if filtersCount > 1 { - return nil, fmt.Errorf("only one filter can be specified at a time: --userId, --sessionId, and --trackId are mutually exclusive") - } - - var inputPath string - if globalArgs.InputFile != "" { - inputPath = globalArgs.InputFile - } else { - // TODO: Handle S3 validation - return nil, fmt.Errorf("Not implemented for now") - } - - // Parse metadata to validate the single specified argument - logger := setupLogger(false) // Use non-verbose for validation - parser := NewMetadataParser(logger) - metadata, err := parser.ParseMetadataOnly(inputPath) - if err != nil { - return nil, fmt.Errorf("failed to parse recording for validation: %w", err) - } - - // If no filters specified, no validation needed - if filtersCount == 0 { - return metadata, nil - } - - // Validate the single specified filter - if trackID != "" { - found := false - for _, track := range metadata.Tracks { - if track.TrackID == trackID { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("trackID '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) - } - } else if sessionID != "" { - found := false - for _, track := range metadata.Tracks { - if track.SessionID == sessionID { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("sessionID '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) - } - } else if userID != "" { - found := false - for _, uid := range metadata.UserIDs { - if uid == userID { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("userID '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) - } - } - - return metadata, nil -} diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index 80659d9..429752e 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -15,8 +15,16 @@ type ExtractAudioArgs struct { FillGaps bool } -func runExtractAudio(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { - printHelpIfAsked(args, printExtractAudioUsage) +type ExtractAudioProcess struct { + logger *getstream.DefaultLogger +} + +func NewExtractAudioProcess(logger *getstream.DefaultLogger) *ExtractAudioProcess { + return &ExtractAudioProcess{logger: logger} +} + +func (p *ExtractAudioProcess) runExtractAudio(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) // Parse command-specific flags fs := flag.NewFlagSet("extract-audio", flag.ExitOnError) @@ -38,8 +46,18 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs, logger *getstream.De os.Exit(1) } - logger.Info("Starting extract-audio command") + p.logger.Info("Starting extract-audio command") + p.printBanner(globalArgs, extractAudioArgs) + + // Implement extract audio functionality + if e := extractAudioTracks(globalArgs, extractAudioArgs, metadata, p.logger); e != nil { + p.logger.Error("Failed to extract audio: %v", e) + } + p.logger.Info("Extract audio command completed") +} + +func (p *ExtractAudioProcess) printBanner(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs) { fmt.Printf("Extract audio command with hierarchical filtering:\n") if globalArgs.InputFile != "" { fmt.Printf(" Input file: %s\n", globalArgs.InputFile) @@ -60,16 +78,9 @@ func runExtractAudio(args []string, globalArgs *GlobalArgs, logger *getstream.De fmt.Printf(" → Processing all audio tracks (no filters)\n") } fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) - - // Implement extract audio functionality - if e := extractAudioTracks(globalArgs, extractAudioArgs, metadata, logger); e != nil { - logger.Error("Failed to extract audio: %v", e) - } - - logger.Info("Extract audio command completed") } -func printExtractAudioUsage() { +func (p *ExtractAudioProcess) printUsage() { fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-audio [command options]\n\n") fmt.Fprintf(os.Stderr, "Generate playable audio files from raw recording tracks.\n") fmt.Fprintf(os.Stderr, "Supports formats: webm, mp3, and others.\n\n") diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go index a46e638..9e53d0c 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/cmd/raw-recording-tools/extract_track.go @@ -1,10 +1,7 @@ package main import ( - "archive/tar" - "compress/gzip" "fmt" - "io" "os" "path/filepath" "strings" @@ -219,92 +216,3 @@ func processSegmentsWithGapFilling(files []string, suffix string, track *TrackIn } return finalPath, nil } - -// extractToTempDir extracts archive to temp directory or returns the directory path -// Returns: (workingDir, cleanupFunc, error) -func extractToTempDir(inputPath string, logger *getstream.DefaultLogger) (string, func(), error) { - // If it's already a directory, just return it - if stat, err := os.Stat(inputPath); err == nil && stat.IsDir() { - logger.Debug("Input is already a directory: %s", inputPath) - return inputPath, func() {}, nil - } - - // If it's a tar.gz file, extract it to temp directory - if strings.HasSuffix(strings.ToLower(inputPath), ".tar.gz") { - logger.Info("Extracting tar.gz archive to temporary directory...") - - tempDir, err := os.MkdirTemp("", "raw-tools-*") - if err != nil { - return "", nil, fmt.Errorf("failed to create temp directory: %w", err) - } - - cleanup := func() { - os.RemoveAll(tempDir) - } - - err = extractTarGzToDir(inputPath, tempDir, logger) - if err != nil { - cleanup() - return "", nil, fmt.Errorf("failed to extract tar.gz: %w", err) - } - - logger.Debug("Extracted archive to: %s", tempDir) - return tempDir, cleanup, nil - } - - return "", nil, fmt.Errorf("unsupported input format: %s (only tar.gz files and directories supported)", inputPath) -} - -// extractTarGzToDir extracts a tar.gz file to the specified directory -func extractTarGzToDir(tarGzPath, destDir string, logger *getstream.DefaultLogger) error { - file, err := os.Open(tarGzPath) - if err != nil { - return fmt.Errorf("failed to open tar.gz file: %w", err) - } - defer file.Close() - - gzReader, err := gzip.NewReader(file) - if err != nil { - return fmt.Errorf("failed to create gzip reader: %w", err) - } - defer gzReader.Close() - - tarReader := tar.NewReader(gzReader) - - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("failed to read tar entry: %w", err) - } - - // Skip directories - if header.FileInfo().IsDir() { - continue - } - - // Create destination file - destPath := filepath.Join(destDir, header.Name) - - // Create directory structure if needed - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { - return fmt.Errorf("failed to create directory structure: %w", err) - } - - // Extract file - outFile, err := os.Create(destPath) - if err != nil { - return fmt.Errorf("failed to create file %s: %w", destPath, err) - } - - _, err = io.Copy(outFile, tarReader) - outFile.Close() - if err != nil { - return fmt.Errorf("failed to extract file %s: %w", destPath, err) - } - } - - return nil -} diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index cdb7286..4a8bd35 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -15,8 +15,16 @@ type ExtractVideoArgs struct { FillGaps bool } -func runExtractVideo(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { - printHelpIfAsked(args, printExtractVideoUsage) +type ExtractVideoProcess struct { + logger *getstream.DefaultLogger +} + +func NewExtractVideoProcess(logger *getstream.DefaultLogger) *ExtractVideoProcess { + return &ExtractVideoProcess{logger: logger} +} + +func (p *ExtractVideoProcess) runExtractVideo(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) // Parse command-specific flags fs := flag.NewFlagSet("extract-video", flag.ExitOnError) @@ -38,8 +46,19 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs, logger *getstream.De os.Exit(1) } - logger.Info("Starting extract-video command") + p.logger.Info("Starting extract-video command") + p.printBanner(globalArgs, extractVideoArgs) + + // Extract video tracks + if e := extractVideoTracks(globalArgs, extractVideoArgs, metadata, p.logger); e != nil { + p.logger.Error("Failed to extract video tracks: %v", e) + os.Exit(1) + } + p.logger.Info("Extract video command completed successfully") +} + +func (p *ExtractVideoProcess) printBanner(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs) { fmt.Printf("Extract video command with hierarchical filtering:\n") if globalArgs.InputFile != "" { fmt.Printf(" Input file: %s\n", globalArgs.InputFile) @@ -60,17 +79,9 @@ func runExtractVideo(args []string, globalArgs *GlobalArgs, logger *getstream.De fmt.Printf(" → Processing all video tracks (no filters)\n") } fmt.Printf(" Fill gaps: %t\n", extractVideoArgs.FillGaps) - - // Extract video tracks - if e := extractVideoTracks(globalArgs, extractVideoArgs, metadata, logger); e != nil { - logger.Error("Failed to extract video tracks: %v", e) - os.Exit(1) - } - - logger.Info("Extract video command completed successfully") } -func printExtractVideoUsage() { +func (p *ExtractVideoProcess) printUsage() { fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-video [command options]\n\n") fmt.Fprintf(os.Stderr, "Generate playable video files from raw recording tracks.\n") fmt.Fprintf(os.Stderr, "Supports formats: webm, mp4, and others.\n\n") diff --git a/cmd/raw-recording-tools/input.go b/cmd/raw-recording-tools/input.go new file mode 100644 index 0000000..1354833 --- /dev/null +++ b/cmd/raw-recording-tools/input.go @@ -0,0 +1,102 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/GetStream/getstream-go/v3" +) + +// extractToTempDir extracts archive to temp directory or returns the directory path +// Returns: (workingDir, cleanupFunc, error) +func extractToTempDir(inputPath string, logger *getstream.DefaultLogger) (string, func(), error) { + // If it's already a directory, just return it + if stat, err := os.Stat(inputPath); err == nil && stat.IsDir() { + logger.Debug("Input is already a directory: %s", inputPath) + return inputPath, func() {}, nil + } + + // If it's a tar.gz file, extract it to temp directory + if strings.HasSuffix(strings.ToLower(inputPath), ".tar.gz") { + logger.Info("Extracting tar.gz archive to temporary directory...") + + tempDir, err := os.MkdirTemp("", "raw-tools-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + cleanup := func() { + os.RemoveAll(tempDir) + } + + err = extractTarGzToDir(inputPath, tempDir, logger) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to extract tar.gz: %w", err) + } + + logger.Debug("Extracted archive to: %s", tempDir) + return tempDir, cleanup, nil + } + + return "", nil, fmt.Errorf("unsupported input format: %s (only tar.gz files and directories supported)", inputPath) +} + +// extractTarGzToDir extracts a tar.gz file to the specified directory +func extractTarGzToDir(tarGzPath, destDir string, logger *getstream.DefaultLogger) error { + file, err := os.Open(tarGzPath) + if err != nil { + return fmt.Errorf("failed to open tar.gz file: %w", err) + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar entry: %w", err) + } + + // Skip directories + if header.FileInfo().IsDir() { + continue + } + + // Create destination file + destPath := filepath.Join(destDir, header.Name) + + // Create directory structure if needed + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to create directory structure: %w", err) + } + + // Extract file + outFile, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + _, err = io.Copy(outFile, tarReader) + outFile.Close() + if err != nil { + return fmt.Errorf("failed to extract file %s: %w", destPath, err) + } + } + + return nil +} diff --git a/cmd/raw-recording-tools/list_tracks.go b/cmd/raw-recording-tools/list_tracks.go index ae21d14..21cb944 100644 --- a/cmd/raw-recording-tools/list_tracks.go +++ b/cmd/raw-recording-tools/list_tracks.go @@ -7,6 +7,8 @@ import ( "os" "sort" "strings" + + "github.com/GetStream/getstream-go/v3" ) type ListTracksArgs struct { @@ -15,8 +17,16 @@ type ListTracksArgs struct { CompletionType string // For completion format: "users", "sessions", "tracks" } -func runListTracks(args []string, globalArgs *GlobalArgs) { - printHelpIfAsked(args, printListTracksUsage) +type ListTracksProcess struct { + logger *getstream.DefaultLogger +} + +func NewListTracksProcess(logger *getstream.DefaultLogger) *ListTracksProcess { + return &ListTracksProcess{logger: logger} +} + +func (p *ListTracksProcess) runListTracks(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) // Parse command-specific flags fs := flag.NewFlagSet("list-tracks", flag.ExitOnError) @@ -57,17 +67,17 @@ func runListTracks(args []string, globalArgs *GlobalArgs) { // Output in requested format switch listTracksArgs.Format { case "table": - printTracksTable(tracks) + p.printTracksTable(tracks) case "json": - printTracksJSON(metadata) + p.printTracksJSON(metadata) case "completion": - printCompletion(metadata, listTracksArgs.CompletionType) + p.printCompletion(metadata, listTracksArgs.CompletionType) case "users": - printUsers(metadata.UserIDs) + p.printUsers(metadata.UserIDs) case "sessions": - printSessions(metadata.Sessions) + p.printSessions(metadata.Sessions) case "tracks": - printTrackIDs(tracks) + p.printTrackIDs(tracks) default: fmt.Fprintf(os.Stderr, "Unknown format: %s\n", listTracksArgs.Format) os.Exit(1) @@ -77,7 +87,7 @@ func runListTracks(args []string, globalArgs *GlobalArgs) { } // printTracksTable prints tracks in a human-readable table format -func printTracksTable(tracks []*TrackInfo) { +func (p *ListTracksProcess) printTracksTable(tracks []*TrackInfo) { if len(tracks) == 0 { fmt.Println("No tracks found.") return @@ -101,9 +111,9 @@ func printTracksTable(tracks []*TrackInfo) { screenshareStatus = "Yes" } fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8d\n", - truncateString(track.UserID, 22), - truncateString(track.SessionID, 38), - truncateString(track.TrackID, 38), + p.truncateString(track.UserID, 22), + p.truncateString(track.SessionID, 38), + p.truncateString(track.TrackID, 38), track.TrackType, screenshareStatus, track.Codec, @@ -112,7 +122,7 @@ func printTracksTable(tracks []*TrackInfo) { } // truncateString truncates a string to a maximum length, adding "..." if needed -func truncateString(s string, maxLen int) string { +func (p *ListTracksProcess) truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } @@ -120,7 +130,7 @@ func truncateString(s string, maxLen int) string { } // printTracksJSON prints the full metadata in JSON format -func printTracksJSON(metadata *RecordingMetadata) { +func (p *ListTracksProcess) printTracksJSON(metadata *RecordingMetadata) { data, err := json.MarshalIndent(metadata, "", " ") if err != nil { fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) @@ -130,28 +140,28 @@ func printTracksJSON(metadata *RecordingMetadata) { } // printCompletion prints completion-friendly output -func printCompletion(metadata *RecordingMetadata, completionType string) { +func (p *ListTracksProcess) printCompletion(metadata *RecordingMetadata, completionType string) { switch completionType { case "users": - printUsers(metadata.UserIDs) + p.printUsers(metadata.UserIDs) case "sessions": - printSessions(metadata.Sessions) + p.printSessions(metadata.Sessions) case "tracks": trackIDs := make([]string, 0) for _, track := range metadata.Tracks { trackIDs = append(trackIDs, track.TrackID) } // Remove duplicates and sort - uniqueTrackIDs := removeDuplicates(trackIDs) + uniqueTrackIDs := p.removeDuplicates(trackIDs) sort.Strings(uniqueTrackIDs) - printTrackIDs(metadata.Tracks) + p.printTrackIDs(metadata.Tracks) default: fmt.Fprintf(os.Stderr, "Unknown completion type: %s\n", completionType) } } // printUsers prints user IDs, one per line -func printUsers(userIDs []string) { +func (p *ListTracksProcess) printUsers(userIDs []string) { sort.Strings(userIDs) for _, userID := range userIDs { fmt.Println(userID) @@ -159,7 +169,7 @@ func printUsers(userIDs []string) { } // printSessions prints session IDs, one per line -func printSessions(sessions []string) { +func (p *ListTracksProcess) printSessions(sessions []string) { sort.Strings(sessions) for _, session := range sessions { fmt.Println(session) @@ -167,7 +177,7 @@ func printSessions(sessions []string) { } // printTrackIDs prints unique track IDs, one per line -func printTrackIDs(tracks []*TrackInfo) { +func (p *ListTracksProcess) printTrackIDs(tracks []*TrackInfo) { trackIDs := make([]string, 0) seen := make(map[string]bool) @@ -185,7 +195,7 @@ func printTrackIDs(tracks []*TrackInfo) { } // removeDuplicates removes duplicate strings from a slice -func removeDuplicates(input []string) []string { +func (p *ListTracksProcess) removeDuplicates(input []string) []string { keys := make(map[string]bool) result := make([]string, 0) @@ -199,7 +209,7 @@ func removeDuplicates(input []string) []string { return result } -func printListTracksUsage() { +func (p *ListTracksProcess) printUsage() { fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] list-tracks [command options]\n\n") fmt.Fprintf(os.Stderr, "List all tracks in the raw recording with their metadata.\n") fmt.Fprintf(os.Stderr, "Note: --output is optional for this command (only displays information).\n\n") @@ -221,5 +231,4 @@ func printListTracksUsage() { fmt.Fprintf(os.Stderr, " # Get user IDs for completion\n") fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks --format users\n") fmt.Fprintf(os.Stderr, "\nGlobal Options: Use 'raw-tools --help' to see global options.\n") - } diff --git a/cmd/raw-recording-tools/main.go b/cmd/raw-recording-tools/main.go index 10f2676..0889e19 100644 --- a/cmd/raw-recording-tools/main.go +++ b/cmd/raw-recording-tools/main.go @@ -20,7 +20,7 @@ type GlobalArgs struct { func main() { if len(os.Args) < 2 { - printUsage() + printGlobalUsage() os.Exit(1) } @@ -29,7 +29,7 @@ func main() { command, remainingArgs := parseGlobalFlags(os.Args[1:], globalArgs) if command == "" { - printUsage() + printGlobalUsage() os.Exit(1) } @@ -38,11 +38,12 @@ func main() { switch command { case "list-tracks": - runListTracks(remainingArgs, globalArgs) + p := NewListTracksProcess(logger) + p.runListTracks(remainingArgs, globalArgs) case "completion": runCompletion(remainingArgs) case "help", "-h", "--help": - printUsage() + printGlobalUsage() default: if e := processCommand(command, globalArgs, remainingArgs, logger); e != nil { logger.Error("Error processing command %s - %v", command, e) @@ -67,18 +68,23 @@ func processCommand(command string, globalArgs *GlobalArgs, remainingArgs []stri switch command { case "extract-audio": - runExtractAudio(remainingArgs, globalArgs, logger) + p := NewExtractAudioProcess(logger) + p.runExtractAudio(remainingArgs, globalArgs) case "extract-video": - runExtractVideo(remainingArgs, globalArgs, logger) + p := NewExtractVideoProcess(logger) + p.runExtractVideo(remainingArgs, globalArgs) case "mux-av": - runMuxAV(remainingArgs, globalArgs, logger) + p := NewMuxAudioVideoProcess(logger) + p.runMuxAV(remainingArgs, globalArgs) case "mix-audio": - runMixAudio(remainingArgs, globalArgs, logger) + p := NewMixAudioProcess(logger) + p.runMixAudio(remainingArgs, globalArgs) case "process-all": - runProcessAll(remainingArgs, globalArgs, logger) + p := NewProcessAllProcess(logger) + p.runProcessAll(remainingArgs, globalArgs) default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) - printUsage() + printGlobalUsage() os.Exit(1) } @@ -132,7 +138,7 @@ func parseGlobalFlags(args []string, globalArgs *GlobalArgs) (string, []string) // Validate global arguments if e := validateGlobalArgs(globalArgs, command); e != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", e) - printUsage() + printGlobalUsage() os.Exit(1) } @@ -167,7 +173,86 @@ func validateGlobalArgs(globalArgs *GlobalArgs, command string) error { return nil } -func printUsage() { +// validateInputArgs validates input arguments using mutually exclusive logic +func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*RecordingMetadata, error) { + // Count how many filters are specified + filtersCount := 0 + if userID != "" { + filtersCount++ + } + if sessionID != "" { + filtersCount++ + } + if trackID != "" { + filtersCount++ + } + + // Ensure filters are mutually exclusive + if filtersCount > 1 { + return nil, fmt.Errorf("only one filter can be specified at a time: --userId, --sessionId, and --trackId are mutually exclusive") + } + + var inputPath string + if globalArgs.InputFile != "" { + inputPath = globalArgs.InputFile + } else { + // TODO: Handle S3 validation + return nil, fmt.Errorf("Not implemented for now") + } + + // Parse metadata to validate the single specified argument + logger := setupLogger(false) // Use non-verbose for validation + parser := NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return nil, fmt.Errorf("failed to parse recording for validation: %w", err) + } + + // If no filters specified, no validation needed + if filtersCount == 0 { + return metadata, nil + } + + // Validate the single specified filter + if trackID != "" { + found := false + for _, track := range metadata.Tracks { + if track.TrackID == trackID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("trackID '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) + } + } else if sessionID != "" { + found := false + for _, track := range metadata.Tracks { + if track.SessionID == sessionID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("sessionID '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) + } + } else if userID != "" { + found := false + for _, uid := range metadata.UserIDs { + if uid == userID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("userID '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) + } + } + + return metadata, nil +} + +func printGlobalUsage() { fmt.Fprintf(os.Stderr, "Raw Recording Post Processing Tools\n\n") fmt.Fprintf(os.Stderr, "Usage: %s [global options] [command options]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Global Options:\n") diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index e54dbc0..007ae69 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -31,9 +31,17 @@ type AudioFileWithTiming struct { TrackInfo *TrackInfo // Original track metadata } +type MixAudioProcess struct { + logger *getstream.DefaultLogger +} + +func NewMixAudioProcess(logger *getstream.DefaultLogger) *MixAudioProcess { + return &MixAudioProcess{logger: logger} +} + // runMixAudio handles the mix-audio command -func runMixAudio(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { - printHelpIfAsked(args, printMixAudioUsage) +func (p *MixAudioProcess) runMixAudio(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) mixAudioArgs := &MixAudioArgs{ UserID: "", // Default: all users (empty) @@ -49,19 +57,19 @@ func runMixAudio(args []string, globalArgs *GlobalArgs, logger *getstream.Defaul os.Exit(1) } - logger.Info("Starting mix-audio command") + p.logger.Info("Starting mix-audio command") // Execute the mix-audio operation - if e := mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, logger); e != nil { - logger.Error("Mix-audio failed: %v", e) + if e := p.mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, p.logger); e != nil { + p.logger.Error("Mix-audio failed: %v", e) os.Exit(1) } - logger.Info("Mix-audio command completed successfully") + p.logger.Info("Mix-audio command completed successfully") } // mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic -func mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Step 1: Extract all matching audio tracks using existing extractTracks function logger.Info("Step 1/2: Extracting all matching audio tracks...") err := extractTracks(globalArgs.WorkDir, globalArgs.Output, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, metadata, "audio", "user", mixAudioArgs.FillGaps, logger) @@ -71,7 +79,7 @@ func mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metad // Step 2: Find all extracted audio files and prepare them for mixing logger.Info("Step 2/2: Discovering extracted files and mixing...") - audioFiles, err := discoverExtractedAudioFiles(globalArgs.Output, logger) + audioFiles, err := p.discoverExtractedAudioFiles(globalArgs.Output, logger) if err != nil { return fmt.Errorf("failed to discover extracted audio files: %w", err) } @@ -110,7 +118,7 @@ func mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metad } // discoverExtractedAudioFiles finds all audio files that were extracted and prepares them for mixing -func discoverExtractedAudioFiles(outputDir string, logger *getstream.DefaultLogger) ([]AudioFileWithTiming, error) { +func (p *MixAudioProcess) discoverExtractedAudioFiles(outputDir string, logger *getstream.DefaultLogger) ([]AudioFileWithTiming, error) { var audioFiles []AudioFileWithTiming // Find all .webm audio files in the output directory @@ -164,7 +172,7 @@ func discoverExtractedAudioFiles(outputDir string, logger *getstream.DefaultLogg // Note: We removed mixAudioFilesUsingExistingLogic since we now use webm.MixAudioFiles directly // printMixAudioUsage prints the usage information for the mix-audio command -func printMixAudioUsage() { +func (p *MixAudioProcess) printUsage() { fmt.Println("Usage: raw-tools [global-options] mix-audio [options]") fmt.Println() fmt.Println("Mix all audio tracks from multiple users/sessions into a single audio file") diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 082dc92..22d8f13 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -18,8 +18,16 @@ type MuxAVArgs struct { Media string // "user", "display", or "both" (default) } -func runMuxAV(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { - printHelpIfAsked(args, printMuxAVUsage) +type MuxAudioVideoProcess struct { + logger *getstream.DefaultLogger +} + +func NewMuxAudioVideoProcess(logger *getstream.DefaultLogger) *MuxAudioVideoProcess { + return &MuxAudioVideoProcess{logger: logger} +} + +func (p *MuxAudioVideoProcess) runMuxAV(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) // Parse command-specific flags fs := flag.NewFlagSet("mux-av", flag.ExitOnError) @@ -41,7 +49,7 @@ func runMuxAV(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLo os.Exit(1) } - logger.Info("Starting mux-av command") + p.logger.Info("Starting mux-av command") // Display hierarchy information for user clarity fmt.Printf("Mux audio and video command with hierarchical filtering:\n") @@ -63,15 +71,15 @@ func runMuxAV(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLo } // Extract and mux audio/video tracks - if err := muxAudioVideoTracks(globalArgs, muxAVArgs, metadata, logger); err != nil { - logger.Error("Failed to mux audio/video tracks: %v", err) + if err := p.muxAudioVideoTracks(globalArgs, muxAVArgs, metadata, p.logger); err != nil { + p.logger.Error("Failed to mux audio/video tracks: %v", err) os.Exit(1) } - logger.Info("Mux audio and video command completed successfully") + p.logger.Info("Mux audio and video command completed successfully") } -func printMuxAVUsage() { +func (p *MuxAudioVideoProcess) printUsage() { fmt.Printf("Usage: mux-av [OPTIONS]\n") fmt.Printf("\nMux audio and video tracks into a single file\n") fmt.Printf("\nOptions:\n") @@ -85,7 +93,7 @@ func printMuxAVUsage() { fmt.Printf(" --media both Mux both types, but ensure consistent pairing (default)\n") } -func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *MuxAudioVideoProcess) muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Create a temporary directory for intermediate files tempDir, err := os.MkdirTemp("", "mux-av-*") if err != nil { @@ -131,7 +139,7 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata logger.Info("Found %d audio files and %d video files to mux", len(audioFiles), len(videoFiles)) // Group files by media type for proper pairing - audioGroups, videoGroups, err := groupFilesByMediaType(globalArgs.InputFile, audioFiles, videoFiles, muxAVArgs.Media, metadata, logger) + audioGroups, videoGroups, err := p.groupFilesByMediaType(globalArgs.InputFile, audioFiles, videoFiles, muxAVArgs.Media, metadata, logger) if err != nil { return fmt.Errorf("failed to group files by media type: %w", err) } @@ -139,7 +147,7 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata // Mux user tracks if userAudio, userVideo := audioGroups["user"], videoGroups["user"]; len(userAudio) > 0 && len(userVideo) > 0 { logger.Info("Muxing %d user audio/video pairs", len(userAudio)) - err = muxTrackPairs(globalArgs.InputFile, userAudio, userVideo, globalArgs.Output, "user", metadata, logger) + err = p.muxTrackPairs(globalArgs.InputFile, userAudio, userVideo, globalArgs.Output, "user", metadata, logger) if err != nil { logger.Error("Failed to mux user tracks: %v", err) } @@ -148,7 +156,7 @@ func muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata // Mux display tracks if displayAudio, displayVideo := audioGroups["display"], videoGroups["display"]; len(displayAudio) > 0 && len(displayVideo) > 0 { logger.Info("Muxing %d display audio/video pairs", len(displayAudio)) - err = muxTrackPairs(globalArgs.InputFile, displayAudio, displayVideo, globalArgs.Output, "display", metadata, logger) + err = p.muxTrackPairs(globalArgs.InputFile, displayAudio, displayVideo, globalArgs.Output, "display", metadata, logger) if err != nil { logger.Error("Failed to mux display tracks: %v", err) } @@ -218,7 +226,7 @@ func generateMuxedFilename(audioFile, videoFile, outputDir string) string { } // groupFilesByMediaType groups audio and video files by media type (user vs display) -func groupFilesByMediaType(inputPath string, audioFiles, videoFiles []string, mediaFilter string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) (map[string][]string, map[string][]string, error) { +func (p *MuxAudioVideoProcess) groupFilesByMediaType(inputPath string, audioFiles, videoFiles []string, mediaFilter string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) (map[string][]string, map[string][]string, error) { // Create track ID to screenshare type mapping trackScreenshareMap := make(map[string]bool) for _, track := range metadata.Tracks { @@ -295,7 +303,7 @@ func groupFilesByMediaType(inputPath string, audioFiles, videoFiles []string, me } // muxTrackPairs muxes audio/video pairs of the same media type -func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, mediaTypeName string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *MuxAudioVideoProcess) muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, mediaTypeName string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { minLen := len(audioFiles) if len(videoFiles) < minLen { minLen = len(videoFiles) @@ -318,7 +326,7 @@ func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, } // Generate output filename with media type indicator - outputFile := generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName) + outputFile := p.generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName) // Mux the audio and video files logger.Info("Muxing %s %s + %s → %s (offset: %dms)", @@ -345,7 +353,7 @@ func muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, } // generateMediaAwareMuxedFilename creates output filename that indicates media type -func generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName string) string { +func (p *MuxAudioVideoProcess) generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName string) string { audioBase := filepath.Base(audioFile) audioBase = strings.TrimSuffix(audioBase, ".webm") diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 2841b09..14252a9 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -16,8 +16,16 @@ type ProcessAllArgs struct { TrackID string } -func runProcessAll(args []string, globalArgs *GlobalArgs, logger *getstream.DefaultLogger) { - printHelpIfAsked(args, printProcessAllUsage) +type ProcessAllProcess struct { + logger *getstream.DefaultLogger +} + +func NewProcessAllProcess(logger *getstream.DefaultLogger) *ProcessAllProcess { + return &ProcessAllProcess{logger: logger} +} + +func (p *ProcessAllProcess) runProcessAll(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) // Parse command-specific flags fs := flag.NewFlagSet("process-all", flag.ExitOnError) @@ -38,7 +46,7 @@ func runProcessAll(args []string, globalArgs *GlobalArgs, logger *getstream.Defa os.Exit(1) } - logger.Info("Starting process-all command") + p.logger.Info("Starting process-all command") // Display hierarchy information for user clarity fmt.Printf("Process-all command (audio + video + mux) with hierarchical filtering:\n") @@ -60,15 +68,15 @@ func runProcessAll(args []string, globalArgs *GlobalArgs, logger *getstream.Defa } // Process all tracks and mux them - if err := processAllTracks(globalArgs, processAllArgs, metadata, logger); err != nil { - logger.Error("Failed to process and mux tracks: %v", err) + if err := p.processAllTracks(globalArgs, processAllArgs, metadata, p.logger); err != nil { + p.logger.Error("Failed to process and mux tracks: %v", err) os.Exit(1) } - logger.Info("Process-all command completed successfully") + p.logger.Info("Process-all command completed successfully") } -func printProcessAllUsage() { +func (p *ProcessAllProcess) printUsage() { fmt.Printf("Usage: process-all [OPTIONS]\n") fmt.Printf("\nProcess audio, video, and mux them into combined files (all-in-one workflow)\n") fmt.Printf("Outputs 3 files per session: audio WebM, video WebM, and muxed WebM\n") @@ -83,7 +91,7 @@ func printProcessAllUsage() { fmt.Printf(" muxed_{userId}_{sessionId}_{trackId}.webm - Combined audio+video file\n") } -func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Step 1: Extract audio tracks with gap filling logger.Info("Step 1/3: Extracting audio tracks with gap filling...") err := extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, logger) @@ -100,7 +108,7 @@ func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, me // Step 3: Mux audio and video files (keeping originals) logger.Info("Step 3/3: Muxing audio and video tracks...") - err = muxAudioVideoTracksKeepOriginals(globalArgs, processAllArgs, metadata, logger) + err = p.muxAudioVideoTracksKeepOriginals(globalArgs, processAllArgs, metadata, logger) if err != nil { return fmt.Errorf("failed to mux audio and video tracks: %w", err) } @@ -130,7 +138,7 @@ func processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, me } // muxAudioVideoTracksKeepOriginals is like muxAudioVideoTracks but keeps the original audio/video files -func muxAudioVideoTracksKeepOriginals(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *ProcessAllProcess) muxAudioVideoTracksKeepOriginals(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Find the generated audio and video WebM files audioFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "audio_*.webm")) if err != nil { From 5d4a709d584d9237f024cde8fa63d29896474761 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 10:13:13 +0200 Subject: [PATCH 19/38] Refactor extractAudio --- cmd/raw-recording-tools/extract_audio.go | 7 +- cmd/raw-recording-tools/extract_track.go | 149 +++++----------------- cmd/raw-recording-tools/extract_video.go | 4 +- cmd/raw-recording-tools/metadata.go | 31 +++-- cmd/raw-recording-tools/mix_audio.go | 20 +-- cmd/raw-recording-tools/mux_av.go | 10 +- cmd/raw-recording-tools/process_all.go | 4 +- cmd/raw-recording-tools/webm/converter.go | 24 ++-- 8 files changed, 86 insertions(+), 163 deletions(-) diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index 429752e..b647c73 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -13,6 +13,7 @@ type ExtractAudioArgs struct { SessionID string TrackID string FillGaps bool + FixDtx bool } type ExtractAudioProcess struct { @@ -32,7 +33,8 @@ func (p *ExtractAudioProcess) runExtractAudio(args []string, globalArgs *GlobalA fs.StringVar(&extractAudioArgs.UserID, "userId", "", "Specify a userId (empty for all)") fs.StringVar(&extractAudioArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") fs.StringVar(&extractAudioArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") - fs.BoolVar(&extractAudioArgs.FillGaps, "fill_gaps", false, "Fix DTX shrink audio, and fill with silence when track was muted") + fs.BoolVar(&extractAudioArgs.FillGaps, "fill_gaps", true, "Fill with silence when track was muted (default true)") + fs.BoolVar(&extractAudioArgs.FixDtx, "fix_dtx", true, "Fix DTX shrink audio (default true)") if err := fs.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) @@ -78,6 +80,7 @@ func (p *ExtractAudioProcess) printBanner(globalArgs *GlobalArgs, extractAudioAr fmt.Printf(" → Processing all audio tracks (no filters)\n") } fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) + fmt.Printf(" Fix DTX: %t\n", extractAudioArgs.FixDtx) } func (p *ExtractAudioProcess) printUsage() { @@ -107,5 +110,5 @@ func (p *ExtractAudioProcess) printUsage() { } func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - return extractTracks(globalArgs.WorkDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, logger) + return extractTracks(globalArgs.WorkDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, extractAudioArgs.FixDtx, logger) } diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go index 9e53d0c..b823094 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/cmd/raw-recording-tools/extract_track.go @@ -8,10 +8,11 @@ import ( "github.com/GetStream/getstream-go/v3" "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" + "github.com/pion/webrtc/v4" ) // Generic track extraction function that works for both audio and video -func extractTracks(workingDir, outputDir, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps bool, logger *getstream.DefaultLogger) error { +func extractTracks(workingDir, outputDir, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { // Filter tracks to specified type only and apply hierarchical filtering filteredTracks := FilterTracks(metadata.Tracks, userID, sessionID, trackID, trackType, mediaFilter) if len(filteredTracks) == 0 { @@ -25,7 +26,7 @@ func extractTracks(workingDir, outputDir, userID, sessionID, trackID string, met for i, track := range filteredTracks { logger.Info("Processing %s track %d/%d: %s", trackType, i+1, len(filteredTracks), track.TrackID) - err := extractSingleTrackWithOptions(workingDir, track, outputDir, trackType, fillGaps, logger) + err := extractSingleTrackWithOptions(workingDir, track, outputDir, trackType, fillGaps, fixDtx, logger) if err != nil { logger.Error("Failed to extract %s track %s: %v", trackType, track.TrackID, err) continue @@ -35,41 +36,32 @@ func extractTracks(workingDir, outputDir, userID, sessionID, trackID string, met return nil } -func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir string, trackType string, fillGaps bool, logger *getstream.DefaultLogger) error { - // Create a temp directory for extraction and processing - tempDir, err := os.MkdirTemp("", fmt.Sprintf("%s-extract-*", trackType)) - if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - defer os.RemoveAll(tempDir) - - // Copy track files from working directory (now always a directory) - err = copyTrackFiles(inputPath, track, tempDir, trackType) - if err != nil { - return fmt.Errorf("failed to copy track files: %w", err) +func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir string, trackType string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { + accept := func(path string, info os.FileInfo) bool { + for _, s := range track.Segments { + if strings.Contains(info.Name(), s.metadata.BaseFilename) { + if track.Codec == webrtc.MimeTypeH264 { + s.ContainerExt = webm.Mp4 + } else { + s.ContainerExt = webm.Webm + } + s.RtpDumpPath = path + s.SdpPath = strings.Replace(path, webm.SuffixRtpDump, webm.SuffixSdp, -1) + s.ContainerPath = strings.Replace(path, webm.SuffixRtpDump, "."+s.ContainerExt, -1) + return true + } + } + return false } // Convert using the WebM converter - err = webm.ConvertDirectory(tempDir, logger) + err := webm.ConvertDirectory(inputPath, accept, fixDtx, logger) if err != nil { return fmt.Errorf("failed to convert %s track: %w", trackType, err) } - // Find ALL generated .webm files - suffix := "webm" - files, _ := filepath.Glob(filepath.Join(tempDir, "*."+suffix)) - if len(files) == 0 { - suffix = "mp4" - files, _ = filepath.Glob(filepath.Join(tempDir, "*."+suffix)) - } - if len(files) == 0 { - return fmt.Errorf("no webm/mp4 output files found") - } - - logger.Info("Found %d %s segment files for %s track %s", len(files), suffix, trackType, track.TrackID) - // Create segments with timing info and fill gaps - finalFile, err := processSegmentsWithGapFilling(files, suffix, track, trackType, outputDir, fillGaps, logger) + finalFile, err := processSegmentsWithGapFilling( /*files, suffix,*/ track, trackType, outputDir, fillGaps, logger) if err != nil { return fmt.Errorf("failed to process segments with gap filling: %w", err) } @@ -78,97 +70,25 @@ func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir return nil } -// NOTE: extractTrackFiles removed - now always use copyTrackFiles since we always work with directories - -// copyTrackFiles copies the rtpdump and sdp files for a specific track to the destination directory -func copyTrackFiles(inputPath string, track *TrackInfo, destDir string, trackType string) error { - // Walk through the input directory and copy files related to this track - return filepath.Walk(inputPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - if !info.IsDir() { - fileName := info.Name() - // Check if this file belongs to our track (using trackType parameter) - if strings.Contains(fileName, track.TrackID) && strings.Contains(fileName, trackType) { - if strings.HasSuffix(fileName, ".rtpdump") || strings.HasSuffix(fileName, ".sdp") { - // Copy this file to destination - destPath := filepath.Join(destDir, fileName) - - err = copyFile(path, destPath) - if err != nil { - return err - } - } - } - } - return nil - }) -} - -// Helper function to copy a file -func copyFile(src, dst string) error { - srcFile, err := os.Open(src) - if err != nil { - return err - } - defer srcFile.Close() - - dstFile, err := os.Create(dst) - if err != nil { - return err - } - defer dstFile.Close() - - _, err = srcFile.WriteTo(dstFile) - return err -} - // processSegmentsWithGapFilling processes webm segments, fills gaps if requested, and concatenates into final file -func processSegmentsWithGapFilling(files []string, suffix string, track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { - if len(files) == 1 { - // Single segment, just copy it with final name - finalName := fmt.Sprintf("%s_%s_%s_%s.%s", trackType, track.UserID, track.SessionID, track.TrackID, suffix) - finalPath := filepath.Join(outputDir, finalName) - err := copyFile(files[0], finalPath) - return finalPath, err - } - - // Multiple segments - sort, optionally fill gaps, and concatenate - if fillGaps { - logger.Info("Processing %d segments with gap filling for %s track %s", len(files), trackType, track.TrackID) - } else { - logger.Info("Processing %d segments (no gap filling) for %s track %s", len(files), trackType, track.TrackID) - } - - // Map webm files to their original segment timing using filenames - segmentMap := make(map[string]string) // originalFilename -> webmFilePath - for _, webmFile := range files { - // Extract original filename from webm filename (remove .webm, add .rtpdump) - baseName := strings.TrimSuffix(filepath.Base(webmFile), "."+suffix) - originalName := baseName + ".rtpdump" - segmentMap[originalName] = webmFile - } - +func processSegmentsWithGapFilling( /*files []string, suffix string,*/ track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { // Build list of files to concatenate (with optional gap fillers) var filesToConcat []string - for i, segment := range track.Segments { // Add the segment file - filesToConcat = append(filesToConcat, segmentMap[segment.BaseFilename]) + filesToConcat = append(filesToConcat, segment.ContainerPath) // Add gap filler if requested and there's a gap before the next segment if fillGaps && i < track.SegmentCount-1 { nextSegment := track.Segments[i+1] - gapDuration := FirstPacketNtpTimestamp(nextSegment) - LastPacketNtpTimestamp(segment) + gapDuration := FirstPacketNtpTimestamp(nextSegment.metadata) - LastPacketNtpTimestamp(segment.metadata) if gapDuration > 0 { // There's a gap gapSeconds := float64(gapDuration) / 1000.0 logger.Info("Detected %dms gap between segments, generating %s filler", gapDuration, trackType) // Create gap filler file - gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.%s", trackType, i, suffix)) + gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.%s", trackType, i, segment.ContainerExt)) if trackType == "audio" { err := webm.GenerateSilence(gapFilePath, gapSeconds, logger) @@ -185,13 +105,15 @@ func processSegmentsWithGapFilling(files []string, suffix string, track *TrackIn } } + defer os.Remove(gapFilePath) + filesToConcat = append(filesToConcat, gapFilePath) } } } // Create final output file - finalName := fmt.Sprintf("%s_%s_%s_%s.webm", trackType, track.UserID, track.SessionID, track.TrackID) + finalName := fmt.Sprintf("%s_%s_%s_%s.%s", trackType, track.UserID, track.SessionID, track.TrackID, track.Segments[0].ContainerExt) finalPath := filepath.Join(outputDir, finalName) // Concatenate all segments (with gap fillers if any) @@ -200,19 +122,6 @@ func processSegmentsWithGapFilling(files []string, suffix string, track *TrackIn return "", fmt.Errorf("failed to concatenate segments: %w", err) } - // Clean up temporary gap filler files - if fillGaps { - for _, file := range filesToConcat { - if strings.Contains(file, "gap_") { - os.Remove(file) - } - } - } - - if fillGaps { - logger.Info("Successfully concatenated %d segments with gap filling into %s", track.SegmentCount, finalPath) - } else { - logger.Info("Successfully concatenated %d segments into %s", track.SegmentCount, finalPath) - } + logger.Info("Successfully concatenated %d segments into %s (gap filled %t)", track.SegmentCount, finalPath, fillGaps) return finalPath, nil } diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index 4a8bd35..82ef7c4 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -32,7 +32,7 @@ func (p *ExtractVideoProcess) runExtractVideo(args []string, globalArgs *GlobalA fs.StringVar(&extractVideoArgs.UserID, "userId", "", "Specify a userId (empty for all)") fs.StringVar(&extractVideoArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") fs.StringVar(&extractVideoArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") - fs.BoolVar(&extractVideoArgs.FillGaps, "fill_gaps", false, "Fill with black frame when track was muted") + fs.BoolVar(&extractVideoArgs.FillGaps, "fill_gaps", true, "Fill with black frame when track was muted (default true)") if err := fs.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) @@ -108,5 +108,5 @@ func (p *ExtractVideoProcess) printUsage() { } func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - return extractTracks(globalArgs.WorkDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, logger) + return extractTracks(globalArgs.WorkDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, false, logger) } diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go index 4cbe3a9..4ef4a19 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/cmd/raw-recording-tools/metadata.go @@ -17,14 +17,23 @@ import ( // TrackInfo represents a single track with its metadata (deduplicated across segments) type TrackInfo struct { - UserID string `json:"userId"` // participant_id from timing metadata - SessionID string `json:"sessionId"` // user_session_id from timing metadata - TrackID string `json:"trackId"` // track_id from segment - TrackType string `json:"trackType"` // "audio" or "video" (cleaned from TRACK_TYPE_*) - IsScreenshare bool `json:"isScreenshare"` // true if this is a screenshare track - Codec string `json:"codec"` // codec info - SegmentCount int `json:"segmentCount"` // number of segments for this track - Segments []*rawrecorder.SegmentMetadata `json:"segments"` // list of filenames (for JSON output only) + UserID string `json:"userId"` // participant_id from timing metadata + SessionID string `json:"sessionId"` // user_session_id from timing metadata + TrackID string `json:"trackId"` // track_id from segment + TrackType string `json:"trackType"` // "audio" or "video" (cleaned from TRACK_TYPE_*) + IsScreenshare bool `json:"isScreenshare"` // true if this is a screenshare track + Codec string `json:"codec"` // codec info + SegmentCount int `json:"segmentCount"` // number of segments for this track + Segments []*SegmentInfo `json:"segments"` // list of filenames (for JSON output only) +} + +type SegmentInfo struct { + metadata *rawrecorder.SegmentMetadata + + RtpDumpPath string + SdpPath string + ContainerPath string + ContainerExt string } // RecordingMetadata contains all tracks and session information @@ -194,7 +203,7 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, err trackType) if existingTrack, exists := trackMap[key]; exists { - existingTrack.Segments = append(existingTrack.Segments, segment) + existingTrack.Segments = append(existingTrack.Segments, &SegmentInfo{metadata: segment}) existingTrack.SegmentCount++ } else { // Create new track @@ -206,7 +215,7 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, err IsScreenshare: p.isScreenshareTrack(segment.TrackType), Codec: segment.Codec, SegmentCount: 1, - Segments: []*rawrecorder.SegmentMetadata{segment}, + Segments: []*SegmentInfo{{metadata: segment}}, } trackMap[key] = track } @@ -226,7 +235,7 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, err tracks := make([]*TrackInfo, 0, len(trackMap)) for _, track := range trackMap { sort.Slice(track.Segments, func(i, j int) bool { - return track.Segments[i].FirstRtpUnixTimestamp < track.Segments[j].FirstRtpUnixTimestamp + return track.Segments[i].metadata.FirstRtpUnixTimestamp < track.Segments[j].metadata.FirstRtpUnixTimestamp }) tracks = append(tracks, track) } diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index 007ae69..a86a713 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -13,10 +13,7 @@ import ( // MixAudioArgs represents the arguments for the mix-audio command type MixAudioArgs struct { - UserID string // Optional: filter by specific user, * for all users - SessionID string // Optional: filter by specific session, * for all sessions - TrackID string // Optional: filter by specific track, * for all tracks - FillGaps bool // Whether to fill gaps with silence (always true for mixing) + IncludeScreenShare bool } // AudioFileWithTiming represents an audio file with its timing information @@ -44,14 +41,11 @@ func (p *MixAudioProcess) runMixAudio(args []string, globalArgs *GlobalArgs) { printHelpIfAsked(args, p.printUsage) mixAudioArgs := &MixAudioArgs{ - UserID: "", // Default: all users (empty) - SessionID: "", // Default: all sessions (empty) - TrackID: "", // Default: all tracks (empty) - FillGaps: true, // Always fill gaps for proper mixing + IncludeScreenShare: false, } // Validate input arguments against actual recording data - metadata, err := validateInputArgs(globalArgs, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID) + metadata, err := validateInputArgs(globalArgs, "", "", "") if err != nil { fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) os.Exit(1) @@ -72,7 +66,13 @@ func (p *MixAudioProcess) runMixAudio(args []string, globalArgs *GlobalArgs) { func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Step 1: Extract all matching audio tracks using existing extractTracks function logger.Info("Step 1/2: Extracting all matching audio tracks...") - err := extractTracks(globalArgs.WorkDir, globalArgs.Output, mixAudioArgs.UserID, mixAudioArgs.SessionID, mixAudioArgs.TrackID, metadata, "audio", "user", mixAudioArgs.FillGaps, logger) + + mediaFilter := "user" + if mixAudioArgs.IncludeScreenShare { + mediaFilter = "both" + } + + err := extractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", mediaFilter, true, true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 22d8f13..4aa995f 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -103,14 +103,14 @@ func (p *MuxAudioVideoProcess) muxAudioVideoTracks(globalArgs *GlobalArgs, muxAV // Extract audio tracks with gap filling enabled logger.Info("Extracting audio tracks with gap filling...") - err = extractTracks(globalArgs.WorkDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "audio", muxAVArgs.Media, true, logger) + err = extractTracks(globalArgs.WorkDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "audio", muxAVArgs.Media, true, true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Extract video tracks with gap filling enabled logger.Info("Extracting video tracks with gap filling...") - err = extractTracks(globalArgs.WorkDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "video", muxAVArgs.Media, true, logger) + err = extractTracks(globalArgs.WorkDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "video", muxAVArgs.Media, true, true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } @@ -191,12 +191,12 @@ func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, metada } // Calculate offset: positive means video starts before audio - audioTs := FirstPacketNtpTimestamp(audioTrack.Segments[0]) - videoTs := FirstPacketNtpTimestamp(videoTrack.Segments[0]) + audioTs := FirstPacketNtpTimestamp(audioTrack.Segments[0].metadata) + videoTs := FirstPacketNtpTimestamp(videoTrack.Segments[0].metadata) offset := audioTs - videoTs logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", - audioTrack.Segments[0].FirstRtpUnixTimestamp, audioTs, videoTrack.Segments[0].FirstRtpUnixTimestamp, videoTs, offset)) + audioTrack.Segments[0].metadata.FirstRtpUnixTimestamp, audioTs, videoTrack.Segments[0].metadata.FirstRtpUnixTimestamp, videoTs, offset)) return offset, nil } diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 14252a9..80eb305 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -94,14 +94,14 @@ func (p *ProcessAllProcess) printUsage() { func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Step 1: Extract audio tracks with gap filling logger.Info("Step 1/3: Extracting audio tracks with gap filling...") - err := extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, logger) + err := extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Step 2: Extract video tracks with gap filling logger.Info("Step 2/3: Extracting video tracks with gap filling...") - err = extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "video", "both", true, logger) + err = extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "video", "both", true, true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go index 77c0992..847d08e 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/cmd/raw-recording-tools/webm/converter.go @@ -43,7 +43,7 @@ func newRTPDump2WebMConverter(logger *getstream.DefaultLogger) *RTPDump2WebMConv } } -func ConvertDirectory(directory string, logger *getstream.DefaultLogger) error { +func ConvertDirectory(directory string, accept func(path string, info os.FileInfo) bool, fixDtx bool, logger *getstream.DefaultLogger) error { var rtpdumpFiles []string // Walk through directory to find .rtpdump files @@ -52,7 +52,7 @@ func ConvertDirectory(directory string, logger *getstream.DefaultLogger) error { return err } - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), SuffixRtpDump) { + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), SuffixRtpDump) && accept(path, info) { rtpdumpFiles = append(rtpdumpFiles, path) } @@ -64,7 +64,7 @@ func ConvertDirectory(directory string, logger *getstream.DefaultLogger) error { for _, rtpdumpFile := range rtpdumpFiles { c := newRTPDump2WebMConverter(logger) - if err := c.ConvertFile(rtpdumpFile); err != nil { + if err := c.ConvertFile(rtpdumpFile, fixDtx); err != nil { c.logger.Error("Failed to convert %s: %v", rtpdumpFile, err) continue } @@ -73,7 +73,7 @@ func ConvertDirectory(directory string, logger *getstream.DefaultLogger) error { return nil } -func (c *RTPDump2WebMConverter) ConvertFile(inputFile string) error { +func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error { c.logger.Info("Converting %s", inputFile) // Parse the RTP dump file @@ -91,24 +91,26 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string) error { sdpContent, _ := rawsdputil.ReadSDP(strings.Replace(inputFile, SuffixRtpDump, SuffixSdp, 1)) mType, _ := rawsdputil.MimeType(sdpContent) - defaultReleasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) + releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) switch mType { case webrtc.MimeTypeAV1: - c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, defaultReleasePacketHandler) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, releasePacketHandler) c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeVP9: - c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, defaultReleasePacketHandler) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeH264: - c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, defaultReleasePacketHandler) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, releasePacketHandler) c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixMp4, 1), sdpContent, c.logger) case webrtc.MimeTypeVP8: - c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP8Packet{}, 90000, defaultReleasePacketHandler) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP8Packet{}, 90000, releasePacketHandler) c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeOpus: - options := samplebuilder.WithPacketReleaseHandler(c.buildOpusReleasePacketHandler()) - c.sampleBuilder = samplebuilder.New(audioMaxLate, &codecs.OpusPacket{}, 48000, options) + if fixDtx { + releasePacketHandler = samplebuilder.WithPacketReleaseHandler(c.buildOpusReleasePacketHandler()) + } + c.sampleBuilder = samplebuilder.New(audioMaxLate, &codecs.OpusPacket{}, 48000, releasePacketHandler) c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) default: return fmt.Errorf("unsupported codec type: %s", mType) From eb4baebd03b249d0e06de60010be22bf8d9b3271 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 11:58:17 +0200 Subject: [PATCH 20/38] Refactor mix and mux --- cmd/raw-recording-tools/audio_mixer.go | 87 +++++++ cmd/raw-recording-tools/av_muxer.go | 138 +++++++++++ cmd/raw-recording-tools/extract_track.go | 3 +- cmd/raw-recording-tools/metadata.go | 2 + cmd/raw-recording-tools/mix_audio.go | 122 +--------- cmd/raw-recording-tools/mux_av.go | 280 +---------------------- cmd/raw-recording-tools/process_all.go | 101 +------- cmd/raw-recording-tools/webm/helper.go | 2 +- 8 files changed, 238 insertions(+), 497 deletions(-) create mode 100644 cmd/raw-recording-tools/audio_mixer.go create mode 100644 cmd/raw-recording-tools/av_muxer.go diff --git a/cmd/raw-recording-tools/audio_mixer.go b/cmd/raw-recording-tools/audio_mixer.go new file mode 100644 index 0000000..d4b486e --- /dev/null +++ b/cmd/raw-recording-tools/audio_mixer.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "path/filepath" + "sort" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" +) + +type AudioMixer struct { + logger *getstream.DefaultLogger +} + +func NewAudioMixer(logger *getstream.DefaultLogger) *AudioMixer { + return &AudioMixer{logger: logger} +} + +// mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic +func (p *AudioMixer) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Step 1: Extract all matching audio tracks using existing extractTracks function + logger.Info("Step 1/2: Extracting all matching audio tracks...") + + mediaFilter := "user" + if mixAudioArgs.IncludeScreenShare { + mediaFilter = "both" + } + + err := extractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", mediaFilter, true, true, logger) + if err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } + + fileOffsetMap := p.offset(metadata, logger) + if len(fileOffsetMap) == 0 { + return fmt.Errorf("no audio files were extracted - check your filter criteria") + } + + logger.Info("Found %d extracted audio files to mix", len(fileOffsetMap)) + + // Step 3: Mix all discovered audio files using existing webm.MixAudioFiles + outputFile := filepath.Join(globalArgs.Output, "mixed_audio.webm") + + err = webm.MixAudioFiles(outputFile, fileOffsetMap, logger) + if err != nil { + return fmt.Errorf("failed to mix audio files: %w", err) + } + + logger.Info("Successfully created mixed audio file: %s", outputFile) + + //// Clean up individual audio files (optional) + //for _, audioFile := range audioFiles { + // if err := os.Remove(audioFile.FilePath); err != nil { + // logger.Warn("Failed to clean up temporary file %s: %v", audioFile.FilePath, err) + // } + //} + + return nil +} + +func (p *AudioMixer) offset(metadata *RecordingMetadata, logger *getstream.DefaultLogger) map[string]int64 { + offsetMap := make(map[string]int64) + + sort.Slice(metadata.Tracks, func(i, j int) bool { + return metadata.Tracks[i].Segments[0].metadata.FirstRtpUnixTimestamp < metadata.Tracks[j].Segments[0].metadata.FirstRtpUnixTimestamp + }) + + var firstTrack *TrackInfo + for _, t := range metadata.Tracks { + if t.TrackType == "audio" && !t.IsScreenshare { + if firstTrack == nil { + firstTrack = t + offsetMap[t.ConcatenatedContainerPath] = 0 + } else { + offset, err := calculateSyncOffsetFromFiles(firstTrack, t, logger) + if err != nil { + logger.Warn("Failed to calculate sync offset for audio tracks: %v", err) + continue + } + offsetMap[t.ConcatenatedContainerPath] = offset + } + } + } + + return offsetMap +} diff --git a/cmd/raw-recording-tools/av_muxer.go b/cmd/raw-recording-tools/av_muxer.go new file mode 100644 index 0000000..7c19916 --- /dev/null +++ b/cmd/raw-recording-tools/av_muxer.go @@ -0,0 +1,138 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" +) + +type AudioVideoMuxer struct { + logger *getstream.DefaultLogger +} + +func NewAudioVideoMuxer(logger *getstream.DefaultLogger) *AudioVideoMuxer { + return &AudioVideoMuxer{logger: logger} +} + +func (p *AudioVideoMuxer) muxAudioVideoTracks(globalArgs *GlobalArgs, userID, sessionID, trackID, media string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Extract audio tracks with gap filling enabled + logger.Info("Extracting audio tracks with gap filling...") + err := extractTracks(globalArgs.WorkDir, globalArgs.Output, userID, sessionID, trackID, metadata, "audio", media, true, true, logger) + if err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } + + // Extract video tracks with gap filling enabled + logger.Info("Extracting video tracks with gap filling...") + err = extractTracks(globalArgs.WorkDir, globalArgs.Output, userID, sessionID, trackID, metadata, "video", media, true, true, logger) + if err != nil { + return fmt.Errorf("failed to extract video tracks: %w", err) + } + + // Group files by media type for proper pairing + pairedTracks := p.groupFilesByMediaType(metadata) + for audioTrack, videoTrack := range pairedTracks { + //logger.Info("Muxing %d user audio/video pairs", len(userAudio)) + err = p.muxTrackPairs(audioTrack, videoTrack, globalArgs.Output, "user", logger) + if err != nil { + logger.Error("Failed to mux user tracks: %v", err) + } + } + + return nil +} + +// calculateSyncOffsetFromFiles calculates sync offset between audio and video files using metadata +func calculateSyncOffsetFromFiles(audioTrack, videoTrack *TrackInfo, logger *getstream.DefaultLogger) (int64, error) { + // Calculate offset: positive means video starts before audio + audioTs := FirstPacketNtpTimestamp(audioTrack.Segments[0].metadata) + videoTs := FirstPacketNtpTimestamp(videoTrack.Segments[0].metadata) + offset := audioTs - videoTs + + logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", + audioTrack.Segments[0].metadata.FirstRtpUnixTimestamp, audioTs, videoTrack.Segments[0].metadata.FirstRtpUnixTimestamp, videoTs, offset)) + + return offset, nil +} + +// groupFilesByMediaType groups audio and video files by media type (user vs display) +func (p *AudioVideoMuxer) groupFilesByMediaType(metadata *RecordingMetadata) map[*TrackInfo]*TrackInfo { + pairedTracks := make(map[*TrackInfo]*TrackInfo) + + matches := func(audio *TrackInfo, video *TrackInfo) bool { + return audio.UserID == video.UserID && + audio.SessionID == video.SessionID && + audio.IsScreenshare == video.IsScreenshare + } + + for _, at := range metadata.Tracks { + if at.TrackType == "audio" { + for _, vt := range metadata.Tracks { + if vt.TrackType == "video" && matches(at, vt) { + pairedTracks[at] = vt + break + } + } + } + } + + return pairedTracks +} + +// muxTrackPairs muxes audio/video pairs of the same media type +func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir, mediaTypeName string, logger *getstream.DefaultLogger) error { + // Calculate sync offset using segment timing information + offset, err := calculateSyncOffsetFromFiles(audio, video, logger) + if err != nil { + logger.Warn("Failed to calculate sync offset, using 0: %v", err) + offset = 0 + } + + // Generate output filename with media type indicator + outputFile := p.generateMediaAwareMuxedFilename(audio, video, outputDir) + + audioFile := audio.ConcatenatedContainerPath + videoFile := video.ConcatenatedContainerPath + + // Mux the audio and video files + logger.Info("Muxing %s %s + %s → %s (offset: %dms)", + mediaTypeName, filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) + + err = webm.MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) + if err != nil { + logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) + return err + } + + logger.Info("Successfully created %s muxed file: %s", mediaTypeName, outputFile) + + // Clean up individual track files to avoid clutter + //os.Remove(audioFile) + //os.Remove(videoFile) + //} + // + //if len(audioFiles) != len(videoFiles) { + // logger.Warn("Mismatched %s track counts: %d audio, %d video", mediaTypeName, len(audioFiles), len(videoFiles)) + //} + + return nil +} + +// generateMediaAwareMuxedFilename creates output filename that indicates media type +func (p *AudioVideoMuxer) generateMediaAwareMuxedFilename(audioFile, videoFile *TrackInfo, outputDir string) string { + audioBase := filepath.Base(audioFile.Segments[0].ContainerPath) + audioBase = strings.TrimSuffix(audioBase, "."+audioFile.Segments[0].ContainerExt) + + // Replace "audio_" with "muxed_{mediaType}_" to create output name + var muxedName string + if audioFile.IsScreenshare { + muxedName = strings.Replace(audioBase, "audio_", "muxed_display_", 1) + "." + videoFile.Segments[0].ContainerExt + } else { + muxedName = strings.Replace(audioBase, "audio_", "muxed_", 1) + "." + videoFile.Segments[0].ContainerExt + } + + return filepath.Join(outputDir, muxedName) +} diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go index b823094..bed6f46 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/cmd/raw-recording-tools/extract_track.go @@ -61,11 +61,12 @@ func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir } // Create segments with timing info and fill gaps - finalFile, err := processSegmentsWithGapFilling( /*files, suffix,*/ track, trackType, outputDir, fillGaps, logger) + finalFile, err := processSegmentsWithGapFilling(track, trackType, outputDir, fillGaps, logger) if err != nil { return fmt.Errorf("failed to process segments with gap filling: %w", err) } + track.ConcatenatedContainerPath = finalFile logger.Info("Successfully extracted %s track to: %s", trackType, finalFile) return nil } diff --git a/cmd/raw-recording-tools/metadata.go b/cmd/raw-recording-tools/metadata.go index 4ef4a19..23266bb 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/cmd/raw-recording-tools/metadata.go @@ -25,6 +25,8 @@ type TrackInfo struct { Codec string `json:"codec"` // codec info SegmentCount int `json:"segmentCount"` // number of segments for this track Segments []*SegmentInfo `json:"segments"` // list of filenames (for JSON output only) + + ConcatenatedContainerPath string } type SegmentInfo struct { diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index a86a713..8865e11 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -3,12 +3,8 @@ package main import ( "fmt" "os" - "path/filepath" - "sort" - "strings" "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" ) // MixAudioArgs represents the arguments for the mix-audio command @@ -16,18 +12,6 @@ type MixAudioArgs struct { IncludeScreenShare bool } -// AudioFileWithTiming represents an audio file with its timing information -type AudioFileWithTiming struct { - FilePath string // Path to the WebM audio file - UserID string // User who created this audio - SessionID string // Session ID - TrackID string // Track ID - StartOffsetMs int64 // When this audio should start in the final mix (milliseconds) - DurationMs int64 // Duration of the audio file - EndOffsetMs int64 // When this audio ends (StartOffsetMs + DurationMs) - TrackInfo *TrackInfo // Original track metadata -} - type MixAudioProcess struct { logger *getstream.DefaultLogger } @@ -64,113 +48,11 @@ func (p *MixAudioProcess) runMixAudio(args []string, globalArgs *GlobalArgs) { // mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Step 1: Extract all matching audio tracks using existing extractTracks function - logger.Info("Step 1/2: Extracting all matching audio tracks...") - - mediaFilter := "user" - if mixAudioArgs.IncludeScreenShare { - mediaFilter = "both" - } - - err := extractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", mediaFilter, true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract audio tracks: %w", err) - } - - // Step 2: Find all extracted audio files and prepare them for mixing - logger.Info("Step 2/2: Discovering extracted files and mixing...") - audioFiles, err := p.discoverExtractedAudioFiles(globalArgs.Output, logger) - if err != nil { - return fmt.Errorf("failed to discover extracted audio files: %w", err) - } - - if len(audioFiles) == 0 { - return fmt.Errorf("no audio files were extracted - check your filter criteria") - } - - logger.Info("Found %d extracted audio files to mix", len(audioFiles)) - - // Step 3: Mix all discovered audio files using existing webm.MixAudioFiles - outputFile := filepath.Join(globalArgs.Output, "mixed_audio.webm") - - // Convert AudioFileWithTiming to the format expected by webm.MixAudioFiles - // webm.MixAudioFiles expects: map[string]int where key=filepath, value=offset_ms - fileOffsetMap := make(map[string]int) - for _, audioFile := range audioFiles { - fileOffsetMap[audioFile.FilePath] = int(audioFile.StartOffsetMs) - } - - err = webm.MixAudioFiles(outputFile, fileOffsetMap, logger) - if err != nil { - return fmt.Errorf("failed to mix audio files: %w", err) - } - - logger.Info("Successfully created mixed audio file: %s", outputFile) - - // Clean up individual audio files (optional) - for _, audioFile := range audioFiles { - if err := os.Remove(audioFile.FilePath); err != nil { - logger.Warn("Failed to clean up temporary file %s: %v", audioFile.FilePath, err) - } - } - + mixer := NewAudioMixer(logger) + mixer.mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, logger) return nil } -// discoverExtractedAudioFiles finds all audio files that were extracted and prepares them for mixing -func (p *MixAudioProcess) discoverExtractedAudioFiles(outputDir string, logger *getstream.DefaultLogger) ([]AudioFileWithTiming, error) { - var audioFiles []AudioFileWithTiming - - // Find all .webm audio files in the output directory - err := filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Look for audio WebM files (created by extractTracks) - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".webm") && strings.Contains(strings.ToLower(info.Name()), "audio") { - logger.Debug("Found extracted audio file: %s", path) - - // Parse filename to extract timing info (if available) - audioFile := AudioFileWithTiming{ - FilePath: path, - StartOffsetMs: 0, // Will be calculated from metadata if needed - DurationMs: 0, // Will be calculated if needed - EndOffsetMs: 0, // Will be calculated if needed - } - - // Try to extract user/session/track info from filename - // Expected format: audio_userID_sessionID_trackID.webm - basename := filepath.Base(path) - basename = strings.TrimSuffix(basename, ".webm") - parts := strings.Split(basename, "_") - - if len(parts) >= 4 && parts[0] == "audio" { - audioFile.UserID = parts[1] - audioFile.SessionID = parts[2] - audioFile.TrackID = parts[3] - } - - audioFiles = append(audioFiles, audioFile) - } - - return nil - }) - - if err != nil { - return nil, fmt.Errorf("failed to scan output directory: %w", err) - } - - // Sort by filename for consistent ordering - sort.Slice(audioFiles, func(i, j int) bool { - return audioFiles[i].FilePath < audioFiles[j].FilePath - }) - - return audioFiles, nil -} - -// Note: We removed mixAudioFilesUsingExistingLogic since we now use webm.MixAudioFiles directly - // printMixAudioUsage prints the usage information for the mix-audio command func (p *MixAudioProcess) printUsage() { fmt.Println("Usage: raw-tools [global-options] mix-audio [options]") diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 4aa995f..bf78671 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -4,11 +4,8 @@ import ( "flag" "fmt" "os" - "path/filepath" - "strings" "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" ) type MuxAVArgs struct { @@ -94,280 +91,9 @@ func (p *MuxAudioVideoProcess) printUsage() { } func (p *MuxAudioVideoProcess) muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Create a temporary directory for intermediate files - tempDir, err := os.MkdirTemp("", "mux-av-*") - if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - defer os.RemoveAll(tempDir) - - // Extract audio tracks with gap filling enabled - logger.Info("Extracting audio tracks with gap filling...") - err = extractTracks(globalArgs.WorkDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "audio", muxAVArgs.Media, true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract audio tracks: %w", err) - } - - // Extract video tracks with gap filling enabled - logger.Info("Extracting video tracks with gap filling...") - err = extractTracks(globalArgs.WorkDir, globalArgs.Output, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, metadata, "video", muxAVArgs.Media, true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract video tracks: %w", err) - } - - // Find the generated audio and video WebM files - audioFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "audio_*.webm")) - if err != nil { - return fmt.Errorf("failed to find audio files: %w", err) - } - if len(audioFiles) == 0 { - return fmt.Errorf("no audio files generated") - } - - webmVideoFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.webm")) - mp4VideoFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.mp4")) - - videoFiles := append(webmVideoFiles, mp4VideoFiles...) - - if err != nil { - return fmt.Errorf("failed to find video files: %w", err) - } - if len(videoFiles) == 0 { - return fmt.Errorf("no video files generated") - } - - logger.Info("Found %d audio files and %d video files to mux", len(audioFiles), len(videoFiles)) - - // Group files by media type for proper pairing - audioGroups, videoGroups, err := p.groupFilesByMediaType(globalArgs.InputFile, audioFiles, videoFiles, muxAVArgs.Media, metadata, logger) - if err != nil { - return fmt.Errorf("failed to group files by media type: %w", err) - } - - // Mux user tracks - if userAudio, userVideo := audioGroups["user"], videoGroups["user"]; len(userAudio) > 0 && len(userVideo) > 0 { - logger.Info("Muxing %d user audio/video pairs", len(userAudio)) - err = p.muxTrackPairs(globalArgs.InputFile, userAudio, userVideo, globalArgs.Output, "user", metadata, logger) - if err != nil { - logger.Error("Failed to mux user tracks: %v", err) - } - } - - // Mux display tracks - if displayAudio, displayVideo := audioGroups["display"], videoGroups["display"]; len(displayAudio) > 0 && len(displayVideo) > 0 { - logger.Info("Muxing %d display audio/video pairs", len(displayAudio)) - err = p.muxTrackPairs(globalArgs.InputFile, displayAudio, displayVideo, globalArgs.Output, "display", metadata, logger) - if err != nil { - logger.Error("Failed to mux display tracks: %v", err) - } - } - - return nil -} - -// calculateSyncOffsetFromFiles calculates sync offset between audio and video files using metadata -func calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) (int64, error) { - // Extract track IDs from filenames - audioTrackID := extractTrackIDFromFilename(audioFile) - videoTrackID := extractTrackIDFromFilename(videoFile) - - if audioTrackID == "" || videoTrackID == "" { - return 0, fmt.Errorf("could not extract track IDs from filenames") - } - - // Find the audio and video tracks - var audioTrack, videoTrack *TrackInfo - for _, track := range metadata.Tracks { - if track.TrackID == audioTrackID && track.TrackType == "audio" { - audioTrack = track - } - if track.TrackID == videoTrackID && track.TrackType == "video" { - videoTrack = track - } - } - - if audioTrack == nil || videoTrack == nil { - return 0, fmt.Errorf("could not find matching tracks in metadata") - } - - // Calculate offset: positive means video starts before audio - audioTs := FirstPacketNtpTimestamp(audioTrack.Segments[0].metadata) - videoTs := FirstPacketNtpTimestamp(videoTrack.Segments[0].metadata) - offset := audioTs - videoTs - - logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", - audioTrack.Segments[0].metadata.FirstRtpUnixTimestamp, audioTs, videoTrack.Segments[0].metadata.FirstRtpUnixTimestamp, videoTs, offset)) - - return offset, nil -} - -// extractTrackIDFromFilename extracts track ID from generated filename -func extractTrackIDFromFilename(filename string) string { - // Filename format: {type}_{userId}_{sessionId}_{trackId}.suffix - base := filepath.Base(filename) - split := strings.Split(base, ".") - parts := strings.Split(split[0], "_") - if len(parts) >= 4 { - return parts[3] // trackId is the 4th part - } - return "" -} - -// generateMuxedFilename creates output filename for muxed file -func generateMuxedFilename(audioFile, videoFile, outputDir string) string { - // Extract common parts from audio filename - videoBase := filepath.Base(videoFile) - split := strings.Split(videoBase, ".") - - // Replace "audio_" with "muxed_" to create output name - muxedName := strings.Replace(split[0], "audio_", "muxed_", 1) + "." + split[1] - - return filepath.Join(outputDir, muxedName) -} - -// groupFilesByMediaType groups audio and video files by media type (user vs display) -func (p *MuxAudioVideoProcess) groupFilesByMediaType(inputPath string, audioFiles, videoFiles []string, mediaFilter string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) (map[string][]string, map[string][]string, error) { - // Create track ID to screenshare type mapping - trackScreenshareMap := make(map[string]bool) - for _, track := range metadata.Tracks { - trackScreenshareMap[track.TrackID] = track.IsScreenshare - } - - // Group files by media type - audioGroups := map[string][]string{"user": {}, "display": {}} - videoGroups := map[string][]string{"user": {}, "display": {}} - - // Process audio files - for _, audioFile := range audioFiles { - trackID := extractTrackIDFromFilename(audioFile) - if trackID == "" { - logger.Warn("Could not extract track ID from audio file: %s", audioFile) - continue - } - - isScreenshare, exists := trackScreenshareMap[trackID] - if !exists { - logger.Warn("Track ID %s not found in metadata for audio file: %s", trackID, audioFile) - continue - } - - // Apply media filter - if mediaFilter == "user" && isScreenshare { - continue // Skip display tracks when only user requested - } - if mediaFilter == "display" && !isScreenshare { - continue // Skip user tracks when only display requested - } - - if isScreenshare { - audioGroups["display"] = append(audioGroups["display"], audioFile) - } else { - audioGroups["user"] = append(audioGroups["user"], audioFile) - } - } - - // Process video files - for _, videoFile := range videoFiles { - trackID := extractTrackIDFromFilename(videoFile) - if trackID == "" { - logger.Warn("Could not extract track ID from video file: %s", videoFile) - continue - } - - isScreenshare, exists := trackScreenshareMap[trackID] - if !exists { - logger.Warn("Track ID %s not found in metadata for video file: %s", trackID, videoFile) - continue - } - - // Apply media filter - if mediaFilter == "user" && isScreenshare { - continue // Skip display tracks when only user requested - } - if mediaFilter == "display" && !isScreenshare { - continue // Skip user tracks when only display requested - } - - if isScreenshare { - videoGroups["display"] = append(videoGroups["display"], videoFile) - } else { - videoGroups["user"] = append(videoGroups["user"], videoFile) - } - } - - logger.Info("Grouped files: user audio=%d, user video=%d, display audio=%d, display video=%d", - len(audioGroups["user"]), len(videoGroups["user"]), - len(audioGroups["display"]), len(videoGroups["display"])) - - return audioGroups, videoGroups, nil -} - -// muxTrackPairs muxes audio/video pairs of the same media type -func (p *MuxAudioVideoProcess) muxTrackPairs(inputPath string, audioFiles, videoFiles []string, outputDir, mediaTypeName string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - minLen := len(audioFiles) - if len(videoFiles) < minLen { - minLen = len(videoFiles) - } - - if minLen == 0 { - logger.Warn("No %s audio/video pairs to mux", mediaTypeName) - return nil - } - - for i := 0; i < minLen; i++ { - audioFile := audioFiles[i] - videoFile := videoFiles[i] - - // Calculate sync offset using segment timing information - offset, err := calculateSyncOffsetFromFiles(inputPath, audioFile, videoFile, metadata, logger) - if err != nil { - logger.Warn("Failed to calculate sync offset, using 0: %v", err) - offset = 0 - } - - // Generate output filename with media type indicator - outputFile := p.generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName) - - // Mux the audio and video files - logger.Info("Muxing %s %s + %s → %s (offset: %dms)", - mediaTypeName, filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) - - err = webm.MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) - if err != nil { - logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) - continue - } - - logger.Info("Successfully created %s muxed file: %s", mediaTypeName, outputFile) - - // Clean up individual track files to avoid clutter - //os.Remove(audioFile) - //os.Remove(videoFile) + muxer := NewAudioVideoMuxer(p.logger) + if e := muxer.muxAudioVideoTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, muxAVArgs.Media, metadata, logger); e != nil { + return e } - - if len(audioFiles) != len(videoFiles) { - logger.Warn("Mismatched %s track counts: %d audio, %d video", mediaTypeName, len(audioFiles), len(videoFiles)) - } - return nil } - -// generateMediaAwareMuxedFilename creates output filename that indicates media type -func (p *MuxAudioVideoProcess) generateMediaAwareMuxedFilename(audioFile, videoFile, outputDir, mediaTypeName string) string { - audioBase := filepath.Base(audioFile) - audioBase = strings.TrimSuffix(audioBase, ".webm") - - // Extract common parts from audio filename - videoBase := filepath.Base(videoFile) - split := strings.Split(videoBase, ".") - - // Replace "audio_" with "muxed_{mediaType}_" to create output name - var muxedName string - if mediaTypeName == "display" { - muxedName = strings.Replace(audioBase, "audio_", "muxed_display_", 1) + "." + split[1] - } else { - muxedName = strings.Replace(audioBase, "audio_", "muxed_", 1) + "." + split[1] - } - - return filepath.Join(outputDir, muxedName) -} diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 80eb305..c5e02b6 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -4,10 +4,8 @@ import ( "flag" "fmt" "os" - "path/filepath" "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" ) type ProcessAllArgs struct { @@ -92,102 +90,9 @@ func (p *ProcessAllProcess) printUsage() { } func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Step 1: Extract audio tracks with gap filling - logger.Info("Step 1/3: Extracting audio tracks with gap filling...") - err := extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "audio", "both", true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract audio tracks: %w", err) - } - - // Step 2: Extract video tracks with gap filling - logger.Info("Step 2/3: Extracting video tracks with gap filling...") - err = extractTracks(globalArgs.WorkDir, globalArgs.Output, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID, metadata, "video", "both", true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract video tracks: %w", err) - } - - // Step 3: Mux audio and video files (keeping originals) - logger.Info("Step 3/3: Muxing audio and video tracks...") - err = p.muxAudioVideoTracksKeepOriginals(globalArgs, processAllArgs, metadata, logger) - if err != nil { - return fmt.Errorf("failed to mux audio and video tracks: %w", err) - } - - // Report final output - audioFiles, _ := filepath.Glob(filepath.Join(globalArgs.Output, "audio_*.webm")) - videoFiles, _ := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.webm")) - muxedFiles, _ := filepath.Glob(filepath.Join(globalArgs.Output, "muxed_*.webm")) - - logger.Info("Process-all completed successfully:") - logger.Info(" - %d audio files", len(audioFiles)) - logger.Info(" - %d video files", len(videoFiles)) - logger.Info(" - %d muxed files", len(muxedFiles)) - - fmt.Printf("\n✅ Generated files in %s:\n", globalArgs.Output) - for _, file := range audioFiles { - fmt.Printf(" 🎵 %s\n", filepath.Base(file)) - } - for _, file := range videoFiles { - fmt.Printf(" 🎬 %s\n", filepath.Base(file)) - } - for _, file := range muxedFiles { - fmt.Printf(" 🎞️ %s\n", filepath.Base(file)) - } - - return nil -} - -// muxAudioVideoTracksKeepOriginals is like muxAudioVideoTracks but keeps the original audio/video files -func (p *ProcessAllProcess) muxAudioVideoTracksKeepOriginals(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Find the generated audio and video WebM files - audioFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "audio_*.webm")) - if err != nil { - return fmt.Errorf("failed to find audio files: %w", err) - } - if len(audioFiles) == 0 { - return fmt.Errorf("no audio files generated") - } - - videoFiles, err := filepath.Glob(filepath.Join(globalArgs.Output, "video_*.webm")) - if err != nil { - return fmt.Errorf("failed to find video files: %w", err) - } - if len(videoFiles) == 0 { - return fmt.Errorf("no video files generated") - } - - logger.Info("Found %d audio files and %d video files to mux", len(audioFiles), len(videoFiles)) - - // Mux each audio/video pair - for i, audioFile := range audioFiles { - if i >= len(videoFiles) { - logger.Warn("No matching video file for audio file %s", audioFile) - continue - } - videoFile := videoFiles[i] - - // Calculate sync offset using segment timing information - offset, err := calculateSyncOffsetFromFiles(globalArgs.InputFile, audioFile, videoFile, metadata, logger) - if err != nil { - logger.Warn("Failed to calculate sync offset, using 0: %v", err) - offset = 0 - } - - // Generate output filename - outputFile := generateMuxedFilename(audioFile, videoFile, globalArgs.Output) - - // Mux the audio and video files - logger.Info("Muxing %s + %s → %s (offset: %dms)", - filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) - - err = webm.MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) - if err != nil { - logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) - continue - } - - logger.Info("Successfully created muxed file: %s", outputFile) - // NOTE: Unlike muxAudioVideoTracks, we DON'T clean up the individual files here + muxer := NewAudioVideoMuxer(p.logger) + if e := muxer.muxAudioVideoTracks(globalArgs, "", "", "", "both", metadata, logger); e != nil { + return e } return nil diff --git a/cmd/raw-recording-tools/webm/helper.go b/cmd/raw-recording-tools/webm/helper.go index 51ffe2f..73d8aa2 100644 --- a/cmd/raw-recording-tools/webm/helper.go +++ b/cmd/raw-recording-tools/webm/helper.go @@ -69,7 +69,7 @@ func MuxFiles(fileName string, audioFile string, videoFile string, offsetMs floa return runFFMEPGCpmmand(args, logger) } -func MixAudioFiles(fileName string, files map[string]int, logger *getstream.DefaultLogger) error { +func MixAudioFiles(fileName string, files map[string]int64, logger *getstream.DefaultLogger) error { var args []string args = append(args, "ffmpeg") From ac9bcbbeb226a09511e1dea3b7df40d853bbd65d Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 12:26:21 +0200 Subject: [PATCH 21/38] Extract process from cmd --- cmd/raw-recording-tools/audio_mixer.go | 29 +++++++++----- cmd/raw-recording-tools/av_muxer.go | 48 +++++++++++++++--------- cmd/raw-recording-tools/extract_track.go | 2 +- cmd/raw-recording-tools/mix_audio.go | 8 +++- cmd/raw-recording-tools/mux_av.go | 11 +++++- cmd/raw-recording-tools/process_all.go | 29 +++++++++++++- 6 files changed, 96 insertions(+), 31 deletions(-) diff --git a/cmd/raw-recording-tools/audio_mixer.go b/cmd/raw-recording-tools/audio_mixer.go index d4b486e..ebfa196 100644 --- a/cmd/raw-recording-tools/audio_mixer.go +++ b/cmd/raw-recording-tools/audio_mixer.go @@ -9,6 +9,14 @@ import ( "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" ) +type AudioMixerConfig struct { + WorkDir string + OutputDir string + WithScreenshare bool + WithExtract bool + WithCleanup bool +} + type AudioMixer struct { logger *getstream.DefaultLogger } @@ -18,18 +26,19 @@ func NewAudioMixer(logger *getstream.DefaultLogger) *AudioMixer { } // mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic -func (p *AudioMixer) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *AudioMixer) mixAllAudioTracks(config *AudioMixerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { // Step 1: Extract all matching audio tracks using existing extractTracks function logger.Info("Step 1/2: Extracting all matching audio tracks...") - mediaFilter := "user" - if mixAudioArgs.IncludeScreenShare { - mediaFilter = "both" - } + if config.WithExtract { + mediaFilter := "user" + if config.WithScreenshare { + mediaFilter = "both" + } - err := extractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", mediaFilter, true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract audio tracks: %w", err) + if err := extractTracks(config.WorkDir, config.OutputDir, "", "", "", metadata, "audio", mediaFilter, true, true, logger); err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } } fileOffsetMap := p.offset(metadata, logger) @@ -40,9 +49,9 @@ func (p *AudioMixer) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *Mix logger.Info("Found %d extracted audio files to mix", len(fileOffsetMap)) // Step 3: Mix all discovered audio files using existing webm.MixAudioFiles - outputFile := filepath.Join(globalArgs.Output, "mixed_audio.webm") + outputFile := filepath.Join(config.OutputDir, "mixed_audio.webm") - err = webm.MixAudioFiles(outputFile, fileOffsetMap, logger) + err := webm.MixAudioFiles(outputFile, fileOffsetMap, logger) if err != nil { return fmt.Errorf("failed to mix audio files: %w", err) } diff --git a/cmd/raw-recording-tools/av_muxer.go b/cmd/raw-recording-tools/av_muxer.go index 7c19916..d54b446 100644 --- a/cmd/raw-recording-tools/av_muxer.go +++ b/cmd/raw-recording-tools/av_muxer.go @@ -9,6 +9,18 @@ import ( "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" ) +type AudioVideoMuxerConfig struct { + WorkDir string + OutputDir string + UserID string + SessionID string + TrackID string + Media string + + WithExtract bool + WithCleanup bool +} + type AudioVideoMuxer struct { logger *getstream.DefaultLogger } @@ -17,26 +29,28 @@ func NewAudioVideoMuxer(logger *getstream.DefaultLogger) *AudioVideoMuxer { return &AudioVideoMuxer{logger: logger} } -func (p *AudioVideoMuxer) muxAudioVideoTracks(globalArgs *GlobalArgs, userID, sessionID, trackID, media string, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Extract audio tracks with gap filling enabled - logger.Info("Extracting audio tracks with gap filling...") - err := extractTracks(globalArgs.WorkDir, globalArgs.Output, userID, sessionID, trackID, metadata, "audio", media, true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract audio tracks: %w", err) - } +func (p *AudioVideoMuxer) muxAudioVideoTracks(config *AudioVideoMuxerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + if config.WithExtract { + // Extract audio tracks with gap filling enabled + logger.Info("Extracting audio tracks with gap filling...") + err := extractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "audio", config.Media, true, true, logger) + if err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } - // Extract video tracks with gap filling enabled - logger.Info("Extracting video tracks with gap filling...") - err = extractTracks(globalArgs.WorkDir, globalArgs.Output, userID, sessionID, trackID, metadata, "video", media, true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract video tracks: %w", err) + // Extract video tracks with gap filling enabled + logger.Info("Extracting video tracks with gap filling...") + err = extractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "video", config.Media, true, true, logger) + if err != nil { + return fmt.Errorf("failed to extract video tracks: %w", err) + } } // Group files by media type for proper pairing pairedTracks := p.groupFilesByMediaType(metadata) for audioTrack, videoTrack := range pairedTracks { //logger.Info("Muxing %d user audio/video pairs", len(userAudio)) - err = p.muxTrackPairs(audioTrack, videoTrack, globalArgs.Output, "user", logger) + err := p.muxTrackPairs(audioTrack, videoTrack, config.OutputDir, logger) if err != nil { logger.Error("Failed to mux user tracks: %v", err) } @@ -83,7 +97,7 @@ func (p *AudioVideoMuxer) groupFilesByMediaType(metadata *RecordingMetadata) map } // muxTrackPairs muxes audio/video pairs of the same media type -func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir, mediaTypeName string, logger *getstream.DefaultLogger) error { +func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir string, logger *getstream.DefaultLogger) error { // Calculate sync offset using segment timing information offset, err := calculateSyncOffsetFromFiles(audio, video, logger) if err != nil { @@ -98,8 +112,8 @@ func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir, medi videoFile := video.ConcatenatedContainerPath // Mux the audio and video files - logger.Info("Muxing %s %s + %s → %s (offset: %dms)", - mediaTypeName, filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) + logger.Info("Muxing %s + %s → %s (offset: %dms)", + filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) err = webm.MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) if err != nil { @@ -107,7 +121,7 @@ func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir, medi return err } - logger.Info("Successfully created %s muxed file: %s", mediaTypeName, outputFile) + logger.Info("Successfully created muxed file: %s", outputFile) // Clean up individual track files to avoid clutter //os.Remove(audioFile) diff --git a/cmd/raw-recording-tools/extract_track.go b/cmd/raw-recording-tools/extract_track.go index bed6f46..4baa90d 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/cmd/raw-recording-tools/extract_track.go @@ -72,7 +72,7 @@ func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir } // processSegmentsWithGapFilling processes webm segments, fills gaps if requested, and concatenates into final file -func processSegmentsWithGapFilling( /*files []string, suffix string,*/ track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { +func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { // Build list of files to concatenate (with optional gap fillers) var filesToConcat []string for i, segment := range track.Segments { diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index 8865e11..aa27a29 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -49,7 +49,13 @@ func (p *MixAudioProcess) runMixAudio(args []string, globalArgs *GlobalArgs) { // mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { mixer := NewAudioMixer(logger) - mixer.mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, logger) + mixer.mixAllAudioTracks(&AudioMixerConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + WithScreenshare: false, + WithExtract: true, + WithCleanup: false, + }, metadata, logger) return nil } diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index bf78671..33c61e0 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -92,7 +92,16 @@ func (p *MuxAudioVideoProcess) printUsage() { func (p *MuxAudioVideoProcess) muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { muxer := NewAudioVideoMuxer(p.logger) - if e := muxer.muxAudioVideoTracks(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID, muxAVArgs.Media, metadata, logger); e != nil { + if e := muxer.muxAudioVideoTracks(&AudioVideoMuxerConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + UserID: muxAVArgs.UserID, + SessionID: muxAVArgs.SessionID, + TrackID: muxAVArgs.TrackID, + Media: muxAVArgs.Media, + WithExtract: true, + WithCleanup: false, + }, metadata, logger); e != nil { return e } return nil diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index c5e02b6..84d1291 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -90,8 +90,35 @@ func (p *ProcessAllProcess) printUsage() { } func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + + if e := extractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", "both", true, true, logger); e != nil { + return e + } + + if e := extractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "video", "both", true, true, logger); e != nil { + return e + } + + mixer := NewAudioMixer(logger) + mixer.mixAllAudioTracks(&AudioMixerConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + WithScreenshare: false, + WithExtract: false, + WithCleanup: false, + }, metadata, logger) + muxer := NewAudioVideoMuxer(p.logger) - if e := muxer.muxAudioVideoTracks(globalArgs, "", "", "", "both", metadata, logger); e != nil { + if e := muxer.muxAudioVideoTracks(&AudioVideoMuxerConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + UserID: "", + SessionID: "", + TrackID: "", + Media: "", + WithExtract: false, + WithCleanup: false, + }, metadata, logger); e != nil { return e } From 8493f31aa6069bae828f5ea03b0d96e106ec9599 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 15:38:21 +0200 Subject: [PATCH 22/38] Fix AudioMixing --- cmd/raw-recording-tools/audio_mixer.go | 29 ++++++++++++----------- cmd/raw-recording-tools/webm/converter.go | 15 +++++++++--- cmd/raw-recording-tools/webm/helper.go | 27 ++++++++++++++++----- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/cmd/raw-recording-tools/audio_mixer.go b/cmd/raw-recording-tools/audio_mixer.go index ebfa196..34c48bb 100644 --- a/cmd/raw-recording-tools/audio_mixer.go +++ b/cmd/raw-recording-tools/audio_mixer.go @@ -3,7 +3,6 @@ package main import ( "fmt" "path/filepath" - "sort" "github.com/GetStream/getstream-go/v3" "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" @@ -41,7 +40,7 @@ func (p *AudioMixer) mixAllAudioTracks(config *AudioMixerConfig, metadata *Recor } } - fileOffsetMap := p.offset(metadata, logger) + fileOffsetMap := p.offset(metadata, config.WithScreenshare, logger) if len(fileOffsetMap) == 0 { return fmt.Errorf("no audio files were extracted - check your filter criteria") } @@ -68,29 +67,31 @@ func (p *AudioMixer) mixAllAudioTracks(config *AudioMixerConfig, metadata *Recor return nil } -func (p *AudioMixer) offset(metadata *RecordingMetadata, logger *getstream.DefaultLogger) map[string]int64 { - offsetMap := make(map[string]int64) - - sort.Slice(metadata.Tracks, func(i, j int) bool { - return metadata.Tracks[i].Segments[0].metadata.FirstRtpUnixTimestamp < metadata.Tracks[j].Segments[0].metadata.FirstRtpUnixTimestamp - }) - +func (p *AudioMixer) offset(metadata *RecordingMetadata, withScreenshare bool, logger *getstream.DefaultLogger) []*webm.FileOffset { + var offsets []*webm.FileOffset var firstTrack *TrackInfo for _, t := range metadata.Tracks { - if t.TrackType == "audio" && !t.IsScreenshare { + if t.TrackType == "audio" && (!t.IsScreenshare || withScreenshare) { if firstTrack == nil { firstTrack = t - offsetMap[t.ConcatenatedContainerPath] = 0 + offsets = append(offsets, &webm.FileOffset{ + Name: t.ConcatenatedContainerPath, + Offset: 0, // Will be sorted later and rearranged + }) } else { - offset, err := calculateSyncOffsetFromFiles(firstTrack, t, logger) + offset, err := calculateSyncOffsetFromFiles(t, firstTrack, logger) if err != nil { logger.Warn("Failed to calculate sync offset for audio tracks: %v", err) continue } - offsetMap[t.ConcatenatedContainerPath] = offset + + offsets = append(offsets, &webm.FileOffset{ + Name: t.ConcatenatedContainerPath, + Offset: offset, + }) } } } - return offsetMap + return offsets } diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go index 847d08e..33e8904 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/cmd/raw-recording-tools/webm/converter.go @@ -27,8 +27,9 @@ type RTPDump2WebMConverter struct { recorder WebmRecorder sampleBuilder *samplebuilder.SampleBuilder - lastPkt *rtp.Packet - inserted uint16 + lastPkt *rtp.Packet + lastPktDuration uint32 + inserted uint16 } type WebmRecorder interface { @@ -194,7 +195,15 @@ func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Pa tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover lastPktDuration := opusPacketDurationMs(c.lastPkt.Payload) rtpDuration := uint32(lastPktDuration * 48) - if tsDiff > rtpDuration { + + if rtpDuration == 0 { + rtpDuration = c.lastPktDuration + c.logger.Info("LastPacket with no duration, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } else { + c.lastPktDuration = rtpDuration + } + + if rtpDuration > 0 && tsDiff > rtpDuration { // Calculate how many packets we need to insert, taking care of packet losses var toAdd uint16 diff --git a/cmd/raw-recording-tools/webm/helper.go b/cmd/raw-recording-tools/webm/helper.go index 73d8aa2..e52cf69 100644 --- a/cmd/raw-recording-tools/webm/helper.go +++ b/cmd/raw-recording-tools/webm/helper.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "sort" "strings" "github.com/GetStream/getstream-go/v3" @@ -11,6 +12,11 @@ import ( const TmpDir = "/tmp" +type FileOffset struct { + Name string + Offset int64 +} + func ConcatFile(outputPath string, files []string, logger *getstream.DefaultLogger) error { // Write to a temporary file concatFile, err := os.CreateTemp(TmpDir, "concat_*.txt") @@ -69,15 +75,24 @@ func MuxFiles(fileName string, audioFile string, videoFile string, offsetMs floa return runFFMEPGCpmmand(args, logger) } -func MixAudioFiles(fileName string, files map[string]int64, logger *getstream.DefaultLogger) error { +func MixAudioFiles(fileName string, files []*FileOffset, logger *getstream.DefaultLogger) error { var args []string - args = append(args, "ffmpeg") - i := 0 var filterParts []string var mixParts []string - for file, offset := range files { - args = append(args, "-i", file) + + sort.Slice(files, func(i, j int) bool { + return files[i].Offset < files[j].Offset + }) + + var offsetToAdd int64 + for i, fo := range files { + args = append(args, "-i", fo.Name) + + if i == 0 { + offsetToAdd = -fo.Offset + } + offset := fo.Offset + offsetToAdd if offset > 0 { // for stereo: offset|offset @@ -98,7 +113,7 @@ func MixAudioFiles(fileName string, files map[string]int64, logger *getstream.De filter += strings.Join(mixParts, "") + fmt.Sprintf("amix=inputs=%d:normalize=0", len(files)) - args = append(args, "-filter_complex", fmt.Sprintf("\"%s\"", filter)) + args = append(args, "-filter_complex", filter) args = append(args, "-c:a", "libopus") args = append(args, "-b:a", "128k") args = append(args, fileName) From 68e191d282e2801fad7a4e3500ac619c4900dd44 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 17:14:43 +0200 Subject: [PATCH 23/38] Fix AudioMixing --- cmd/raw-recording-tools/webm/converter.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/cmd/raw-recording-tools/webm/converter.go b/cmd/raw-recording-tools/webm/converter.go index 33e8904..0314449 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/cmd/raw-recording-tools/webm/converter.go @@ -195,15 +195,7 @@ func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Pa tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover lastPktDuration := opusPacketDurationMs(c.lastPkt.Payload) rtpDuration := uint32(lastPktDuration * 48) - - if rtpDuration == 0 { - rtpDuration = c.lastPktDuration - c.logger.Info("LastPacket with no duration, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) - } else { - c.lastPktDuration = rtpDuration - } - - if rtpDuration > 0 && tsDiff > rtpDuration { + if tsDiff > rtpDuration { // Calculate how many packets we need to insert, taking care of packet losses var toAdd uint16 @@ -276,7 +268,14 @@ func opusPacketDurationMs(packet []byte) int { frameCount = 2 case 3: if len(packet) > 1 { - frameCount = int(packet[1] & 0x3F) + fc := packet[1] & 0x3F + if fc > 0 && fc <= 48 { + frameCount = int(fc) + } else { + frameCount = 1 // fallback if invalid + } + } else { + frameCount = 1 // fallback } } From db0644c15e7b7649cce9f5da0c29f631a87d300c Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 18:37:19 +0200 Subject: [PATCH 24/38] Extract to processing package --- cmd/raw-recording-tools/extract_audio.go | 5 +++-- cmd/raw-recording-tools/extract_video.go | 5 +++-- cmd/raw-recording-tools/list_tracks.go | 13 ++++++------ cmd/raw-recording-tools/main.go | 7 ++++--- cmd/raw-recording-tools/mix_audio.go | 7 ++++--- cmd/raw-recording-tools/mux_av.go | 7 ++++--- cmd/raw-recording-tools/process_all.go | 15 ++++++------- .../audio_mixer.go | 21 +++++++++---------- .../av_muxer.go | 11 +++++----- .../webm => processing}/constants.go | 2 +- .../webm => processing}/converter.go | 7 +++---- .../cursor_gstreamer_webm_recorder.go | 2 +- .../cursor_webm_recorder.go | 5 ++--- .../extract_track.go | 21 +++++++++---------- .../webm => processing}/helper.go | 2 +- .../input.go | 4 ++-- .../metadata.go | 15 +++++++------ .../raw-recorder => processing}/raw.go | 2 +- .../rawsdputil => processing}/sdp_writer.go | 2 +- 19 files changed, 77 insertions(+), 76 deletions(-) rename {cmd/raw-recording-tools => processing}/audio_mixer.go (79%) rename {cmd/raw-recording-tools => processing}/av_muxer.go (93%) rename {cmd/raw-recording-tools/webm => processing}/constants.go (91%) rename {cmd/raw-recording-tools/webm => processing}/converter.go (97%) rename {cmd/raw-recording-tools/webm => processing}/cursor_gstreamer_webm_recorder.go (99%) rename {cmd/raw-recording-tools/webm => processing}/cursor_webm_recorder.go (96%) rename {cmd/raw-recording-tools => processing}/extract_track.go (86%) rename {cmd/raw-recording-tools/webm => processing}/helper.go (99%) rename {cmd/raw-recording-tools => processing}/input.go (96%) rename {cmd/raw-recording-tools => processing}/metadata.go (95%) rename {cmd/raw-recording-tools/raw-recorder => processing}/raw.go (98%) rename {cmd/raw-recording-tools/rawsdputil => processing}/sdp_writer.go (98%) diff --git a/cmd/raw-recording-tools/extract_audio.go b/cmd/raw-recording-tools/extract_audio.go index b647c73..f302ef8 100644 --- a/cmd/raw-recording-tools/extract_audio.go +++ b/cmd/raw-recording-tools/extract_audio.go @@ -6,6 +6,7 @@ import ( "os" "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/processing" ) type ExtractAudioArgs struct { @@ -109,6 +110,6 @@ func (p *ExtractAudioProcess) printUsage() { fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") } -func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - return extractTracks(globalArgs.WorkDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, extractAudioArgs.FixDtx, logger) +func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + return processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, extractAudioArgs.FixDtx, logger) } diff --git a/cmd/raw-recording-tools/extract_video.go b/cmd/raw-recording-tools/extract_video.go index 82ef7c4..b6fb349 100644 --- a/cmd/raw-recording-tools/extract_video.go +++ b/cmd/raw-recording-tools/extract_video.go @@ -6,6 +6,7 @@ import ( "os" "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/processing" ) type ExtractVideoArgs struct { @@ -107,6 +108,6 @@ func (p *ExtractVideoProcess) printUsage() { fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") } -func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - return extractTracks(globalArgs.WorkDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, false, logger) +func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + return processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, false, logger) } diff --git a/cmd/raw-recording-tools/list_tracks.go b/cmd/raw-recording-tools/list_tracks.go index 21cb944..5da416d 100644 --- a/cmd/raw-recording-tools/list_tracks.go +++ b/cmd/raw-recording-tools/list_tracks.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/processing" ) type ListTracksArgs struct { @@ -55,14 +56,14 @@ func (p *ListTracksProcess) runListTracks(args []string, globalArgs *GlobalArgs) } // Use efficient metadata-only parsing (optimized for list-tracks) - parser := NewMetadataParser(logger) + parser := processing.NewMetadataParser(logger) metadata, err := parser.ParseMetadataOnly(inputPath) if err != nil { logger.Error("Failed to parse recording: %v", err) } // Filter tracks if track type is specified - tracks := FilterTracks(metadata.Tracks, "", "", "", listTracksArgs.TrackType, "") + tracks := processing.FilterTracks(metadata.Tracks, "", "", "", listTracksArgs.TrackType, "") // Output in requested format switch listTracksArgs.Format { @@ -87,7 +88,7 @@ func (p *ListTracksProcess) runListTracks(args []string, globalArgs *GlobalArgs) } // printTracksTable prints tracks in a human-readable table format -func (p *ListTracksProcess) printTracksTable(tracks []*TrackInfo) { +func (p *ListTracksProcess) printTracksTable(tracks []*processing.TrackInfo) { if len(tracks) == 0 { fmt.Println("No tracks found.") return @@ -130,7 +131,7 @@ func (p *ListTracksProcess) truncateString(s string, maxLen int) string { } // printTracksJSON prints the full metadata in JSON format -func (p *ListTracksProcess) printTracksJSON(metadata *RecordingMetadata) { +func (p *ListTracksProcess) printTracksJSON(metadata *processing.RecordingMetadata) { data, err := json.MarshalIndent(metadata, "", " ") if err != nil { fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) @@ -140,7 +141,7 @@ func (p *ListTracksProcess) printTracksJSON(metadata *RecordingMetadata) { } // printCompletion prints completion-friendly output -func (p *ListTracksProcess) printCompletion(metadata *RecordingMetadata, completionType string) { +func (p *ListTracksProcess) printCompletion(metadata *processing.RecordingMetadata, completionType string) { switch completionType { case "users": p.printUsers(metadata.UserIDs) @@ -177,7 +178,7 @@ func (p *ListTracksProcess) printSessions(sessions []string) { } // printTrackIDs prints unique track IDs, one per line -func (p *ListTracksProcess) printTrackIDs(tracks []*TrackInfo) { +func (p *ListTracksProcess) printTrackIDs(tracks []*processing.TrackInfo) { trackIDs := make([]string, 0) seen := make(map[string]bool) diff --git a/cmd/raw-recording-tools/main.go b/cmd/raw-recording-tools/main.go index 0889e19..907cf10 100644 --- a/cmd/raw-recording-tools/main.go +++ b/cmd/raw-recording-tools/main.go @@ -7,6 +7,7 @@ import ( "os" "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/processing" ) type GlobalArgs struct { @@ -54,7 +55,7 @@ func main() { func processCommand(command string, globalArgs *GlobalArgs, remainingArgs []string, logger *getstream.DefaultLogger) error { // Extract to temp directory if needed (unified approach) - workingDir, cleanup, err := extractToTempDir(globalArgs.InputFile, logger) + workingDir, cleanup, err := processing.ExtractToTempDir(globalArgs.InputFile, logger) if err != nil { return fmt.Errorf("failed to prepare working directory: %w", err) } @@ -174,7 +175,7 @@ func validateGlobalArgs(globalArgs *GlobalArgs, command string) error { } // validateInputArgs validates input arguments using mutually exclusive logic -func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*RecordingMetadata, error) { +func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*processing.RecordingMetadata, error) { // Count how many filters are specified filtersCount := 0 if userID != "" { @@ -202,7 +203,7 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string // Parse metadata to validate the single specified argument logger := setupLogger(false) // Use non-verbose for validation - parser := NewMetadataParser(logger) + parser := processing.NewMetadataParser(logger) metadata, err := parser.ParseMetadataOnly(inputPath) if err != nil { return nil, fmt.Errorf("failed to parse recording for validation: %w", err) diff --git a/cmd/raw-recording-tools/mix_audio.go b/cmd/raw-recording-tools/mix_audio.go index aa27a29..2fe3912 100644 --- a/cmd/raw-recording-tools/mix_audio.go +++ b/cmd/raw-recording-tools/mix_audio.go @@ -5,6 +5,7 @@ import ( "os" "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/processing" ) // MixAudioArgs represents the arguments for the mix-audio command @@ -47,9 +48,9 @@ func (p *MixAudioProcess) runMixAudio(args []string, globalArgs *GlobalArgs) { } // mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic -func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - mixer := NewAudioMixer(logger) - mixer.mixAllAudioTracks(&AudioMixerConfig{ +func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + mixer := processing.NewAudioMixer(logger) + mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, WithScreenshare: false, diff --git a/cmd/raw-recording-tools/mux_av.go b/cmd/raw-recording-tools/mux_av.go index 33c61e0..4493933 100644 --- a/cmd/raw-recording-tools/mux_av.go +++ b/cmd/raw-recording-tools/mux_av.go @@ -6,6 +6,7 @@ import ( "os" "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/processing" ) type MuxAVArgs struct { @@ -90,9 +91,9 @@ func (p *MuxAudioVideoProcess) printUsage() { fmt.Printf(" --media both Mux both types, but ensure consistent pairing (default)\n") } -func (p *MuxAudioVideoProcess) muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - muxer := NewAudioVideoMuxer(p.logger) - if e := muxer.muxAudioVideoTracks(&AudioVideoMuxerConfig{ +func (p *MuxAudioVideoProcess) muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + muxer := processing.NewAudioVideoMuxer(p.logger) + if e := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, UserID: muxAVArgs.UserID, diff --git a/cmd/raw-recording-tools/process_all.go b/cmd/raw-recording-tools/process_all.go index 84d1291..3bf43ff 100644 --- a/cmd/raw-recording-tools/process_all.go +++ b/cmd/raw-recording-tools/process_all.go @@ -6,6 +6,7 @@ import ( "os" "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/getstream-go/v3/processing" ) type ProcessAllArgs struct { @@ -89,18 +90,18 @@ func (p *ProcessAllProcess) printUsage() { fmt.Printf(" muxed_{userId}_{sessionId}_{trackId}.webm - Combined audio+video file\n") } -func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { - if e := extractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", "both", true, true, logger); e != nil { + if e := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", "both", true, true, logger); e != nil { return e } - if e := extractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "video", "both", true, true, logger); e != nil { + if e := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "video", "both", true, true, logger); e != nil { return e } - mixer := NewAudioMixer(logger) - mixer.mixAllAudioTracks(&AudioMixerConfig{ + mixer := processing.NewAudioMixer(logger) + mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, WithScreenshare: false, @@ -108,8 +109,8 @@ func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllA WithCleanup: false, }, metadata, logger) - muxer := NewAudioVideoMuxer(p.logger) - if e := muxer.muxAudioVideoTracks(&AudioVideoMuxerConfig{ + muxer := processing.NewAudioVideoMuxer(p.logger) + if e := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, UserID: "", diff --git a/cmd/raw-recording-tools/audio_mixer.go b/processing/audio_mixer.go similarity index 79% rename from cmd/raw-recording-tools/audio_mixer.go rename to processing/audio_mixer.go index 34c48bb..efea81c 100644 --- a/cmd/raw-recording-tools/audio_mixer.go +++ b/processing/audio_mixer.go @@ -1,11 +1,10 @@ -package main +package processing import ( "fmt" "path/filepath" "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" ) type AudioMixerConfig struct { @@ -24,9 +23,9 @@ func NewAudioMixer(logger *getstream.DefaultLogger) *AudioMixer { return &AudioMixer{logger: logger} } -// mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic -func (p *AudioMixer) mixAllAudioTracks(config *AudioMixerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { - // Step 1: Extract all matching audio tracks using existing extractTracks function +// MixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic +func (p *AudioMixer) MixAllAudioTracks(config *AudioMixerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Step 1: Extract all matching audio tracks using existing ExtractTracks function logger.Info("Step 1/2: Extracting all matching audio tracks...") if config.WithExtract { @@ -35,7 +34,7 @@ func (p *AudioMixer) mixAllAudioTracks(config *AudioMixerConfig, metadata *Recor mediaFilter = "both" } - if err := extractTracks(config.WorkDir, config.OutputDir, "", "", "", metadata, "audio", mediaFilter, true, true, logger); err != nil { + if err := ExtractTracks(config.WorkDir, config.OutputDir, "", "", "", metadata, "audio", mediaFilter, true, true, logger); err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } } @@ -50,7 +49,7 @@ func (p *AudioMixer) mixAllAudioTracks(config *AudioMixerConfig, metadata *Recor // Step 3: Mix all discovered audio files using existing webm.MixAudioFiles outputFile := filepath.Join(config.OutputDir, "mixed_audio.webm") - err := webm.MixAudioFiles(outputFile, fileOffsetMap, logger) + err := MixAudioFiles(outputFile, fileOffsetMap, logger) if err != nil { return fmt.Errorf("failed to mix audio files: %w", err) } @@ -67,14 +66,14 @@ func (p *AudioMixer) mixAllAudioTracks(config *AudioMixerConfig, metadata *Recor return nil } -func (p *AudioMixer) offset(metadata *RecordingMetadata, withScreenshare bool, logger *getstream.DefaultLogger) []*webm.FileOffset { - var offsets []*webm.FileOffset +func (p *AudioMixer) offset(metadata *RecordingMetadata, withScreenshare bool, logger *getstream.DefaultLogger) []*FileOffset { + var offsets []*FileOffset var firstTrack *TrackInfo for _, t := range metadata.Tracks { if t.TrackType == "audio" && (!t.IsScreenshare || withScreenshare) { if firstTrack == nil { firstTrack = t - offsets = append(offsets, &webm.FileOffset{ + offsets = append(offsets, &FileOffset{ Name: t.ConcatenatedContainerPath, Offset: 0, // Will be sorted later and rearranged }) @@ -85,7 +84,7 @@ func (p *AudioMixer) offset(metadata *RecordingMetadata, withScreenshare bool, l continue } - offsets = append(offsets, &webm.FileOffset{ + offsets = append(offsets, &FileOffset{ Name: t.ConcatenatedContainerPath, Offset: offset, }) diff --git a/cmd/raw-recording-tools/av_muxer.go b/processing/av_muxer.go similarity index 93% rename from cmd/raw-recording-tools/av_muxer.go rename to processing/av_muxer.go index d54b446..50f6e15 100644 --- a/cmd/raw-recording-tools/av_muxer.go +++ b/processing/av_muxer.go @@ -1,4 +1,4 @@ -package main +package processing import ( "fmt" @@ -6,7 +6,6 @@ import ( "strings" "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" ) type AudioVideoMuxerConfig struct { @@ -29,18 +28,18 @@ func NewAudioVideoMuxer(logger *getstream.DefaultLogger) *AudioVideoMuxer { return &AudioVideoMuxer{logger: logger} } -func (p *AudioVideoMuxer) muxAudioVideoTracks(config *AudioVideoMuxerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *AudioVideoMuxer) MuxAudioVideoTracks(config *AudioVideoMuxerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { if config.WithExtract { // Extract audio tracks with gap filling enabled logger.Info("Extracting audio tracks with gap filling...") - err := extractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "audio", config.Media, true, true, logger) + err := ExtractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "audio", config.Media, true, true, logger) if err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Extract video tracks with gap filling enabled logger.Info("Extracting video tracks with gap filling...") - err = extractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "video", config.Media, true, true, logger) + err = ExtractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "video", config.Media, true, true, logger) if err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } @@ -115,7 +114,7 @@ func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir strin logger.Info("Muxing %s + %s → %s (offset: %dms)", filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) - err = webm.MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) + err = MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) if err != nil { logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) return err diff --git a/cmd/raw-recording-tools/webm/constants.go b/processing/constants.go similarity index 91% rename from cmd/raw-recording-tools/webm/constants.go rename to processing/constants.go index c91e756..5d1f595 100644 --- a/cmd/raw-recording-tools/webm/constants.go +++ b/processing/constants.go @@ -1,4 +1,4 @@ -package webm +package processing const ( RtpDump = "rtpdump" diff --git a/cmd/raw-recording-tools/webm/converter.go b/processing/converter.go similarity index 97% rename from cmd/raw-recording-tools/webm/converter.go rename to processing/converter.go index 0314449..702b29e 100644 --- a/cmd/raw-recording-tools/webm/converter.go +++ b/processing/converter.go @@ -1,4 +1,4 @@ -package webm +package processing import ( "errors" @@ -10,7 +10,6 @@ import ( "time" "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/rawsdputil" "github.com/pion/rtp" "github.com/pion/rtp/codecs" "github.com/pion/webrtc/v4" @@ -89,8 +88,8 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error reader, _, _ := rtpdump.NewReader(file) c.reader = reader - sdpContent, _ := rawsdputil.ReadSDP(strings.Replace(inputFile, SuffixRtpDump, SuffixSdp, 1)) - mType, _ := rawsdputil.MimeType(sdpContent) + sdpContent, _ := ReadSDP(strings.Replace(inputFile, SuffixRtpDump, SuffixSdp, 1)) + mType, _ := MimeType(sdpContent) releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) diff --git a/cmd/raw-recording-tools/webm/cursor_gstreamer_webm_recorder.go b/processing/cursor_gstreamer_webm_recorder.go similarity index 99% rename from cmd/raw-recording-tools/webm/cursor_gstreamer_webm_recorder.go rename to processing/cursor_gstreamer_webm_recorder.go index 277324b..01d9762 100644 --- a/cmd/raw-recording-tools/webm/cursor_gstreamer_webm_recorder.go +++ b/processing/cursor_gstreamer_webm_recorder.go @@ -1,4 +1,4 @@ -package webm +package processing import ( "context" diff --git a/cmd/raw-recording-tools/webm/cursor_webm_recorder.go b/processing/cursor_webm_recorder.go similarity index 96% rename from cmd/raw-recording-tools/webm/cursor_webm_recorder.go rename to processing/cursor_webm_recorder.go index 8fc6ef2..88dcf0f 100644 --- a/cmd/raw-recording-tools/webm/cursor_webm_recorder.go +++ b/processing/cursor_webm_recorder.go @@ -1,4 +1,4 @@ -package webm +package processing import ( "context" @@ -13,7 +13,6 @@ import ( "time" "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/rawsdputil" "github.com/pion/rtcp" "github.com/pion/rtp" ) @@ -80,7 +79,7 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port return err } - if _, err := sdpFile.WriteString(rawsdputil.ReplaceSDP(sdpContent, port)); err != nil { + if _, err := sdpFile.WriteString(ReplaceSDP(sdpContent, port)); err != nil { sdpFile.Close() return err } diff --git a/cmd/raw-recording-tools/extract_track.go b/processing/extract_track.go similarity index 86% rename from cmd/raw-recording-tools/extract_track.go rename to processing/extract_track.go index 4baa90d..290e9ad 100644 --- a/cmd/raw-recording-tools/extract_track.go +++ b/processing/extract_track.go @@ -1,4 +1,4 @@ -package main +package processing import ( "fmt" @@ -7,12 +7,11 @@ import ( "strings" "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/webm" "github.com/pion/webrtc/v4" ) // Generic track extraction function that works for both audio and video -func extractTracks(workingDir, outputDir, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { +func ExtractTracks(workingDir, outputDir, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { // Filter tracks to specified type only and apply hierarchical filtering filteredTracks := FilterTracks(metadata.Tracks, userID, sessionID, trackID, trackType, mediaFilter) if len(filteredTracks) == 0 { @@ -41,13 +40,13 @@ func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir for _, s := range track.Segments { if strings.Contains(info.Name(), s.metadata.BaseFilename) { if track.Codec == webrtc.MimeTypeH264 { - s.ContainerExt = webm.Mp4 + s.ContainerExt = Mp4 } else { - s.ContainerExt = webm.Webm + s.ContainerExt = Webm } s.RtpDumpPath = path - s.SdpPath = strings.Replace(path, webm.SuffixRtpDump, webm.SuffixSdp, -1) - s.ContainerPath = strings.Replace(path, webm.SuffixRtpDump, "."+s.ContainerExt, -1) + s.SdpPath = strings.Replace(path, SuffixRtpDump, SuffixSdp, -1) + s.ContainerPath = strings.Replace(path, SuffixRtpDump, "."+s.ContainerExt, -1) return true } } @@ -55,7 +54,7 @@ func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir } // Convert using the WebM converter - err := webm.ConvertDirectory(inputPath, accept, fixDtx, logger) + err := ConvertDirectory(inputPath, accept, fixDtx, logger) if err != nil { return fmt.Errorf("failed to convert %s track: %w", trackType, err) } @@ -92,14 +91,14 @@ func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.%s", trackType, i, segment.ContainerExt)) if trackType == "audio" { - err := webm.GenerateSilence(gapFilePath, gapSeconds, logger) + err := GenerateSilence(gapFilePath, gapSeconds, logger) if err != nil { logger.Warn("Failed to generate silence, skipping gap: %v", err) continue } } else if trackType == "video" { // Use 720p quality as defaults - err := webm.GenerateBlackVideo(gapFilePath, track.Codec, gapSeconds, 1280, 720, 30, logger) + err := GenerateBlackVideo(gapFilePath, track.Codec, gapSeconds, 1280, 720, 30, logger) if err != nil { logger.Warn("Failed to generate black video, skipping gap: %v", err) continue @@ -118,7 +117,7 @@ func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir finalPath := filepath.Join(outputDir, finalName) // Concatenate all segments (with gap fillers if any) - err := webm.ConcatFile(finalPath, filesToConcat, logger) + err := ConcatFile(finalPath, filesToConcat, logger) if err != nil { return "", fmt.Errorf("failed to concatenate segments: %w", err) } diff --git a/cmd/raw-recording-tools/webm/helper.go b/processing/helper.go similarity index 99% rename from cmd/raw-recording-tools/webm/helper.go rename to processing/helper.go index e52cf69..b725001 100644 --- a/cmd/raw-recording-tools/webm/helper.go +++ b/processing/helper.go @@ -1,4 +1,4 @@ -package webm +package processing import ( "fmt" diff --git a/cmd/raw-recording-tools/input.go b/processing/input.go similarity index 96% rename from cmd/raw-recording-tools/input.go rename to processing/input.go index 1354833..5f61d8d 100644 --- a/cmd/raw-recording-tools/input.go +++ b/processing/input.go @@ -1,4 +1,4 @@ -package main +package processing import ( "archive/tar" @@ -14,7 +14,7 @@ import ( // extractToTempDir extracts archive to temp directory or returns the directory path // Returns: (workingDir, cleanupFunc, error) -func extractToTempDir(inputPath string, logger *getstream.DefaultLogger) (string, func(), error) { +func ExtractToTempDir(inputPath string, logger *getstream.DefaultLogger) (string, func(), error) { // If it's already a directory, just return it if stat, err := os.Stat(inputPath); err == nil && stat.IsDir() { logger.Debug("Input is already a directory: %s", inputPath) diff --git a/cmd/raw-recording-tools/metadata.go b/processing/metadata.go similarity index 95% rename from cmd/raw-recording-tools/metadata.go rename to processing/metadata.go index 23266bb..890af09 100644 --- a/cmd/raw-recording-tools/metadata.go +++ b/processing/metadata.go @@ -1,4 +1,4 @@ -package main +package processing import ( "archive/tar" @@ -12,7 +12,6 @@ import ( "strings" "github.com/GetStream/getstream-go/v3" - rawrecorder "github.com/GetStream/getstream-go/v3/cmd/raw-recording-tools/raw-recorder" ) // TrackInfo represents a single track with its metadata (deduplicated across segments) @@ -30,7 +29,7 @@ type TrackInfo struct { } type SegmentInfo struct { - metadata *rawrecorder.SegmentMetadata + metadata *SegmentMetadata RtpDumpPath string SdpPath string @@ -188,7 +187,7 @@ func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*Recordin // parseTimingMetadataFile parses a timing metadata JSON file and extracts tracks func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, error) { - var sessionMetadata rawrecorder.SessionTimingMetadata + var sessionMetadata SessionTimingMetadata err := json.Unmarshal(data, &sessionMetadata) if err != nil { return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err) @@ -197,7 +196,7 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, err // Use a map to deduplicate tracks by unique key trackMap := make(map[string]*TrackInfo) - processSegment := func(segment *rawrecorder.SegmentMetadata, trackType string) { + processSegment := func(segment *SegmentMetadata, trackType string) { key := fmt.Sprintf("%s|%s|%s|%s", sessionMetadata.ParticipantID, sessionMetadata.UserSessionID, @@ -341,7 +340,7 @@ func FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID, trackType, me return filtered } -func FirstPacketNtpTimestamp(segment *rawrecorder.SegmentMetadata) int64 { +func FirstPacketNtpTimestamp(segment *SegmentMetadata) int64 { if segment.FirstRtcpNtpTimestamp != 0 && segment.FirstRtcpRtpTimestamp != 0 { rtpNtpTs := (segment.FirstRtcpRtpTimestamp - segment.FirstRtpRtpTimestamp) / sampleRate(segment) return segment.FirstRtcpNtpTimestamp - int64(rtpNtpTs) @@ -350,7 +349,7 @@ func FirstPacketNtpTimestamp(segment *rawrecorder.SegmentMetadata) int64 { } } -func LastPacketNtpTimestamp(segment *rawrecorder.SegmentMetadata) int64 { +func LastPacketNtpTimestamp(segment *SegmentMetadata) int64 { if segment.LastRtcpNtpTimestamp != 0 && segment.LastRtcpRtpTimestamp != 0 { rtpNtpTs := (segment.LastRtpRtpTimestamp - segment.LastRtcpRtpTimestamp) / sampleRate(segment) return segment.LastRtcpNtpTimestamp + int64(rtpNtpTs) @@ -359,7 +358,7 @@ func LastPacketNtpTimestamp(segment *rawrecorder.SegmentMetadata) int64 { } } -func sampleRate(segment *rawrecorder.SegmentMetadata) uint32 { +func sampleRate(segment *SegmentMetadata) uint32 { switch segment.TrackType { case "TRACK_TYPE_AUDIO", "TRACK_TYPE_SCREEN_SHARE_AUDIO": diff --git a/cmd/raw-recording-tools/raw-recorder/raw.go b/processing/raw.go similarity index 98% rename from cmd/raw-recording-tools/raw-recorder/raw.go rename to processing/raw.go index cf67311..85dfc81 100644 --- a/cmd/raw-recording-tools/raw-recorder/raw.go +++ b/processing/raw.go @@ -1,4 +1,4 @@ -package rawrecorder +package processing type SessionTimingMetadata struct { ParticipantID string `json:"participant_id"` diff --git a/cmd/raw-recording-tools/rawsdputil/sdp_writer.go b/processing/sdp_writer.go similarity index 98% rename from cmd/raw-recording-tools/rawsdputil/sdp_writer.go rename to processing/sdp_writer.go index 8aa2502..c92bb44 100644 --- a/cmd/raw-recording-tools/rawsdputil/sdp_writer.go +++ b/processing/sdp_writer.go @@ -1,4 +1,4 @@ -package rawsdputil +package processing import ( "fmt" From b27f2918c714a1f0573734b77c9302eb252d6965 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 18:38:57 +0200 Subject: [PATCH 25/38] Revert "Fix AudioMixing" This reverts commit dd2ca337c5220e3f99978784fdab1907f0c23638. --- processing/converter.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/processing/converter.go b/processing/converter.go index 702b29e..46dbc86 100644 --- a/processing/converter.go +++ b/processing/converter.go @@ -194,7 +194,15 @@ func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Pa tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover lastPktDuration := opusPacketDurationMs(c.lastPkt.Payload) rtpDuration := uint32(lastPktDuration * 48) - if tsDiff > rtpDuration { + + if rtpDuration == 0 { + rtpDuration = c.lastPktDuration + c.logger.Info("LastPacket with no duration, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } else { + c.lastPktDuration = rtpDuration + } + + if rtpDuration > 0 && tsDiff > rtpDuration { // Calculate how many packets we need to insert, taking care of packet losses var toAdd uint16 @@ -267,14 +275,7 @@ func opusPacketDurationMs(packet []byte) int { frameCount = 2 case 3: if len(packet) > 1 { - fc := packet[1] & 0x3F - if fc > 0 && fc <= 48 { - frameCount = int(fc) - } else { - frameCount = 1 // fallback if invalid - } - } else { - frameCount = 1 // fallback + frameCount = int(packet[1] & 0x3F) } } From 7b9863dbc34640c947a65d5b4144f28ddb14bfbc Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 18:46:00 +0200 Subject: [PATCH 26/38] protected method --- processing/audio_mixer.go | 4 ++-- processing/av_muxer.go | 6 +++--- processing/converter.go | 4 ++-- processing/cursor_webm_recorder.go | 2 +- processing/extract_track.go | 8 ++++---- processing/helper.go | 18 +++++++++--------- processing/metadata.go | 4 ++-- processing/sdp_writer.go | 8 ++++---- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/processing/audio_mixer.go b/processing/audio_mixer.go index efea81c..b69751f 100644 --- a/processing/audio_mixer.go +++ b/processing/audio_mixer.go @@ -46,10 +46,10 @@ func (p *AudioMixer) MixAllAudioTracks(config *AudioMixerConfig, metadata *Recor logger.Info("Found %d extracted audio files to mix", len(fileOffsetMap)) - // Step 3: Mix all discovered audio files using existing webm.MixAudioFiles + // Step 3: Mix all discovered audio files using existing webm.mixAudioFiles outputFile := filepath.Join(config.OutputDir, "mixed_audio.webm") - err := MixAudioFiles(outputFile, fileOffsetMap, logger) + err := mixAudioFiles(outputFile, fileOffsetMap, logger) if err != nil { return fmt.Errorf("failed to mix audio files: %w", err) } diff --git a/processing/av_muxer.go b/processing/av_muxer.go index 50f6e15..b1369b1 100644 --- a/processing/av_muxer.go +++ b/processing/av_muxer.go @@ -61,8 +61,8 @@ func (p *AudioVideoMuxer) MuxAudioVideoTracks(config *AudioVideoMuxerConfig, met // calculateSyncOffsetFromFiles calculates sync offset between audio and video files using metadata func calculateSyncOffsetFromFiles(audioTrack, videoTrack *TrackInfo, logger *getstream.DefaultLogger) (int64, error) { // Calculate offset: positive means video starts before audio - audioTs := FirstPacketNtpTimestamp(audioTrack.Segments[0].metadata) - videoTs := FirstPacketNtpTimestamp(videoTrack.Segments[0].metadata) + audioTs := firstPacketNtpTimestamp(audioTrack.Segments[0].metadata) + videoTs := firstPacketNtpTimestamp(videoTrack.Segments[0].metadata) offset := audioTs - videoTs logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", @@ -114,7 +114,7 @@ func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir strin logger.Info("Muxing %s + %s → %s (offset: %dms)", filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) - err = MuxFiles(outputFile, audioFile, videoFile, float64(offset), logger) + err = muxFiles(outputFile, audioFile, videoFile, float64(offset), logger) if err != nil { logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) return err diff --git a/processing/converter.go b/processing/converter.go index 46dbc86..1dc5683 100644 --- a/processing/converter.go +++ b/processing/converter.go @@ -88,8 +88,8 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error reader, _, _ := rtpdump.NewReader(file) c.reader = reader - sdpContent, _ := ReadSDP(strings.Replace(inputFile, SuffixRtpDump, SuffixSdp, 1)) - mType, _ := MimeType(sdpContent) + sdpContent, _ := readSDP(strings.Replace(inputFile, SuffixRtpDump, SuffixSdp, 1)) + mType, _ := mimeType(sdpContent) releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) diff --git a/processing/cursor_webm_recorder.go b/processing/cursor_webm_recorder.go index 88dcf0f..602a37e 100644 --- a/processing/cursor_webm_recorder.go +++ b/processing/cursor_webm_recorder.go @@ -79,7 +79,7 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port return err } - if _, err := sdpFile.WriteString(ReplaceSDP(sdpContent, port)); err != nil { + if _, err := sdpFile.WriteString(replaceSDP(sdpContent, port)); err != nil { sdpFile.Close() return err } diff --git a/processing/extract_track.go b/processing/extract_track.go index 290e9ad..445081b 100644 --- a/processing/extract_track.go +++ b/processing/extract_track.go @@ -81,7 +81,7 @@ func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir // Add gap filler if requested and there's a gap before the next segment if fillGaps && i < track.SegmentCount-1 { nextSegment := track.Segments[i+1] - gapDuration := FirstPacketNtpTimestamp(nextSegment.metadata) - LastPacketNtpTimestamp(segment.metadata) + gapDuration := firstPacketNtpTimestamp(nextSegment.metadata) - lastPacketNtpTimestamp(segment.metadata) if gapDuration > 0 { // There's a gap gapSeconds := float64(gapDuration) / 1000.0 @@ -91,14 +91,14 @@ func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.%s", trackType, i, segment.ContainerExt)) if trackType == "audio" { - err := GenerateSilence(gapFilePath, gapSeconds, logger) + err := generateSilence(gapFilePath, gapSeconds, logger) if err != nil { logger.Warn("Failed to generate silence, skipping gap: %v", err) continue } } else if trackType == "video" { // Use 720p quality as defaults - err := GenerateBlackVideo(gapFilePath, track.Codec, gapSeconds, 1280, 720, 30, logger) + err := generateBlackVideo(gapFilePath, track.Codec, gapSeconds, 1280, 720, 30, logger) if err != nil { logger.Warn("Failed to generate black video, skipping gap: %v", err) continue @@ -117,7 +117,7 @@ func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir finalPath := filepath.Join(outputDir, finalName) // Concatenate all segments (with gap fillers if any) - err := ConcatFile(finalPath, filesToConcat, logger) + err := concatFile(finalPath, filesToConcat, logger) if err != nil { return "", fmt.Errorf("failed to concatenate segments: %w", err) } diff --git a/processing/helper.go b/processing/helper.go index b725001..0d7d70f 100644 --- a/processing/helper.go +++ b/processing/helper.go @@ -17,19 +17,19 @@ type FileOffset struct { Offset int64 } -func ConcatFile(outputPath string, files []string, logger *getstream.DefaultLogger) error { +func concatFile(outputPath string, files []string, logger *getstream.DefaultLogger) error { // Write to a temporary file - concatFile, err := os.CreateTemp(TmpDir, "concat_*.txt") + tmpFile, err := os.CreateTemp(TmpDir, "concat_*.txt") if err != nil { return err } defer func() { - concatFile.Close() + tmpFile.Close() // _ = os.Remove(concatFile.Name()) }() for _, file := range files { - if _, err := concatFile.WriteString(fmt.Sprintf("file '%s'\n", file)); err != nil { + if _, err := tmpFile.WriteString(fmt.Sprintf("file '%s'\n", file)); err != nil { return err } } @@ -37,13 +37,13 @@ func ConcatFile(outputPath string, files []string, logger *getstream.DefaultLogg args := []string{} args = append(args, "-f", "concat") args = append(args, "-safe", "0") - args = append(args, "-i", concatFile.Name()) + args = append(args, "-i", tmpFile.Name()) args = append(args, "-c", "copy") args = append(args, outputPath) return runFFMEPGCpmmand(args, logger) } -func MuxFiles(fileName string, audioFile string, videoFile string, offsetMs float64, logger *getstream.DefaultLogger) error { +func muxFiles(fileName string, audioFile string, videoFile string, offsetMs float64, logger *getstream.DefaultLogger) error { args := []string{} // Apply offset using itsoffset @@ -75,7 +75,7 @@ func MuxFiles(fileName string, audioFile string, videoFile string, offsetMs floa return runFFMEPGCpmmand(args, logger) } -func MixAudioFiles(fileName string, files []*FileOffset, logger *getstream.DefaultLogger) error { +func mixAudioFiles(fileName string, files []*FileOffset, logger *getstream.DefaultLogger) error { var args []string var filterParts []string @@ -123,7 +123,7 @@ func MixAudioFiles(fileName string, files []*FileOffset, logger *getstream.Defau return runFFMEPGCpmmand(args, logger) } -func GenerateSilence(fileName string, duration float64, logger *getstream.DefaultLogger) error { +func generateSilence(fileName string, duration float64, logger *getstream.DefaultLogger) error { args := []string{} args = append(args, "-f", "lavfi") args = append(args, "-t", fmt.Sprintf("%.3f", duration)) @@ -135,7 +135,7 @@ func GenerateSilence(fileName string, duration float64, logger *getstream.Defaul return runFFMEPGCpmmand(args, logger) } -func GenerateBlackVideo(fileName, mimeType string, duration float64, width, height, frameRate int, logger *getstream.DefaultLogger) error { +func generateBlackVideo(fileName, mimeType string, duration float64, width, height, frameRate int, logger *getstream.DefaultLogger) error { var codecLib string switch strings.ToLower(mimeType) { case "video/vp8": diff --git a/processing/metadata.go b/processing/metadata.go index 890af09..dbc4790 100644 --- a/processing/metadata.go +++ b/processing/metadata.go @@ -340,7 +340,7 @@ func FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID, trackType, me return filtered } -func FirstPacketNtpTimestamp(segment *SegmentMetadata) int64 { +func firstPacketNtpTimestamp(segment *SegmentMetadata) int64 { if segment.FirstRtcpNtpTimestamp != 0 && segment.FirstRtcpRtpTimestamp != 0 { rtpNtpTs := (segment.FirstRtcpRtpTimestamp - segment.FirstRtpRtpTimestamp) / sampleRate(segment) return segment.FirstRtcpNtpTimestamp - int64(rtpNtpTs) @@ -349,7 +349,7 @@ func FirstPacketNtpTimestamp(segment *SegmentMetadata) int64 { } } -func LastPacketNtpTimestamp(segment *SegmentMetadata) int64 { +func lastPacketNtpTimestamp(segment *SegmentMetadata) int64 { if segment.LastRtcpNtpTimestamp != 0 && segment.LastRtcpRtpTimestamp != 0 { rtpNtpTs := (segment.LastRtpRtpTimestamp - segment.LastRtcpRtpTimestamp) / sampleRate(segment) return segment.LastRtcpNtpTimestamp + int64(rtpNtpTs) diff --git a/processing/sdp_writer.go b/processing/sdp_writer.go index c92bb44..ed61c08 100644 --- a/processing/sdp_writer.go +++ b/processing/sdp_writer.go @@ -8,7 +8,7 @@ import ( "github.com/pion/webrtc/v4" ) -func ReadSDP(sdpFilePath string) (string, error) { +func readSDP(sdpFilePath string) (string, error) { content, err := os.ReadFile(sdpFilePath) if err != nil { return "", fmt.Errorf("failed to read SDP file %s: %w", sdpFilePath, err) @@ -16,7 +16,7 @@ func ReadSDP(sdpFilePath string) (string, error) { return string(content), nil } -func ReplaceSDP(sdpContent string, port int) string { +func replaceSDP(sdpContent string, port int) string { lines := strings.Split(sdpContent, "\n") for i, line := range lines { if strings.HasPrefix(line, "m=") { @@ -33,7 +33,7 @@ func ReplaceSDP(sdpContent string, port int) string { return strings.Join(lines, "\n") } -func MimeType(sdp string) (string, error) { +func mimeType(sdp string) (string, error) { upper := strings.ToUpper(sdp) if strings.Contains(upper, "VP9") { return webrtc.MimeTypeVP9, nil @@ -51,5 +51,5 @@ func MimeType(sdp string) (string, error) { return webrtc.MimeTypeH264, nil } - return "", fmt.Errorf("MimeType should be OPUS, VP8, VP9, AV1, H264") + return "", fmt.Errorf("mimeType should be OPUS, VP8, VP9, AV1, H264") } From 01387d326683065d180011c4c8ae4c269dacf311 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 10 Oct 2025 22:49:18 +0200 Subject: [PATCH 27/38] protected method --- processing/converter.go | 110 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 2 deletions(-) diff --git a/processing/converter.go b/processing/converter.go index 1dc5683..064078b 100644 --- a/processing/converter.go +++ b/processing/converter.go @@ -184,15 +184,17 @@ func (c *RTPDump2WebMConverter) buildDefaultReleasePacketHandler() func(pkt *rtp func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Packet) { return func(pkt *rtp.Packet) { + c.opusPacketDurationMsCorrected(pkt) + pkt.SequenceNumber += c.inserted - if c.lastPkt != nil { + if false && c.lastPkt != nil { if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) } tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover - lastPktDuration := opusPacketDurationMs(c.lastPkt.Payload) + lastPktDuration := c.opusPacketDurationMsCorrected(c.lastPkt) rtpDuration := uint32(lastPktDuration * 48) if rtpDuration == 0 { @@ -281,3 +283,107 @@ func opusPacketDurationMs(packet []byte) int { return frameDuration * frameCount } + +// opusPacketDurationMsCorrected implements the correct OPUS RFC 6716 specification +// with comprehensive logging to debug duration calculation issues +func (c *RTPDump2WebMConverter) opusPacketDurationMsCorrected(pkt *rtp.Packet) int { + payload := pkt.Payload + if len(payload) < 1 { + c.logger.Warn("OPUS packet too short: %d bytes", len(payload)) + return 0 + } + + toc := payload[0] + + // Skip special OPUS packets (DTX, padding, etc.) + // TOC=0xF8 is a special packet type that should be excluded entirely + if toc == 0xF8 { + c.logger.Info("OPUS: Skipping special packet TOC=0x%02X RTP=%d seq=%d", toc, pkt.Timestamp, pkt.SequenceNumber) + return 0 + } + + // Also skip the specific 3-byte sequence if it exists + if len(payload) >= 3 && payload[0] == 0xF8 && payload[1] == 0xFF && payload[2] == 0xFE { + c.logger.Info("OPUS: Skipping special packet payload=[0x%02X 0x%02X 0x%02X] RTP=%d seq=%d", payload[0], payload[1], payload[2], pkt.Timestamp, pkt.SequenceNumber) + return 0 + } + + config := toc & 0x1F + frameCountCode := (toc >> 6) & 0x03 + + // Calculate frame duration according to OPUS RFC 6716 + // The frame duration depends on the config value, not a simple formula + var frameDuration int + switch { + case config <= 3: + // SILK mode: 10, 20, 40, 60 ms + frameDuration = 10 * (1 << config) + case config <= 7: + // SILK mode: 10, 20, 40, 60 ms + frameDuration = 10 * (1 << (config & 0x03)) + case config <= 11: + // SILK mode: 10, 20, 40, 60 ms + frameDuration = 10 * (1 << (config & 0x03)) + case config <= 13: + // SILK mode: 10, 20 ms + frameDuration = 10 * (1 << (config & 0x01)) + case config <= 19: + // CELT mode: 2.5, 5, 10, 20 ms + frameDuration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + case config <= 23: + // CELT mode: 5, 10, 20, 40 ms + frameDuration = 50 * (1 << (config & 0x03)) // 5ms * 10 for integer math + case config <= 27: + // CELT mode: 10, 20, 40, 80 ms + frameDuration = 100 * (1 << (config & 0x03)) // 10ms * 10 for integer math + default: + // Default case + frameDuration = 200 // 20ms * 10 for integer math + } + + var frameCount int + var secondByte uint8 + var secondByteBinary string + + switch frameCountCode { + case 0: + frameCount = 1 + case 1: + frameCount = 2 + case 2: + frameCount = 2 + case 3: + if len(payload) > 1 { + secondByte = payload[1] + secondByteBinary = fmt.Sprintf("%08b", secondByte) + // Frame count is in lower 6 bits, 0-indexed + frameCount = int(secondByte&0x3F) + 1 + } else { + c.logger.Warn("Frame count code 3 but no second byte available") + frameCount = 1 + } + default: + c.logger.Warn("Invalid frame count code: %d", frameCountCode) + frameCount = 1 + } + + totalDuration := frameDuration * frameCount + + // Convert back to actual milliseconds for logging + frameDurationMs := frameDuration / 10 + totalDurationMs := totalDuration / 10 + + if totalDurationMs != 20 { + // Compact inline logging for debugging + if frameCountCode == 3 && len(payload) > 1 { + c.logger.Info("OPUS: TOC=0x%02X cfg=%d fcc=%d 2nd=0x%02X(%s) fc=%d fd=%dms td=%dms RTP=%d seq=%d", + toc, config, frameCountCode, secondByte, secondByteBinary, frameCount, frameDurationMs, totalDurationMs, pkt.Timestamp, pkt.SequenceNumber) + } else { + c.logger.Info("OPUS: TOC=0x%02X cfg=%d fcc=%d fc=%d fd=%dms td=%dms RTP=%d seq=%d", + toc, config, frameCountCode, frameCount, frameDurationMs, totalDurationMs, pkt.Timestamp, pkt.SequenceNumber) + } + + } + + return totalDuration +} From 3321a3f0d1e4ba42d8be155a8a8f6d1e525f5dc8 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Sat, 11 Oct 2025 00:07:37 +0200 Subject: [PATCH 28/38] Fix opus RtpDuration calculation --- processing/converter.go | 177 ++++++++++++---------------------------- 1 file changed, 52 insertions(+), 125 deletions(-) diff --git a/processing/converter.go b/processing/converter.go index 064078b..3cf0a96 100644 --- a/processing/converter.go +++ b/processing/converter.go @@ -184,17 +184,15 @@ func (c *RTPDump2WebMConverter) buildDefaultReleasePacketHandler() func(pkt *rtp func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Packet) { return func(pkt *rtp.Packet) { - c.opusPacketDurationMsCorrected(pkt) - pkt.SequenceNumber += c.inserted - if false && c.lastPkt != nil { + if c.lastPkt != nil { if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) } tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover - lastPktDuration := c.opusPacketDurationMsCorrected(c.lastPkt) + lastPktDuration := opusPacketDurationMs(c.lastPkt) rtpDuration := uint32(lastPktDuration * 48) if rtpDuration == 0 { @@ -241,149 +239,78 @@ func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Pa } } -func opusPacketDurationMs(packet []byte) int { - if len(packet) < 1 { - return 0 - } - toc := packet[0] - config := toc & 0x1F - c := (toc >> 6) & 0x03 - - frameDuration := 0 - switch { - case config <= 3: - frameDuration = 10 << (config & 0x03) // 10,20,40,60 - case config <= 7: - frameDuration = 10 << (config & 0x03) - case config <= 11: - frameDuration = 10 << (config & 0x03) - case config <= 13: - frameDuration = 10 << (config & 0x01) - case config <= 19: - frameDuration = 25 / 10 // 2.5ms - case config <= 23: - frameDuration = 5 - case config <= 27: - frameDuration = 10 - default: - frameDuration = 20 - } - - var frameCount int - switch c { - case 0: - frameCount = 1 - case 1, 2: - frameCount = 2 - case 3: - if len(packet) > 1 { - frameCount = int(packet[1] & 0x3F) - } - } - - return frameDuration * frameCount -} - -// opusPacketDurationMsCorrected implements the correct OPUS RFC 6716 specification -// with comprehensive logging to debug duration calculation issues -func (c *RTPDump2WebMConverter) opusPacketDurationMsCorrected(pkt *rtp.Packet) int { +func opusPacketDurationMs(pkt *rtp.Packet) int { + // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | config |s|1|1|0|p| M | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ payload := pkt.Payload if len(payload) < 1 { - c.logger.Warn("OPUS packet too short: %d bytes", len(payload)) return 0 } toc := payload[0] + config := (toc >> 3) & 0x1F + c := toc & 0x03 - // Skip special OPUS packets (DTX, padding, etc.) - // TOC=0xF8 is a special packet type that should be excluded entirely - if toc == 0xF8 { - c.logger.Info("OPUS: Skipping special packet TOC=0x%02X RTP=%d seq=%d", toc, pkt.Timestamp, pkt.SequenceNumber) - return 0 - } - - // Also skip the specific 3-byte sequence if it exists - if len(payload) >= 3 && payload[0] == 0xF8 && payload[1] == 0xFF && payload[2] == 0xFE { - c.logger.Info("OPUS: Skipping special packet payload=[0x%02X 0x%02X 0x%02X] RTP=%d seq=%d", payload[0], payload[1], payload[2], pkt.Timestamp, pkt.SequenceNumber) - return 0 - } - - config := toc & 0x1F - frameCountCode := (toc >> 6) & 0x03 - - // Calculate frame duration according to OPUS RFC 6716 - // The frame duration depends on the config value, not a simple formula - var frameDuration int + // Calculate frame duration according to OPUS RFC 6716 table (use x10 factor) + // Frame duration is determined by the config value + var duration int switch { - case config <= 3: - // SILK mode: 10, 20, 40, 60 ms - frameDuration = 10 * (1 << config) - case config <= 7: - // SILK mode: 10, 20, 40, 60 ms - frameDuration = 10 * (1 << (config & 0x03)) - case config <= 11: - // SILK mode: 10, 20, 40, 60 ms - frameDuration = 10 * (1 << (config & 0x03)) + case config < 3: + // SILK-only NB: 10, 20, 40 ms + duration = 100 * (1 << (config & 0x03)) + case config == 3: + // SILK-only NB: 60 ms + duration = 600 + case config < 7: + // SILK-only MB: 10, 20, 40 ms + duration = 100 * (1 << (config & 0x03)) + case config == 7: + // SILK-only MB: 60 ms + duration = 600 + case config < 11: + // SILK-only WB: 10, 20, 40 ms + duration = 100 * (1 << (config & 0x03)) + case config == 11: + // SILK-only WB: 60 ms + duration = 600 case config <= 13: - // SILK mode: 10, 20 ms - frameDuration = 10 * (1 << (config & 0x01)) + // Hybrid SWB: 10, 20 ms + duration = 100 * (1 << (config & 0x01)) + case config <= 15: + // Hybrid FB: 10, 20 ms + duration = 100 * (1 << (config & 0x01)) case config <= 19: - // CELT mode: 2.5, 5, 10, 20 ms - frameDuration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + // CELT-only NB: 2.5, 5, 10, 20 ms + duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math case config <= 23: - // CELT mode: 5, 10, 20, 40 ms - frameDuration = 50 * (1 << (config & 0x03)) // 5ms * 10 for integer math + // CELT-only WB: 2.5, 5, 10, 20 ms + duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math case config <= 27: - // CELT mode: 10, 20, 40, 80 ms - frameDuration = 100 * (1 << (config & 0x03)) // 10ms * 10 for integer math + // CELT-only SWB: 2.5, 5, 10, 20 ms + duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + case config <= 31: + // CELT-only FB: 2.5, 5, 10, 20 ms + duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math default: - // Default case - frameDuration = 200 // 20ms * 10 for integer math + // MUST NOT HAPPEN + duration = 0 } - var frameCount int - var secondByte uint8 - var secondByteBinary string + frameDuration := float32(duration) / 10 - switch frameCountCode { + var frameCount float32 + switch c { case 0: frameCount = 1 - case 1: - frameCount = 2 - case 2: + case 1, 2: frameCount = 2 case 3: if len(payload) > 1 { - secondByte = payload[1] - secondByteBinary = fmt.Sprintf("%08b", secondByte) - // Frame count is in lower 6 bits, 0-indexed - frameCount = int(secondByte&0x3F) + 1 - } else { - c.logger.Warn("Frame count code 3 but no second byte available") - frameCount = 1 - } - default: - c.logger.Warn("Invalid frame count code: %d", frameCountCode) - frameCount = 1 - } - - totalDuration := frameDuration * frameCount - - // Convert back to actual milliseconds for logging - frameDurationMs := frameDuration / 10 - totalDurationMs := totalDuration / 10 - - if totalDurationMs != 20 { - // Compact inline logging for debugging - if frameCountCode == 3 && len(payload) > 1 { - c.logger.Info("OPUS: TOC=0x%02X cfg=%d fcc=%d 2nd=0x%02X(%s) fc=%d fd=%dms td=%dms RTP=%d seq=%d", - toc, config, frameCountCode, secondByte, secondByteBinary, frameCount, frameDurationMs, totalDurationMs, pkt.Timestamp, pkt.SequenceNumber) - } else { - c.logger.Info("OPUS: TOC=0x%02X cfg=%d fcc=%d fc=%d fd=%dms td=%dms RTP=%d seq=%d", - toc, config, frameCountCode, frameCount, frameDurationMs, totalDurationMs, pkt.Timestamp, pkt.SequenceNumber) + frameCount = float32(payload[1] & 0x3F) } - } - return totalDuration + return int(frameDuration * frameCount) } From 03268a4620b638025984034459a747272c469522 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 13 Oct 2025 09:12:41 +0200 Subject: [PATCH 29/38] gstreamer --- processing/cursor_gstreamer_webm_recorder.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/processing/cursor_gstreamer_webm_recorder.go b/processing/cursor_gstreamer_webm_recorder.go index 01d9762..0b0f187 100644 --- a/processing/cursor_gstreamer_webm_recorder.go +++ b/processing/cursor_gstreamer_webm_recorder.go @@ -97,7 +97,7 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath // Start with common GStreamer arguments optimized for RTP dump replay args := []string{ "--gst-debug-level=3", - "--gst-debug=udpsrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5", + "--gst-debug=udpsrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", } // Add UDP source with timestamp handling for RTP dump replay @@ -138,12 +138,20 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath args = append(args, "application/x-rtp,media=video,encoding-name=VP9,clock-rate=90000", "!", "rtpjitterbuffer", - "latency=200", + "latency=0", "mode=none", - "do-retransmission=false", "!", + "do-retransmission=false", + "drop-on-latency=false", + "buffer-mode=slave", + "max-dropout-time=5000000000", + "max-reorder-delay=1000000000", + "!", "rtpvp9depay", "!", "vp9parse", "!", - "webmmux", "!", + "webmmux", + "writing-app=GStreamer-VP9", + "streamable=false", + "min-index-interval=2000000000", "!", "filesink", fmt.Sprintf("location=%s", outputFilePath), ) } else if isVP8 { @@ -462,8 +470,8 @@ func (r *CursorGstreamerWebmRecorder) startGStreamerNoJitterBuffer(sdpContent, o // Start with common GStreamer arguments args := []string{ - "--gst-debug-level=3", - "--gst-debug=udpsrc:5,rtp*:5,webm*:5", + //"--gst-debug-level=3", + // "--gst-debug=udpsrc:5,rtp*:5,webm*:5", "-e", // Enable EOS handling } From 4318db9c4779358f5fd745f311b8fcf6d1d81d2b Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 13 Oct 2025 11:08:17 +0200 Subject: [PATCH 30/38] gstreamer --- processing/converter.go | 4 +- processing/cursor_gstreamer_webm_recorder.go | 544 +++++-------------- processing/cursor_webm_recorder.go | 4 + 3 files changed, 128 insertions(+), 424 deletions(-) diff --git a/processing/converter.go b/processing/converter.go index 3cf0a96..5f2d7ac 100644 --- a/processing/converter.go +++ b/processing/converter.go @@ -34,6 +34,7 @@ type RTPDump2WebMConverter struct { type WebmRecorder interface { OnRTP(pkt *rtp.Packet) error PushRtpBuf(payload []byte) error + PushRtcpBuf(payload []byte) error Close() error } @@ -98,7 +99,7 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, releasePacketHandler) c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeVP9: - c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) + c.sampleBuilder = nil //samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeH264: c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, releasePacketHandler) @@ -137,6 +138,7 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { } else if err != nil { return err } else if packet.IsRTCP { + _ = c.recorder.PushRtcpBuf(packet.Payload) continue } diff --git a/processing/cursor_gstreamer_webm_recorder.go b/processing/cursor_gstreamer_webm_recorder.go index 0b0f187..f5b94bd 100644 --- a/processing/cursor_gstreamer_webm_recorder.go +++ b/processing/cursor_gstreamer_webm_recorder.go @@ -19,7 +19,8 @@ import ( type CursorGstreamerWebmRecorder struct { logger *getstream.DefaultLogger outputPath string - conn *net.UDPConn + rtpConn *net.UDPConn + rtcpConn *net.UDPConn gstreamerCmd *exec.Cmd mu sync.Mutex ctx context.Context @@ -40,11 +41,13 @@ func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getst cancel: cancel, } - r.logger.Info("SDP created for GStreamer\n%s\n", sdpContent) - // Set up UDP connections r.port = rand.Intn(10000) + 10000 - if err := r.setupConnections(r.port); err != nil { + if err := r.setupConnections(r.port, true); err != nil { + cancel() + return nil, err + } + if err := r.setupConnections(r.port, false); err != nil { cancel() return nil, err } @@ -58,7 +61,7 @@ func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getst return r, nil } -func (r *CursorGstreamerWebmRecorder) setupConnections(port int) error { +func (r *CursorGstreamerWebmRecorder) setupConnections(port int, rtp bool) error { // Setup UDP connection addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:"+strconv.Itoa(port)) if err != nil { @@ -68,8 +71,11 @@ func (r *CursorGstreamerWebmRecorder) setupConnections(port int) error { if err != nil { return err } - r.conn = conn - + if rtp { + r.rtpConn = conn + } else { + r.rtcpConn = conn + } return nil } @@ -81,12 +87,16 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath } r.sdpFile = sdpFile - if _, err := sdpFile.WriteString(sdpContent); err != nil { + updatedSdp := replaceSDP(sdpContent, r.port) + + if _, err := sdpFile.WriteString(updatedSdp); err != nil { sdpFile.Close() return err } sdpFile.Close() + r.logger.Info("SDP created for GStreamer\n%s\n", updatedSdp) + // Determine codec from SDP content and build GStreamer arguments isVP9 := strings.Contains(strings.ToUpper(sdpContent), "VP9") isVP8 := strings.Contains(strings.ToUpper(sdpContent), "VP8") @@ -98,16 +108,31 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath args := []string{ "--gst-debug-level=3", "--gst-debug=udpsrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", + "-e", // Send EOS on interrupt for clean shutdown } - // Add UDP source with timestamp handling for RTP dump replay - args = append(args, - "-e", - "udpsrc", - fmt.Sprintf("port=%d", r.port), - "buffer-size=10000000", - "!", - ) + // Build pipeline based on codec + // For VP9, we use direct UDP source without rtpbin for simpler RTP timestamp handling + if isVP9 { + r.logger.Info("Detected VP9 codec, building optimized pipeline for RTP dump replay...") + // Don't add the rtpbin/sdpsrc setup for VP9 + } else { + // Add UDP source with timestamp handling for RTP dump replay + args = append(args, + "rtpbin", + "name=rtpbin", + + "sdpsrc", + "location=sdp://"+sdpFile.Name(), + "name=sdp", + "sdp.stream_0", + "!", + + "rtpbin.recv_rtp_sink_0", + "rtpbin.", + "!", + ) + } // Build pipeline based on codec with simplified RTP timestamp handling for dump replay // @@ -120,7 +145,7 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath // // This approach focuses on preserving original RTP timestamps without // artificial buffering that can interfere with dump replay timing. - if isH264 { + if false && isH264 { r.logger.Info("Detected H.264 codec, building H.264 pipeline with timestamp handling...") args = append(args, "application/x-rtp,media=video,encoding-name=H264,clock-rate=90000", "!", @@ -133,7 +158,7 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "mp4mux", "!", "filesink", fmt.Sprintf("location=%s", outputFilePath), ) - } else if isVP9 { + } else if false && isVP9 { r.logger.Info("Detected VP9 codec, building VP9 pipeline with timestamp handling...") args = append(args, "application/x-rtp,media=video,encoding-name=VP9,clock-rate=90000", "!", @@ -154,7 +179,56 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "min-index-interval=2000000000", "!", "filesink", fmt.Sprintf("location=%s", outputFilePath), ) - } else if isVP8 { + } else if isVP9 { + r.logger.Info("Detected VP9 codec, building VP9 pipeline with RTP timestamp handling...") + args = append(args, + // Start with UDP source receiving RTP packets + "udpsrc", + fmt.Sprintf("port=%d", r.port), + "caps=application/x-rtp,media=video,encoding-name=VP9,clock-rate=90000,payload=96", + "do-timestamp=false", // Don't apply udpsrc timestamps, use RTP timestamps + "!", + + // Use rtpjitterbuffer in synced mode for RTP timestamp-based timing + // This is critical for proper duration calculation from RTP timestamps + "rtpjitterbuffer", + "name=jitterbuffer", + "latency=0", // No artificial latency for fast replay + "mode=4", // mode=synced (4): use RTP timestamps for timing + "do-lost=false", // Don't generate lost events + "do-retransmission=false", // No retransmission for dump replay + "drop-on-latency=false", // Don't drop any packets + "rtx-delay=-1", // Disable retransmission delay + "!", + + // Depayload RTP to get VP9 frames + "rtpvp9depay", + "!", + + // Parse VP9 stream to ensure valid frame structure + "vp9parse", + "!", + + // Queue for buffering + "queue", + "!", + + // Mux into Matroska/WebM container + "matroskamux", + "name=mux", + "streamable=false", // Allow seeking and proper duration + "!", + + // Write to file + "filesink", + fmt.Sprintf("location=%s", outputFilePath), + ) + + //gst-launch-1.0 -v \ + //sdpsrc location=sdp:///chemin/vers/stream.sdp name=sdp \ + //sdp.stream_0 ! rtpjitterbuffer latency=0 drop-on-latency=true ! rtpvp9depay ! vp9parse ! queue ! matroskamux name=mux ! filesink location=out.webm -e + + } else if false && isVP8 { r.logger.Info("Detected VP8 codec, building VP8 pipeline with timestamp handling...") args = append(args, "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", @@ -167,7 +241,7 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "webmmux", "writing-app=GStreamer", "streamable=false", "min-index-interval=2000000000", "!", "filesink", fmt.Sprintf("location=%s", outputFilePath), ) - } else if isAV1 { + } else if false && isAV1 { r.logger.Info("Detected AV1 codec, building AV1 pipeline with timestamp handling...") args = append(args, "application/x-rtp,media=video,encoding-name=AV1,clock-rate=90000", "!", @@ -180,7 +254,7 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "webmmux", "!", "filesink", fmt.Sprintf("location=%s", outputFilePath), ) - } else if isOpus { + } else if false && isOpus { r.logger.Info("Detected Opus codec, building Opus pipeline with timestamp handling...") args = append(args, "application/x-rtp,media=audio,encoding-name=OPUS,clock-rate=48000,payload=111", "!", @@ -193,7 +267,7 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "webmmux", "!", "filesink", fmt.Sprintf("location=%s", outputFilePath), ) - } else { + } else if false { // Default to VP8 if codec is not detected r.logger.Info("Unknown or no codec detected, defaulting to VP8 pipeline with timestamp handling...") args = append(args, @@ -209,7 +283,7 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath ) } - r.logger.Info("GStreamer pipeline: %s", strings.Join(args[3:], " ")) // Skip debug args for display + r.logger.Info("GStreamer pipeline: %s", strings.Join(args, " ")) // Skip debug args for display r.gstreamerCmd = exec.Command("gst-launch-1.0", args...) @@ -246,13 +320,31 @@ func (r *CursorGstreamerWebmRecorder) OnRTP(packet *rtp.Packet) error { return r.PushRtpBuf(buf) } +func (r *CursorGstreamerWebmRecorder) OnRTCP(packet *rtp.Packet) error { + // Marshal RTP packet + buf, err := packet.Marshal() + if err != nil { + return err + } + + return r.PushRtpBuf(buf) +} + +func (r *CursorGstreamerWebmRecorder) PushRtcpBuf(buf []byte) error { + return nil // r.pushBuf(r.rtcpConn, buf) +} + func (r *CursorGstreamerWebmRecorder) PushRtpBuf(buf []byte) error { + return r.pushBuf(r.rtpConn, buf) +} + +func (r *CursorGstreamerWebmRecorder) pushBuf(conn *net.UDPConn, buf []byte) error { r.mu.Lock() defer r.mu.Unlock() // Send RTP packet over UDP to GStreamer udpsrc - if r.conn != nil { - _, err := r.conn.Write(buf) + if conn != nil { + _, err := conn.Write(buf) if err != nil { // Log error but don't fail completely - some packet loss is acceptable r.logger.Debug("Failed to write RTP packet: %v", err) @@ -275,7 +367,7 @@ func (r *CursorGstreamerWebmRecorder) Close() error { } // Close UDP connection with goodbye message - if r.conn != nil { + if r.rtpConn != nil { r.logger.Info("Closing UDP connection...") // Send RTCP Goodbye packet to signal end of stream @@ -283,15 +375,15 @@ func (r *CursorGstreamerWebmRecorder) Close() error { // Sources: []uint32{1}, // fixed SSRC is ok // Reason: "bye", //}.Marshal() - //_, _ = r.conn.Write(buf) + //_, _ = r.rtpConn.Write(buf) r.logger.Info("Goodbye sent") // Give some time for the goodbye packet to be processed time.Sleep(1 * time.Second) - _ = r.conn.Close() - r.conn = nil + _ = r.rtpConn.Close() + r.rtpConn = nil r.logger.Info("UDP connection closed") } @@ -339,29 +431,6 @@ func (r *CursorGstreamerWebmRecorder) Close() error { // Post-process WebM to fix duration metadata if needed if r.tempOutputPath != "" && r.finalOutputPath != "" { r.logger.Info("Starting WebM duration post-processing...") - - // Choose post-processing approach based on temp file extension - if strings.HasSuffix(r.tempOutputPath, ".gst") { - // Simple approach for .gst files - if err := r.simpleWebMDurationFix(); err != nil { - r.logger.Error("Simple WebM duration fix failed: %v", err) - } - } else if strings.HasSuffix(r.tempOutputPath, ".direct") { - // Simple approach for .direct files (direct timing with post-processing) - if err := r.simpleWebMDurationFix(); err != nil { - r.logger.Error("Direct WebM duration fix failed: %v", err) - } - } else if strings.HasSuffix(r.tempOutputPath, ".minimal") { - // Simple approach for .minimal files (minimal timing with post-processing) - if err := r.simpleWebMDurationFix(); err != nil { - r.logger.Error("Minimal WebM duration fix failed: %v", err) - } - } else { - // Enhanced approach for .temp files - if err := r.postProcessWebMDuration(); err != nil { - r.logger.Error("Enhanced WebM post-processing failed: %v", err) - } - } } r.logger.Info("GStreamer WebM recorder closed") @@ -384,374 +453,3 @@ func (r *CursorGstreamerWebmRecorder) IsRecording() bool { return r.gstreamerCmd != nil && r.gstreamerCmd.Process != nil } - -// BufferConfig holds the configuration for RTP jitter buffer settings -type BufferConfig struct { - Latency int // Buffer latency in milliseconds - MaxMisorderTime int // Maximum time to wait for out-of-order packets (ms) - MaxDropoutTime int // Maximum time before considering packet lost (ms) - RtxDelay int // Retransmission delay in milliseconds - DoLost bool // Generate lost packet events - DropOnLatency bool // Drop packets that arrive too late -} - -// DefaultBufferConfig returns optimized settings for RTP dump replay with reordering -func DefaultBufferConfig() BufferConfig { - return BufferConfig{ - Latency: 500, // 500ms buffer for reordering - MaxMisorderTime: 2000, // Wait up to 2 seconds for missing packets - MaxDropoutTime: 60000, // Consider packets lost after 60 seconds - RtxDelay: 40, // Request retransmission after 40ms - DoLost: true, // Generate lost packet events for debugging - DropOnLatency: false, // Don't drop packets, buffer them for proper ordering - } -} - -// RealtimeBufferConfig returns optimized settings for real-time streaming -func RealtimeBufferConfig() BufferConfig { - return BufferConfig{ - Latency: 100, // Lower latency for real-time - MaxMisorderTime: 500, // Shorter wait time - MaxDropoutTime: 5000, // Faster dropout detection - RtxDelay: 20, // Faster retransmission - DoLost: true, - DropOnLatency: true, // Drop late packets to maintain real-time performance - } -} - -// NewCursorGstreamerWebmRecorderNoJitterBuffer creates a recorder without jitter buffer for direct timing -// This bypasses all buffering and lets the depayloaders handle RTP timestamps directly. -func NewCursorGstreamerWebmRecorderNoJitterBuffer(outputPath, sdp string, port int) (*CursorGstreamerWebmRecorder, error) { - ctx, cancel := context.WithCancel(context.Background()) - - r := &CursorGstreamerWebmRecorder{ - outputPath: outputPath, - ctx: ctx, - cancel: cancel, - port: port, - } - - r.logger.Info("SDP created for GStreamer (no jitter buffer)\n%s\n", sdp) - - // Set up UDP connections - if err := r.setupConnections(port); err != nil { - cancel() - return nil, err - } - - // Start GStreamer without jitter buffer - if err := r.startGStreamerNoJitterBuffer(sdp, outputPath); err != nil { - cancel() - return nil, err - } - - return r, nil -} - -func (r *CursorGstreamerWebmRecorder) startGStreamerNoJitterBuffer(sdpContent, outputFilePath string) error { - // Write SDP to a temporary file - sdpFile, err := os.CreateTemp("", "cursor_gstreamer_webm_*.sdp") - if err != nil { - return err - } - r.sdpFile = sdpFile - - if _, err := sdpFile.WriteString(sdpContent); err != nil { - sdpFile.Close() - return err - } - sdpFile.Close() - - // Determine codec from SDP content - isVP9 := strings.Contains(strings.ToUpper(sdpContent), "VP9") - isVP8 := strings.Contains(strings.ToUpper(sdpContent), "VP8") - isAV1 := strings.Contains(strings.ToUpper(sdpContent), "AV1") - isH264 := strings.Contains(strings.ToUpper(sdpContent), "H264") || strings.Contains(strings.ToUpper(sdpContent), "H.264") - - // Start with common GStreamer arguments - args := []string{ - //"--gst-debug-level=3", - // "--gst-debug=udpsrc:5,rtp*:5,webm*:5", - "-e", // Enable EOS handling - } - - // Add UDP source with timing preservation for direct recording - args = append(args, - "udpsrc", - fmt.Sprintf("port=%d", r.port), - "buffer-size=10000000", - "!", - "queue", - "max-size-buffers=1000", - "max-size-time=10000000000", // 10 seconds of buffering - "!", - ) - - // Build pipeline based on codec WITHOUT jitter buffer - // Use provided output file path directly - if isH264 { - r.logger.Info("Detected H.264 codec, building direct H.264 pipeline...") - args = append(args, - "application/x-rtp,media=video,encoding-name=H264,clock-rate=90000", "!", - "rtph264depay", "!", - "h264parse", "!", - "identity", "sync=true", "!", // Force timing synchronization - "mp4mux", "faststart=true", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else if isVP9 { - r.logger.Info("Detected VP9 codec, building direct VP9 pipeline...") - args = append(args, - "application/x-rtp,media=video,encoding-name=VP9,clock-rate=90000", "!", - "rtpvp9depay", "!", - "vp9parse", "!", - "identity", "sync=true", "!", // Force timing synchronization - "webmmux", "writing-app=GStreamer-Direct", "streamable=false", "min-index-interval=1000000000", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else if isVP8 { - r.logger.Info("Detected VP8 codec, building direct VP8 pipeline...") - args = append(args, - "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", - "rtpvp8depay", "!", - "vp8parse", "!", - "identity", "sync=true", "!", // Force timing synchronization - "webmmux", "writing-app=GStreamer-Direct", "streamable=false", "min-index-interval=1000000000", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else if isAV1 { - r.logger.Info("Detected AV1 codec, building direct AV1 pipeline...") - args = append(args, - "application/x-rtp,media=video,encoding-name=AV1,clock-rate=90000", "!", - "rtpav1depay", "!", - "av1parse", "!", - "identity", "sync=true", "!", // Force timing synchronization - "webmmux", "writing-app=GStreamer-Direct", "streamable=false", "min-index-interval=1000000000", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else { - // Default to VP8 if codec is not detected - r.logger.Info("Unknown or no codec detected, defaulting to direct VP8 pipeline...") - args = append(args, - "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", - "rtpvp8depay", "!", - "vp8parse", "!", - "identity", "sync=true", "!", // Force timing synchronization - "webmmux", "writing-app=GStreamer-Direct", "streamable=false", "min-index-interval=1000000000", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } - - r.logger.Info("GStreamer direct pipeline: %s", strings.Join(args[3:], " ")) - - r.gstreamerCmd = exec.CommandContext(r.ctx, "gst-launch-1.0", args...) - - // Redirect output for debugging - r.gstreamerCmd.Stdout = os.Stdout - r.gstreamerCmd.Stderr = os.Stderr - - // Start GStreamer process - if err := r.gstreamerCmd.Start(); err != nil { - return err - } - - r.logger.Info("GStreamer direct pipeline started with PID: %d", r.gstreamerCmd.Process.Pid) - - // Monitor the process in a goroutine - go func() { - if err := r.gstreamerCmd.Wait(); err != nil { - r.logger.Error("GStreamer process exited with error: %v", err) - } else { - r.logger.Info("GStreamer process exited normally") - } - }() - - return nil -} - -// postProcessWebMDuration fixes WebM duration metadata using FFmpeg -// This ensures the WebM file has proper duration information for browser playback -func (r *CursorGstreamerWebmRecorder) postProcessWebMDuration() error { - if r.tempOutputPath == "" || r.finalOutputPath == "" { - // No post-processing needed - return nil - } - - r.logger.Info("Post-processing WebM duration metadata...") - - // Check if temp file exists - if _, err := os.Stat(r.tempOutputPath); os.IsNotExist(err) { - r.logger.Warn("Temp file does not exist for post-processing: %s", r.tempOutputPath) - return nil - } - - // First get the duration from the file - durationCmd := exec.Command("ffprobe", - "-v", "quiet", - "-show_entries", "format=duration", - "-of", "csv=p=0", - r.tempOutputPath, - ) - - durationOutput, err := durationCmd.Output() - if err != nil { - r.logger.Error("Failed to get duration with ffprobe: %v", err) - } - - duration := strings.TrimSpace(string(durationOutput)) - r.logger.Info("Detected file duration: %s seconds", duration) - - // Use more aggressive FFmpeg approach to ensure duration is written to WebM header - cmd := exec.Command("ffmpeg", - "-i", r.tempOutputPath, - "-c:v", "copy", // Copy video stream - "-avoid_negative_ts", "make_zero", - "-fflags", "+genpts", // Generate presentation timestamps - "-f", "webm", // Force WebM format - "-write_crc32", "0", // Disable CRC for compatibility - "-cluster_size_limit", "2097152", // 2MB clusters for better seeking - "-cluster_time_limit", "5000", // 5 second clusters - "-y", // Overwrite output file - r.finalOutputPath, - ) - - // Set up logging - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - r.logger.Info("Running enhanced FFmpeg post-processing: %s", strings.Join(cmd.Args, " ")) - - // Run FFmpeg - if err := cmd.Run(); err != nil { - r.logger.Error("Enhanced FFmpeg post-processing failed: %v", err) - - // Try a simpler WebM remux approach - r.logger.Info("Trying fallback WebM remux...") - fallbackCmd := exec.Command("ffmpeg", - "-i", r.tempOutputPath, - "-c", "copy", - "-f", "webm", - "-y", - r.finalOutputPath, - ) - - fallbackCmd.Stdout = os.Stdout - fallbackCmd.Stderr = os.Stderr - - if fallbackErr := fallbackCmd.Run(); fallbackErr != nil { - r.logger.Error("Fallback FFmpeg also failed: %v", fallbackErr) - // Last resort - just move the file - return os.Rename(r.tempOutputPath, r.finalOutputPath) - } - } - - // Remove temporary file - os.Remove(r.tempOutputPath) - - r.logger.Info("WebM duration metadata fixed successfully") - return nil -} - -// NewCursorGstreamerWebmRecorderWithDurationFix creates a recorder that automatically fixes WebM duration -// This version writes to a temporary file and post-processes it to ensure proper duration metadata -func NewCursorGstreamerWebmRecorderWithDurationFix(outputPath, sdp string, port int) (*CursorGstreamerWebmRecorder, error) { - ctx, cancel := context.WithCancel(context.Background()) - - r := &CursorGstreamerWebmRecorder{ - outputPath: outputPath + ".temp", // Write to temp file first - ctx: ctx, - cancel: cancel, - port: port, - finalOutputPath: outputPath, - tempOutputPath: outputPath + ".temp", - } - - r.logger.Info("SDP created for GStreamer with duration fix\n%s\n", sdp) - - // Set up UDP connections - if err := r.setupConnections(port); err != nil { - cancel() - return nil, err - } - - // Start GStreamer - if err := r.startGStreamer(sdp, r.tempOutputPath); err != nil { - cancel() - return nil, err - } - - return r, nil -} - -// NewCursorGstreamerWebmRecorderSimpleDuration creates a recorder with the simplest duration fix -// This version uses a minimal FFmpeg remux specifically for WebM duration metadata -func NewCursorGstreamerWebmRecorderSimpleDuration(outputPath, sdp string, port int) (*CursorGstreamerWebmRecorder, error) { - ctx, cancel := context.WithCancel(context.Background()) - - r := &CursorGstreamerWebmRecorder{ - outputPath: outputPath + ".gst", - ctx: ctx, - cancel: cancel, - port: port, - finalOutputPath: outputPath, - tempOutputPath: outputPath + ".gst", - } - - r.logger.Info("Creating simple duration fix recorder\n%s\n", sdp) - - // Set up UDP connections - if err := r.setupConnections(port); err != nil { - cancel() - return nil, err - } - - // Start GStreamer - if err := r.startGStreamer(sdp, r.tempOutputPath); err != nil { - cancel() - return nil, err - } - - return r, nil -} - -// simpleWebMDurationFix performs a minimal FFmpeg remux to fix duration for browsers -func (r *CursorGstreamerWebmRecorder) simpleWebMDurationFix() error { - if r.tempOutputPath == "" || r.finalOutputPath == "" { - return nil - } - - r.logger.Info("Applying simple WebM duration fix...") - - // Check if temp file exists - if _, err := os.Stat(r.tempOutputPath); os.IsNotExist(err) { - r.logger.Warn("Source file does not exist: %s", r.tempOutputPath) - return nil - } - - // Simple FFmpeg remux that should preserve duration for browser playback - cmd := exec.Command("ffmpeg", - "-i", r.tempOutputPath, - "-c", "copy", // Copy all streams - "-f", "webm", // Ensure WebM format - "-avoid_negative_ts", "make_zero", - "-y", - r.finalOutputPath, - ) - - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - r.logger.Info("Running simple WebM fix: %s", strings.Join(cmd.Args, " ")) - - if err := cmd.Run(); err != nil { - r.logger.Error("Simple WebM fix failed: %v", err) - // Fall back to just moving the file - return os.Rename(r.tempOutputPath, r.finalOutputPath) - } - - // Remove temp file - os.Remove(r.tempOutputPath) - - r.logger.Info("Simple WebM duration fix completed") - return nil -} diff --git a/processing/cursor_webm_recorder.go b/processing/cursor_webm_recorder.go index 602a37e..612eb80 100644 --- a/processing/cursor_webm_recorder.go +++ b/processing/cursor_webm_recorder.go @@ -183,6 +183,10 @@ func (r *CursorWebmRecorder) PushRtpBuf(buf []byte) error { return nil } +func (r *CursorWebmRecorder) PushRtcpBuf(buf []byte) error { + return nil +} + func (r *CursorWebmRecorder) Close() error { r.mu.Lock() defer r.mu.Unlock() From a23d739e59fc2c9d9d8dae92e3f49c4b81225983 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 13 Oct 2025 11:37:23 +0200 Subject: [PATCH 31/38] gstreamer --- processing/cursor_gstreamer_webm_recorder.go | 53 +++++--------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/processing/cursor_gstreamer_webm_recorder.go b/processing/cursor_gstreamer_webm_recorder.go index f5b94bd..b4b25dd 100644 --- a/processing/cursor_gstreamer_webm_recorder.go +++ b/processing/cursor_gstreamer_webm_recorder.go @@ -111,28 +111,14 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "-e", // Send EOS on interrupt for clean shutdown } - // Build pipeline based on codec - // For VP9, we use direct UDP source without rtpbin for simpler RTP timestamp handling - if isVP9 { - r.logger.Info("Detected VP9 codec, building optimized pipeline for RTP dump replay...") - // Don't add the rtpbin/sdpsrc setup for VP9 - } else { - // Add UDP source with timestamp handling for RTP dump replay - args = append(args, - "rtpbin", - "name=rtpbin", - - "sdpsrc", - "location=sdp://"+sdpFile.Name(), - "name=sdp", - "sdp.stream_0", - "!", - - "rtpbin.recv_rtp_sink_0", - "rtpbin.", - "!", - ) - } + // Add SDP source - this handles UDP connection and RTP setup automatically + args = append(args, + "sdpsrc", + "location=sdp://"+sdpFile.Name(), + "name=sdp", + "sdp.stream_0", + "!", + ) // Build pipeline based on codec with simplified RTP timestamp handling for dump replay // @@ -182,22 +168,13 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath } else if isVP9 { r.logger.Info("Detected VP9 codec, building VP9 pipeline with RTP timestamp handling...") args = append(args, - // Start with UDP source receiving RTP packets - "udpsrc", - fmt.Sprintf("port=%d", r.port), - "caps=application/x-rtp,media=video,encoding-name=VP9,clock-rate=90000,payload=96", - "do-timestamp=false", // Don't apply udpsrc timestamps, use RTP timestamps - "!", - - // Use rtpjitterbuffer in synced mode for RTP timestamp-based timing - // This is critical for proper duration calculation from RTP timestamps + // jitterbuffer for packet reordering and timestamp handling "rtpjitterbuffer", "name=jitterbuffer", - "latency=0", // No artificial latency for fast replay - "mode=4", // mode=synced (4): use RTP timestamps for timing - "do-lost=false", // Don't generate lost events - "do-retransmission=false", // No retransmission for dump replay - "drop-on-latency=false", // Don't drop any packets + "latency=0", // No artificial latency - process immediately + "do-lost=false", // Don't generate lost events for missing packets + "do-retransmission=false", // No retransmission for offline replay + "drop-on-latency=false", // Keep all packets even if late "rtx-delay=-1", // Disable retransmission delay "!", @@ -224,10 +201,6 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath fmt.Sprintf("location=%s", outputFilePath), ) - //gst-launch-1.0 -v \ - //sdpsrc location=sdp:///chemin/vers/stream.sdp name=sdp \ - //sdp.stream_0 ! rtpjitterbuffer latency=0 drop-on-latency=true ! rtpvp9depay ! vp9parse ! queue ! matroskamux name=mux ! filesink location=out.webm -e - } else if false && isVP8 { r.logger.Info("Detected VP8 codec, building VP8 pipeline with timestamp handling...") args = append(args, From c2c379bdb0e69fdd8dc3ef00e0fb3f0c827685a2 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 13 Oct 2025 15:36:39 +0200 Subject: [PATCH 32/38] Get StartOffset from FFMEPG --- processing/converter.go | 30 ++++++++++----- processing/cursor_webm_recorder.go | 62 ++++++++++++++++++++++++++++-- processing/extract_track.go | 6 +-- processing/metadata.go | 1 + 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/processing/converter.go b/processing/converter.go index 5f2d7ac..0a22678 100644 --- a/processing/converter.go +++ b/processing/converter.go @@ -34,7 +34,6 @@ type RTPDump2WebMConverter struct { type WebmRecorder interface { OnRTP(pkt *rtp.Packet) error PushRtpBuf(payload []byte) error - PushRtcpBuf(payload []byte) error Close() error } @@ -44,8 +43,8 @@ func newRTPDump2WebMConverter(logger *getstream.DefaultLogger) *RTPDump2WebMConv } } -func ConvertDirectory(directory string, accept func(path string, info os.FileInfo) bool, fixDtx bool, logger *getstream.DefaultLogger) error { - var rtpdumpFiles []string +func ConvertDirectory(directory string, accept func(path string, info os.FileInfo) (*SegmentInfo, bool), fixDtx bool, logger *getstream.DefaultLogger) error { + rtpdumpFiles := make(map[string]*SegmentInfo) // Walk through directory to find .rtpdump files err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { @@ -53,8 +52,11 @@ func ConvertDirectory(directory string, accept func(path string, info os.FileInf return err } - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), SuffixRtpDump) && accept(path, info) { - rtpdumpFiles = append(rtpdumpFiles, path) + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), SuffixRtpDump) { + segment, accepted := accept(path, info) + if accepted { + rtpdumpFiles[path] = segment + } } return nil @@ -63,12 +65,20 @@ func ConvertDirectory(directory string, accept func(path string, info os.FileInf return err } - for _, rtpdumpFile := range rtpdumpFiles { + for rtpdumpFile, segment := range rtpdumpFiles { c := newRTPDump2WebMConverter(logger) if err := c.ConvertFile(rtpdumpFile, fixDtx); err != nil { c.logger.Error("Failed to convert %s: %v", rtpdumpFile, err) continue } + + switch c.recorder.(type) { + case *CursorWebmRecorder: + offset, exists := c.recorder.(*CursorWebmRecorder).StartOffset() + if exists { + segment.FFMpegOffset = offset + } + } } return nil @@ -97,10 +107,12 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error switch mType { case webrtc.MimeTypeAV1: c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, releasePacketHandler) - c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeVP9: - c.sampleBuilder = nil //samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + // c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + // c.recorder, err = NewIvfDumpRecorder(strings.Replace(inputFile, SuffixRtpDump, ".ivf", 1), webrtc.MimeTypeVP9) case webrtc.MimeTypeH264: c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, releasePacketHandler) c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixMp4, 1), sdpContent, c.logger) @@ -138,7 +150,7 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { } else if err != nil { return err } else if packet.IsRTCP { - _ = c.recorder.PushRtcpBuf(packet.Payload) + // _ = c.recorder.PushRtcpBuf(packet.Payload) continue } diff --git a/processing/cursor_webm_recorder.go b/processing/cursor_webm_recorder.go index 612eb80..cfd88d8 100644 --- a/processing/cursor_webm_recorder.go +++ b/processing/cursor_webm_recorder.go @@ -1,6 +1,7 @@ package processing import ( + "bufio" "context" "fmt" "io" @@ -8,7 +9,9 @@ import ( "net" "os" "os/exec" + "regexp" "strconv" + "strings" "sync" "time" @@ -26,6 +29,10 @@ type CursorWebmRecorder struct { mu sync.Mutex ctx context.Context cancel context.CancelFunc + + // Parsed from FFmpeg output: "Duration: N/A, start: , bitrate: N/A" + startOffsetSeconds float64 + hasStartOffset bool } func NewCursorWebmRecorder(outputPath, sdpContent string, logger *getstream.DefaultLogger) (*CursorWebmRecorder, error) { @@ -139,9 +146,15 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port r.ffmpegCmd = exec.Command("ffmpeg", args...) - // Redirect output for debugging - r.ffmpegCmd.Stdout = os.Stdout - r.ffmpegCmd.Stderr = os.Stderr + // Capture stdout/stderr to parse FFmpeg logs while mirroring to console + stdoutPipe, err := r.ffmpegCmd.StdoutPipe() + if err != nil { + return err + } + stderrPipe, err := r.ffmpegCmd.StderrPipe() + if err != nil { + return err + } // Create stdin pipe to send commands to FFmpeg //var err error @@ -150,6 +163,10 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port fmt.Println("Error creating stdin pipe:", err) } + // Begin scanning output streams after process has started + go r.scanFFmpegOutput(stdoutPipe, false) + go r.scanFFmpegOutput(stderrPipe, true) + // Start FFmpeg process if err := r.ffmpegCmd.Start(); err != nil { return err @@ -158,6 +175,45 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port return nil } +// scanFFmpegOutput reads lines from FFmpeg output, mirrors to console, and extracts start offset. +func (r *CursorWebmRecorder) scanFFmpegOutput(reader io.Reader, isStderr bool) { + scanner := bufio.NewScanner(reader) + re := regexp.MustCompile(`\bstart:\s*([0-9]+(?:\.[0-9]+)?)`) + for scanner.Scan() { + line := scanner.Text() + // Mirror output + if isStderr { + fmt.Fprintln(os.Stderr, line) + } else { + fmt.Fprintln(os.Stdout, line) + } + + // Try to extract the start value from those lines "Duration: N/A, start: 0.000000, bitrate: N/A" + if !strings.Contains(line, "Duration") || !strings.Contains(line, "bitrate") { + continue + } else if matches := re.FindStringSubmatch(line); len(matches) == 2 { + if v, parseErr := strconv.ParseFloat(matches[1], 64); parseErr == nil { + // Save only once + r.mu.Lock() + if !r.hasStartOffset { + r.startOffsetSeconds = v + r.hasStartOffset = true + r.logger.Info("Detected FFmpeg start offset: %.6f seconds", v) + } + r.mu.Unlock() + } + } + } + _ = scanner.Err() +} + +// StartOffset returns the parsed FFmpeg start offset in seconds and whether it was found. +func (r *CursorWebmRecorder) StartOffset() (float64, bool) { + r.mu.Lock() + defer r.mu.Unlock() + return r.startOffsetSeconds, r.hasStartOffset +} + func (r *CursorWebmRecorder) OnRTP(packet *rtp.Packet) error { // Marshal RTP packet buf, err := packet.Marshal() diff --git a/processing/extract_track.go b/processing/extract_track.go index 445081b..30a958c 100644 --- a/processing/extract_track.go +++ b/processing/extract_track.go @@ -36,7 +36,7 @@ func ExtractTracks(workingDir, outputDir, userID, sessionID, trackID string, met } func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir string, trackType string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { - accept := func(path string, info os.FileInfo) bool { + accept := func(path string, info os.FileInfo) (*SegmentInfo, bool) { for _, s := range track.Segments { if strings.Contains(info.Name(), s.metadata.BaseFilename) { if track.Codec == webrtc.MimeTypeH264 { @@ -47,10 +47,10 @@ func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir s.RtpDumpPath = path s.SdpPath = strings.Replace(path, SuffixRtpDump, SuffixSdp, -1) s.ContainerPath = strings.Replace(path, SuffixRtpDump, "."+s.ContainerExt, -1) - return true + return s, true } } - return false + return nil, false } // Convert using the WebM converter diff --git a/processing/metadata.go b/processing/metadata.go index dbc4790..a58a70e 100644 --- a/processing/metadata.go +++ b/processing/metadata.go @@ -35,6 +35,7 @@ type SegmentInfo struct { SdpPath string ContainerPath string ContainerExt string + FFMpegOffset float64 } // RecordingMetadata contains all tracks and session information From 6b6f7f2ff1b3c224012ab44fc9a5f054dd803748 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 13 Oct 2025 15:48:51 +0200 Subject: [PATCH 33/38] Get StartOffset from FFMEPG --- processing/av_muxer.go | 4 ++-- processing/cursor_webm_recorder.go | 10 +++++----- processing/extract_track.go | 2 +- processing/metadata.go | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/processing/av_muxer.go b/processing/av_muxer.go index b1369b1..20ef319 100644 --- a/processing/av_muxer.go +++ b/processing/av_muxer.go @@ -61,8 +61,8 @@ func (p *AudioVideoMuxer) MuxAudioVideoTracks(config *AudioVideoMuxerConfig, met // calculateSyncOffsetFromFiles calculates sync offset between audio and video files using metadata func calculateSyncOffsetFromFiles(audioTrack, videoTrack *TrackInfo, logger *getstream.DefaultLogger) (int64, error) { // Calculate offset: positive means video starts before audio - audioTs := firstPacketNtpTimestamp(audioTrack.Segments[0].metadata) - videoTs := firstPacketNtpTimestamp(videoTrack.Segments[0].metadata) + audioTs := audioTrack.Segments[0].FFMpegOffset + firstPacketNtpTimestamp(audioTrack.Segments[0].metadata) + videoTs := videoTrack.Segments[0].FFMpegOffset + firstPacketNtpTimestamp(videoTrack.Segments[0].metadata) offset := audioTs - videoTs logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", diff --git a/processing/cursor_webm_recorder.go b/processing/cursor_webm_recorder.go index cfd88d8..fd1d728 100644 --- a/processing/cursor_webm_recorder.go +++ b/processing/cursor_webm_recorder.go @@ -31,8 +31,8 @@ type CursorWebmRecorder struct { cancel context.CancelFunc // Parsed from FFmpeg output: "Duration: N/A, start: , bitrate: N/A" - startOffsetSeconds float64 - hasStartOffset bool + startOffsetMs int64 + hasStartOffset bool } func NewCursorWebmRecorder(outputPath, sdpContent string, logger *getstream.DefaultLogger) (*CursorWebmRecorder, error) { @@ -196,7 +196,7 @@ func (r *CursorWebmRecorder) scanFFmpegOutput(reader io.Reader, isStderr bool) { // Save only once r.mu.Lock() if !r.hasStartOffset { - r.startOffsetSeconds = v + r.startOffsetMs = int64(v * 1000) r.hasStartOffset = true r.logger.Info("Detected FFmpeg start offset: %.6f seconds", v) } @@ -208,10 +208,10 @@ func (r *CursorWebmRecorder) scanFFmpegOutput(reader io.Reader, isStderr bool) { } // StartOffset returns the parsed FFmpeg start offset in seconds and whether it was found. -func (r *CursorWebmRecorder) StartOffset() (float64, bool) { +func (r *CursorWebmRecorder) StartOffset() (int64, bool) { r.mu.Lock() defer r.mu.Unlock() - return r.startOffsetSeconds, r.hasStartOffset + return r.startOffsetMs, r.hasStartOffset } func (r *CursorWebmRecorder) OnRTP(packet *rtp.Packet) error { diff --git a/processing/extract_track.go b/processing/extract_track.go index 30a958c..4aba07c 100644 --- a/processing/extract_track.go +++ b/processing/extract_track.go @@ -81,7 +81,7 @@ func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir // Add gap filler if requested and there's a gap before the next segment if fillGaps && i < track.SegmentCount-1 { nextSegment := track.Segments[i+1] - gapDuration := firstPacketNtpTimestamp(nextSegment.metadata) - lastPacketNtpTimestamp(segment.metadata) + gapDuration := nextSegment.FFMpegOffset + firstPacketNtpTimestamp(nextSegment.metadata) - lastPacketNtpTimestamp(segment.metadata) if gapDuration > 0 { // There's a gap gapSeconds := float64(gapDuration) / 1000.0 diff --git a/processing/metadata.go b/processing/metadata.go index a58a70e..f1ae828 100644 --- a/processing/metadata.go +++ b/processing/metadata.go @@ -35,7 +35,7 @@ type SegmentInfo struct { SdpPath string ContainerPath string ContainerExt string - FFMpegOffset float64 + FFMpegOffset int64 } // RecordingMetadata contains all tracks and session information From 336f6ddfa4943243c2de5ccd207a30884268f15d Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 13 Oct 2025 15:49:01 +0200 Subject: [PATCH 34/38] Get StartOffset from FFMEPG --- processing/cursor_gstreamer_webm_recorder.go | 24 ++++++----- processing/ivf_recorder.go | 44 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 processing/ivf_recorder.go diff --git a/processing/cursor_gstreamer_webm_recorder.go b/processing/cursor_gstreamer_webm_recorder.go index b4b25dd..a34feb4 100644 --- a/processing/cursor_gstreamer_webm_recorder.go +++ b/processing/cursor_gstreamer_webm_recorder.go @@ -147,7 +147,6 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath } else if false && isVP9 { r.logger.Info("Detected VP9 codec, building VP9 pipeline with timestamp handling...") args = append(args, - "application/x-rtp,media=video,encoding-name=VP9,clock-rate=90000", "!", "rtpjitterbuffer", "latency=0", "mode=none", @@ -168,16 +167,18 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath } else if isVP9 { r.logger.Info("Detected VP9 codec, building VP9 pipeline with RTP timestamp handling...") args = append(args, - // jitterbuffer for packet reordering and timestamp handling + + //// jitterbuffer for packet reordering and timestamp handling "rtpjitterbuffer", "name=jitterbuffer", - "latency=0", // No artificial latency - process immediately - "do-lost=false", // Don't generate lost events for missing packets - "do-retransmission=false", // No retransmission for offline replay - "drop-on-latency=false", // Keep all packets even if late - "rtx-delay=-1", // Disable retransmission delay + "mode=none", + //"latency=0", // No artificial latency - process immediately + //"do-lost=false", // Don't generate lost events for missing packets + //"do-retransmission=false", // No retransmission for offline replay + //"drop-on-latency=false", // Keep all packets even if late + //"rtx-delay=-1", // Disable retransmission delay "!", - + // // Depayload RTP to get VP9 frames "rtpvp9depay", "!", @@ -191,9 +192,10 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "!", // Mux into Matroska/WebM container - "matroskamux", - "name=mux", - "streamable=false", // Allow seeking and proper duration + "webmmux", + "writing-app=GStreamer-VP9", + "streamable=false", + "min-index-interval=2000000000", "!", // Write to file diff --git a/processing/ivf_recorder.go b/processing/ivf_recorder.go new file mode 100644 index 0000000..e4a8987 --- /dev/null +++ b/processing/ivf_recorder.go @@ -0,0 +1,44 @@ +package processing + +import ( + "os" + "time" + + "github.com/pion/rtp" + "github.com/pion/webrtc/v4/pkg/media/ivfwriter" +) + +type IvfDumpRecorder struct { + file *os.File + startTime time.Time + writer *ivfwriter.IVFWriter +} + +func NewIvfDumpRecorder(outputPath string, mimeType string) (*IvfDumpRecorder, error) { + writer, _ := ivfwriter.New(outputPath, ivfwriter.WithCodec(mimeType)) + + recorder := &IvfDumpRecorder{ + startTime: time.Now(), + writer: writer, + } + + return recorder, nil +} + +func (r *IvfDumpRecorder) OnRTP(packet *rtp.Packet) error { + err := r.writer.WriteRTP(packet) + return err +} + +func (r *IvfDumpRecorder) PushRtpBuf(buf []byte) error { + rtpPacket := &rtp.Packet{} + if err := rtpPacket.Unmarshal(buf); err != nil { + return err + } + + return r.OnRTP(rtpPacket) +} + +func (r *IvfDumpRecorder) Close() error { + return r.file.Close() +} From a448cba9f4fe39fa245e3f69729c7533d82cfeb5 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 13 Oct 2025 17:30:41 +0200 Subject: [PATCH 35/38] Working VP9 --- processing/converter.go | 21 +++++++++++++++++++- processing/cursor_gstreamer_webm_recorder.go | 13 ++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/processing/converter.go b/processing/converter.go index 0a22678..f897ede 100644 --- a/processing/converter.go +++ b/processing/converter.go @@ -109,7 +109,7 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, releasePacketHandler) c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeVP9: - c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, samplebuilder.WithPacketReleaseHandler(c.buildDetectLossReleasePacketHandler())) c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) // c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) // c.recorder, err = NewIvfDumpRecorder(strings.Replace(inputFile, SuffixRtpDump, ".ivf", 1), webrtc.MimeTypeVP9) @@ -196,6 +196,25 @@ func (c *RTPDump2WebMConverter) buildDefaultReleasePacketHandler() func(pkt *rtp } } +func (c *RTPDump2WebMConverter) buildDetectLossReleasePacketHandler() func(pkt *rtp.Packet) { + return func(pkt *rtp.Packet) { + pkt.SequenceNumber += c.inserted + + if c.lastPkt != nil { + if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { + c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } + } + + c.lastPkt = pkt + + c.logger.Debug("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) + if e := c.recorder.OnRTP(pkt); e != nil { + c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) + } + } +} + func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Packet) { return func(pkt *rtp.Packet) { pkt.SequenceNumber += c.inserted diff --git a/processing/cursor_gstreamer_webm_recorder.go b/processing/cursor_gstreamer_webm_recorder.go index a34feb4..e328d9c 100644 --- a/processing/cursor_gstreamer_webm_recorder.go +++ b/processing/cursor_gstreamer_webm_recorder.go @@ -106,8 +106,8 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath // Start with common GStreamer arguments optimized for RTP dump replay args := []string{ - "--gst-debug-level=3", - "--gst-debug=udpsrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", + //"--gst-debug-level=3", + //"--gst-debug=udpsrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", "-e", // Send EOS on interrupt for clean shutdown } @@ -172,11 +172,10 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "rtpjitterbuffer", "name=jitterbuffer", "mode=none", - //"latency=0", // No artificial latency - process immediately - //"do-lost=false", // Don't generate lost events for missing packets - //"do-retransmission=false", // No retransmission for offline replay - //"drop-on-latency=false", // Keep all packets even if late - //"rtx-delay=-1", // Disable retransmission delay + "latency=0", // No artificial latency - process immediately + "do-lost=false", // Don't generate lost events for missing packets + "do-retransmission=false", // No retransmission for offline replay + "drop-on-latency=false", // Keep all packets even if late "!", // // Depayload RTP to get VP9 frames From b9e58e9d19bcfd21020e6f867488b4e0325c5665 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Tue, 14 Oct 2025 08:55:27 +0200 Subject: [PATCH 36/38] logs --- processing/cursor_gstreamer_webm_recorder.go | 22 ++++++++++++++------ processing/cursor_webm_recorder.go | 2 ++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/processing/cursor_gstreamer_webm_recorder.go b/processing/cursor_gstreamer_webm_recorder.go index e328d9c..8607a53 100644 --- a/processing/cursor_gstreamer_webm_recorder.go +++ b/processing/cursor_gstreamer_webm_recorder.go @@ -47,7 +47,8 @@ func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getst cancel() return nil, err } - if err := r.setupConnections(r.port, false); err != nil { + // Use rtcp on r.port+1 to match RTP/RTCP convention + if err := r.setupConnections(r.port+1, false); err != nil { cancel() return nil, err } @@ -71,6 +72,8 @@ func (r *CursorGstreamerWebmRecorder) setupConnections(port int, rtp bool) error if err != nil { return err } + // Increase socket send buffer to reduce kernel-level drops + _ = conn.SetWriteBuffer(4 << 20) // 4 MiB if rtp { r.rtpConn = conn } else { @@ -106,11 +109,11 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath // Start with common GStreamer arguments optimized for RTP dump replay args := []string{ - //"--gst-debug-level=3", + "--gst-debug-level=2", //"--gst-debug=udpsrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", + //"--gst-debug-no-color", "-e", // Send EOS on interrupt for clean shutdown } - // Add SDP source - this handles UDP connection and RTP setup automatically args = append(args, "sdpsrc", @@ -118,6 +121,13 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath "name=sdp", "sdp.stream_0", "!", + // Add a large in-process queue to absorb bursts and decouple socket IO from depay + "queue", + "max-size-buffers=0", + "max-size-bytes=268435456", // 256 MiB + "max-size-time=0", + "leaky=0", + "!", ) // Build pipeline based on codec with simplified RTP timestamp handling for dump replay @@ -301,11 +311,11 @@ func (r *CursorGstreamerWebmRecorder) OnRTCP(packet *rtp.Packet) error { return err } - return r.PushRtpBuf(buf) + return r.PushRtcpBuf(buf) } func (r *CursorGstreamerWebmRecorder) PushRtcpBuf(buf []byte) error { - return nil // r.pushBuf(r.rtcpConn, buf) + return r.pushBuf(r.rtcpConn, buf) } func (r *CursorGstreamerWebmRecorder) PushRtpBuf(buf []byte) error { @@ -321,7 +331,7 @@ func (r *CursorGstreamerWebmRecorder) pushBuf(conn *net.UDPConn, buf []byte) err _, err := conn.Write(buf) if err != nil { // Log error but don't fail completely - some packet loss is acceptable - r.logger.Debug("Failed to write RTP packet: %v", err) + r.logger.Warn("Failed to write RTP packet: %v", err) } } return nil diff --git a/processing/cursor_webm_recorder.go b/processing/cursor_webm_recorder.go index fd1d728..a71ab8a 100644 --- a/processing/cursor_webm_recorder.go +++ b/processing/cursor_webm_recorder.go @@ -144,6 +144,8 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port outputFilePath, ) + r.logger.Info("FFMpeg pipeline: %s", strings.Join(args, " ")) // Skip debug args for display + r.ffmpegCmd = exec.Command("ffmpeg", args...) // Capture stdout/stderr to parse FFmpeg logs while mirroring to console From d810d9961a255446260d7e787990b1a8937f4380 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Tue, 14 Oct 2025 09:50:50 +0200 Subject: [PATCH 37/38] Gstreamer working with tcp --- processing/cursor_gstreamer_webm_recorder.go | 196 ++++++++++++------- 1 file changed, 126 insertions(+), 70 deletions(-) diff --git a/processing/cursor_gstreamer_webm_recorder.go b/processing/cursor_gstreamer_webm_recorder.go index 8607a53..200fb93 100644 --- a/processing/cursor_gstreamer_webm_recorder.go +++ b/processing/cursor_gstreamer_webm_recorder.go @@ -2,6 +2,7 @@ package processing import ( "context" + "encoding/binary" "fmt" "math/rand" "net" @@ -19,8 +20,8 @@ import ( type CursorGstreamerWebmRecorder struct { logger *getstream.DefaultLogger outputPath string - rtpConn *net.UDPConn - rtcpConn *net.UDPConn + rtpConn net.Conn + rtcpConn net.Conn gstreamerCmd *exec.Cmd mu sync.Mutex ctx context.Context @@ -41,20 +42,17 @@ func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getst cancel: cancel, } - // Set up UDP connections + // Choose TCP listen port for GStreamer tcpserversrc r.port = rand.Intn(10000) + 10000 - if err := r.setupConnections(r.port, true); err != nil { - cancel() - return nil, err - } - // Use rtcp on r.port+1 to match RTP/RTCP convention - if err := r.setupConnections(r.port+1, false); err != nil { + + // Start GStreamer with codec detection + if err := r.startGStreamer(sdpContent, outputPath); err != nil { cancel() return nil, err } - // Start GStreamer with codec detection - if err := r.startGStreamer(sdpContent, outputPath); err != nil { + // Establish TCP client connection to the local tcpserversrc + if err := r.setupConnections(r.port, true); err != nil { cancel() return nil, err } @@ -63,17 +61,21 @@ func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getst } func (r *CursorGstreamerWebmRecorder) setupConnections(port int, rtp bool) error { - // Setup UDP connection - addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:"+strconv.Itoa(port)) - if err != nil { - return err - } - conn, err := net.DialUDP("udp", nil, addr) - if err != nil { - return err + // Setup TCP connection with retry to match GStreamer tcpserversrc readiness + address := "127.0.0.1:" + strconv.Itoa(port) + deadline := time.Now().Add(10 * time.Second) + var conn net.Conn + var err error + for { + conn, err = net.DialTimeout("tcp", address, 500*time.Millisecond) + if err == nil { + break + } + if time.Now().After(deadline) { + return fmt.Errorf("failed to connect to tcpserversrc at %s: %w", address, err) + } + time.Sleep(100 * time.Millisecond) } - // Increase socket send buffer to reduce kernel-level drops - _ = conn.SetWriteBuffer(4 << 20) // 4 MiB if rtp { r.rtpConn = conn } else { @@ -83,22 +85,9 @@ func (r *CursorGstreamerWebmRecorder) setupConnections(port int, rtp bool) error } func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath string) error { - // Write SDP to a temporary file - sdpFile, err := os.CreateTemp("", "cursor_gstreamer_webm_*.sdp") - if err != nil { - return err - } - r.sdpFile = sdpFile - - updatedSdp := replaceSDP(sdpContent, r.port) - - if _, err := sdpFile.WriteString(updatedSdp); err != nil { - sdpFile.Close() - return err - } - sdpFile.Close() - - r.logger.Info("SDP created for GStreamer\n%s\n", updatedSdp) + // Parse SDP to determine RTP caps for rtpstreamdepay + media, encodingName, payloadType, clockRate := parseRtpCapsFromSDP(sdpContent) + r.logger.Info("Starting TCP-based GStreamer pipeline (media=%s, encoding=%s, payload=%d, clock-rate=%d)", media, encodingName, payloadType, clockRate) // Determine codec from SDP content and build GStreamer arguments isVP9 := strings.Contains(strings.ToUpper(sdpContent), "VP9") @@ -109,25 +98,31 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath // Start with common GStreamer arguments optimized for RTP dump replay args := []string{ - "--gst-debug-level=2", - //"--gst-debug=udpsrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", + "--gst-debug-level=3", + "--gst-debug=tcpserversrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", //"--gst-debug-no-color", "-e", // Send EOS on interrupt for clean shutdown } - // Add SDP source - this handles UDP connection and RTP setup automatically + // Source from TCP (RFC4571 framed) and depayload back to application/x-rtp args = append(args, - "sdpsrc", - "location=sdp://"+sdpFile.Name(), - "name=sdp", - "sdp.stream_0", + "tcpserversrc", + "host=127.0.0.1", + fmt.Sprintf("port=%d", r.port), + "name=tcp_in", "!", - // Add a large in-process queue to absorb bursts and decouple socket IO from depay "queue", "max-size-buffers=0", - "max-size-bytes=268435456", // 256 MiB + "max-size-bytes=268435456", "max-size-time=0", "leaky=0", "!", + // Ensure rtpstreamdepay sink has caps + "application/x-rtp-stream", + "!", + "rtpstreamdepay", + "!", + fmt.Sprintf("application/x-rtp,media=%s,encoding-name=%s,clock-rate=%d,payload=%d", media, encodingName, clockRate, payloadType), + "!", ) // Build pipeline based on codec with simplified RTP timestamp handling for dump replay @@ -294,6 +289,74 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath return nil } +// parseRtpCapsFromSDP extracts basic RTP caps from an SDP for use with application/x-rtp caps +// Prioritizes video codecs (H264/VP9/VP8/AV1) over audio (OPUS) and parses payload/clock-rate +func parseRtpCapsFromSDP(sdp string) (media string, encodingName string, payload int, clockRate int) { + upper := strings.ToUpper(sdp) + + // Defaults + media = "video" + encodingName = "VP9" + payload = 96 + clockRate = 90000 + + // Select target encoding with priority: H264 > VP9 > VP8 > AV1 > OPUS (audio) + if strings.Contains(upper, "H264") || strings.Contains(upper, "H.264") { + encodingName = "H264" + media = "video" + clockRate = 90000 + } else if strings.Contains(upper, "VP9") { + encodingName = "VP9" + media = "video" + clockRate = 90000 + } else if strings.Contains(upper, "VP8") { + encodingName = "VP8" + media = "video" + clockRate = 90000 + } else if strings.Contains(upper, "AV1") { + encodingName = "AV1" + media = "video" + clockRate = 90000 + } else if strings.Contains(upper, "OPUS") { + encodingName = "OPUS" + media = "audio" + clockRate = 48000 + } + + // Parse matching a=rtpmap for the chosen encoding to refine payload and clock + chosen := encodingName + for _, line := range strings.Split(sdp, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToLower(line), "a=rtpmap:") { + continue + } + // Example: a=rtpmap:96 VP9/90000 + after := strings.TrimSpace(line[len("a=rtpmap:"):]) + fields := strings.Fields(after) + if len(fields) < 2 { + continue + } + ptStr := fields[0] + codec := strings.ToUpper(fields[1]) + parts := strings.Split(codec, "/") + name := parts[0] + if name != chosen { + continue + } + if v, err := strconv.Atoi(ptStr); err == nil { + payload = v + } + if len(parts) >= 2 { + if v, err := strconv.Atoi(parts[1]); err == nil { + clockRate = v + } + } + break + } + + return +} + func (r *CursorGstreamerWebmRecorder) OnRTP(packet *rtp.Packet) error { // Marshal RTP packet buf, err := packet.Marshal() @@ -314,24 +377,30 @@ func (r *CursorGstreamerWebmRecorder) OnRTCP(packet *rtp.Packet) error { return r.PushRtcpBuf(buf) } -func (r *CursorGstreamerWebmRecorder) PushRtcpBuf(buf []byte) error { - return r.pushBuf(r.rtcpConn, buf) -} +func (r *CursorGstreamerWebmRecorder) PushRtcpBuf(buf []byte) error { return nil } func (r *CursorGstreamerWebmRecorder) PushRtpBuf(buf []byte) error { return r.pushBuf(r.rtpConn, buf) } -func (r *CursorGstreamerWebmRecorder) pushBuf(conn *net.UDPConn, buf []byte) error { +func (r *CursorGstreamerWebmRecorder) pushBuf(conn net.Conn, buf []byte) error { r.mu.Lock() defer r.mu.Unlock() - // Send RTP packet over UDP to GStreamer udpsrc + // Send RTP packet over TCP using RFC4571 2-byte length prefix if conn != nil { - _, err := conn.Write(buf) - if err != nil { - // Log error but don't fail completely - some packet loss is acceptable + if len(buf) > 0xFFFF { + return fmt.Errorf("rtp packet too large for TCP framing: %d bytes", len(buf)) + } + header := make([]byte, 2) + binary.BigEndian.PutUint16(header, uint16(len(buf))) + if _, err := conn.Write(header); err != nil { + r.logger.Warn("Failed to write RTP length header: %v", err) + return err + } + if _, err := conn.Write(buf); err != nil { r.logger.Warn("Failed to write RTP packet: %v", err) + return err } } return nil @@ -350,25 +419,12 @@ func (r *CursorGstreamerWebmRecorder) Close() error { r.cancel() } - // Close UDP connection with goodbye message + // Close TCP connection if r.rtpConn != nil { - r.logger.Info("Closing UDP connection...") - - // Send RTCP Goodbye packet to signal end of stream - //buf, _ := rtcp.Goodbye{ - // Sources: []uint32{1}, // fixed SSRC is ok - // Reason: "bye", - //}.Marshal() - //_, _ = r.rtpConn.Write(buf) - - r.logger.Info("Goodbye sent") - - // Give some time for the goodbye packet to be processed - time.Sleep(1 * time.Second) - + r.logger.Info("Closing TCP connection...") _ = r.rtpConn.Close() r.rtpConn = nil - r.logger.Info("UDP connection closed") + r.logger.Info("TCP connection closed") } // Gracefully stop GStreamer From ec4d07b5fb0869972d2ee897e27d62ca87a41a63 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Tue, 14 Oct 2025 13:34:36 +0200 Subject: [PATCH 38/38] Rename --- processing/{input.go => archive_input.go} | 0 processing/{raw.go => archive_json.go} | 0 .../{metadata.go => archive_metadata.go} | 0 .../{av_muxer.go => audio_video_muxer.go} | 0 .../{converter.go => container_converter.go} | 15 +------ ...r_webm_recorder.go => ffmpeg_converter.go} | 11 ++--- processing/{helper.go => ffmpeg_helper.go} | 0 ...ebm_recorder.go => gstreamer_converter.go} | 33 +++----------- processing/ivf_recorder.go | 44 ------------------- processing/{sdp_writer.go => sdp_tool.go} | 0 .../{extract_track.go => track_extractor.go} | 0 11 files changed, 11 insertions(+), 92 deletions(-) rename processing/{input.go => archive_input.go} (100%) rename processing/{raw.go => archive_json.go} (100%) rename processing/{metadata.go => archive_metadata.go} (100%) rename processing/{av_muxer.go => audio_video_muxer.go} (100%) rename processing/{converter.go => container_converter.go} (93%) rename processing/{cursor_webm_recorder.go => ffmpeg_converter.go} (97%) rename processing/{helper.go => ffmpeg_helper.go} (100%) rename processing/{cursor_gstreamer_webm_recorder.go => gstreamer_converter.go} (94%) delete mode 100644 processing/ivf_recorder.go rename processing/{sdp_writer.go => sdp_tool.go} (100%) rename processing/{extract_track.go => track_extractor.go} (100%) diff --git a/processing/input.go b/processing/archive_input.go similarity index 100% rename from processing/input.go rename to processing/archive_input.go diff --git a/processing/raw.go b/processing/archive_json.go similarity index 100% rename from processing/raw.go rename to processing/archive_json.go diff --git a/processing/metadata.go b/processing/archive_metadata.go similarity index 100% rename from processing/metadata.go rename to processing/archive_metadata.go diff --git a/processing/av_muxer.go b/processing/audio_video_muxer.go similarity index 100% rename from processing/av_muxer.go rename to processing/audio_video_muxer.go diff --git a/processing/converter.go b/processing/container_converter.go similarity index 93% rename from processing/converter.go rename to processing/container_converter.go index f897ede..bee8c51 100644 --- a/processing/converter.go +++ b/processing/container_converter.go @@ -109,10 +109,8 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, releasePacketHandler) c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) case webrtc.MimeTypeVP9: - c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, samplebuilder.WithPacketReleaseHandler(c.buildDetectLossReleasePacketHandler())) + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) - // c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) - // c.recorder, err = NewIvfDumpRecorder(strings.Replace(inputFile, SuffixRtpDump, ".ivf", 1), webrtc.MimeTypeVP9) case webrtc.MimeTypeH264: c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, releasePacketHandler) c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixMp4, 1), sdpContent, c.logger) @@ -190,16 +188,6 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { func (c *RTPDump2WebMConverter) buildDefaultReleasePacketHandler() func(pkt *rtp.Packet) { return func(pkt *rtp.Packet) { - if e := c.recorder.OnRTP(pkt); e != nil { - c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) - } - } -} - -func (c *RTPDump2WebMConverter) buildDetectLossReleasePacketHandler() func(pkt *rtp.Packet) { - return func(pkt *rtp.Packet) { - pkt.SequenceNumber += c.inserted - if c.lastPkt != nil { if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) @@ -208,7 +196,6 @@ func (c *RTPDump2WebMConverter) buildDetectLossReleasePacketHandler() func(pkt * c.lastPkt = pkt - c.logger.Debug("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) if e := c.recorder.OnRTP(pkt); e != nil { c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) } diff --git a/processing/cursor_webm_recorder.go b/processing/ffmpeg_converter.go similarity index 97% rename from processing/cursor_webm_recorder.go rename to processing/ffmpeg_converter.go index a71ab8a..e246f68 100644 --- a/processing/cursor_webm_recorder.go +++ b/processing/ffmpeg_converter.go @@ -45,8 +45,6 @@ func NewCursorWebmRecorder(outputPath, sdpContent string, logger *getstream.Defa cancel: cancel, } - r.logger.Info("Sdp created \n%s\n", sdpContent) - // Set up UDP connections port := rand.Intn(10000) + 10000 if err := r.setupConnections(port); err != nil { @@ -86,7 +84,10 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port return err } - if _, err := sdpFile.WriteString(replaceSDP(sdpContent, port)); err != nil { + updatedSdp := replaceSDP(sdpContent, port) + r.logger.Info("Using Sdp:\n%s\n", updatedSdp) + + if _, err := sdpFile.WriteString(updatedSdp); err != nil { sdpFile.Close() return err } @@ -241,10 +242,6 @@ func (r *CursorWebmRecorder) PushRtpBuf(buf []byte) error { return nil } -func (r *CursorWebmRecorder) PushRtcpBuf(buf []byte) error { - return nil -} - func (r *CursorWebmRecorder) Close() error { r.mu.Lock() defer r.mu.Unlock() diff --git a/processing/helper.go b/processing/ffmpeg_helper.go similarity index 100% rename from processing/helper.go rename to processing/ffmpeg_helper.go diff --git a/processing/cursor_gstreamer_webm_recorder.go b/processing/gstreamer_converter.go similarity index 94% rename from processing/cursor_gstreamer_webm_recorder.go rename to processing/gstreamer_converter.go index 200fb93..64714a5 100644 --- a/processing/cursor_gstreamer_webm_recorder.go +++ b/processing/gstreamer_converter.go @@ -21,7 +21,6 @@ type CursorGstreamerWebmRecorder struct { logger *getstream.DefaultLogger outputPath string rtpConn net.Conn - rtcpConn net.Conn gstreamerCmd *exec.Cmd mu sync.Mutex ctx context.Context @@ -52,7 +51,7 @@ func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getst } // Establish TCP client connection to the local tcpserversrc - if err := r.setupConnections(r.port, true); err != nil { + if err := r.setupConnections(r.port); err != nil { cancel() return nil, err } @@ -60,7 +59,7 @@ func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getst return r, nil } -func (r *CursorGstreamerWebmRecorder) setupConnections(port int, rtp bool) error { +func (r *CursorGstreamerWebmRecorder) setupConnections(port int) error { // Setup TCP connection with retry to match GStreamer tcpserversrc readiness address := "127.0.0.1:" + strconv.Itoa(port) deadline := time.Now().Add(10 * time.Second) @@ -76,11 +75,7 @@ func (r *CursorGstreamerWebmRecorder) setupConnections(port int, rtp bool) error } time.Sleep(100 * time.Millisecond) } - if rtp { - r.rtpConn = conn - } else { - r.rtcpConn = conn - } + r.rtpConn = conn return nil } @@ -367,38 +362,22 @@ func (r *CursorGstreamerWebmRecorder) OnRTP(packet *rtp.Packet) error { return r.PushRtpBuf(buf) } -func (r *CursorGstreamerWebmRecorder) OnRTCP(packet *rtp.Packet) error { - // Marshal RTP packet - buf, err := packet.Marshal() - if err != nil { - return err - } - - return r.PushRtcpBuf(buf) -} - -func (r *CursorGstreamerWebmRecorder) PushRtcpBuf(buf []byte) error { return nil } - func (r *CursorGstreamerWebmRecorder) PushRtpBuf(buf []byte) error { - return r.pushBuf(r.rtpConn, buf) -} - -func (r *CursorGstreamerWebmRecorder) pushBuf(conn net.Conn, buf []byte) error { r.mu.Lock() defer r.mu.Unlock() // Send RTP packet over TCP using RFC4571 2-byte length prefix - if conn != nil { + if r.rtpConn != nil { if len(buf) > 0xFFFF { return fmt.Errorf("rtp packet too large for TCP framing: %d bytes", len(buf)) } header := make([]byte, 2) binary.BigEndian.PutUint16(header, uint16(len(buf))) - if _, err := conn.Write(header); err != nil { + if _, err := r.rtpConn.Write(header); err != nil { r.logger.Warn("Failed to write RTP length header: %v", err) return err } - if _, err := conn.Write(buf); err != nil { + if _, err := r.rtpConn.Write(buf); err != nil { r.logger.Warn("Failed to write RTP packet: %v", err) return err } diff --git a/processing/ivf_recorder.go b/processing/ivf_recorder.go deleted file mode 100644 index e4a8987..0000000 --- a/processing/ivf_recorder.go +++ /dev/null @@ -1,44 +0,0 @@ -package processing - -import ( - "os" - "time" - - "github.com/pion/rtp" - "github.com/pion/webrtc/v4/pkg/media/ivfwriter" -) - -type IvfDumpRecorder struct { - file *os.File - startTime time.Time - writer *ivfwriter.IVFWriter -} - -func NewIvfDumpRecorder(outputPath string, mimeType string) (*IvfDumpRecorder, error) { - writer, _ := ivfwriter.New(outputPath, ivfwriter.WithCodec(mimeType)) - - recorder := &IvfDumpRecorder{ - startTime: time.Now(), - writer: writer, - } - - return recorder, nil -} - -func (r *IvfDumpRecorder) OnRTP(packet *rtp.Packet) error { - err := r.writer.WriteRTP(packet) - return err -} - -func (r *IvfDumpRecorder) PushRtpBuf(buf []byte) error { - rtpPacket := &rtp.Packet{} - if err := rtpPacket.Unmarshal(buf); err != nil { - return err - } - - return r.OnRTP(rtpPacket) -} - -func (r *IvfDumpRecorder) Close() error { - return r.file.Close() -} diff --git a/processing/sdp_writer.go b/processing/sdp_tool.go similarity index 100% rename from processing/sdp_writer.go rename to processing/sdp_tool.go diff --git a/processing/extract_track.go b/processing/track_extractor.go similarity index 100% rename from processing/extract_track.go rename to processing/track_extractor.go