ニコニコ動画の FLV ファイルを音質劣化なしで YouTube 用 FLV ファイルに変換するシェル・スクリプト

2008-03-19 追記

  • どんくさいミス (シェル・スクリプトなのに数値比較を不等号でやってた) を修正。
  • FFmpeg のサイズ指定はパッド部分を含まないものなのに含めてたのを修正。
  • FFmpegエンコード・オプションにクロップ処理をつけ忘れてたのを修正。

改良した

なんとなくタイトルを内容にそったものに変えたけど、"YouTube に高音質でアップロードできる動画ファイルを作るシェル・スクリプト (改良 MEncoder 版) - だらりな。" の改良版。
ニコニコ動画がついに MP4 (H.264 + AAC) 対応し ("http://blog.nicovideo.jp/niconews/2008/03/000956.html")、YouTube も同様に対応しはじめてる ("YouTube Videos in High Quality") 今日このごろ、余命は短いけどちまちまといじってたものをメモがわりに残しておく。
以下、改良点。

  • MEncoder 版と FFmpeg 版にわけてたのをひとつに統合。設定でどちらのエンコーダを使うか選べるようにした。ちょっとすっきり。
  • 設定やファイルの妥当性をチェックするようにした。
  • 元動画ファイルと外部の音声ファイルで長さがちがう場合、どこで合わせるか指定できるようにした。
  • awk の使いかたをちょっと憶えたので、どんくさいハックはやめて小数を扱う計算を awk でやるようにしたりした*1。すっきり。
  • 元動画ファイルのアスペクト比を強制指定できるようにした。アスペクト比の狂った映像用。
  • 元動画ファイルの黒枠を自動でクロップできるようにした。
  • 指定した FPS が元動画ファイルの FPS より大きい場合、自動で元動画ファイルの FPS に合わせるようにしたり、柔軟に FPS を指定できるようにしたりした。
  • MEncoder を使う場合、エンコードのパス数を指定できるようにした。
  • FFmpeg のバグが修正された*2ので、FFmpeg を使う場合、MEncoder を併用しなくていいようにした。すっきり。

たぶんこれくらい。
エンコード例も貼っておく。オリジナルは "【初音ミク】ソニック☆サイクラー【オリジナル】 - ニコニコ動画"。

これ、ニコニコ動画の FLV ファイルだと 512 x 389 になってて (ふつうは 512 x 384)、アスペクト比を保ったまま 4:3 にしようとすると左右にも黒帯がついちゃって、なんだかみっともないから、このために自動でクロップできるようにしたのだった。zoome の MP4 ファイルを使えばいいというのはあとで気づいた。Stage 6 に高画質版が公開されてたというのを知ったときには、Stage 6 はもう店じまいしてた。
ところで、FFmpeg はいろいろ不親切。たとえば、映像サイズが縦横それぞれ偶数じゃないとエラーを吐く。MEncoder は勝手に良きにはからってくれるのに。
あとは、音声もコピーだけじゃなくエンコードもできるようにしよう。

ちょっとどんくささの減ったシェル・スクリプト

awk のおかげでみっともなさがちょっと減った。

#! /bin/sh
#
# nicovideo2youtube.sh
#
# Encode nicovideo.flv to youtube.flv.

MSG_PREFIX="[nicovideo2youtube] "

#CONF_FILE=${HOME}/.nicovideo2youtube.conf
CONF_FILE=${HOME}/bin/nicovideo2youtube.conf
[ ! -r ${CONF_FILE} ] && { echo "${MSG_PREFIX}ERROR: Can't read \"${CONF_FILE}\"." >&2; exit 1; }

source ${CONF_FILE}

[ ! ${ENCODER} ] && ENCODER=MENCODER
[ ${ENCODER} != "FFMPEG" ] && ENCODER=MENCODER
echo "${MSG_PREFIX}ENCODER: ${ENCODER}"

[ ! ${TOTAL_BITRATE} ] && { echo "${MSG_PREFIX}ERROR: Please set TOTAL_BITRATE." >&2; exit 1; }

INPUT_FILE=`echo $1 | sed -e 's/"/\\"/g' -e 's/!/\\!/g'`
[ ! -r "${INPUT_FILE}" ] && { echo "${MSG_PREFIX}ERROR: Can't read \"${INPUT_FILE}\"." >&2; exit 1; }

OUTPUT_FILE=`basename "${INPUT_FILE}" | sed "s/\w\+$/forYouTube.flv/"`
#[ -f "${OUTPUT_FILE}" ] && {echo "${MSG_PREFIX}ERROR: \"${OUTPUT_FILE}\" already exists." >&2; exit 1; }

INPUT_AUDIO_FILE=`echo "${INPUT_FILE}" | sed "s/\w\+$/mp3/"`
[ -r "${INPUT_AUDIO_FILE}" ] && echo "${MSG_PREFIX}Found \"${INPUT_AUDIO_FILE}\"." || INPUT_AUDIO_FILE="${INPUT_FILE}"

#
# Probe
#

probe () {
	eval `mplayer -vo null -ao null -frames 0 -identify "$1" 2> /dev/null | awk '/^=+$/ { exit; } { print; }' | grep -e "$2"`
}

probe "${INPUT_AUDIO_FILE}" "^ID_AUDIO_FORMAT"
[ ${ID_AUDIO_FORMAT} != "85" ] && { echo "${MSG_PREFIX}Audio Stream is not MP3."; exit 1; }
#exit 0

probe "${INPUT_FILE}" "^ID_LENGTH"
INPUT_FILE_LENGTH=${ID_LENGTH}
probe "${INPUT_AUDIO_FILE}" "^ID_LENGTH"
INPUT_AUDIO_FILE_LENGTH=${ID_LENGTH}
PROBE_DELAY=`echo ${INPUT_AUDIO_FILE_LENGTH} ${INPUT_FILE_LENGTH} | awk '{ print $1 - $2; }'`

[ ${CUSTOM_DELAY} ] && AUTO_DELAY="CUSTOM"
[ ! ${AUTO_DELAY} ] && AUTO_DELAY="NONE"
[ ${ENCODER} = "FFMPEG" ] && AUTO_DELAY="NONE"
case "${AUTO_DELAY}" in
	CUSTOM)	DELAY=${CUSTOM_DELAY};;
	FULL)	DELAY=${PROBE_DELAY};;
	HALF)	DELAY=`echo ${PROBE_DELAY} | awk '{ print $1 / 2; }'`;;
	NONE|*)	DELAY=0; AUTO_DELAY="NONE";;
esac

echo "${MSG_PREFIX}PROBE_DELAY: ${PROBE_DELAY}; AUTO_DELAY: ${AUTO_DELAY}; DELAY: ${DELAY}"
#exit 0

probe "${INPUT_AUDIO_FILE}" "^ID_AUDIO"

AUDIO_BITRATE=`echo ${ID_AUDIO_BITRATE} | awk '{ print $1 / 1000; }'`
VIDEO_BITRATE=`echo ${TOTAL_BITRATE} ${AUDIO_BITRATE} | awk '{ print int( $1 - $2 ); }'`

echo "${MSG_PREFIX}AUDIO_BITRATE: ${AUDIO_BITRATE}; VIDEO_BITRATE: ${VIDEO_BITRATE}"
[ ${VIDEO_BITRATE} -le 0  ] && { echo "${MSG_PREFIX}ERROR: TOTAL_BITRATE is too low." >&2; exit 1; }
#exit 0

probe "${INPUT_FILE}" "^ID_VIDEO"

[ ${FORCE_ASPECT} ] && { ID_VIDEO_ASPECT=${FORCE_ASPECT}; echo "${MSG_PREFIX}FORCE_ASPECT: ${FORCE_ASPECT}"; }

ORIGINAL_WIDTH=${ID_VIDEO_WIDTH}
ORIGINAL_HEIGHT=`echo ${ID_VIDEO_ASPECT} ${ID_VIDEO_HEIGHT} ${ID_VIDEO_WIDTH} | awk '{ print ( $1 == 0 ? $2 : $3 / $1 ); }'`

echo "${MSG_PREFIX}ORIGINAL_WIDTH: ${ORIGINAL_WIDTH}; ORIGINAL_HEIGHT: ${ORIGINAL_HEIGHT}"
#exit 0

[ ! ${AUTO_CROP} ] && AUTO_CROP="OFF"
if [ ${AUTO_CROP} = "ON" ]; then
	echo "${MSG_PREFIX}AUTO_CROP: ${AUTO_CROP}"
	DETECTED_CROP=`mplayer -benchmark -vo null -nosound -vf cropdetect=16 "${INPUT_FILE}" 2> /dev/null | \
		grep -e "-vf crop" | tail -n 1 | sed -e "s/.*(-vf\ crop=//" -e "s/)\..*//"`
	VF_CROP="crop=${DETECTED_CROP},"
	CROPPED_WIDTH=`echo ${DETECTED_CROP} | awk -F : '{ print $1; }'`
	CROPPED_HEIGHT=`echo ${DETECTED_CROP} | awk -F : '{ print $2; }'`
	CROPPED_HEIGHT=`echo ${ID_VIDEO_ASPECT} ${CROPPED_HEIGHT}  ${ORIGINAL_HEIGHT}  ${ID_VIDEO_HEIGHT} | \
		awk '{ print ( $1 == 0 ? $2 : $2 * $3 / $4 ); }'`

	CROPLEFT=`echo ${DETECTED_CROP} | awk -F : '{ print $3; }'`
	CROPRIGHT=`expr ${ORIGINAL_WIDTH} - ${CROPPED_WIDTH} - ${CROPLEFT}`
	CROPTOP=`echo ${DETECTED_CROP} | awk -F : '{ print $4; }'`
	CROPBOTTOM=`expr ${ORIGINAL_HEIGHT} - ${CROPPED_HEIGHT} - ${CROPTOP}`

	case "${ENCODER}" in
		MENCODER)	echo "${MSG_PREFIX}CROPPED_WIDTH: ${CROPPED_WIDTH}; CROPPED_HEIGHT: ${CROPPED_HEIGHT}";;
		FFMPEG)		echo "${MSG_PREFIX}CROPLEFT: ${CROPLEFT}; CROPRIGHT: ${CROPRIGHT}; CROPTOP: ${CROPTOP}; CROPBOTTOM: ${CROPBOTTOM}";;
	esac
	#exit 0
else
	CROPPED_WIDTH=${ORIGINAL_WIDTH}
	CROPPED_HEIGHT=${ORIGINAL_HEIGHT}
fi

if [ `echo ${CROPPED_WIDTH} ${CROPPED_HEIGHT} | awk '{ print ( ( $1 / $2 ) >= ( 4 / 3 ) ); }'` -eq 1 ]; then
	[ ! ${WIDTH} ] && WIDTH=${CROPPED_WIDTH}
	SCALE_WIDTH=${WIDTH}

	HEIGHT=`echo ${WIDTH} | awk '{ print int( $1 * 3 / 4 + 0.5 ); }'`
	SCALE_HEIGHT=`echo ${CROPPED_HEIGHT} ${WIDTH} ${CROPPED_WIDTH} | awk '{ print int( $1 * $2 / $3 + 0.5 ); }'`
else
	if [ ! ${WIDTH} ]; then
		HEIGHT=`echo ${CROPPED_HEIGHT} | awk '{ print int( $1 + 0.5 ); }'`
		WIDTH=`echo ${HEIGHT} | awk '{ print int( $1 * 4 / 3 + 0.5 ); }'`
	else
		HEIGHT=`echo ${WIDTH} | awk '{ print int( $1 * 3 / 4 + 0.5 ); }'`
	fi
	SCALE_HEIGHT=${HEIGHT}

	SCALE_WIDTH=`echo ${CROPPED_WIDTH} ${HEIGHT} ${CROPPED_HEIGHT} | awk '{ print int( $1 * $2 / $3 + 0.5 ); }'`
fi

WIDTH=`expr ${WIDTH} + ${WIDTH} % 2`
HEIGHT=`expr ${HEIGHT} + ${HEIGHT} % 2`
SCALE_WIDTH=`expr ${SCALE_WIDTH} + ${SCALE_WIDTH} % 2`
SCALE_HEIGHT=`expr ${SCALE_HEIGHT} + ${SCALE_HEIGHT} % 2`

PADTOP=`expr \( ${HEIGHT} - ${SCALE_HEIGHT} \) / 2`
PADBOTTOM=`expr ${HEIGHT} - ${SCALE_HEIGHT} - ${PADTOP}`
PADLEFT=`expr \( ${WIDTH} - ${SCALE_WIDTH} \) / 2`
PADRIGHT=`expr ${WIDTH} - ${SCALE_WIDTH} - ${PADLEFT}`

case "${ENCODER}" in
	MENCODER)	echo "${MSG_PREFIX}SCALE_WIDTH: ${SCALE_WIDTH}; SCALE_HEIGHT: ${SCALE_HEIGHT}";;
	FFMPEG)		echo "${MSG_PREFIX}PADLEFT: ${PADLEFT}; PADRIGHT: ${PADRIGHT}; PADTOP: ${PADTOP}; PADBOTTOM: ${PADBOTTOM}";;
esac

echo "${MSG_PREFIX}WIDTH: ${WIDTH}; HEIGHT: ${HEIGHT}"
#exit 0

[ ! ${FPS} ] && FPS=${ID_VIDEO_FPS}
FPS=`echo ${FPS} ${ID_VIDEO_FPS} | awk '{ print ( $1 == 0 ? $2 : $1 ); }'`
FPS=`echo ${FPS} ${ID_VIDEO_FPS} | awk '{ print ( $1 < 0 ? $2 / $1 * -1 : $1 ); }'`
FPS=`echo ${FPS} ${ID_VIDEO_FPS} | awk '{ print ( $1 > $2 ? $2 : $1 ); }'`

echo "${MSG_PREFIX}FPS: ${FPS}"
#exit 0

#
# Encode
#

if [ ${ENCODER} = "MENCODER" ]; then

	MSGLEVEL="all=1"
	VF="${VF_CROP}${ADDITIONAL_VF}scale=${SCALE_WIDTH}:${SCALE_HEIGHT},expand=${WIDTH}:${HEIGHT}"
	KEYINT=":keyint=`echo ${FPS} | awk '{ print int( $1 * 10 ); }'`"
	LAVCOPTS="vcodec=flv:vbitrate=${VIDEO_BITRATE}:mbd=2:mv0:trell:v4mv:cbp:last_pred=3${KEYINT}"
	PASSLOGFILE="nicovideo2youtube-mencoder-`date +%Y%m%d%H%M%S`.log"

	encode () {
		mencoder -msglevel ${MSGLEVEL}\
			-of lavf -oac copy -delay ${DELAY} -vf ${VF} -ofps ${FPS} -ovc lavc -lavcopts ${LAVCOPTS}:vpass=$1 -passlogfile ${PASSLOGFILE}\
			-o "${OUTPUT_FILE}" -audiofile "${INPUT_AUDIO_FILE}" "${INPUT_FILE}" ||\
			{ echo "${MSG_PREFIX}ERROR: MEncoder error occured." >&2; exit 1; }
	}

	[ ! ${PASS} -o ${PASS} -lt 2] && PASS=2
	i=1
	while [ ${i} -le ${PASS} ]
	do
		echo "${MSG_PREFIX}Encoding \"${INPUT_FILE}\" (pass ${i}/${PASS})."
		encode `echo ${i} | awk '{ print ( $1 == 1 ? 1 : 3 ); }'`
		i=`expr ${i} + 1`
	done

	[ -f ${PASSLOGFILE} ] && rm ${PASSLOGFILE}

else
	CROP="-croptop ${CROPTOP} -cropbottom ${CROPBOTTOM} -cropleft ${CROPLEFT} -cropright ${CROPRIGHT}"
	PAD="-padtop ${PADTOP} -padbottom ${PADBOTTOM} -padleft ${PADLEFT} -padright ${PADRIGHT}"
	VIDEO_BITRATE=`expr ${VIDEO_BITRATE} \* 1000`
	VIDEO_OPTIONS="-mbd 2 -mv0 -trell -last_pred 3"
	PASSLOGFILE="nicovideo2youtube-ffmpeg-`date +%Y%m%d%H%M%S`"

	encode () {
		ffmpeg -y -i "${INPUT_AUDIO_FILE}" -i "${INPUT_FILE}"\
			-acodec copy -pass $1 -passlogfile ${PASSLOGFILE}\
			${CROP} -s ${SCALE_WIDTH}x${SCALE_HEIGHT} ${PAD} -r ${FPS} -vcodec flv -b ${VIDEO_BITRATE} ${VIDEO_OPTIONS}\
			"${OUTPUT_FILE}" || { echo "${MSG_PREFIX}ERROR: FFmpeg error occured." >&2; exit 1; }
	}

	echo "${MSG_PREFIX}Encoding \"${INPUT_FILE}\" (pass 1/2)"
	encode 1
	echo "${MSG_PREFIX}Encoding \"${INPUT_FILE}\" (pass 2/2)"
	encode 2

	#rm ${PASSLOGFILE}*.log

fi

OUTPUT_FILE_SIZE=`ls -l "${OUTPUT_FILE}" | awk '{ print $5; }'`
probe "${OUTPUT_FILE}" "^ID_LENGTH"
echo "${MSG_PREFIX}TOTAL_BITRATE: `echo ${OUTPUT_FILE_SIZE} ${ID_LENGTH} | awk '{ print ( $1 * 8 / $2 / 1000 ); }'` kbps"

exit 0

設定用ファイル。

# nicovideo2youtube.conf
#
# nicovideo2youtube.sh の設定ファイル

####### BASIC OPTIONS #######

#
# TOTAL_BITRATE
#
# 音声と映像をあわせての目標ビットレートを単位 kbps で指定 (必須)。
# YouTube での再エンコードを回避するためには、350 kbps 未満である必要がある模様 (2008 年 3 月現在)。
# 340 にしておくと 350 kbps をちょっと下回るぐらいになることが多い。
# 微調整して 350 kbps 未満になるようにしたりする。
# MEncoder が必要なしと判断した場合は、指定を下回るビットレートになる (不必要に大きなファイルにはならない)。

#TOTAL_BITRATE=468	# 音声が 192 kbps なのに 320 kbps とか自己申請する動画用
#TOTAL_BITRATE=436	# 音声が 128 kbps なのに 224 kbps とか、160 kbps なのに 256 kbps とか自己申請する動画用
#TOTAL_BITRATE=420	# 音声が 112 kbps なのに 192 kbps とか自己申請する動画用
TOTAL_BITRATE=340
#TOTAL_BITRATE=308	# 音声が 160 kbps なのに 128 kbps とか、192 kbps なのに 160 kbps とか自己申請する動画用
#TOTAL_BITRATE=276	# 音声が  96 kbps なのに  32 kbps とか、192 kbps なのに 128 kbps とか自己申請する動画用
#TOTAL_BITRATE=260	# 音声が 112 kbps なのに  32 kbps とか、192 kbps なのに 112 kbps とか自己申請する動画用
#TOTAL_BITRATE=244	# 音声が 128 kbps なのに  32 kbps とか、320 kbps なのに 224 kbps とか自己申請する動画用
#TOTAL_BITRATE=212	# 音声が 160 kbps なのに  32 kbps とか自己申請する動画用
#TOTAL_BITRATE=180	# 音声が 192 kbps なのに  32 kbps とか自己申請する動画用
#TOTAL_BITRATE=148	# 音声が 224 kbps なのに  32 kbps とか、320 kbps なのに 128 kbps とか自己申請する動画用
#TOTAL_BITRATE=116	# 音声が 256 kbps なのに  32 kbps とか自己申請する動画用
#TOTAL_BITRATE=52	# 音声が 320 kbps なのに  32 kbps とか自己申請する動画用

#
# WIDTH
#
# 出力する映像の横幅をピクセル数で指定。
# 指定しなかった場合 (すべてコメント・アウトした場合) 元動画ファイルと同じになる。

#WIDTH=640
#WIDTH=480
#WIDTH=440
#WIDTH=400
#WIDTH=360
#WIDTH=320
#WIDTH=280
#WIDTH=240
#WIDTH=200
#WIDTH=160
#WIDTH=120
#WIDTH=80

#
# FPS = Frames Per Second
#
# 1 秒あたり何フレームの映像にするかを指定。
# 指定しなかった場合 (すべてコメント・アウトした場合) や、
# 指定より元動画のほうが FPS が低かった場合は、元動画ファイルと同じになる。
# また、負の値の場合は、元動画ファイルの FPS をその絶対値で割った数値になる。
# たとえば、FPS が 24 である元動画ファイルに対して -2 を指定した場合、12 になる。

#FPS=-5
#FPS=-4
#FPS=-3
FPS=-2
#FPS=15
#FPS=12
#FPS=10
#FPS=8
#FPS=6
#FPS=5
#FPS=4
#FPS=3
#FPS=2
#FPS=1

#
# PASS (ENCODER="MENCODER" の場合のみ有効)
#
# エンコードのパス数を指定する。
# 指定しなかった場合 (すべてコメント・アウトした場合) は 2 になる。
# ENCODER="FFMPEG" の場合は、強制的に 2 になる。

PASS=3

####### ADVANCED OPTIONS #######

#
# ENCODER
#
# エンコーダを指定する。
# 指定しなかった場合 (すべてコメント・アウトした場合) や、
# "FFMPEG" 以外の文字列だった場合は "MENCODER" になる。

ENCODER="MENCODER"
#ENCODER="FFMPEG"

#
# AUTO_DELAY (ENCODER="MENCODER" の場合のみ有効)
#
# 元動画ファイルと、外部の音声ファイルで長さが異なる場合、映像ストリームと音声ストリームを──
# はじめで合わせたければ "NONE" を指定。
# 真ん中で合わせたければ "HALF" を指定。
# 終わりで合わせたければ "FULL" を指定。
# ただし、自己申請に頼っているため、あまり信用できないので注意。
# 別の文字列を指定した場合や、指定しなかった場合 (コメント・アウトした場合) は "NONE" になる。
# ENCODER="FFMPEG" の場合は、強制的に "NONE" になる。

AUTO_DELAY="NONE"
#AUTO_DELAY="HALF"
#AUTO_DELAY="FULL"

#
# CUSTOM_DELAY (ENCODER="MENCODER" の場合のみ有効)
#
# DELAY の値を自分で指定する。
# 映像を遅らせたい場合は正の値を指定。
# 音声を遅らせたい場合は負の値を指定。
# これを指定すると AUTO_DELAY の指定は無視される。不要な場合はコメント・アウト。
# ENCODER="FFMPEG" の場合は、強制的に指定なしになる。

#CUSTOM_DELAY=-3.38	# "サイハテ" で使用した値
#CUSTOM_DELAY=-11.80	# "電空少女" で使用した値

#
# AUTO_CROP
#
# 映像周囲の黒枠を自動検出し、消去するかどうかを指定。
# 指定しなかった場合 (コメント・アウトした場合) や、
# "ON" 以外の文字列だった場合は "OFF" になる。

AUTO_CROP="OFF"
#AUTO_CROP="ON"

#
# FORCE_ASPECT
#
# 元動画のアスペクト比を数値や計算式で強制指定。
# アスペクト比の狂った映像を修正するために使う。
# 不要な場合はコメント・アウト。

#FORCE_ASPECT=`echo 4 3 | awk '{print ( $1 / $2 );}'`	# 元動画のアスペクト比が、本来 4:3 であるとき用
#FORCE_ASPECT=`echo 16 9 | awk '{print ( $1 / $2 );}'`	# 元動画のアスペクト比が、本来 16:9 であるとき用

#
# ADDITIONAL_VF (ENCODER="MENCODER" の場合のみ有効)
#
# エンコード時、動画に適用するフィルタを指定。
# 基本的にいじる必要なし。
# ENCODER="FFMPEG" の場合は、この指定の意味はない。

ADDITIONAL_VF="pp=ac,"
#ADDITIONAL_VF="flip,pp=ac,"	# 元動画ファイルを MPlayer で再生したとき、映像が上下逆になる場合
#ADDITIONAL_VF="pp=md/ac,"	# インターレース解除されてない場合

*1:bc を使う方法もわかったけど awk のほうが条件式とか便利だし、なによりうち bc 入ってなかったから。awk のほうが計算式は見にくくなるけど、汎用性があるのでこれでいいかと。

*2:Debian 非公式パッケージ 3:20071206-0.1 唯一の変更点。"http://www.nabble.com/Remux-problem-td15137531.html" にあるバグ。