【第1回】ブラウザ上で動画をカット編集する

ブラウザ上で動画を表示し、指定した時間でカット編集する方法をメモしておきます。
動画編集はMoviePyというPythonライブラリを用いることとし、環境はAWSのcloud9で作成します。
全2回の構成で、第1回ではMoviePyを使って動画編集を行えるようにします。
第2回では、javascriptでカットする領域を表示するなどします。
間違い等あった場合は、コメントでご指摘いただけると幸いです。

[第2回](https://qiita.com/a_takeeeee/items/6a949264995ca246f7b1)はこちら。

##1. 全体概要
###1.1 ディレクトリ構成
project/
　　├ static/
　　　　├ cut.js
　　　　├ main.css
　　├ templates/
　　　　├ index.html
　　　　├ cut.html
　　├ forms.py
　　├ models.py
　　├ urls.py
　　├ views.py

###1.2 完成形
完成形はこのようになります。

【index.html】
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1668358/ec53526c-2173-eb40-e763-b7dc4100ae73.png)
【cut.html】
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1668358/a895678e-dfe3-c35a-8e82-a147ec10afd0.png)
使っている動画は[こちら](https://www2.nhk.or.jp/archives/creative/material/view.cgi?m=D0002050717_00000)になります。

index.htmlの画面には保存した動画が全て表示され、「カット編集」ボタンを押すとcut.htmlに飛びます。
cut.htmlの画面には指定した動画が大きく表示され、編集できます。
カット開始時間・終了時間を変更し、「カットする」を押すとその範囲をカットします。カットする範囲は黒いバーで表示されます。その状態で「完了」を押すと、黒く表示された部分がカットされた動画が保存されます。
「元に戻す」を押すとカットする予定のを取り消し、「キャンセル」では元動画に戻して保存します。
また、「戻る」を押すとindex.htmlに戻ります。

###1.3 その他
前提としては、
- AWSのcloud9でDjangoの開発環境を作成
- 動画の保存先はS3
- 動画編集はMoviePyを利用
とします。
開発環境作成については、以下の記事などを参考にしてください。
https://qiita.com/Ajyarimochi/items/674b703622155e46dc1d
https://qiita.com/frosty/items/e793da61f9525d7afbe6

##2. 事前準備
###2.1 MoviePyのインストール
今回はMoviePyというライブラリを使うため、先にインストールしておきましょう。
AWSのコンソール画面で
"pip install MoviePy"
とすればOKです。

###2.2 index.htmlを表示
Djangoを通じてトップ画面を表示させます。
トップ画面には、「動画」という見出しと動画一覧、「編集する」というボタンを表示します。
ここは説明なしで記述だけしていきます。
なお、models.pyで動画を2つ用意しているのは、編集後も元動画に戻せるようにするためです。最初に動画をアップロードするときはどちらも同じ動画をアップする必要があります。また、models.pyを適用するには、記述した後にコンソールで"python manage.py makemigrations" "python manage.py migrate"の順にコマンドを打ってモデルを登録する必要があります。

```python:settings.py
~~省略~~

AWS_STORAGE_BUCKET_NAME = "??????"  # 作成したS3の名前
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
S3_URL = 'http://%s.s3.amazonaws.com/' % AWS_STORAGE_BUCKET_NAME
MEDIA_URL = S3_URL
AWS_S3_FILE_OVERWRITE = False
AWS_DEFAULT_ACL = None

~~省略~~

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
                'django.template.context_processors.media',  # ←追加
            ],
        },
    },
]
```

```python:models.py
from django.db import models
class Record(models.Model):
    Original = models.FileField("元動画", upload_to='movies/', null=True)
    Edited = models.FileField("編集用動画", upload_to='movies/', null=True)

class Rec_intervalTime(models.Model):
    startTime = models.DecimalField("開始時間", max_digits=10, decimal_places=4, blank=True, null=True, default=0);
    endTime = models.DecimalField("終了時間", max_digits=10, decimal_places=4, blank=True, null=True, default=0);
    record = models.ForeignKey(Record, on_delete=models.CASCADE)
```

```python:urls.py
from django.contrib import admin
from django.urls import include, path
from . import views

app_name = 'project'

urlpatterns = [
    path('', views.index, name='index'),
    path('cut/<int:record_id>/', views.recordcut, name='recordcut'),
]
```

```python:forms.py
from django import forms
from project.models import Record
from django.db.models import Q
       
class RecordCutForm(forms.ModelForm):
    class Meta:
        model = Record
        fields=(
        )
        
    startTime = forms.DecimalField(required=False)
    def clean_startTime(self):
        startTime = self.cleaned_data['startTime']
        return startTime
    
    endTime = forms.DecimalField(required=False)
    def clean_endTime(self):
        endTime = self.cleaned_data['endTime']
        return endTime
```

```python:views.py
# この時点で使わないものも含めてライブラリをimportしています
from django.shortcuts import render, redirect
from django.views.generic import TemplateView
from project.models import Record, Rec_intervalTime
from django.conf import settings
from django.http import HttpResponse, Http404, HttpResponseRedirect
from django.views.decorators.http import require_POST
from project.forms import RecordCutForm
from django.urls import reverse
from moviepy.editor import *
import subprocess
import os

DATA_DIR = settings.MEDIA_URL + "movies/"
SAVE_DIR = "s3://" + settings.AWS_STORAGE_BUCKET_NAME + "/movies/"

def index(request):
    records = Record.objects.all()
    context = {'records': records}
    return render(request, 'index.html', context)
```

```html:index.html
<div id="menu">
    <h1>動画</h1>
    <P>MEDIA: {{ MEDIA_URL }}</P>
    {% for record in records %}
    <video controls height='200px' src='{{ MEDIA_URL }}{{ record.Edited }}'></video>
    <form action="{% url 'project:recordcut' record.id %}" method="get">
        <button id="cut">カット編集</button>
    </form>
    {% endfor %}
</div>
```

##3. cut.htmlの作成
カット編集画面をcut.htmlに記述していきます。
動画・各種ボタン・カットする時間を順番に表示しています。
###3.1 views.pyの情報取得

```html:cut.html
            <video id=video controls src='{{ MEDIA_URL }}{{ record.Original }}'></video>
            {% for time in cuttime_list %}
            <div hidden class='cut_starttime'>{{ time.startTime }}</div>
            <div hidden class='cut_endtime'>{{ time.endTime }}</div>
            {% endfor %}
            <div hidden id='video_duration'>{{ video_duration }}</div>
```
動画はオリジナルのものを表示、編集します。
その後、カットする領域と動画の長さを取得します(表示しません)。
これらは後に説明するviews.pyに記述しています。

###3.2 カット領域の表示

```html:cut.html
            <div class="flagBox">
                {% for time in cuttime_list %}
                <div name="cutflag"></div>
                {% endfor %}
            </div>
```
カットする領域をバーで表示します。
divの作成だけしておき、表示はjavascriptで設定します。

###3.3 ボタンの表示

```html:cut.html
            <div class="changeTime">
                <button id="changeStartTime">カット開始時間変更</button>
                <button id="changeEndTime">カット終了時間変更</button>
            </div>
            
            <form method="post">
            {% csrf_token %}
                <div class='edit_time'>
                    <input type='number' value=5 min=0 max={{ video_duration }} step='0.01' id='startTime' name='startTime'>
                    <input type='number' value=10 min=0 max={{ video_duration }} step='0.01' id='endTime' name='endTime'>
                </div>
                <div class='cut_operation'>
                    <input type="submit" name="cutCanceled" value="キャンセル">
                    <input type="submit" name="cutReset" id="cutReset" value="元に戻す">
                    <input type="submit" name="cutExecute" id="cutExecute" value="カットする">
                    <input type="submit" name="cutFinished" id="cutFinished" value="完了">
                </div>
            </form>
            <a href="/"><p>戻る</p></a>
```
カット開始時間と各種ボタンを表示します。
「カット開始時間変更」ボタンを押すと、カット開始時間を押した時点の動画の再生時間に変更します。「カット終了時間変更」でも同様です。これらはjavascriptで制御します。
「キャンセル」「元に戻す」「カットする」「完了」については、views.pyで制御します。

最後にcut.htmlの全体像を示しておきます。

```html:cut.html
{% load static %}

<html lang="ja">
    <head>
        <meta charset="Shift-JIS">
        <title>video edit</title>
        <link rel="stylesheet" href="{% static 'main.css' %}">
    </head>
    <body>
        <h1>カット編集画面</h1>
        <section class="cut_left">
            <video id=video controls src='{{ MEDIA_URL }}{{ record.Original }}'></video>
            {% for time in cuttime_list %}
            <div hidden class='cut_starttime'>{{ time.startTime }}</div>
            <div hidden class='cut_endtime'>{{ time.endTime }}</div>
            {% endfor %}
            <div hidden id='video_duration'>{{ video_duration }}</div>
            
            <div class="flagBox">
                {% for time in cuttime_list %}
                <div name="cutflag"></div>
                {% endfor %}
            </div>
            <div class="changeTime">
                <button id="changeStartTime">カット開始時間変更</button>
                <button id="changeEndTime">カット終了時間変更</button>
            </div>
            
            <form method="post">
            {% csrf_token %}
                <div class='edit_time'>
                    <input type='number' value=5 min=0 max={{ video_duration }} step='0.01' id='startTime' name='startTime'>
                    <input type='number' value=10 min=0 max={{ video_duration }} step='0.01' id='endTime' name='endTime'>
                </div>
                <div class='cut_operation'>
                    <input type="submit" name="cutCanceled" value="キャンセル">
                    <input type="submit" name="cutReset" id="cutReset" value="元に戻す">
                    <input type="submit" name="cutExecute" id="cutExecute" value="カットする">
                    <input type="submit" name="cutFinished" id="cutFinished" value="完了">
                </div>
            </form>
            <a href="/"><p>戻る</p></a>
        </section>
        <script src={% static 'cut.js' %}></script>
    </body>
</html>
```

##4. views.pyの修正

###4.1 共通する処理
最初の部分は、カット編集画面を表示するところです。
引数として与えているrecord_idは、編集したい動画のキーです。
キーが存在するか確認した後、動画の編集時間一覧とオリジナル動画の時間を変数に記録しておきます。

```python:views.py
def recordcut(request, record_id):
    try:
        record = Record.objects.get(pk=record_id)
    except Record.DoesNotExist:
        raise Http404("Record does not exist")
    cuttime_list = Rec_intervalTime.objects.filter(record=record_id).order_by('startTime')
    video_duration = VideoFileClip(DATA_DIR + os.path.basename(str(record.movieOriginal))).duration
```
###4.2 ボタンを押したときの処理
次に、「カットする」などのボタンを押したときの動作を記述します。
cutExecuteが「カットする」、cutResetが「元に戻す」、cutFinishedが「完了」、cutCanceledが「キャンセル」に対応します。

#### "カットする"
「カットする」が押された時は、Rec_intervalTimeにカット開始時間・終了時間を登録します。
このとき、すでに登録済みの時間と被っている場合や開始時間>終了時間となっていたら登録できないようにするため、先ほど記録したcuttime_listを確認するようにします。
最後にcuttime_listを更新してからセーブします。

```python:views.py
        if 'cutExecute' in request.POST:
            form = RecordCutForm(request.POST, instance=record)
            ngFlg = False
            if form.is_valid():
                if form.cleaned_data['startTime'] > form.cleaned_data['endTime']:
                    print("NG")
                    ngFlg = True
                for time in cuttime_list:
                    if form.cleaned_data['startTime'] < time.endTime and form.cleaned_data['endTime'] > time.startTime:
                        print("NG")
                        ngFlg = True
                        break
                if ngFlg == False:    
                    newTime = Rec_intervalTime(startTime=form.cleaned_data['startTime'], 
                                              endTime=form.cleaned_data['endTime'], 
                                              record=Record.objects.get(pk=record_id))
                    cuttime_list = Rec_intervalTime.objects.filter(record=record_id).order_by('startTime')
                    newTime.save()
```
#### "元に戻す"
「元に戻す」のときは、指定した動画に関してRec_intervalTimeに登録した情報を全て消去します。

```python:views.py
        if 'cutReset' in request.POST:
            form = RecordCutForm(request.POST, instance=record)
            if form.is_valid():
                Rec_intervalTime.objects.filter(record=record_id).delete()
                
```
#### "カット完了"
「カット完了」のときは、cuttime_listの間の時間を全てカットしてEditedを更新します。
ただし、MoviePyでカットを行うとき、指定するのは残したい時間になります。
例えば30秒の動画があり、10-20秒をカットしたいときは、"0, 10"と"20, 30"という時間の組が必要になります。
時間の組の登録先がtimesという配列になっています。どんな操作で登録しているかはコードを読んで確認してください。
その後、trimmingsave関数でカットを行い、Editedを修正して保存します。
trimmingsave関数についてはこの章の最後に説明します。

```python:views.py
        if 'cutFinished' in request.POST:
            form = RecordCutForm(request.POST, instance=record)
            if form.is_valid():                
                # cuttime_listの情報を取り込む
                times0 = []
                for time in cuttime_list:
                    times0.append(time.startTime)
                    times0.append(time.endTime)
                
                # 最初と最後の時間を付け加えて加工
                times = []
                for index, item in enumerate(times0):
                    if index == 0:
                        start = 0
                        end = item
                        times.append([start, end])
                    elif index == len(times0) - 1:
                        start = item
                        end = video_duration
                        times.append([start, end])
                    elif index % 2 == 1:
                        start = item
                    else:
                        end = item
                        times.append([start, end])
                record.Edited = trimmingsave(record_id, *times)
                record.save()
                return redirect('project:recordedit', record_id)
```
#### "キャンセル"
「キャンセル」のときは、EditedをOriginalで上書きして終了します。

```python:views.py
        if 'cutCanceled' in request.POST:
            form = RecordCutForm(request.POST, instance=record)
            if form.is_valid():
                Rec_intervalTime.objects.filter(record=record_id).delete()
                record.Edited = movieOriginal
                record.save()
                return redirect('project:recordedit', record_id)
```
####trimmingsave関数
この関数は動画のキーと編集する時間の組を引数にとり、カットした後の動画を作成してurlを返します。
"VideoFileClip(動画名).subclip(開始時間, 終了時間)"で動画のカットを行い、"concatenate_videoclips(動画名の配列)"で全ての動画を連結します。
"write_videofile(動画名, fps=n)"でfpsがnの動画として保存します。

```python:views.py
def trimmingsave(record_id, *times):
    videos = []
    record = Record.objects.get(pk=record_id)
    inputname = os.path.basename(str(record.movieOriginal))
    outputname = "output_" + inputname
    for index, time in enumerate(times):
        video = VideoFileClip(DATA_DIR + inputname).subclip(float(time[0]), float(time[1]))
        videos.append(video)
    final_clip = concatenate_videoclips(videos)
    final_clip.write_videofile(outputname, fps=10)
    subprocess.call( ["aws", "s3", "mv", outputname, SAVE_DIR] )
    return "movies/" + outputname
```

最後にrecordcutの全体像を書いておきます。

```python:views.py
from django.shortcuts import render, redirect
from django.views.generic import TemplateView
from project.models import Record, Rec_intervalTime
from django.conf import settings
from django.http import HttpResponse, Http404, HttpResponseRedirect
from django.views.decorators.http import require_POST
from project.forms import RecordCutForm
from django.urls import reverse
from moviepy.editor import *
import subprocess
import os

DATA_DIR = settings.MEDIA_URL + "movies/"
SAVE_DIR = "s3://" + settings.AWS_STORAGE_BUCKET_NAME + "/movies/"

def index(request):
    records = Record.objects.all()
    context = {'records': records}
    return render(request, 'index.html', context)
    
def recordcut(request, record_id):
    try:
        record = Record.objects.get(pk=record_id)
    except Record.DoesNotExist:
        raise Http404("Record does not exist")
    # context = {'record': record}
    # return render(request, 'cut.html', context)
    
    cuttime_list = Rec_intervalTime.objects.filter(record=record_id).order_by('startTime')
    video_duration = VideoFileClip(DATA_DIR + os.path.basename(str(record.Original))).duration
    
    if request.method == 'POST':
        if 'cutExecute' in request.POST:
            form = RecordCutForm(request.POST, instance=record)
            ngFlg = False
            if form.is_valid():
                if form.cleaned_data['startTime'] > form.cleaned_data['endTime']:
                    print("NG")
                    ngFlg = True
                for time in cuttime_list:
                    if form.cleaned_data['startTime'] < time.endTime and form.cleaned_data['endTime'] > time.startTime:
                        print("NG")
                        ngFlg = True
                        break
                if ngFlg == False:    
                    newTime = Rec_intervalTime(startTime=form.cleaned_data['startTime'], 
                                              endTime=form.cleaned_data['endTime'], 
                                              record=Record.objects.get(pk=record_id))
                    cuttime_list = Rec_intervalTime.objects.filter(record=record_id).order_by('startTime')
                    newTime.save()

        if 'cutReset' in request.POST:
            form = RecordCutForm(request.POST, instance=record)
            if form.is_valid():
                Rec_intervalTime.objects.filter(record=record_id).delete()

        if 'cutFinished' in request.POST:
            form = RecordCutForm(request.POST, instance=record)
            if form.is_valid():
                trimmingTime = Rec_intervalTime.objects.filter(record=record_id).order_by('startTime')

                # trimmingTimeの情報を取り込む
                times0 = []
                for time in trimmingTime:
                    times0.append(time.startTime)
                    times0.append(time.endTime)
                print(times0)

                # 最初と最後の時間を付け加えて加工
                times = []
                for index, item in enumerate(times0):
                    print(index, item)
                    if index == 0:
                        start = 0
                        end = item
                        times.append([start, end])
                    elif index == len(times0) - 1:
                        start = item
                        end = video_duration
                        times.append([start, end])
                    elif index % 2 == 1:
                        start = item
                    else:
                        end = item
                        times.append([start, end])

                print(times)
                record.Edited = trimmingsave(record_id, *times)
                record.save()
                
                records = Record.objects.all()
                context = {'records': records}
                return render(request, 'index.html', context)
        if 'cutCanceled' in request.POST:
            form = RecordCutForm(request.POST, instance=record)
            if form.is_valid():
                Rec_intervalTime.objects.filter(record=record_id).delete()
                record.Edited = record.Original
                record.save()
                
                records = Record.objects.all()
                context = {'records': records}
                return render(request, 'index.html', context)
    else:
        form = RecordCutForm(instance=record)

    context = {'record': record, 'cuttime_list': cuttime_list, 'video_duration': video_duration, 'form': form}
    return render(request, 'cut.html', context)

def trimmingsave(record_id, *times):
    videos = []
    record = Record.objects.get(pk=record_id)
    inputname = os.path.basename(str(record.Original))
    outputname = "output_" + inputname
    for index, time in enumerate(times):
        video = VideoFileClip(DATA_DIR + inputname).subclip(float(time[0]), float(time[1]))
        videos.append(video)
    final_clip = concatenate_videoclips(videos)
    final_clip.write_videofile(outputname, fps=10)
    subprocess.call( ["aws", "s3", "mv", outputname, SAVE_DIR] )
    return "movies/" + outputname
```

##5. 最後に
長くなってきたので、続きは[第2回](https://qiita.com/a_takeeeee/items/6a949264995ca246f7b1)に載せます。