見出し画像

テレビ録画メディアサーバー構築入門(第21回)

多重化(muxing)

病院や公共施設の待合室でつけっぱなしになっているテレビに、字幕テロップ -- 字幕放送 -- が流れているのをよく目にします。音が聞こえにくい環境でも、手持ち無沙汰な人々の注意を引き、ちょっとした気晴らしになる。なかなか親切な配慮だと思う。

放送波(m2ts)には映像と主音声だけではなく、解説[解]や字幕など、様々な付加的情報が含まれています。個人的に、あまり意識することはありませんでしたが、現在放送されている地上波放送、とくに視聴者層の厚そうな昼間の番組については、ほぼすべてが文字多重放送[字]を含んでいます。まぁ時代の要請なのでしょう。ちょっとした驚きです。

できるなら、mp4に変換するときに、できるだけ忠実に情報を残せたらうれしい。

個人的には、ため込んだ録画番組は、倍速で消化することが多いので、もしかすると文字情報は便利かもしれない、と思った。調べて試行錯誤してみて、なかなか一筋縄ではいかない(最先端な?)問題なことがわかった。

今回のテーマは、第11回の「その場しのぎ」の改善です。「些細な問題」どころではなかった。

方針

EPGStation 内部で使用するエンコード処理スクリプト enc.js のサードパーティによる拡張版があります。

基本部分を参考にしながら、
1.(EPGStationに依存しない)独立なコマンドにして、
2. (特にNHKの)字幕ストリームをうまく取り込めるように、
拡張改変したい。

過去回にて、エンコード部分は
enc0.sh input.m2ts output.mp4
のようにしてきた。けれども、よく考えると
./enc0.sh "$cleaned_filename".m2ts "$cleaned_filename".mp4
のように、同じファイル名を並べた使い方をしてきた。入出力ファイル名を区別しないなら、出力用の第2引数は必要ない。

ということで、録画m2tsを受け取って、
enc.js input.m2ts
で拡張子だけを変えたinput.mp4ができるようにする。

ただし、これだけでは不十分です。上のenc.jsではEPGStationから変数AudioComponentType を引き継いでいます。EPGStationから独立したいので、この変数を別の手段で取得して第2引数として渡すようにする。つまり、

enc.js input.m2ts AudioComponentType

という形で使うことにする。

AudioComponentTypeは、「番組名」$NAMEを引数とするスクリプト act.sh (Audio Component Typeだからact) から得るようにする。

AUDIOCOMPONENTTYPE=`./act.sh $NAME`

ちなみに、この変数が 2 の場合が(トラブルの元になる)デュアルモノとなる。2か否かだけが重要。

enc.jsではffmpegを字幕処理に使うので、ffmpegをコンパイルし直す必要がある。

まとめ

  1. ffmpegを再コンパイル。

  2. act.sh と enc.js を、作業ディレクトリ~/workと ~/work2につくる。

  3. enc01.sh を書き換える。

  4. Jellyfinクライエントの個人設定(字幕)を調整する。


ffmpeg

sudo apt install libaribb24-0 libaribb24-dev
sudo ln -s /usr/lib/x86_64-linux-gnu/pkgconfig/aribb24.pc /usr/lib/x86_64-linux-gnu/pkgconfig/libaribb24.pc
cd ~/ffmpeg_611
make distclean
PKG_CONFIG_PATH="$HOME/build/ffmpeg_build/lib/pkgconfig" ./configure --prefix="/usr/local" --pkg-config-flags="--static" --extra-cflags="-I$HOME/build/ffmpeg_build/include"  --extra-ldflags="-L$HOME/build/ffmpeg_build/lib:/usr/lib/x86_64-linux-gnu"   --extra-libs="-lpthread -lm"   --ld="g++"  --bindir="$HOME/bin"  --enable-gpl  --enable-version3  --enable-gnutls  --enable-libaom  --enable-libass --enable-libfdk-aac  --enable-libfreetype  --enable-libmp3lame  --enable-libopus  --enable-libdav1d  --enable-libvorbis  --enable-libvpl  --enable-libx264  --enable-shared  --enable-avisynth  --enable-vaapi  --enable-libvpx --enable-nonfree --disable-doc  --disable-debug --enable-libaribb24  && make -j"$(nproc)"
sudo cp ffmpeg ffprobe /usr/local/bin/
sudo make install

第4回との違いは、--enable-libaribb24 の追加

act.sh

説明:EPGStationのデータベースに登録された録画番組のAudioComponentTypeを出力する

使用法: ./act.sh 録画番組名

takya@fearow22:~/work$ cat act.sh
#!/bin/bash

#番組名を引数として受取り、audioComponentTypeを出力

URL='http://localhost:8888/api/recorded?isHalfWidth=false&offset=0&limit=1000'
TARGET_NAME="$1"
curl -s -X 'GET' $URL| jq -r --arg name "$TARGET_NAME" '
  .records[]
  | select(.name == $name)
  | .audioComponentType
  ' 2>/dev/null

enc.js

説明:地上波m2tsに含まれる多重ストリーム(副音声[二] [解] [多]と文字[字])を保持しつつmp4に変換する

使用法: ./enc.js /path/to/番組.m2ts [AudioComponentType(省略可)]

課題:1. ffprobeで見えない副音声は拾えない. 2. 一部文字化け〓〓

takya@fearow22:~/work$ cat enc.js
#!/usr/bin/env node

// モジュールの読み込み
const { spawn } = require('child_process');
const { execFileSync } = require('child_process');
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');

// コマンドライン引数の解析
const args = process.argv.slice(2);
const inputFile = args[0];
const audioComponentType = args[1] || '0';

if (!inputFile) {
    console.error('Usage: node enc.js <input_file_path> [audio_component_type]');
    console.error('Example: node enc.js /path/to/番組名.m2ts 2');
    process.exit(1);
}

// 入力ファイルの存在確認
if (!fs.existsSync(inputFile)) {
    console.error(`Error: Input file not found: ${inputFile}`);
    process.exit(1);
}

// 出力ファイル名の生成(カレントディレクトリに出力)
const inputFileName = path.parse(inputFile).name;
const outputFile = `./${inputFileName}.mp4`;

// 固定設定
const epgsConfig = {
    recordedFileExtension: '.m2ts'
};

// エンコード設定
const ffmpegLogOutOnlyOnError = true;
const progressLogOutMax = 0; // 進捗表示を無効化
// デフォルトコーデック設定
const useCodec = 'h264_qsv'; //libx264, h264_qsv, h264_vaapi
// 固定で3秒カット
const fixedCutSecond = 3;

// 環境変数代替関数
function getEnv(variableName) {
    const envs = {
        INPUT: inputFile,
        OUTPUT: outputFile,
        NAME: inputFileName,
        AUDIOCOMPONENTTYPE: audioComponentType,
        FFMPEG: 'ffmpeg',
        FFPROBE: 'ffprobe'
    };
    return envs[variableName];
}

// メイン処理
(() => {
    // デバッグモードを有効にする
    const DEBUG_MODE = true;
    
    if (DEBUG_MODE) {
        debugAllStreams();
    }
    
    const useCodecPreArgs = [];
    const useCodecPostArgs = [];

    if (useCodec === 'h264_qsv') {
        useCodecPreArgs.push('-fflags', '+genpts');
        useCodecPostArgs.push('-vf', 'yadif'); //cmcutで最適
        useCodecPostArgs.push('-preset', 'fast');
        useCodecPostArgs.push('-global_quality', '20');
    } else if (useCodec === 'libx264') {
        useCodecPreArgs.push('-fflags', '+genpts');
        useCodecPostArgs.push('-vf', 'yadif');
        useCodecPostArgs.push('-preset', 'veryfast');
        useCodecPostArgs.push('-crf', '26');
    } else if (useCodec === 'h264_vaapi') {
        useCodecPreArgs.push('-hwaccel', 'vaapi');
        useCodecPreArgs.push('-hwaccel_device', '/dev/dri/renderD128');
        useCodecPostArgs.push('-vf', 'format=nv12,hwupload,deinterlace_vaapi,scale_vaapi=w=1280:h=720');
        useCodecPostArgs.push('-compression_level', '1');
        useCodecPostArgs.push('-global_quality', '20');
    }


    // 字幕設定
    const sub = getSubTitlesArg();
    const audio = getAudioArgs();
    
    // 固定で3秒カット
    const cutSecond = fixedCutSecond;
    const ss = cutSecond > 0 ? ['-ss', cutSecond.toString()] : [];

    // 字幕ストリームが有効かどうかをチェック
    const hasValidSubtitles = sub.map.length > 0;
    
    // FFmpeg引数の組み立て
    let outputArgs;
    if (hasValidSubtitles) {
        // 字幕ありでエンコード
        outputArgs = [
            '-y',
            ...getAnalyze(),
            ...sub.fix,
            ...useCodecPreArgs,  // 入力ファイルの前に追加
            ...ss,
            '-i', getEnv('INPUT'),
            '-map', '0:v',
            '-c:v', useCodec,
            ...audio.args,
            ...useCodecPostArgs, // 入力ファイルの後に追加
            ...sub.map,
            getEnv('OUTPUT')
        ];
        console.log('Encoding with subtitles');
    } else {
        // 字幕なしでエンコード
        outputArgs = [
            '-y',
            ...getAnalyze(),
            ...sub.fix,
            ...useCodecPreArgs,  // 入力ファイルの前に追加
            ...ss,
            '-i', getEnv('INPUT'),
            '-map', '0:v',
            '-c:v', useCodec,
            ...audio.args,
            ...useCodecPostArgs, // 入力ファイルの後に追加
            getEnv('OUTPUT')
        ];
        console.log('Encoding without subtitles');
    }

    console.log('Input file:', getEnv('INPUT'));
    console.log('Output file:', getEnv('OUTPUT'));
    console.log('FFmpeg command:', 'ffmpeg', outputArgs.join(' '));

    // 実行処理
    const durationInfo = getFFprobe('-show_format');
    const duration = durationInfo && durationInfo.format ? durationInfo.format.duration : 0;
    const startTime = process.uptime();
    const logBuffers = [];

    const child = spawn(getEnv('FFMPEG'), outputArgs, { 
        stdio: ['ignore', 'pipe', 'pipe'] 
    });

    // 標準出力も捕捉
    child.stdout.on('data', data => {
        logBuffers.push(String(data).trim());
    });

    child.stderr.on('data', data => {
        const dataStr = String(data);
        if (!dataStr.startsWith('frame=')) {
            logBuffers.push(dataStr.trim());
        }
    });

    child.on('exit', code => {
        const isError = code !== 0;
        
        if (!ffmpegLogOutOnlyOnError || isError) {
            console.log('FFmpeg messages:', logBuffers.join('\n'));
        }

        const elapsed = parseFloat(process.uptime() - startTime);
        const logs = {
            outputArgs: outputArgs.join(' '),
            duration: convertSecToTime(duration),
            elapsedTime: convertSecToTime(elapsed),
            averageSpeed: duration > 0 ? Math.floor(duration / elapsed) + 'x' : 'N/A',
            useCodec, cutSecond,
            subtitlesIncluded: hasValidSubtitles
        };
        
        if (isError) {
            console.error('Error code:' + code, logs);
            process.exit(code);
        } else {
            console.log('Successfully encoded:', logs);
        }
    });

    child.on('error', error => {
        console.error('Spawn error:', error);
        process.exit(1);
    });

    process.on('SIGINT', () => child.kill('SIGINT'));
})();

// すべての字幕ストリームを検出する包括的な関数
function detectAllSubtitleStreams() {
    try {
        const options = [
            '-v', 'error',
            '-select_streams', 's',
            '-show_entries', 'stream=index,codec_name,codec_type,tags:stream_tags:stream_tags=language',
            '-of', 'json',
            getEnv('INPUT')
        ];
        
        const result = execFileSync(getEnv('FFPROBE'), options, { encoding: 'utf8' });
        const info = JSON.parse(result);
        const subtitleStreams = [];
        
        if (info.streams && info.streams.length > 0) {
            // すべての字幕ストリームを対象とする(コーデック名に関わらず)
            for (const stream of info.streams) {
                if (stream.codec_type === 'subtitle') {
                    const lang = stream.tags && stream.tags.language ? stream.tags.language : 'unknown';
                    subtitleStreams.push(stream.index);
                    console.log(`Found subtitle stream: index=${stream.index}, codec=${stream.codec_name}, language=${lang}`);
                    
                    // arib_captionの場合は特別にログ出力
                    if (stream.codec_name === 'arib_caption') {
                        console.log(`  ARIB caption stream detected: index=${stream.index}`);
                    }
                }
            }
        }
        
        console.log('All subtitle streams found:', subtitleStreams);
        return subtitleStreams;
    } catch (error) {
        console.error('Error detecting subtitle streams:', error.message);
        return [];
    }
}

// 字幕の設定を取得
function getSubTitlesArg() {
    const fix = [];
    const map = [];
    const fileName = getEnv('NAME');
    const isSub = /\[字\]/.test(fileName);

    console.log('Subtitle detection:', { fileName, isSub });

    // [字]がある場合のみ字幕処理を実行
    if (isSub) {
        // libaribb24が利用可能かチェック
        const hasLibaribb24 = checkLibaribb24Availability();
        
        if (hasLibaribb24) {
            fix.push('-fix_sub_duration');
            console.log('libaribb24 is available, using -fix_sub_duration');
        } else {
            console.log('libaribb24 is not available, will try without special subtitle processing');
        }
        
        // 複数の方法で字幕ストリームを検出
        const subtitleStreams = detectAllSubtitleStreams();
        console.log('All detected subtitle streams:', subtitleStreams);
        
        if (subtitleStreams.length > 0) {
            // 検出されたすべての字幕ストリームをマップ
            for (let i = 0; i < subtitleStreams.length; i++) {
                map.push('-map', `0:${subtitleStreams[i]}?`);
                map.push(`-c:s:${i}`, 'mov_text');
                map.push(`-metadata:s:${i}`, 'language=jpn');
            }
            console.log('Mapped subtitle streams:', map);
        } else {
            console.log('No subtitle streams found, trying fallback method');
            
            // フォールバック: すべての字幕ストリームをマップ
            map.push('-map', '0:s?');
            map.push('-c:s', 'mov_text');
            map.push('-metadata:s:0', 'language=jpn');
        }
    } else {
        console.log('No [字] tag in filename, skipping subtitle processing');
    }

    return { fix: fix, map: map, isSub: isSub };
}

// libaribb24の利用可能性をチェック
function checkLibaribb24Availability() {
    try {
        // 方法1: ffmpegのビルド設定を確認
        const buildconfOptions = ['-buildconf'];
        const buildconfResult = execFileSync(getEnv('FFMPEG'), buildconfOptions, { encoding: 'utf8' });
        const hasInBuildconf = buildconfResult.includes('--enable-libaribb24');
        
        // 方法2: コーデックリストを確認
        const codecsOptions = ['-codecs'];
        const codecsResult = execFileSync(getEnv('FFMPEG'), codecsOptions, { encoding: 'utf8' });
        const hasInCodecs = codecsResult.includes('arib_caption') && codecsResult.includes('libaribb24');
        
        console.log(`libaribb24 detection - Buildconf: ${hasInBuildconf}, Codecs: ${hasInCodecs}`);
        
        // どちらかで検出されたら利用可能と判断
        return hasInBuildconf || hasInCodecs;
    } catch (error) {
        console.error('Error checking libaribb24 availability:', error.message);
        return false;
    }
}

// 補助関数
function isTs() {
    const reg = new RegExp(epgsConfig.recordedFileExtension + '$');
    return (getEnv('INPUT').match(reg) !== null)
}

function getAnalyze() {
    return ['-analyzeduration', '10M'];
}

function getFFprobe(showOptions) {
    try {
        const options = [].concat(getAnalyze(), '-v', '0', showOptions, '-of', 'json',  getEnv('INPUT'));
        const stdout = execFileSync(getEnv('FFPROBE'), options);
        return JSON.parse(stdout);
    } catch (error) { 
        console.error('getFFprobe error:', error.message);
        return null;
    }
}

function convertTimeToSec(time) {
    const times = time.split(':');
    return parseFloat(times[0]) * 3600 + parseFloat(times[1]) * 60 + parseFloat(times[2]);
}

function convertSecToTime(second) {
    const date = new Date(0);
    date.setSeconds(second);
    return date.toISOString().substring(11, 19);
}

// 実際の音声ストリームインデックスを取得する関数
function getActualAudioStreamIndices() {
    try {
        const options = [
            '-v', 'error',
            '-select_streams', 'a',
            '-show_entries', 'stream=index,codec_name,channels,bit_rate,sample_rate',
            '-of', 'json',
            getEnv('INPUT')
        ];
        
        const result = execFileSync(getEnv('FFPROBE'), options, { encoding: 'utf8' });
        const info = JSON.parse(result);
        const audioIndices = [];
        
        if (info.streams && info.streams.length > 0) {
            console.log('All audio streams found:');
            for (const stream of info.streams) {
                console.log(`  Stream #${stream.index}: ${stream.codec_name}, ${stream.channels} channels, ${stream.bit_rate} bitrate, ${stream.sample_rate} Hz`);
                audioIndices.push(stream.index);
            }
        } else {
            console.log('No audio streams found, using default stream 0');
            audioIndices.push(0);
        }
        
        return audioIndices;
    } catch (error) {
        console.error('Error getting actual audio stream indices:', error.message);
        // エラー時は安全策として[0]を返す
        return [0];
    }
}

// 音声の設定を取得する関数
function getAudioArgs() {
    const fileName = getEnv('NAME');
    const audioComponentType = parseInt(getEnv('AUDIOCOMPONENTTYPE'), 10);
    const isDualMono = audioComponentType == 2;
    const isBilingual = /\[二\]/.test(fileName);
    const isExplanation = /\[解\]/.test(fileName);
    const isMultiAudio = /\[多\]/.test(fileName);
    const args = [];
    
    console.log('Audio component type:', audioComponentType, 'isDualMono:', isDualMono, 'isBilingual:', isBilingual, 'isExplanation:', isExplanation);

    // 実際の音声ストリームインデックスを取得
    let actualAudioIndices = getActualAudioStreamIndices();
    
    // 必要な音声ストリーム数を決定
    let needAudioCount = 1;
    if ((isBilingual || isExplanation || isMultiAudio) && !isDualMono) {
        needAudioCount = 2;
    }
    
    // 実際のストリーム数と必要なストリーム数の小さい方を採用
    const audioCount = Math.min(actualAudioIndices.length, needAudioCount);
    console.log('Using audio streams:', audioCount, 'out of', actualAudioIndices.length, 'available');

    // 音声多重放送(デュアルモノ) - audioComponentTypeが2の場合のみ処理
    if (isDualMono && actualAudioIndices.length >= 1) {
        console.log('Processing as dual mono');
        // 左右チャンネルを分割して2つのモノラルストリームを作成
        args.push('-filter_complex', `[0:${actualAudioIndices[0]}]channelsplit=channel_layout=stereo[left][right]`);
        // 左チャンネル(日本語)
        args.push('-map', '[left]');
        // 右チャンネル(英語)
        args.push('-map', '[right]');
        // メタデータ設定(日本語→英語の順序)
        args.push('-metadata:s:a:0', 'language=jpn');
        args.push('-metadata:s:a:1', 'language=eng');
        // 音声コーデック設定(libfdk_aacを維持)
        args.push('-c:a', 'libfdk_aac');
        args.push('-b:a:0', '192k');
        args.push('-b:a:1', '192k');
        args.push('-ac:0', '1');
        args.push('-ac:1', '1');
        return { args: args };
    }

    // 通常の音声処理
    console.log('Processing as normal audio');
    
    // 音声マッピング - 取得したインデックスを使ってマップ(オプショナルマッピング)
    for (let i = 0; i < audioCount; i++) {
        const streamIndex = actualAudioIndices[i];
        args.push('-map', `0:${streamIndex}?`); // オプショナルマッピング
        console.log(`Mapping audio stream: 0:${streamIndex}?`);
    }
    
    // メタデータ設定 - 実際にマップされたストリームにのみ設定
    for (let index = 0; index < audioCount; index++) {
        let lang = 'jpn';
        if (audioCount > 1) {
            // 二ヶ国語放送の場合のみ2番目の音声を英語に設定
            if (isBilingual && index === 1) {
                lang = 'eng'; // 二ヶ国語放送の副音声は英語
            } else if (isExplanation || isMultiAudio) {
                lang = 'jpn'; // 解説放送や多重放送は日本語
            }
        }
        args.push(`-metadata:s:a:${index}`, `language=${lang}`);
    }

    // 音声エンコード設定(libfdk_aacを維持)
    args.push('-c:a', 'libfdk_aac');
    args.push('-b:a', '192k');
    args.push('-ac', '2');

    return { args: args };
}

// 入力ファイルのすべてのストリーム情報を表示する関数
function debugAllStreams() {
    try {
        const options = [
            '-v', 'error',
            '-show_streams',
            '-of', 'json',
            getEnv('INPUT')
        ];
        
        const result = execFileSync(getEnv('FFPROBE'), options, { encoding: 'utf8' });
        const info = JSON.parse(result);
        
        console.log('=== DEBUG: All streams in input file ===');
        if (info.streams && info.streams.length > 0) {
            for (const stream of info.streams) {
                const lang = stream.tags && stream.tags.language ? stream.tags.language : 'unknown';
                console.log(`  Stream #${stream.index}: type=${stream.codec_type}, codec=${stream.codec_name}, lang=${lang}`);
                
                // 詳細情報
                if (stream.codec_type === 'video') {
                    console.log(`    Resolution: ${stream.width}x${stream.height}, duration: ${stream.duration}`);
                } else if (stream.codec_type === 'audio') {
                    console.log(`    Channels: ${stream.channels}, sample_rate: ${stream.sample_rate}`);
                } else if (stream.codec_type === 'subtitle') {
                    console.log(`    Subtitle details available`);
                }
            }
        } else {
            console.log('  No streams found');
        }
        console.log('=== DEBUG END ===');
    } catch (error) {
        console.error('Error debugging streams:', error.message);
    }
}

// https://note.com/leal_walrus5520/n/n74a7c7561d43
// Time stamp: 2025/10/05

第2作業フォルダ~/work2 (第13回)については、act.shとenc.jsはそのままコピーでよい。


enc01.sh

takya@fearow22:~/work$ cat enc01.sh
WORKDIR="$HOME/work/"
OUTDIR="$HOME/media/"
NHK1="GR26"
NHK2="GR27"
NTFY_URL="https://fearow22.taile153a7.ts.net/PX-S1UD"
#CMCUT=false #CMカットしないなら false

trim() {
    local INPUT="$1"
    local TRIMFILE="$2"
    local OUTPUT="$3"

    # 地上波のfpsを指定(例: 29.97)
    FPS=29.97

    # jls_out.txt が空なら入力をそのまま出力
    if [ ! -s "$TRIMFILE" ]; then
        echo "No trim info. Copying input to output..."
        ffmpeg -hide_banner -loglevel error -y \
               -i "$INPUT" \
               -c copy -map 0 \
               "$OUTPUT"
        exit 0
    fi

    TEMPDIR=$(mktemp -d)
    PARTS_LIST="$TEMPDIR/parts.txt"
    INDEX=0
    > "$PARTS_LIST"

    bc_calc() {
        echo "scale=6; $1" | bc -l | awk '{printf "%.6f", $0}'
    }

    # 入力に字幕ストリームがあるかチェック
    if ffprobe -v error -select_streams s -show_entries stream=index \
               -of csv=p=0 "$INPUT" | grep -q .; then
        SUB_OPT=(-c:s copy -map 0)
    else
        SUB_OPT=()
    fi

    while read -r line; do
        while [[ "$line" =~ Trim\(([0-9]+),([0-9]+)\) ]]; do
            STARTF="${BASH_REMATCH[1]}"
            ENDF="${BASH_REMATCH[2]}"

            STARTSEC=$(bc_calc "$STARTF / $FPS")
            ENDSEC=$(bc_calc "$ENDF / $FPS")
            DURATION=$(bc_calc "$ENDSEC - $STARTSEC")

            PART="$TEMPDIR/part_${INDEX}.mp4"

            #動けばよいなら
#            ffmpeg -hide_banner -loglevel error -y -i "$INPUT" -ss "$STARTSEC" -t "$DURATION" -c:v libx264 -crf 26 -preset veryfast -c:a copy -c:s copy -map 0 "$PART"
            #小メモリ環境
            ffmpeg -threads 1 -hide_banner -loglevel error -y -i "$INPUT" -ss "$STARTSEC" -t "$DURATION" -c:v h264_qsv -global_quality 20 -preset fast -c:a copy -c:s copy -map 0 "$PART"
 
        #注:vaapiは不向き

            echo "file '$PART'" >> "$PARTS_LIST"
            INDEX=$((INDEX+1))
            line=${line#*"Trim("}
        done
    done < "$TRIMFILE"

# concat で結合
    ffmpeg -hide_banner -loglevel error -y \
           -f concat -safe 0 -i "$PARTS_LIST" \
           -c copy -map 0 \
           "$OUTPUT"

    rm -rf "$TEMPDIR"
    echo "Done: $OUTPUT"
}

jls() {
    local filename="$1"
    local output_file="$2"

    # 引数チェック
    if [[ -z "$filename" || -z "$output_file" ]]; then
        echo "Usage: jls FILENAME OUTPUT_FILE" >&2
        return 1
    fi

    # 一時ファイルの作成
    local avs_file="join.avs"

    # avsファイルの作成
    cat > "$avs_file" << EOF
TSFilePath="$filename"
LWLibavVideoSource(TSFilePath, repeat=true, dominance=1)
AudioDub(last,LWLibavAudioSource(TSFilePath, av_sync=true))
EOF

    # 各処理の実行
    timeout -k 60 1800 chapter_exe -v "$avs_file" -o chap_out.txt || {
        echo "chapter_exe failed" >&2
        return 1
    }

    if [ ! -f "$GRSTRING.lgd" ]; then
        echo "Error: $GRSTRING.lgd is not found" >&2
        return 1
    fi

    logoframe "$avs_file" -logo "$GRSTRING.lgd" -oa lf_out.txt || {
        echo "logoframe failed" >&2
        return 1
    }

    join_logo_scp -inlogo lf_out.txt -inscp chap_out.txt -incmd JL_標準.txt -o "$output_file" || {
        echo "join_logo_scp failed" >&2
        return 1
    }

    # 一時ファイルのクリーンアップ(必要に応じてコメントアウト)
    # rm -f "$avs_file" chap_out.txt lf_out.txt
}


$WORKDIR/toprocess.py | while IFS= read -r FILE;
do
    cd $SOURCEDIR
    if [ -f "$FILE" ]; then
        start_time=$(date +%s)
        FILENAME=${FILE%.*}
        GRSTRING=`echo $FILENAME | sed -n 's/.*\(GR[0-9][0-9]\).*/\1/p'`
#       $WORKDIR/chkdrp.sh "$FILE" #エラーチェック
        cd $WORKDIR
        NAME=$(echo "$FILENAME" | sed 's/__.*//')
        AUDIOCOMPONENTTYPE=`./act.sh $NAME`
        ./enc.js $SOURCEDIR/"$FILE" $AUDIOCOMPONENTTYPE
        if [  -f "$FILENAME".mp4 ] && [ "$CMCUT" != "false" ] && [ "$GRSTRING" != "$NHK1" ] && [ "$GRSTRING" != "$NHK2" ]; then

            jls "$FILENAME".mp4 jls_out.txt
            trim "$FILENAME".mp4  jls_out.txt  temp$$.mp4
            if [ -s temp$$.mp4 ] ; then
                rm "$FILENAME".mp4
                mv temp$$.mp4 "$FILENAME".mp4
            else
                echo "Error: trim failed" >&2
                curl -H "X-Priority: 3" -d "ERROR: trim failed: $FILENAME" $NTFY_URL
                exit 1
            fi
            rm "$FILENAME".mp4.lwi
        fi
        if [ -s "$FILENAME".mp4 ] ; then
            ./mvjf.sh "$FILENAME".mp4 $OUTDIR
            curl -H "X-Priority: 2" -d "mp4 created: $FILENAME" $NTFY_URL
        else
            echo "ERROR: mp4 not created" >&2
            curl -H "X-Priority: 3" -d "ERROR: mp4 not created: $FILENAME" $NTFY_URL
            exit 1
        fi
        ./processed.py "$FILE" || true

        end_time=$(date +%s)
        duration=$((end_time - start_time))
        minutes=$((duration / 60))
        seconds=$((duration % 60))
        echo "RUN TIME; $minutes min $seconds sec"
    fi
done

こちらは、第2作業フォルダ~/work2にコピーする際に、冒頭の変数を適宜変更する。

CMカットが不要ならば、CMCUT=falseで全局が今回の仕様(多重化)になる。

Jellyfin

右上の人マークの個人設定の「字幕」から文字の大きさ、影付け、垂直位置などを調整する。


おわりに

2段目のトランスコード(trim)は、ハードウェア的に非力なPCでは辛くなる。2段目のせいで作業時間が2〜3倍になるので、encode.sh の変数speedを適宜変更する。

Error while filtering: Cannot allocate memory
Failed to inject frame into filter network: Cannot allocate memory

こんなエラーを吐きながらも、とりあえず動いてはいる。愛機をいじめまくってみて、こうしてハードウェアトランスコードの泥沼にはまっていくのか、と痛感している。


参考メモ:字幕ストリームを filter_complex で切り貼りすることはできない

おまけ:こんな感じ


索引

過去回の概要一覧

第1回:はじめに。準備が必要なもの

おすすめPCなど

第2回:Ubuntu OS インストール。はじめてのlinux

新PCが使えるようになる

第3回:デバイス認識。EPGStation, Mirakurunインストール

テレビ録画できるようになる

第4回:AviSynth, chapter_exe, logoframe, join_logo_scp, ffmpeg

昔風のインストール作業

第5回:CMカット。ロゴファイル作成

市民的自由

第6回:作業フォルダ、メディアフォルダ。Jellyfin インストール

視聴環境が整う

第7回:処理済み判定、番組名でフォルダ整理

随時更新中

第8回:空き時間に編集自動化

全自動化完了

第9回:録画ルール設定、処理済み動画自動削除

コンセプトと使用方法

第10回:スマホに自動通知

ちゃんと動いているか不安になるので

第11回:使用雑感。不具合対策。再起動法


感想とか

第12回:外部データベース連携。TMDB登録方法

見た目がよくなる

第13回:ライブラリ追加「映画」

ますます、カッコよくなる

第14回:ダッシュボード(ホームページ)

スマホで便利に

第15回:ネットダウンローダー

TVerとか、youtubeとか

第16回:ハードウェアアクセラレーション

動画処理効率化

第17回:スマートテレビ

リビングの大画面で

第18回:外部ストレージ増設

録画マニアへの入り口

第19回:トラブルシューティング

戦いは続く

第20回:イントロスキップ

豊富なプラグインで、ますます便利に

いいなと思ったら応援しよう!

ピックアップされています

全自動TV録画 視聴環境構築

  • 26本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
予約録画、カット編集、番組整理を全自動化し、複数デバイスで手軽に視聴可能な、自分だけのメディアサーバーを、ゼロから作り上げる過程の全記録。誰でもたどれるよう必要なすべてを書き残す。https://github.com/takyaO/epg2jelly/
テレビ録画メディアサーバー構築入門(第21回)|takya
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1