2012/09/27

MediaElement.jsでビデオ視聴

MediaElement.jsをredmine上のビデオ視聴プラグインに組み込んでみた

初めに思ったのは、まだ見てないやつとか見たやつとか分かるように出来ないかな?ってことでした。ビデオの再生ボタンを押したことを検出して、データベースに視聴済みと記録できないかな?って。
更には、どこまで観たかを記録できれば途中再生もできるよねとか、ビデオコントローラをカスタマイズして15秒スキップとかできればCMスキップも楽だよねとか、視聴する部分の拡張をしてみたいと。

上記のようなことは、まだまだ出来そうにないんですが、まずはそういったことがやりやすそうなフレームワークを探してみたところ、有名どころで Video.jsとMediaElement.js がありました。
どちらもコントローラのカスタマイズとかイベントAPIとかを持っていて色々出来そう。

これらのドキュメントを読んでみると、どんなブラウザでも再生できるようにすることが目的になっているようです。そういえば自分のシステムでは素のHTML5のvideoタグでソースはH264だけなのでFirefoxとかでは観れない。
このフレームワークを使用するとH264だけでもFirefoxで見れるようになると。
うーん素晴らしい。
どうして見れるんだ?ってことですがH264を再生できないブラウザでは自動的にプレーヤープラグインを通して再生するってことらしい。そのためのプラグインが内包されています。

Video.js

簡単そうだったので、初めはこっちを使ってみましたが、ドキュメント通りにやってみて、CDNホストバージョンだろうがセルフホストだろうが、Firefoxで視聴できるように出来ませんでした。
セルフホスト用をダウンロードすると、video-js.swfというのが入ってるんで、勝手に使ってくれるんだよね?って思っていましたが、甘かったようです。よくよく読んでみるとH264だけで大丈夫とは書いてない・・・
ということで、さようならです。

MediaElement.js

結果から言うと、特に色々やることなくFirefoxでは勝手にブラウザプラグインが使われて、ソースがH264だけでも再生してくれました。更にビデオイベント取得やビデオオブジェクト操作APIや、何やらカスタムコントローラにボタン追加したり出来てしまうらしい。冒頭に書いたようなことが出来そうな可能性を感じます。

ダウンロード

こちらは、フリーCDNホストはないので、パッケージをダウンロードして使用します。
$ wget http://github.com/johndyer/mediaelement/zipball/master
ってやるとダウンロードされますが、ファイル名が'master'になっちゃいます。でも気にせず
$ unzip master
で解凍して'master'はポイです。

中身を覗いてみると、/buildにフレームワーク一式。デモサンプルのHTMLとメディアファイル、Silverlight用とFlash用のプラグインのビルドファイルとソースコード一式がどっさり入っておりました。


視聴システムへの組み込み

視聴部分はredmineプラグインで構築しているので、言うなればRailsアプリへの組み込みってことになります。ビデオ視聴プラグインの一部として組み込みたいので、必要なファイルをそっちへ移動させます。
ここでは、ビデオ視聴プラグインのフォルダを、/redmine_videoとして記述しておきます。

JavaScriptとプラグインをassets/javascriptsへコピー
MediaElement/build/*.js => /redmine_video/assets/javascripts
MediaElement/build/flashmediaelement.swf => /redmine_video/assets/javascripts
MediaElement/build/silverlightmediaelement.xap => /redmine_video/assets/javascripts

cssとイメージファイルをassets/stylesheetsへコピー
MediaElement/build/*.css => /redmine_video/assets/stylesheets
MediaElement/build/*.png => /redmine_video/assets/stylesheets
MediaElement/build/*.gif => /redmine_video/assets/stylesheets

Railsではassetsフォルダの中をjavascripts/stylesheets/imagesに分けることになってますが、MediaElementはフラットなので、このような分け方にしました。全部assets/javascriptsに入れてしまっても動くでしょうけどrailsでシンプルに書きたいんで。

redmineを再起動すると、redmine_video/assetsが、redmine/public/plugin_assets/redmine_video/へ自動コピーされて使用できる状態になります。

最初なぜかredmine/public/plugin_assets/redmine_video/がルートパーミッションになってしまっていてコピーでエラーになってしまい、ちょっと悩みました。なんで?誰がやった?自分??原因不明。

ビュー作成

視聴ビューとしていた以前のplay.html.erbは以下のようなものでした。
<h2><%= @issue.subject %></h2>

<% video = @issue.attachments.detect {|a| a.content_type == "video/mp4"} %>
<% thumb = @issue.attachments.detect {|a| a.content_type == "image/jpg"} %>

<%= video_tag video.disk_filename, :id => "video",
    :poster => image_path("/videos/"+thumb.disk_filename),
:autoplay => true, :autobuffer => true, :controls => true, :size => "960x540" %&gt\
;

これを、MediaElement.jsのデモサンプルやらサイトやら色々物色して、不要なものをすべてそぎ落として、Railsで書き直したら以下のようになりました。

<% content_for :header_tags do %>
  <%= javascript_include_tag "jquery.js", :plugin => 'redmine_video' %>
  <%= javascript_include_tag "mediaelement-and-player.min.js", :plugin => 'redmine_video\
' %>
  <%= stylesheet_link_tag "mediaelementplayer.min.css", :plugin => 'redmine_video' %>
  <%= stylesheet_link_tag "mejs-skins.css", :plugin => 'redmine_video' %>
<% end %>

<% video = @issue.attachments.detect {|a| a.content_type == "video/mp4"} %>
<% thumb = @issue.attachments.detect {|a| a.content_type == "image/jpg"} %>

<%= video_tag video.disk_filename, :poster => image_path("/videos/"+thumb.disk_filename)\
,
:type => "video/mp4", :size => "960x540" %>

<%= javascript_tag "$('video').mediaelementplayer()" %>

最後のmediaelementplayer()が全て色々よろしくやってくれるようです。
この関数はイベントリスナーやら数あるオプション設定やらいろいろカスタマイズできます。

ここまで到達するのに悩んだことがいくつかありました。
  • content_for :head じゃなくて、content_for :header_tagsだった。
    railsのドキュメントには:headって書いてあるんだけどなぁ。かなり悩んだ。
  • javascript_include_tagやstylesheet_link_tagに、:pluginオプションが必要だった。
    railsドキュメントではなく、redmineプラグインドキュメントを読むべきだった。
    これにより、プラグインのassets以下を指定したことになるらしい。
  • video_tagには、:type => "video/mp4"が必要だった。
    これがないとMediaElement.jsが全く機能しなかった。
    MediaElement.jsのセットアップで、サイトにAddTypeでMIMEを追加せよと書いてあったけど、やっていない。もしかしたらそのため? でもこれで動いたんで大丈夫。
これで、やりたかったことの一つ、Firefoxで視聴できるようになりました。
実際にやってみると、付属のSilverlightプラグインを通して再生されているようです。

次回は、もう少し突っ込んだ使い方を書いてみたいと思ってますが、実はなかなかうまくいっていません。よくわからない状態継続中です。また暫く間が開くかもです・・・

2012/09/12

Jenkins新規ジョブに所要時間を設定する

Jenkinsのジョブは繰り返しビルドされることが前提。2回目からどれくらいで終了するかプログレス表示される。これが精神衛生上とってもいいわけだけど、前回の結果が分からないと表示されない。

録画システムで使用しているJenkinsのビルド所要時間を最初に設定できないかな?

録画ジョブは性質上1回実行したらお役御免。同じ日時の2回目はない。だけど、どれくらいで終了するかを表示させたい。どうにか出来ないかな?ということでやってみた。

思惑
録画時間は分かっている。エンコード時間も予測できる。時間予測は可能だ。
Jenkinsが過去にビルドした情報として扱ってくれれば出るはず。

所要時間予測
今までの実績から算出すると録画時間に対しての所要時間は2.2倍程度かな。
番組時間が30分程度の場合は1.7〜2.2位、2、3時間の場合は2.2〜2.4位。
単純に、ビルド予想時間=番組時間 * 2.2 ってことでいいかな。
沢山のサンプリングデータから統計を取って非線形計算が出来れば精度は上がるだろうね。

ビルドデータ
#{JENKINS_HOME}/jobs/#{ジョブ名}/builds/#{ビルド日時}/build.xml
に、このビルドについての情報が記述されている。この中で重要なのが、durationタグだ。
Jenkinsは前回のビルドのこの情報をもとにメーター表示しているようだ。
その一例
<?xml version='1.0' encoding='UTF-8'?>
<build>
  <actions>
    <hudson.model.CauseAction>
      <causes>
        <hudson.triggers.TimerTrigger_-TimerTriggerCause/>
      </causes>
    </hudson.model.CauseAction>
  </actions>
  <number>1</number>
  <result>SUCCESS</result>
  <duration>3977984</duration>
  <charset>US-ASCII</charset>
  <keepLog>false</keepLog>
  <builtOn></builtOn>
  <workspace>/var/lib/jenkins/jobs/record_14147/workspace</workspace>
  <hudsonVersion>1.424.6</hudsonVersion>
  <scm class="hudson.scm.NullChangeLogParser"/>
  <culprits/>
</build>

適当なジョブを作って、適当なビルド結果をコピーしてどうなるか試してみた。
Jenkinsのシステム設定からリロードをしたら、情報を拾ってくれて反映した。
色々やって、最低限必要な情報のみにしたbuild.xmlは以下のようになった。
<build>
 <number>0</number>
 <result>SUCCESS</result>
 <duration>#{duration}</duration>
</build>

ビルド番号は0番でいけた。最初のビルドが1番っていう約束事が崩れないで済みそう。

APIじゃ出来なそう
ビルドディレクトリとファイルをあらかじめ作成してしまえばいいわけだけれど、REST-APIじゃ出来ないみたい。Jenkinsプラグインなら?ってちょっと調べたけど、ジョブ作成フックみたいな切り口がないようで断念。Redmineのチケット編集フックみたいに出来ればよかったんだけれど。

パーミッションをどうするかなぁ
Jenkins側を拡張する方法がだめっぽいので外部タスクで実行することになる。そうすると、その辺のディレクトリパスはJenkinsユーザだぜっていうパーミッションが絡んでくる。選択肢はいくつか考えられる。

1. Jenkins側をを実行タスクと同じユーザにする
この場合、redmine(rails)ユーザと同じwww-dataにするってことか。

Jenkinsが扱うパス/ファイルオーナー’をwww-dataにする。
/etc/default/jenkinsファイルのJENKINS_USERをwww-dataにする。

2. 実行タスク側をJenkinsと同じjenkinsにする
Redmine(rails)実行ユーザをJenkinsにするってことか。

redmineが扱うパス/ファイルオーナーをjenkinsにする。
結果、passengerを通してredmine(rails)が、redmine/config/environment.rbのオーナーで実行されることになる。

もしくは、redmine/filesとredmine/public/videosオーナーをjenkinsにして
apacheのpassengerオプションで
PassengerUserSwitching off
PassengerDefaultUser jenkins
を追加記述する。

3. Jenkinsをソースからいじって、誰でも書き込めるようにする
これはだめでしょ。

4. ビルド#0を作成する処理をJenkinsジョブにしてリモート実行する
これかなぁ。

1と2は簡単だし普通はこれかなぁと思うけど、システム依存だし保守性(オリジナルやデフォルトを極力変更しない)が落ちてしまうなぁ。3は可能だけど論外。最後の4の方法でやることにした。

hudson-remote-apiを使ってjob作成&実行するrubyスクリプト
/opt/task/prebuild.rb
#!/usr/bin/ruby                                                                                     
# -*- coding: utf-8 -*-                                                                             

require 'hudson-remote-api'

# Jenkins                                                                                           
Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

def create_estimated_build record_job, record_sec

  # estimated duration                                                                              
  duration = (1000 * record_sec * 2.2).round

  # build datetime                                                                                  
  build_name = Time.now.strftime("%Y-%m-%d_%H-%M-00")

  puts "create_estimated_build: #{record_job} #{record_sec} #{duration} #{build_name}"

  build_dir = "/var/lib/jenkins/jobs/#{record_job}/builds/#{build_name}"
  build_xml = "<build><number>0</number><result>SUCCESS</result><duration>#{duration}</duration></build>"

  # create estimated build                                                                          
  Dir::mkdir(build_dir)
  xml = File.open(build_dir+"/build.xml",'w')
  xml.puts build_xml
  xml.close

  # reload jenkins                                                                                  
  system("curl %s/reload" % Hudson[:url])
end

# MAIN START                                                                                        

abort "no arguments" if ARGV.size<2
job_name = ARGV[0]
rec_time = ARGV[1]
create_estimated_build job_name, rec_time.to_i
build#0として必要最低限の情報を記述したbuild.xmlを今の時間に実行したっていうビルドディレクトリに作成する処理。最後にJenkinsのリロードをcurlを使って実行する。
hudson-remote-apiではreload-APIがなかったので、こんななった。

以前の記事で作成した
/opt/task/video-jobs.rbに以下のメソッドを追加
def remote_estimated_build record_job, record_sec
  puts "remote_estimated_build"

  job_name = "prebuild_#{record_job}"
  job = Hudson::Job.create job_name

  command = "/opt/task/prebuild.rb #{record_job} #{record_sec}"

  config = REXML::Document.new job.config
  element = config.elements['/project/builders'].add_element 'hudson.tasks.Shell'
  element.add_element('command').add_text command
  job.update config.to_s
  job.build
#  job.wait_for_build_to_finish                                                                     
#  job.delete                                                                                       
end

create_jobメソッドの最後に以下の1行を追加
remote_estimated_build jobname, info[:duration]

このcreate_jobはJenkinsジョブとして動いている自動予約スクリプトから呼ばれたり、Redmineフックスクリプトから呼ばれたりする。

出来てしまえば簡単なのだけれど、最初のアプローチで、どうやってJenkinsシステムに分かってもらえるのかをあれこれ調べるのが手間がかかったかな。

実際に活用してみると、録画処理が後どれくらいで終わるのかが視覚的に分かるようになったので、「なんか走ってるなぁ」じゃなくて「後60分くらいかぁ」ってなるので気分がすっきりする。

2012/09/06

簡易ビデオ編集機能(Web開発編)

ビデオ編集(加工)するツールはMP4Boxで取り敢えずOKなので、次はブラウザ上で編集作業が出来るようにする。

視聴するために作っていたRedmineプラグインに編集機能を追加することにした。

プランとしては以下の様な感じ
  • 簡単な入力フォームに適当な設定用フィールドとサブミットボタンからポストするビューとコントローラ
  • フォームはディレイとクリップの2タイプ。メニューで切り替え。どうせなら、ajaxで部分更新する。
  • 実行タスクは非同期のJenkinsジョブで実行する。
  • Jenkinsジョブ状態を見て、実行中、成功、失敗がビューに反映する。
  • 編集ビュー上でオリジナルと編集結果のビデオ再生が出来る。
  • 編集が完了したら、録画チケットのビデオアタッチファイルを更新する。
  • 編集キャンセルして、Jenkinsジョブをビューから削除できる。

コントローラ作成
$ sudo RAILS_ENV=production ruby script/rails generate redmine_plugin_controller redmine_video edit index
'edit'コントローラを作る。アクションは'index'だけ作らせる。
この辺の手順は、video_tagで視聴するredmineプラグインを作るでやった手順と同じ。

コントローラ
app/controllers/edit_controller.rb
class EditController < ApplicationController
  unloadable

  include EditHelper

  def index
    get_params
    init_params unless request.post?
    update if params[:submit_update]
    execute if params[:submit_execute]
    remote_params
    save if params[:submit_save]
    clear if params[:submit_clear]
  end
end

全部の処理をヘルパーに移している。そのためコントローラはごくシンプルになっている。
ここで扱っているアクションはindexだけで、GETもPOSTも複数あるボタンからのサブミットも全部
ここで受け取って処理している。

ヘルパーはコントローラでもビューでも使用するんで、include を使っている。
因みに、ビューだけで使用するなら、helper :edit って簡素に書けるのだそうだ。

POSTだったら、init_paramsを実行
アップデートボタン押されたなら、updateを実行
実行ボタン押されたら、execute実行
保存ボタン押されたら、save実行
クリアボタン押されたら、clear実行

って感じに全部の処理をひとまとめにしている。
必要な処理は全部ヘルパーに書いているんで、開発中コントローラファイルは更新しないで済む。

matchを使ってルート定義
config/routes.rb
match 'edit/index/:id',:to=>'edit#index'
match 'edit/select_mode',:to=>'edit#select_mode'

前回までは、メソッドがGET限定だったけれど、今回は、GETもPOSTもあるんで、matchを使う。
select_modeというアクションがいきなり出てきたけど、これは後から追加したやつ。
select_modeアクションはヘルパーの方に記述している。

ビュー
app/views/edit/index.html.erb

<h2>ビデオ簡易編集</h2>

<%= form_tag :action =>'index',:id=>@issue.id, :result=>@result, :build=>@build do %>

<%= select_tag :mode,
    options_for_select([["Delay Time",0],["Clip Time",1]],@mode),
    :onchange => remote_function(:url => {:action => :select_mode}, :with => "'mode='+this.value+"+@remote_params) %>

<div id='attr'>
<%= render :partial => 'attr' %>
</div>

<table>
  <tr><td>
<!--
    <%= submit_tag "実行", :name => "submit_execute", data:{disable_with:"実行中"} %>
    <%= submit_tag "実行", :name => "submit_execute", :data => {:disable_with=>"実行中"} %>
    data-disable-with属性があるとなぜかparamsにnameが入らない。そのため処理されない
-->
    <%= submit_tag "実行", :name => "submit_execute" %>
    <%= submit_tag "アップデート", :name => "submit_update" %>
    <br>
    <%= video_tag get_video_filename, :controls => true, :size =>"480x270" %>

    <% if @build %>
    <% if @build=='ok' && @result %>
    <td>
      <%= submit_tag "保存", :name => "submit_save", :onclick => "return confirm('Are you sure?')" %>
      <%= submit_tag "消去", :name => "submit_clear", :onclick => "return confirm('Are you sure?')" %>
      <br>
      <%= video_tag @result, :controls => true, :size =>"480x270" %>
      <% end %>
    <td><%= link_to @build, "/jenkins/job/edit_#{@issue.id}" %>
      <% end %>
</table>
<% end %>

<%= form_tag :action =>'index',:id=>@issue.id, :result=>@result, :build=>@build do %>
@resultには、処理実行後のビデオファイル名が入る。@buildには、Jenkinsジョブの状態が入る。どっちも一番最初はNullが入っている。
id、result、buildの設定を追記しているのは、フォーム内の入力フィールド以外のパラメータを渡して保存するため。


フォームのサブミットでコントローラに渡るparams[]に自動的に入るのは、その時の入力フォームだけなのね。
なので、それ以外に常にparams[]に入って欲しいパラメータは自分で追加記述しないとならない。


<%= render :partial => 'attr' %> で、_attr.html.erbをインクルードする
それをajax更新するために、div id='attr'タグで囲っておく。
更新は、ヘルパーに書いてあるselect_mode内で
render :update do |page| page.replace_html "attr", :partial => "attr"
って書いておくと、その部分だけの更新になるそうな。興味本位でやってみたけど、面倒くさい。
勉強にはなったか。


セレクトメニューで選択するとフォームが部分更新されるようにしたけど、色々面倒。


select_tagでajaxな効果を得ようとするには、:onchangeでremote_functionでアクションを指定するのがセオリーらしい。ただ、そうすると、サブミットみたいに勝手にparams[]にパラメーがが渡らない。JavaScriptの処理だから?
仕方なく、:withで全部のパラメータがコントローラに渡るように書かねばならなかった。
そのための変数が@remote_paramsってやつ。


パーシャルレンダリング
app/views/edit/_attr.html.erb

<% if @mode==0 %>
<%= number_field_tag :delay_time, @delay_t %>(msec)
<%= radio_button_tag :track, 1, @track==1? true:false %>映像
<%= radio_button_tag :track, 2, @track==2? true:false %>音声
<%= check_box_tag :preview, true, @preview? true:false, {} %>プレビュー
<% else %>
開始:<%= text_field_tag :start_time, @start_t %>
終了:<%= text_field_tag :end_time, @end_t %>
<% end %>

ディレイだったらクリップだったらにフォーム内容を分けている部分。
特に分ける必要はないんだけど、こんなふうに分けられるんだねって言うためのもの。
普通はビューっぽくない手続き的な処理が多いところを分けておくと見通しが良くなるっていう目的で行うらしいが。
大規模なページデザインなら活用価値があるのだろう。複数人で構築するとかだろうね。

disable_withが活用できない

<%= submit_tag "実行", :name => "submit_execute", data:{disable_with:"実行中"} %>
<%= submit_tag "実行", :name => "submit_execute", :data => {:disable_with=>"実行中"} %>
どっちの書き方をしても出力されるフォームタグには data-disable-with属性が追加される。これをしておくと、実行ボタンの2重押しを防止できるのでそうしたかったが、この記述があるとなぜかparams[:name]がアクションに渡らないのだ。仕方なく
<%= submit_tag "実行", :name => "submit_execute" %>
で我慢である。submit_tagで使った場合の問題のようなので、submitを使わずに普通のボタンだったら大丈夫だと思うが、その場合はフォーム内パラメータを受け渡す処理も自前で書かないとならないだろう。それはそれで大変だ。簡単な解決方法が欲しいぞ。

confirmが使えない(redmine2.0.3のGemfileはjquery標準じゃない)

<%= submit_tag "保存", :name => "submit_save", :onclick => "return confirm('Are you sure?')" %>
<%= submit_tag "消去", :name => "submit_clear", :onclick => "return confirm('Are you sure?')" %>

ここも困っている。保存、消去は取り返しがつかないんで、確認ダイアログを出したい。Railsな書き方を調べると:confirm => "Are you sue?"でいいらしいことが沢山書いてある。やってみた。が、全然機能しない。

更に調べると、:confirmを機能させるには、jquery-railsが必要らしい。Rails3はjqueryが標準らしい。?ん?redmineのGemfileにはjquery-railsが無いぞ?prototypeだぞ? Redmine2.0.3はRails3だけどjquery-railsは標準じゃないってことか。

jquery-railsを入れることも検討したけど、jqueryを入れるとprototypeが勝手に消えるっていう話が何処かのブログに書いてあった。Redmine自体が動かなくなってしまいそうで怖いのでやっていない。いずれ、バックアップを取ってからテストしてみよう。

なので、仕方なくprototypeのconfirmを動かす。こっちは:onclickでconfirmを呼ぶのだそうだ。ちょっとかっこ悪いコンフォームダイアログが出た。ほんとにかっこ悪いんだなこれが。

ジョブステータス表示とジョブへのリンクを兼用
<%= link_to @build, "/jenkins/job/edit_#{@issue.id}" %>
単に@build内容を表示するだけじゃ、勿体無いのでJenkinsジョブページヘのリンクを貼る。
ジョブ説明にも編集ページヘのリンクが張ってあるんで、これで行ったり来たりが出来る。


編集タスク本体
app/helpers/edit_helper.rb

# -*- coding: utf-8 -*-
module EditHelper

#  require 'hudson-remote-api'
  Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

  # create a jenkins job and build the job
  def jenkins command
    jobname = "edit_#{@issue.id}"
    job = Hudson::Job.get jobname
    job.delete if job
    job = Hudson::Job.create jobname

    job.description = command + " <a href=/edit/index/#{@issue.id}>編集</a>";

    config = REXML::Document.new job.config
    element = config.elements['/project/builders'].add_element 'hudson.tasks.Shell'
    element.add_element('command').add_text command
    job.update config.to_s
    job.build
  end

  # execute edit
  def execute
    video = get_video_filename
    system "rm /opt/videos/#{@issue.id}_*"
    if @preview
      @result = video.sub('.mp4','_0_60.mp4')
      system "MP4Box -split-chunk 0:60 /opt/videos/#{video}"
      jenkins "MP4Box -delay #{@track}=#{@delay_t} /opt/videos/#{@result}"
    else
      # execute!
      if @mode == 0 # delay
        @result = video.sub('.mp4',"_#{@track}_#{@delay_t}.mp4")
        jenkins "MP4Box -delay #{@track}=#{@delay_t} -out /opt/videos/#{@result} /opt/videos/#{video}"
      else # clip
        @result = video.sub('.mp4',"_*_*.mp4")
        t_start = str_to_sec @start_t
        t_end = str_to_sec @end_t
        jenkins "MP4Box -split-chunk #{t_start}:#{t_end} /opt/videos/#{video}"
      end
    end
  end

  # just update views
  def update
    @start_t = sec_to_str str_to_sec @start_t unless @start_t.nil?
    @end_t = sec_to_str str_to_sec @end_t unless @end_t.nil?
  end

  # ajax partial render
  def select_mode
    get_params
    init_params
    remote_params
    render :update do |page| page.replace_html "attr", :partial => "attr"
    end
  end

  def init_params
    @mode = 0 if @mode.nil?
    @delay_t = 0 if @delay_t.nil?
    @start_t = "00:00:00" if @start_t.nil?
    @end_t = "00:00:00" if @end_t.nil?
    @track = 1 if @track.nil?
    @preview = 'true' if @preview.nil?
  end

  def get_params
    @issue = Issue.find(params[:id])
    @mode = params[:mode].to_i
    @delay_t = params[:delay_time].to_i
    @start_t = params[:start_time]
    @end_t = params[:end_time]
    @track = params[:track].to_i
    @preview = params[:preview]
    @result = params[:result]
    @build = params[:build]
  end

  def remote_params
    job = Hudson::Job.get "edit_#{@issue.id}"
    if job
      case job.color
      when 'blue'
        @build = 'ok'
        if @result
          @result = get_result_filename @result
        else
          parse_command job.description
        end
      when 'red'
        @build = 'failed'
      else
        @build = 'running'
      end
    end
    @remote_params = "'"
    @remote_params << "&id=#{@issue.id}"
    @remote_params << "&delay_time=#{@delay_t}"
    @remote_params << "&start_time=#{@start_t}"
    @remote_params << "&end_time=#{@end_t}"
    @remote_params << "&track=#{@track}"
    @remote_params << "&preview=#{@preview}"
    @remote_params << "&result=#{@result}"
    @remote_params << "&build=#{@build}"
    @remote_params << "'"
  end

  def clear
    job = Hudson::Job.get "edit_#{@issue.id}"
    job.delete if job
    @result = nil
    @build = nil
    system("rm /opt/videos/#{@issue.id}_*")
  end

  def save
    org_file = "/opt/videos/" + get_video_filename
    dst_file = "/opt/videos/" + @result
    if File.exists?(dst_file) && system("mv -f #{dst_file} #{org_file}")
      clear
      @build = "done"
    else
      @build = "Error, file not found:#{dst_file}"
    end
  end

  # utilities

  def parse_command desc
    cmds = desc.split(' ')
    if cmds[1].include?('split')
      # split-chunk
      @mode = 1
      times = cmds[2].split(':')
      @start_t = sec_to_str times[0].to_i
      @end_t = sec_to_str times[1].to_i
      @result = get_result_filename get_video_filename.sub('.mp4',"_*_*.mp4")
    else
      # delay
      @mode = 0
      delays = cmds[2].split('=')
      @track = delays[0].to_i
      @delay_t = delays[1].to_i
      if cmds[3]=='-out'
        @result = get_result_filename cmds[4]
        @preview = 'false'
      else
        @result = get_result_filename cmds[3]
        @preview = 'true'
      end
    end
  end

  def get_video_filename
    video = @issue.attachments.detect {|a| a.content_type == "video/mp4"}
    video.filename
  end

  def get_result_filename str
    result = str
    result = `ls -1 /opt/videos/#{str}` if str.include?('*')
    result.chomp!
    result.sub('/opt/videos/','')
  end

  def str_to_sec str
    if str
      t = str.split(':').reverse
      s = t[0].to_i if t.size>0
      s += t[1].to_i * 60 if t.size>1
      s += t[2].to_i * 60 * 60 if t.size>2
      s
    else
      0
    end
  end

  def sec_to_str sec
    if sec
      [ "%02d"% [sec / (60*60)], "%02d"% [(sec / 60) % 60], "%02d"% [sec % 60] ].join(':')
    else
      "00:00:00"
    end
  end
end

require 'hudson-remote-api'は行わない
以前の記事でredmineからJenkinsジョブを操作するところで既にやっているので、require 'hudson-remote-api'は記述しない。というか記述してはいけない。

def jenkins command
Jenkinsにジョブを登録して実行する部分

job.description = command + " <a href=/edit/index/#{@issue.id}>編集</a>";
何を実行したか(しているか)を記録しておく場所としてジョブdescriptionを利用した。
ついでに、Jenkins側からRedmineの編集ページへのリンクを作る。行ったり来たりができて便利。そもそもジョブの説明のところに普通にHTMLタグを書いて機能するのがいい。

ジョブのconfig設定もう少し簡素にかけないだろうか。Shellコマンド入れたいだけなのに

config = REXML::Document.new job.config
element = config.elements['/project/builders'].add_element 'hudson.tasks.Shell'
element.add_element('command').add_text command
job.update config.to_s
job.build

録画ジョブとかは色々複雑だったのでテンプレートを作っておいたけれど、今回のジョブはシェルコマンド1つ登録して実行するだけなので、テンプレートを用いずに、直接XMLに追加する方法にした。そして即実行する。もちろんタスクは裏で動くのでビュー側が止まってしまうことはない。

def execute
実行ボタンを押すと呼ばれるところ。
この中で実際の編集コマンドを作ってジョブに登録している。
@preview='true'ならば、最初の60秒間だけの処理になる。60秒分のビデオファイルを作るのは一瞬で終わるので、その場で作成している。
クリップ処理はプレビュービデオじゃ出来ないんでこの処理はない。

def select_mode
編集がディレイなのかクリップなのかのセレクトメニュー操作をすると呼ばれる。
ajaxなことをしてみたくて色々試行錯誤した。

def get_params
アクションが呼ばれたら必ず実行する部分。
ビューとコントローラ間でパラメータを受け渡しするには、ビューからはparams[]、コントローラからはグローバル変数でやり取りするのがルールなのだね。

def remote_params
Jenkinsジョブ状態から、@resultと@buildを設定する部分。
ただ、セレクトメニューでフォームを変更した時でもパラメータが保存されるようにするために@remote_paramsに全部のパラメータ情報を保存する処理も含んでいる。ここは色々調べたけど納得行く方法に至らなかった。

def clear
ジョブを削除して、@result,@buildをリセットする。でもって、編集結果のビデオファイルも削除する。

def save
編集後のビデオファイルをアタッチビデオファイルとして更新する。
更新するといっても単にリネームしているだけ。やり直しは出来ない。

def parse_command desc
ジョブ説明文から、ジョブ内容をパースしてパラメータに反映させる部分。
もっと解析しやすいフォーマットとかJSONとか使ったほうが?とも思ったけれど、説明部分は目に見える部分でもあるので、コマンド文字列そのものにした。やってることは簡単なのでパースも簡単だった。

def get_video_filename
チケットにアタッチされているビデオファイル名を取得する。
ビューからもコントローラからも使用している。例のMP4Boxの-split-chunkでクリップした際に出来上がるファイルを特定する処理もここで行なっている。
簡単にls -1で取得できるように、クリップ実行時に予め @result = video.sub('.mp4',"_*_*.mp4") って書いておいてワイルドカード検索するようにお膳立てしておく。

MP4Boxの--split-chunkで出力されるファイル名を指定も決め打ちも出来ないための苦肉の策。

def str_to_sec str
def sec_to_str sec
"00:00:00"とかの時間フォーマット文字列と秒を相互変換するユーティリティ関数
Timeクラスとかは、こういう処理には不向きなので、簡単なやつを用意した。

~使用感~

我ながら意外と使えるなって思った。
編集中の情報はJenkinsジョブに保存されているので、実行中別のことをしてもいいし、編集再開とかが出来るし複数のビデオ編集を一度に実行することも出来る。編集ジョブを全部走らせて(数時間分の処理だとさすがに遅いのだ)翌日結果を確認するなんてことも出きるかな。
パフォーマンスが低いマシンで、尚且つ、細切れにちょっとずつしかやれる時間がない者にとって都合が良い。

欲を言えば

時間設定を、ビデオ再生のタイムスライダコントローラからドラッグ設定出きたらなぁ。

設定フォームデザインがもっとかっこよく出来たらなぁ。この辺はこつこつ暇な時にやれば出きるか。

実行ジョブが後どれくらいで終わるとかが見えるとか。これは録画ジョブの方も見れるようになるといいな。

Jenkinsジョブは2回目のビルドならどれくらいで終わるかわかるけど、最初はわからない。
こっちで概ねの時間計算は出きるだろうから、ダミーのビルド結果を作ってやって、Jenkinsがビルド2回目だと思ってくれればいい感じになりそうだね。

記事を2回くらいに分けて書こうかと思ったけど、全部書いてしまった。
とにかく、見よう見まねの、簡易ビデオ編集機能は構築できた。Rails初心者にとっては良い題材になりましたとさ。

改良点は死ぬほどあるけど、やりたかったことは出来たと思う。
ということで、録画システム構築は一区切りしようかな。

2012/09/01

簡易ビデオ編集機能(ツール編)

番組取得/予約/録画/視聴までを色んなツールやサーバーシステムを組み合わせて構築して、当初やりたかったことはほぼ出来たと思う。何かやり残したことはないかな?と考えながら録画されたビデオを視聴していたら・・・

なんと!音が思いっきりずれているやつがあるじゃないか。それも保存しておきたい今年のオリンピック閉会式。しかも途中ドラマとかが挟まってるし。
これはこのままじゃだめでしょ。ということで編集することにした。

最初はマックに持ってきてiMovieでやるか?と思った。2時間程度のビデオファイルをインポートに数時間かかった。こりゃだめだ。(我が家のマックは初代iMacなので非力なのだ)

ならば、どうせならウェブベースのビデオ編集機能を作ってみようと思った。
YouTube Video Editorみたいなすごいのは無理だけど、音ずれと前後カットくらいなら既存ツールの組み合わせで出来るんじゃないかな?と思った。
実際それだけでも結構苦労した。

今回はその構築の中のビデオ関連ツール部分の話

最初音ずれ編集は以前の記事のようにsoxでやろうかなと思った。
mp4(H264,aac)からaudioをwaveにデコードしてsoxかけてまたaacにしてマージ?

うーん。時間かかりそうだし音も劣化してしまいそうだ。直接出来ないかな?せめてaacのまま出来ないかな?
そう思って調べてみると、libsox-fmt-ffmegを使うとsoxがffmpegのライブラリを使って読み書きが出来るらしい。aacも扱えるらしい。そのためにはsoxが--with-ffmpegでビルドされている必要があるらしい。ソース取得からやってみた。

$ git clone git://sox.git.sourceforge.net/gitroot/sox/sox
$ autoreconf -i
$ ./configure -help
みると、--with-ffmpegがあった。--with-ffmpeg=dyn ?なんだこれ?
$ sudo apt-get install libsox-fmt-ffmpeg


その後いろいろとconfigureと--with-ffmpegと格闘したものの。ffmpegはyesになったが、フォーマットのffmpegはnoだ。実際ビルドされたsoxにaacファイルやmp4ファイルを与えてみたが知らぬフォーマットだと言われる。情報も乏しく、進展しそうにないのでsoxを利用するのは諦める。

もっといい方法があった。MP4Boxを使うことだ。

既にエンコード処理に組み込んでいるツールだったが、ipod対応のためのツールくらいにしか思ってなくて、ちゃんと調べたことがなかった。
すばらしいことに、mp4(h264+aac)ファイルがそのまま扱える。

MP4Boxでディレイ処理

$ MP4Box -delay {track#}={time msec} v.mp4 -out vv.mp4
これで、v.mp4内の指定トラックをtime(msec)分遅らせたビデオファイルをvv.mp4として出力できる。音声トラックは2番なので
$ MP4Box -delay 2=1500 v.mp4 -out vv.mp4
で、音を1.5秒遅らせたvv.mp4が出来上がる。

トラック1=ビデオ、トラック2=オーディオと決まっているわけじゃないけど、普通にエンコードして作ったビデオファイルはそういうものと決めつけても問題なし。

こんな書き方も出来るようだ。
$ MP4Box -add v.mp4#video -add v.mp4#audio:delay=1500 -new vv.mp4
videoとaudioを別ファイルから読んでマージした新しいファイルvv.mp4を作る。
同時に音声にdelayオプションを付けて遅らせるってことが同時に出来る。

ただ、これで作ったビデオファイルをhtml5のvideoタグで再生するとコントローラのタイムスライダーでの頭出し操作が出来なくなった。必要なchunkがなくなってしまうのだろうか。なのでこっちの書き方は採用しなかった。

MP4Boxでカット(スプリット)処理

$ MP4Box -add v.mp4:dur=60 -new vv.mp4
で、最初の60秒間だけをvv.mp4にする事が出来る。

$ MP4Box -split-cunk S:E src.mp4
で、S(sec)からE(sec)までにクリップできる。src_S_E.mp4というファイルが出来る。
SとEは秒数。こっちはなぜか、-outオプションで出力ファイル名を指定できない。

chunk単位でのスプリットらしいので指定秒数がぴったりじゃない場合、指定通りのファイル名にならない。split-chunk 10:20ってやっても、v_6_20.mp4とかになる。
出力ファイル名が特定しにくいのが難点だが、やりたいことはこれなので何とかする。


以上をふまえて
  • プレビュー用のショートムービー生成
    • $ MP4Box -add v.mp4:dur=60 -new vv.mp4
  • video/audioディレイ処理
    • $ MP4Box -delay #TRACK=#TIME v.mp4 -out vv.mp4
  • 前後クリップ処理
    • $ MP4Box -split-cunk #StartTime:#EndTime v.mp4
      → v_#StartTime_#EndTime.mp4っぽいファイルが出来上がる
で、やりたい編集処理は出来そうだ。実際やってみると結構高速だ。
2時間程度のファイルを数分で処理できる。使える使える。

これで内部タスク処理はいけそうなので、後はフロントエンドのコントローラとフォームビューを作って値の設定とビデオの確認が出来るように。実行タスクはJenkinsジョブで実行させて、結果をビューに反映させるって感じかな。

言うは易し。結構めんどかった。次回はその辺の構築を2、3回に分けて記載予定。