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初心者にとっては良い題材になりましたとさ。

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

0 件のコメント:

コメントを投稿