この記事はTouchDesigner Advent Calendar 2021の24日目の記事です。
自分はスポーツ中継などのCG開発をTouchDesignerでやっています。
野球中継などで画面の下に選手名や得点が表示されているアレです。
グラフィックの技術は割と地味なので、それ以外の箇所を深堀りしてみます。
構成
UIはReact.js、バックエンドはNode.js、グラフィックをTouchDesignerでという構成が多いです。
ブラウザでデータを入力するとテキストファイルが更新されて、Table DAT経由でグラフィックが更新される形です。
要件
いわゆるスポーツ中継などの案件は、
- 機材の設営は前日か当日に行う
- 試合前に選手情報などを流し込み、試合中は公式ウェブサイトや現地映像を見ながらデータ入力する
- 映像に合わせてテンポよくCGを表示する
- データ入力を行うスタッフは1人ではない(得点を入力するスタッフ、選手情報を入力するスタッフ)
- Video Device OutでFill/Keyを出力してキーヤーで合成してもらう
- 役割によって複数機材から複数系統でCGを出力する(得点を表示させるスタッフと選手名を表示させるスタッフは別)
- 案件によってはディレクターさんが指示したタイミングでCGを出せないと死ぬ
という感じです。
もちろん死ぬのは冗談ですが、CGのOn/Offをキーボードで切り替えており、TouchDesignerのウインドウが非アクティブになっていると効かないので、バックグラウンドでもキーボード入力を受け付けるSticky Keyboard In CHOPを書きました。便利です。
難しい点
- 複数の人間が入力したデータ(選手情報や得点など)を複数の機材で共有する必要がある
- 設営やオペレーションを行うスタッフと開発者は別人である
- 現場によるが既設のネットワーク環境(インターネットも)がない
課題
当初はブラウザから普通にHTTPでTouchDesignerのWeb Server DATを叩いていました。
たとえば選手交代のときは、Web Server DATにどのチームのどの選手が誰々と交代しましたよ、というのをHTTPで投げます。複数の機材がある時は、2台に向かってHTTPで投げてました。
しかし、年間何度もやっていると、なんだかネットワークの調子が悪いときがあります。
こっちの機材ではデータが更新されてるのに、こっちには反映されないとか、そもそも応答がなくなるというような感じです。
原因は様々ですが、本番中に原因を探るのが非常に難しいですので、データ共有にHTTPというかTCPを使うのをやめることにしました。
解決方法
データの入力を受け付ける機材をメイン、データ共有を受ける機材をワーカーと呼びます。
- React.jsで入力されたデータはバックエンドのNode.jsでタブ区切りのテキストファイルに保存する
- fs.watchでそのディレクトリを監視し、変更があったら該当ファイルをreadして、シリアルポートから出力する(ファイル名と内容を\x1dで区切ったもの)
- ワーカーはシリアルポートからの入力があったらテキストファイルを更新する
- メインもワーカーもTouchDesignerでデータ参照するときはTable DATでSync to Fileにチェックを入れて使う
この方法ですと、TCPのような軟弱な仕組みに頼らなくても物理線で2台の機材がSyncされます。
しかもTouchDesignerとは関係なくバックグラウンドで同期していますので、TouchDesignerのプロジェクトでは何も考えずにデータを参照するだけで良いです。
さらにシリアル通信のメリットとしては、自分はRS422のG/TXD+/TXD-をXLR3に変換して普通の音声ケーブルで接続しているので、パラボックスなどで、いくつでも好きなだけ分配出来ます。やったことはありませんが10台でデータ共有なども余裕(なはず)です。距離も理論上1.2kmまで伸ばすことが出来ます。
ここまで、全くTouchDesigner感がありませんが、参考までにNode.jsのコードを載せておきます。
複数の入力のタイミングがかぶるとシリアル通信はまずいので、全てasync.queue経由でキューの管理をしています。
実際のコードからの抜粋です。
メリークリスマス
const fs = require("fs")
const { readFile } = require("fs/promises")
const async = require("async")
const SerialPort = require("serialport")
const serialport = new SerialPort(process.env.SERIALPORT, {
baudRate: 806400,
})
const data_dir = "../data"
const read_file = async (path) => await readFile(`${data_dir}/${path}`, "utf8")
const queue = async.queue(async (task) => {
const ports = await SerialPort.list()
if (ports.find((p) => p.path === process.env.SERIALPORT)) {
const payload = `\x02${task.path}\x1d${task.data}\x03`
serialport.write(payload, (err) => {
if (err) console.log(err)
})
}
}, 1)
const fs = require("fs")
const async = require("async")
const SerialPort = require("serialport")
const Readline = require("@serialport/parser-readline")
const serialport = new SerialPort(process.env.SERIALPORT, {
baudRate: 806400,
})
const parser = serialport.pipe(new Readline({ delimiter: "\x03" }))
const data_dir = `../data-worker`
const queue = async.queue((task, executed) => {
const tasksRemaining = queue.length()
fs.open(`${data_dir}/${task.path}`, "w", (err, fd) => {
if (err) return executed(err, { task, tasksRemaining })
fs.writeFile(fd, task.data, (err) => {
if (err) return executed(err, { task, tasksRemaining })
fs.close(fd, (err) => {
if (err) return executed(err, { task, tasksRemaining })
return executed(null, { task, tasksRemaining })
})
})
})
}, 1)
parser.on("data", (payload) => {
if (!payload.match(/^\x02/)) return
payload = payload.replace(/^\x02/, "")
const vals = payload.split(/\x1d/)
const path = vals.shift()
const data = vals.shift()
const task = { path, data }
queue.push(task, (err, { task, remaining }) => {
if (err) {
console.log(`[Error] An error occurred while processing task: ${path}`)
}
})
})