画面外字幕のスクリプト その後

自動改行の文字送りに泣いたので、自前の禁則処理

コーディングのポイント:

  • vttに収録されている文字列をTinySegmenterで分かち書きにする

  • 1行あたりの文字数と比べて、次の節を足すと超えるときは<br>をはさむ

  • 禁則文字の有無を調べ配列の要素を書き換える

    • 行頭禁止の場合は前の要素の末尾に付け加える
    • 行末禁止の場合は次の要素の先頭につける

画面外字幕を表示するdivのスタイル

.display-cues {
    width: 100%;
    height: 160px !important;
    background: whitesmoke;
    padding: 0px 10px !important;
    overflow-y: scroll;
    text-align: center;
    color: gray;
    border-style: solid;
    border-width: 0px 1px 1px 1px !important;
    border-color: #dddddd;
    outline: none;
}
.display-cues p {
    line-height: 20px !important;
    font-size: 16px !important;
    letter-spacing: 1px !important;
    margin: 0px !important;
    margin-top: 20px !important;
    white-space: nowrap !important;
    overflow-x: hidden !important;
    cursor: pointer;
}
/* <p>にスクロールバーを表示させない */
#display-cues-1 p {
  -ms-overflow-style: none; /* for Internet Explorer, Edge */
  scrollbar-width: none; /* for Firefox */
  overflow-y: scroll;
}
#display-cues-1 p::-webkit-scrollbar {
  display: none; /* for Chrome, Safari, and Opera */
}
.cue-active {
    background-color: white;
    color: black;
}

スクリプト

// 字幕トラックの読取りと画面下に表示
// https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/mode
/* 2024.01.03
 * Firefox系のブラウザで起こる縦揺れを避けるために、jQueryを使わないように書き直した
 *
 * 2024.04.14
 * 半角を考慮した全文字数を1行あたりの文字数で割った余りがゼロの場合、予期せぬ改行が起こり、
 * スクロール量が足りない
 *
 * 2024.05.04
 * 同じ条件でも行末ラップで2行になったり1行だったり(´・ω・`)
 *
 * 2024.05.06
 * 分かち書き後の節を積み上げて改行を制御するように書き換える
 */

// display-cues-1にメッセージを表示
document.getElementById('display-cues-1').innerHTML = '<div style="padding-top: 20px"><p>* * * 読込中... * * *</p></div>'

/* ページ読込完了を待って実行
 * 動画の進行、あるいは離れたキューのクリックによって
 * 話されている訳文が字幕コンテナのトップに表示されるようにする
 */
window.onload = function () {
    // vttデータからタイムコードとテキストを抽出するための準備
    const video = document.querySelector("video")
    const jContainer = document.getElementById('display-cues-1')
    const track = document.getElementById('jtrack').track
    track.mode = "hidden"
    const cues = track.cues

    let cuesTime = []
    let cuesText = []
    // cuesからデータをプッシュ
    for ( let c=0; c < cues.length; c++) {
        cuesTime.push([cues[c].startTime, cues[c].endTime])
        cuesText.push(cues[c].text)
    }

    /* 禁則処理が必要な文字の配列
     * kinGyoStartに該当した場合は、前の行の行末にくっつける
     * kinGyoEndに該当した場合は、次の行の行頭に回す
     */
    const kinGyoStart = ['!', '%', ')', ',', '.', ':', ';', '?', ']', '}', '¢', '°', '’', '”', '‰', '′',
                         '″', '℃', '、', '。', '々', '〉', '》', '」', '』', '】', '〕', 'ぁ', 'ぃ', 'ぅ', 'ぇ', 'ぉ',
                         'っ', 'ゃ', 'ゅ', 'ょ', 'ゎ', '゛', '゜', 'ゝ', 'ゞ', 'ァ', 'ィ', 'ゥ', 'ェ', 'ォ', 'ッ',
                         'ャ', 'ュ', 'ョ', 'ヮ', 'ヵ', 'ヶ', '・', 'ー', 'ヽ', 'ヾ', '!', '%', ')', ',',
                         '.', ':', ';', '?', ']', '}', '。', '」', '、', '・',
                         'ァ', 'ィ', 'ゥ', 'ェ', 'ォ', 'ャ', 'ュ', 'ョ', 'ッ', 'ー゙゚', '¢']
    const kinGyoEnd =   ['$', '(', '[', '¥', '{', '£', '¥', '‘', '“', '〈', '《', '「', '『', '【', '〔', '$',
                         '(', '[', '{', '「', '£', '¥']

    // 個々の字幕文字列を分解してつなぎ直すための準備
    let segmenter = new TinySegmenter()
    let segs, jimakuBun, returnMe, brChk, jiKaz, gyoKaz, kekka, newSegs

    // それぞれの<p>に属性をつけるための準備、スクロール量の変数
    let tStart, tEnd, txt, txtPrev, Tx, txtTagRmvd, scAmt, scAmtAccum
    let cueP = ''

    // デバッグ用
    let tcDebug

    // jContainerの幅によって1行に表示できる文字数が可変なので最初に計算
    const jConW = jContainer.clientWidth - 22     // 左右のパディングとボーダー分
    const charPerLine = parseInt(jConW / (16+1)) - 4

    // 半角は0.5文字として数える
    const mojiCount = function (jStr) {   //無名関数
        let len = 0
        for ( let j=0; j < jStr.length; j++) {
            if ( jStr[j].match(/[ -~]/) ) {
                len += 0.5
            } else {
                len += 1
            }
        }
        return len
    }

    // 表示に要した行数と整形済みの文字列を返す
    const brInserted = function (segs) {
        // segs: htmlタグ部分を抜いた文字列を分かち書きした配列
        // orgTxt: 話者識別用に<span></span>で色指定している情報を取るため

        /* part1: 禁則文字の有無を調べ配列の要素を書き換える
         * 行頭禁止の場合は前の要素の末尾に付け加える
         * 行末禁止の場合は次の要素の先頭につける
         * 当該要素はマーカーで書き換えて次のステップで無視する
         */
        for (let k=0; k < segs.length; k++) {
            if ( k != 0 && kinGyoStart.includes(segs[k]) ) {
                segs[k-1] = segs[k-1] + segs[k]
                segs[k] = "👻"
            }
            if ( k != segs.length -1 && kinGyoEnd.includes(segs[k]) ) {
                segs[k+1] = segs[k] + segs[k+1]
                segs[k] = "👻"
            }
        }
        newSegs=[]
        for (let l=0; l < segs.length; l++) {
            if (segs[l] != "👻") {
                /* 禁則文字が続くとオバケが残る場合があるので消しておく */
                newSegs.push(segs[l].replace("👻", ""))
            }
        }

        /* part2: <br>をはさむ
         * 後続の節がまだあり、その文字数を足せば1行あたりの文字数を超えてしまう場合は
         * <br>を付け足して、累計をゼロにする
         */
        returnMe = ''
        jiKaz = 0
        for (let m=0; m < newSegs.length; m++) {
            if ( m != newSegs.length-1 && jiKaz + mojiCount(newSegs[m+1]) >= charPerLine) {
                returnMe = returnMe + newSegs[m] + '<br>'
                jiKaz = 0
            } else {
                returnMe = returnMe + newSegs[m]
                jiKaz = jiKaz + mojiCount(newSegs[m])
            }
        }
        // returnMeが<br>で終わっている時の処理
        if (returnMe.endsWith('<br>')) {
            returnMe = returnMe.slice(0, -4)
        }
        // returnMeに含まれる<br>の数
        brChk = returnMe.match(/<br>/g)
        if ( brChk == null ) {
            gyoKaz = 1
        } else {
            gyoKaz = brChk.length + 1
        }

        /* part3: 話者式別用<span>を入れる
         * orgTxtから<span>部分を抜き出すには?
         * <span style="color:brown">《Announcer》</span>
         * "《"の位置を拾ってそこまでの文字列を切り出して置換
         * var cut1 = str.substr(0, str.indexOf('d'))
         * "》"は</span>を足して置換
         */

        return [returnMe, gyoKaz]
    }

    // タイムコードとスクロール量を入れて表示用文字列を作成
    for (let i = 0; i < cuesTime.length; i++) {
        tStart = cuesTime[i][0]
        tEnd = cuesTime[i][1]
        txt = cuesText[i]

        if (i==0) {
            txtPrev = ''
            tStartPrev = 0
        } else {
            txtPrev = cuesText[i-1]
            tStartPrev = cuesTime[i-1][0]
        }

        // htmlのタグ部分を除去
        txtTagRmvd = txt.replace(/(<([^>]+)>)/gi, '')

        // デバッグ用タイムコード文字列
        function secToTC(given_seconds) {
            let hours, minutes, seconds, timeString
            hours = Math.floor(given_seconds / 3600)
            minutes = Math.floor((given_seconds - (hours * 3600)) / 60)
            seconds = Math.trunc(given_seconds - (hours * 3600) - (minutes * 60))
            timeString = minutes.toString().padStart(2, '0') + ':' +
                seconds.toString().padStart(2, '0')
            return timeString
        }
        tcDebug = secToTC(tStart)

        /* txtTagRmvdを分かち書き
         * 必要に応じて<br>挿入
         * 話者の区別の色を復活させる <--- なくてもいいことにする
         * スクロール量計算、禁則処理
         */
        segs = segmenter.segment(txtTagRmvd)
        //kekka = brInserted(segs, txt) 話者別<span>はなくてもいいのでオリジナル不要
        kekka = brInserted(segs)

        jimakuBun = kekka[0]
        reqdGyo = kekka[1]  // 戻り値は<br>の出現回数+1

        // スクロール量の計算
        if ( i==0 ) {
            scAmtAccum = 0
        } else {
            scAmtAccum = scAmtAccum+scAmt
        }
        scAmt = reqdGyo*20+20 // テキスト表示で消費するピクセル数:(行数x20)+ トップマージン

        //cuesTimeの要素にスクロール位置を追加
        cuesTime[i].push(scAmtAccum)

        // パラグラフを足し込んで1本の文字列にする
        Tx = '<p id="q' + i +
             '" onclick="jumpToTime(' +
              tStart + ',' +
              scAmtAccum +
             ', q' + i +
             ')">' + jimakuBun + '</p>'
        cueP = cueP + Tx

    /* 動作チェック
    console.log("【" + tcDebug + "】", reqdGyo, scAmt, scAmtAccum, jimakuBun, mojiCount(txtTagRmvd))
    console.log("................................................")
     */

    }

    // 字幕文字列をコンテナに収納
    jContainer.innerHTML = cueP

    // 動画の進行につれてスクロール、現行の字幕が動画直下に来る
    let jiStart, jiEnd, jiPrev, vCurrent, qid, qCurrent
    jiPrev = 0
    video.ontimeupdate = function() {
        vCurrent = video.currentTime
        imaDoko(vCurrent)
        function imaDoko(vCurrent) {
            for (var t = 0; t < cuesTime.length; t++) {
                jiStart = cuesTime[t][0]
                jiEnd = cuesTime[t][1]
                if ( vCurrent > jiStart && vCurrent < jiEnd ) {
                    if (jiPrev != jiStart) {
                        // 全部のcueをチェックしてアクティブを外す
                        removeActive()
                        // 次のキューをトップに
                        jContainer.scrollTo(0, cuesTime[t][2])
                        // このキューにアクティブをマーク
                        qid = "q"+t
                        qCurrent = document.getElementById(qid)
                        qCurrent.classList.add("cue-active")
                        jiPrev = jiStart
                        break
                    }
                }
            }
        }
    }

    // クリックで飛んだところから再生
    jumpToTime = function (t, pos, qid) {
        // 全部のcueをチェックしてアクティブを外す
        removeActive()
        // クリックされた行をコンテナのトップに
        video.currentTime = t
        jContainer.scrollTo(0, pos)
        // アクティブクラスにする
        qid.classList.add("cue-active")
    }

    // 既存のcue-activeクラスを外す
    function removeActive() {
        for (var z=0; z < cuesTime.length; z++) {
            let q = "q"+z
            let Q = document.getElementById(q)
            if (Q.classList.contains("cue-active")) {
                Q.classList.remove("cue-active")
            }
        }
    }

    // ビデオのバッファリングが終わったらリロードしてコンテナに字幕を表示
    video.canplaythrough = function() {
        window.location.reload()
    }
}


関連記事