Skip to content

Commit

Permalink
Merge 90931da into 0e707e9
Browse files Browse the repository at this point in the history
  • Loading branch information
escaped committed Oct 21, 2020
2 parents 0e707e9 + 90931da commit f416582
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 95 deletions.
80 changes: 74 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,75 @@ for format in video.format_set.complete().all():
[django-rq]: https://github.com/ui/django-rq
[celery]: http://www.celeryproject.org/

### Generate a video thumbnail

The backend provides a `get_thumbnail()` method to extract a thumbnail from a video.
Here is a basic example on how to generate the thumbnail and store it in the model.

```python
# models.py
from django.db import models

class Video(models.Model):
width = models.PositiveIntegerField(editable=False, null=True)
height = models.PositiveIntegerField(editable=False, null=True)
duration = models.FloatField(editable=False, null=True)

thumbnail = ImageField(blank=True)
file = VideoField(width_field='width', height_field='height',
duration_field='duration')

format_set = GenericRelation(Format)


# tasks.py
from django.core.files import File
from video_encoding.backends import get_backend

from .models import Video


def create_thumbnail(video_pk):
video = Video.objects.get(pk=video_pk)
if not video.file:
# no video file attached
return

if video.thumbnail:
# thumbnail has already been generated
return

encoding_backend = get_backend()
thumbnail_path = encoding_backend.get_thumbnail(video.file.path)
filename = os.path.basename(self.url),

try:
with open(thumbnail_path, 'rb') as file_handler:
django_file = File(file_handler)
video.thumbnail.save(filename, django_file)
video.save()
finally:
os.unlink(thumbnail_path)
```

You should run this method in a separate process by using `django-rq`, `celery`
or similar) and enqueue execution from within a `post_save` signal.

```python
# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django_rq import enqueue

from . import tasks
from .models import Video


@receiver(post_save, sender=Video)
def create_thumbnail(sender, instance, **kwargs):
enqueue(tasks.create_thumbnail, instance.pk)
```

## Configuration

**VIDEO_ENCODING_THREADS** (default: `1`)
Expand Down Expand Up @@ -158,11 +227,11 @@ locate it.

### Custom Backend

You can implement a custom encoding backend. Create a new class which inherits
from `video_encoding.backends.base.BaseEncodingBackend`. You must set the
property `name` and implement the methods `encode`, `get_media_info` and
`get_thumbnail`. For further details see the reference implementation:
`video_encoding.backends.ffmpeg.FFmpegBackend`.
You can implement a custom encoding backend. Create a new class which inherits from
[`video_encoding.backends.base.BaseEncodingBackend`](video_encoding/backends/base.py).
You must set the property `name` and implement the methods `encode`, `get_media_info`
and `get_thumbnail`. For further details see the reference implementation:
[`video_encoding.backends.ffmpeg.FFmpegBackend`](video_encoding/backends/ffmpeg.py).

If you want to open source your backend, follow these steps.

Expand Down Expand Up @@ -210,4 +279,3 @@ cruft update
```

in the root of this repository.

4 changes: 2 additions & 2 deletions test_proj/media_library/tests/test_ffmpeg_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_encode(ffmpeg, video_path):
encoding = ffmpeg.encode(
video_path,
target_path,
['-vf', 'scale=-2:320', '-r', '20', '-codec:v', 'libx264'],
['-vf', 'scale=-2:320', '-r', '90', '-codec:v', 'libx264'],
)
percent = next(encoding)
assert 0 <= percent <= 100
Expand All @@ -33,7 +33,7 @@ def test_encode(ffmpeg, video_path):
assert percent == 100
assert os.path.isfile(target_path)
media_info = ffmpeg.get_media_info(target_path)
assert media_info == {'width': 568, 'height': 320, 'duration': 2.1}
assert media_info == {'width': 568, 'height': 320, 'duration': 2.027}


def test_get_thumbnail(ffmpeg, video_path):
Expand Down
26 changes: 18 additions & 8 deletions video_encoding/backends/base.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,43 @@
import abc
from typing import Dict, Generator, List, Union

from django.core import checks


class BaseEncodingBackend(metaclass=abc.ABCMeta):
# used as key to get all defined formats from `VIDEO_ENCODING_FORMATS`
name = 'undefined'

@classmethod
def check(cls):
def check(cls) -> List[checks.Error]:
return []

@abc.abstractmethod
def encode(self, source_path, target_path, params): # pragma: no cover
def encode(
self, source_path: str, target_path: str, params: List[str]
) -> Generator[float, None, None]: # pragma: no cover
"""
Encodes a video to a specified file. All encoder specific options
are passed in using `params`.
Encode a video.
All encoder specific options are passed in using `params`.
"""
pass

@abc.abstractmethod
def get_media_info(self, video_path): # pragma: no cover
def get_media_info(
self, video_path: str
) -> Dict[str, Union[int, float]]: # pragma: no cover
"""
Returns duration, width and height of the video as dict.
Return duration, width and height of the video.
"""
pass

@abc.abstractmethod
def get_thumbnail(self, video_path): # pragma: no cover
def get_thumbnail(
self, video_path: str, at_time: float = 0.5
) -> str: # pragma: no cover
"""
Extracts an image of a video and returns its path.
Extract an image from a video and return its path.
If the requested thumbnail is not within the duration of the video
an `InvalidTimeError` is thrown.
Expand Down
129 changes: 50 additions & 79 deletions video_encoding/backends/ffmpeg.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import io
import json
import locale
import logging
import os
import re
import subprocess
import tempfile
from shutil import which
from subprocess import PIPE, Popen
from typing import Dict, Generator, List, Union

from django.core import checks

Expand All @@ -14,29 +15,27 @@
from .base import BaseEncodingBackend

logger = logging.getLogger(__name__)
RE_TIMECODE = re.compile(r'time=(\d+:\d+:\d+.\d+) ')

console_encoding = locale.getdefaultlocale()[1] or 'UTF-8'
# regex to extract the progress (time) from ffmpeg
RE_TIMECODE = re.compile(r'time=(\d+:\d+:\d+.\d+) ')


class FFmpegBackend(BaseEncodingBackend):
name = 'FFmpeg'

def __init__(self):

# This will fix errors in tests
self.params = [
def __init__(self) -> None:
self.params: List[str] = [
'-threads',
str(settings.VIDEO_ENCODING_THREADS),
'-y', # overwrite temporary created file
'-strict',
'-2', # support aac codec (which is experimental)
]

self.ffmpeg_path = getattr(
self.ffmpeg_path: str = getattr(
settings, 'VIDEO_ENCODING_FFMPEG_PATH', which('ffmpeg')
)
self.ffprobe_path = getattr(
self.ffprobe_path: str = getattr(
settings, 'VIDEO_ENCODING_FFPROBE_PATH', which('ffprobe')
)

Expand All @@ -51,7 +50,7 @@ def __init__(self):
)

@classmethod
def check(cls):
def check(cls) -> List[checks.Error]:
errors = super(FFmpegBackend, cls).check()
try:
FFmpegBackend()
Expand All @@ -66,90 +65,65 @@ def check(cls):
)
return errors

def _spawn(self, cmds):
def _spawn(self, cmd: List[str]) -> subprocess.Popen:
try:
return Popen(
cmds,
return subprocess.Popen(
cmd,
shell=False,
stdin=PIPE,
stdout=PIPE,
stderr=PIPE,
close_fds=True,
stderr=subprocess.PIPE, # ffmpeg reports live stats to stderr
universal_newlines=False, # stderr will return bytes
)
except OSError as e:
raise exceptions.FFmpegError('Error while running ffmpeg binary') from e

def _check_returncode(self, process):
stdout, stderr = process.communicate()
if process.returncode != 0:
raise exceptions.FFmpegError(
"`{}` exited with code {:d}".format(
' '.join(process.args), process.returncode
)
)
self.stdout = stdout.decode(console_encoding)
self.stderr = stderr.decode(console_encoding)
return self.stdout, self.stderr

# TODO reduce complexity
def encode(self, source_path, target_path, params): # NOQA: C901
def encode(
self, source_path: str, target_path: str, params: List[str]
) -> Generator[float, None, None]:
"""
Encodes a video to a specified file. All encoder specific options
are passed in using `params`.
Encode a video.
All encoder specific options are passed in using `params`.
"""
total_time = self.get_media_info(source_path)['duration']

cmds = [self.ffmpeg_path, '-i', source_path]
cmds.extend(self.params)
cmds.extend(params)
cmds.extend([target_path])

process = self._spawn(cmds)
cmd = [self.ffmpeg_path, '-i', source_path, *self.params, *params, target_path]
process = self._spawn(cmd)
# ffmpeg write the progress to stderr
# each line is either terminated by \n or \r
reader = io.TextIOWrapper(process.stderr, newline=None) # type: ignore

buf = output = ''
# update progress
while True:
# any more data?
out = process.stderr.read(10)
if not out:
break

out = out.decode(console_encoding)
output += out
buf += out

try:
line, buf = buf.split('\r', 1)
except ValueError:
continue
while process.poll() is None: # is process terminated yet?
line = reader.readline()

try:
# format 00:00:00.00
time_str = RE_TIMECODE.findall(line)[0]
except IndexError:
continue

# convert progress to percent
time = 0
# convert time to seconds
time: float = 0
for part in time_str.split(':'):
time = 60 * time + float(part)

percent = time / total_time
percent = round(time / total_time, 2)
logger.debug('yield {}%'.format(percent))
yield percent

if os.path.getsize(target_path) == 0:
raise exceptions.FFmpegError("File size of generated file is 0")

# wait for process to exit
self._check_returncode(process)

logger.debug(output)
if not output:
raise exceptions.FFmpegError("No output from FFmpeg.")
if process.returncode != 0:
raise exceptions.FFmpegError(
"`{}` exited with code {:d}".format(
' '.join(map(str, process.args)), process.returncode
)
)

yield 100

def _parse_media_info(self, data):
def _parse_media_info(self, data: bytes) -> Dict:
media_info = json.loads(data)
media_info['video'] = [
stream
Expand All @@ -169,17 +143,15 @@ def _parse_media_info(self, data):
del media_info['streams']
return media_info

def get_media_info(self, video_path):
def get_media_info(self, video_path: str) -> Dict[str, Union[int, float]]:
"""
Returns information about the given video as dict.
Return information about the given video.
"""
cmds = [self.ffprobe_path, '-i', video_path]
cmds.extend(['-print_format', 'json'])
cmds.extend(['-show_format', '-show_streams'])

process = self._spawn(cmds)
stdout, __ = self._check_returncode(process)
cmd = [self.ffprobe_path, '-i', video_path]
cmd.extend(['-print_format', 'json'])
cmd.extend(['-show_format', '-show_streams'])

stdout = subprocess.check_output(cmd)
media_info = self._parse_media_info(stdout)

return {
Expand All @@ -188,9 +160,9 @@ def get_media_info(self, video_path):
'height': int(media_info['video'][0]['height']),
}

def get_thumbnail(self, video_path, at_time=0.5):
def get_thumbnail(self, video_path: str, at_time: float = 0.5) -> str:
"""
Extracts an image of a video and returns its path.
Extract an image from a video and return its path.
If the requested thumbnail is not within the duration of the video
an `InvalidTimeError` is thrown.
Expand All @@ -204,11 +176,10 @@ def get_thumbnail(self, video_path, at_time=0.5):
raise exceptions.InvalidTimeError()
thumbnail_time = at_time

cmds = [self.ffmpeg_path, '-i', video_path, '-vframes', '1']
cmds.extend(['-ss', str(thumbnail_time), '-y', image_path])
cmd = [self.ffmpeg_path, '-i', video_path, '-vframes', '1']
cmd.extend(['-ss', str(thumbnail_time), '-y', image_path])

process = self._spawn(cmds)
self._check_returncode(process)
subprocess.check_call(cmd)

if not os.path.getsize(image_path):
# we somehow failed to generate thumbnail
Expand Down

0 comments on commit f416582

Please sign in to comment.